fix(web): keep consultation conclusions across turns and surface cache hits (BUG-555, BUG-556)

Session history was silently clipped to the first 4000 characters of the last 12 messages, so follow-ups could not see timing or audit tables. Keep an append-only tail plus a checkpoint summary, retry overflow in the same request, and expose cache hit rate in admin usage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 15:16:18 +08:00
parent 9ce7a374ed
commit bf8ad0d1ff
29 changed files with 1296 additions and 101 deletions
+7
View File
@@ -1,5 +1,12 @@
# BLOCKED # BLOCKED
## 普通咨询上下文窗口与模型缓存(2026-09-06,分支 `codex/consultation-context-and-cache-20260906`
- **`npm run test:db` 未绿:Docker 用户自定义网络地址池耗尽。** `docker compose` 起 postgres fixture 时报 `all predefined address pools have been fully subnetted`。本机 Docker daemon 可用,且已有十余个遗留 `jyotisha-postgres-*` 容器占着网络;本单未做 `docker network prune`(会动共享宿主状态)。`test:db` 36 项里 19 项不需要新网络(备份路径、env 校验等)通过,17 项因建网失败。
- **迁移语法检查(任务书 §5.1 空库口径):** 用默认 `bridge` 起一次性 `postgres:17-alpine`(不新建 compose 网络)。对空库 `psql --set ON_ERROR_STOP=1 -f 20260906010000_chat_session_context_summary.sql` 退出码 3`relation "public.chat_sessions" does not exist`。在同一空实例上 `CREATE ROLE authenticated NOLOGIN; CREATE TABLE public.chat_sessions (id uuid PRIMARY KEY);` 后再跑同一文件:`BEGIN / ALTER TABLE / GRANT / COMMIT` 成功;列 `context_summary jsonb`、check `jsonb_typeof = 'object'``authenticated` 对该列 `UPDATE` 为真。容器已删。
- **`npm run build -- --webpack`** webpack 编译通过(45s)。随后 Next 类型检查停在既有 `api/rectification/cases/[caseId]``dossierResponse` 导出,与 BUG-551/553 同因,本单未改该文件。因此构建未走到 `Collecting page data`,本机看不到 `○ /` Static 行。`page.tsx` 未改;缓存表只在后台用量页。
- 部署前仍需产品负责人跑 Gitea `Migrate Staging Database`(本迁移 + 积压的 `5010000_personal_report_longform_appendices.sql`)。
## 仓库整备任务 1:发布门仍有三条非环境红(2026-09-03,分支 `codex/repo-hygiene-20260903` ## 仓库整备任务 1:发布门仍有三条非环境红(2026-09-03,分支 `codex/repo-hygiene-20260903`
任务 0 之后,附录里 staging 独有的 6 条里,夹具大运 / 用户调用验收 / 技能包验收已绿。剩下 3 条**不改期望值、不改校正打分**: 任务 0 之后,附录里 staging 独有的 6 条里,夹具大运 / 用户调用验收 / 技能包验收已绿。剩下 3 条**不改期望值、不改校正打分**:
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志 # 印度占星 Skill 更新日志
## 2026-09-06 — 普通咨询记住结论,超长会缩窗,后台能看见缓存命中
多轮咨询不再只留每条开头几千字。服务端会维护一份会话摘要,并把后半段原文按模型窗口留给下一轮;报告被截断时会写明省略了多少字。上下文真的超了,会在同一次等待里缩到摘要加最后一对再试一次。后台用量能看到各模型的缓存命中率和缓存读 tokens。Skill 版本未变。
## 2026-09-06 — 历史对话改成主题标题,按活动时间排,并分页加载 ## 2026-09-06 — 历史对话改成主题标题,按活动时间排,并分页加载
咨询第一轮会用当前模型起一个 6–12 字的主题标题,不扣点数;起名失败就留着原来的标题。只有发问和回答会改变列表顺序,改名、收藏、换模型不会把旧会话顶上去。历史按今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早分段,侧栏只显示标题,别人的盘才在下面加一行资料名。列表每页 40 条,滚到底静默续取。Skill 版本未变。 咨询第一轮会用当前模型起一个 6–12 字的主题标题,不扣点数;起名失败就留着原来的标题。只有发问和回答会改变列表顺序,改名、收藏、换模型不会把旧会话顶上去。历史按今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早分段,侧栏只显示标题,别人的盘才在下面加一行资料名。列表每页 40 条,滚到底静默续取。Skill 版本未变。
+32
View File
@@ -8565,4 +8565,36 @@
- 复发自:无 - 复发自:无
- 修复版本:`a1956deb` - 修复版本:`a1956deb`
## BUG-555 | 普通咨询历史只留每条开头 4000 字,下一轮看不到应期和审计表
- 状态:resolved
- 首次发现:2026-09-06
- 最近更新:2026-09-06
- 影响面:`POST /api/consult``consultation-session-history.ts``session-context-summary.ts``chat_sessions.context_summary`
- 用户现象:追问「你上次说的应期」时,模型像没看过上一轮。Level 2 报告的应期、综合判断和技法审计表在下一轮消失,也没有任何截断提示。上下文超限时走普通失败,没有缩窗重试。
- 触发条件:普通咨询多轮追问;单条助手回复超过 4000 字;或累计历史超过模型窗口。
- 根因:历史窗口写死为最近 12 条 × 每条开头 4000 字,不知道模型 `context_window`,也不保留结论。超限错误没有识别为 `context_overflow`
- 修复:历史改为摘要之后的 append-only 尾巴,按模型窗口算预算,超长从最旧整条丢。单条上限 12,000 字并写明省略字数。尾巴超过 16,000 字时在结算后异步做检查点摘要(不扣点、15 秒 ref 超时、乐观并发)。识别上下文溢出后同一请求内缩到「摘要 + 最后一对」重试一次。
- 验证:`frontend/tests/consultation-session-history.test.ts``session-context-summary.test.ts``consultation-context-cache-contract.test.ts`
- 防复发:咨询历史不得再按固定 12 条 × 头部截断静默砍结论。摘要超时不得用 `AbortSignal.timeout()`。溢出重试不得新增用户可见等待态。
- 相关记录:BUG-523
- 复发自:无
- 修复版本:待发布
## BUG-556 | 缓存命中已写入账本,后台看不到;Anthropic 历史没有第二断点
- 状态:resolved
- 首次发现:2026-09-06
- 最近更新:2026-09-06
- 影响面:`api/admin/usage/aggregate``api/admin/usage`、定价测算页、用量列表、`cachedHistoryMessage``skill-binding` 共享方法段
- 用户现象:后台用量页没有缓存命中率或缓存读 tokens。Anthropic 只缓存了系统块,历史每轮全价重算。跨领域不变的 Full-spectrum 与 Event judgment skeleton 每次跟着工具结果重发。
- 触发条件:普通咨询跑过后打开用量聚合或用量列表;使用 Anthropic 或多领域咨询。
- 根因:`complete_usage` 已把 `metadata.cache` 写入 `usage_ledger`,聚合与列表都不读。历史消息没有 Anthropic `cacheControl`。共享方法段放在工具结果里,不在可缓存系统块。
- 修复:聚合按模型给出 7 天 / 30 天命中率与读写未命中;列表加「缓存读 tokens」。历史尾巴最后一条在 Anthropic 上打第二断点。共享两段搬进 `<jyotish-shared-method>`
- 验证:`frontend/tests/admin-usage-aggregate-contract.test.ts``agent-generation-settings.test.ts``skill-binding.test.ts``consultation-methodology.test.ts`
- 防复发:有 `metadata.cache` 的运行必须进入命中率分母;`readTokens = 0` 计未命中。无缓存数据的行不得进分母。系统块 9 段节选与 `providesSkillDiscovery: "on-demand"` 不得回退。
- 相关记录:BUG-555
- 复发自:无
- 修复版本:待发布
+6
View File
@@ -279,3 +279,9 @@ Prevention: make the gateway request official raw evidence without relying on an
`manman-linux` runner 以 `:host` 模式注册在一台同时运行 Nacos/MySQL/Redis/xxl-job 的跳板机上,质量门每次 push 都在该宿主机本地 build 两个按 SHA 打标的镜像且从不回收,累积 1453 个镜像后根分区 99G 用满 94G、Avail 归零。`docker compose up -d --wait postgres` 阶段 `initdb``No space left on device`23 个数据库集成测试级联失败,`publish` 连续 5 次 skipped`staging` 上 4 个提交无法发布。ERR-103 当初据以搬迁的「`xiaoxin` 缺少 Docker Compose v2」是误诊:真实原因是 `xiaoxin``/root/.docker/cli-plugins/docker-compose` 有一个零字节文件遮蔽了系统插件(用户级插件目录优先),Docker Engine 本身一直正常。 `manman-linux` runner 以 `:host` 模式注册在一台同时运行 Nacos/MySQL/Redis/xxl-job 的跳板机上,质量门每次 push 都在该宿主机本地 build 两个按 SHA 打标的镜像且从不回收,累积 1453 个镜像后根分区 99G 用满 94G、Avail 归零。`docker compose up -d --wait postgres` 阶段 `initdb``No space left on device`23 个数据库集成测试级联失败,`publish` 连续 5 次 skipped`staging` 上 4 个提交无法发布。ERR-103 当初据以搬迁的「`xiaoxin` 缺少 Docker Compose v2」是误诊:真实原因是 `xiaoxin``/root/.docker/cli-plugins/docker-compose` 有一个零字节文件遮蔽了系统插件(用户级插件目录优先),Docker Engine 本身一直正常。
Prevention: 构建与测试 runner 不得与其他服务共用宿主文件系统;门禁必须在开跑前自行回收磁盘并在余量不足时 fail-closed 报出磁盘原因,而不是让下游 fixture 代为暴露。判定某台 runner「不支持 Docker Compose」之前,必须先看 `docker info` 的 client plugins 报错与用户级 `~/.docker/cli-plugins` 是否存在遮蔽文件;把能力缺失归因到整台机器会导致错误的迁移决策,本次代价是发布中断。 Prevention: 构建与测试 runner 不得与其他服务共用宿主文件系统;门禁必须在开跑前自行回收磁盘并在余量不足时 fail-closed 报出磁盘原因,而不是让下游 fixture 代为暴露。判定某台 runner「不支持 Docker Compose」之前,必须先看 `docker info` 的 client plugins 报错与用户级 `~/.docker/cli-plugins` 是否存在遮蔽文件;把能力缺失归因到整台机器会导致错误的迁移决策,本次代价是发布中断。
## ERR-106 | 本机 Docker 用户网络地址池耗尽,`test:db` compose fixture 无法建网 | observed 2026-09-06
`docker compose --project-name jyotisha-postgres-* up -d --wait postgres``failed to create network …_app: all predefined address pools have been fully subnetted`。Docker daemon 正常,本机同时存在着十余个遗留 `jyotisha-postgres-*-postgres-1``jyotisha-local-preview-postgres-1``npm run test:db --test-concurrency=1` 仍每测新建 compose 项目网络。咨询上下文单未做 `docker network prune`(会清共享宿主资源)。
Prevention: 需要真实库测时先清本任务自己的 fixture 网络,或在默认 `bridge` 上起一次性 Postgres 做迁移语法检查;不得把地址池耗尽写成「本机无 Docker」。新迁移至少用 `psql --set ON_ERROR_STOP=1 -f` 在可写实例上跑通 ALTER/GRANT。
@@ -0,0 +1,61 @@
# PROGRESS · 普通对话上下文窗口与模型缓存(2026-09-06)
工作树:`.worktrees/consultation-context-and-cache-20260906`
分支:`codex/consultation-context-and-cache-20260906`
基线:任务书写 `origin/staging` @ `d8a0f615`;开工时 `origin/staging` 已到 `9ce7a374`(含 BUG-551/552/553),以当时远端为准。
未改 `page.tsx`、生时校正链路、Skill 正文、引擎。客户端零改动。迁移只加 `chat_sessions.context_summary`。未 bump Skill。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.1 摘要 + 尾巴历史 + 按模型预算 + 溢出重试 | 完成 | BUG-555 |
| 5.2 后台缓存可见 | 完成 | BUG-556 |
| 5.3 Anthropic 历史断点 | 完成 | BUG-556 |
| 5.4 共享方法段搬进系统块 | 完成 | BUG-556 |
| 5.5 BUG_HISTORY / CHANGELOG / testing | 完成 | — |
## 实现要点
- 历史 = `context_summary` 之后的全部消息。超 `historyBudgetChars` 时从最旧整条丢;单条 12,000 字截断并写省略标记。
- 摘要在最后一条用户消息、时间行之后、问题之前。检查点在 `complete_consultation_response` 成功后异步触发,不扣点,15 秒 ref 超时,乐观并发。
- 溢出识别后同一请求把历史缩到摘要 + 最后一对再 `stream` 一次。咨询公共事件是封闭联合,不能发 rectification 的 `attempt.reset`,所以重试留在服务端。
- 用量聚合按 `actual_model_id` 给出 7/30 天缓存命中;列表加 `cacheReadTokens``costMicrousd` 计价口径未改。
## 系统块 / 工具结果字节
| 块 | 之前 | 之后 | 变化 |
| --- | --- | --- | --- |
| `jyotishSkillMethodBlock` | 28,877 B | 35,323 B | +6,446 B(任务预期 +6.4 KB |
| `<jyotish-shared-method>` | 无 | 6,396 B | Full-spectrum + Event judgment skeleton |
| 单领域 career `methodology.sections` 正文 | 约 13.4 KB(含上述两段) | 7,043 B | 只留 baseline + 领域两段 |
| 三领域 career/marriage/wealth 正文 | 约 25.3 KB | 18,914 B | 同上 |
9 段运行时节选与 `providesSkillDiscovery: "on-demand"` 未回退。
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `consultation-session-history` | 最近 12 条 × 头部 4000 字 | 按模型预算从最旧丢整条;单条 12,000 + 省略标记 | BUG-555 静默砍结论 |
| `consultation-methodology` | sections 含 Full-spectrum / Event judgment skeleton | 这两段在系统块;领域清单仍随工具结果 | 工具结果不在缓存前缀里 |
| `chat-session-authority` / consult select | `...,chart_profile_role` | 另加 `context_summary` | 检查点读写 |
| `application-billing-contract` | 三处 `usages.push(result.totalUsage)` | 一处,在 `streamWithOverflowRetry` | 溢出重试共用首次 stream |
| `admin-usage-aggregate-contract` | 只有 30 天功能用量 | 另加 cache 7/30 天窗口 | BUG-556 命中率不可见 |
## 测试
| 命令 | 结果 |
| --- | --- |
| 本单相关 `npx tsx --test`history / summary / cache contract / methodology / skill-binding / generation-settings / admin usage / onboarding-route | 58 pass / 0 fail / cancelled 0;摘要超时用例 cancelled 0 |
| `./node_modules/.bin/tsc --noEmit`(先删 `.next` | 0 错 |
| 改动文件 eslint `--quiet` | 0 error |
| `page.tsx` | 未改 |
| `npm run build -- --webpack` | 编译通过;类型检查停在既有 `dossierResponse` 路由导出(与 BUG-551/553 相同),未打印 `○ /` |
| 全量 `npx tsx --test tests/*.test.ts`(排除 `tests/database-*.test.ts` | 2726 tests / 2712 pass / 14 failDocker 地址池耗尽 + 1 条 onboarding 超时竞态(单跑通过) |
| `npm run test:db` | **blocked**:见 `BLOCKED.md`。迁移在一次性 Postgres 上经 stub 表语法检查通过 |
## 收尾限制
- 溢出重试只包住 `agent.stream()` 抛错;流开始之后供应商才报超窗时不会第二次缩窗(咨询公共事件不能发 `attempt.reset`)。
- 摘要失败只 `console.warn("session context summary failed", error)`,不打印摘要正文。
- `costMicrousd` 仍按全价输入单价。
@@ -0,0 +1,40 @@
# Staging 人肉复核 · 普通咨询上下文与缓存(2026-09-06)
给产品负责人。不要把真实出生资料、真实用户问题或对话正文写进任何记录。试用问题用虚构句,例如「半年内换工作时机」。
对应 BUG-555、BUG-556。测之前先做第 0 条。部署前需先跑 Gitea `Migrate Staging Database`(本迁移与已积压的 `5010000_personal_report_longform_appendices.sql` 一起)。
## 0. 确认测的是新版本
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。
## 1. 多轮之后还能对上前面的应期(P0)
1. 新建一次普通咨询(不要用生时校正)。
2. 用虚构问题连续问 6 轮以上,其中至少一轮要出带应期的 Level 2 报告。
3. 再问:「你前面说的应期是哪年」。
- ✅ 预期:答案与前面报告里的应期一致,或明确说那一轮被收进会话摘要。界面没有新的「加载中」态。
- ❌ 失败:模型像没看过上一轮;或出现截断却完全不提应期。
## 2. 后台用量看得到缓存(P0)
1. 在 staging 用同一会话至少跑 3 轮普通咨询。
2. 打开后台用量 / 定价测算页。
- ✅ 预期:有「缓存命中」表(模型 / 运行数 / 命中率 / 缓存占比 / 读 / 写 / 未命中)。用量列表有「缓存读 tokens」列。没有缓存数据的模型不进命中率分母。
- ❌ 失败:表不存在,或命中率把没有 `metadata.cache` 的运行算进去。
## 3. 三家供应商对照(P1
同一套虚构追问,分别用 DeepSeek、OpenAI、Anthropic 各跑一条(后台有配置的才测)。
- ✅ 预期:三家都能正常回答。Anthropic 多轮后缓存读 tokens 可以大于 0。OpenAI / DeepSeek 不因多了标记而失败。
- ❌ 失败:某家因历史缓存标记报错;或 Anthropic 多轮后缓存读永远是 0 且系统块断点也不在。
## 4. 超长对话仍是一次等待(P1)
用已经很长的会话再发一条虚构追问。
- ✅ 预期:用户仍只看到一次等待、一次揭幕。失败时仍是原来的失败文案,不会出现第二次转圈。
- ❌ 失败:失败后自动又转一圈,或页面上出现新的重试提示。
@@ -65,6 +65,34 @@ function metric(
}; };
} }
type CacheRow = {
days: string;
actual_model_id: string | null;
runs_with_cache: string;
hits: string;
read_tokens: string;
write_tokens: string;
no_cache_tokens: string;
};
function cacheEntry(row: CacheRow) {
const runsWithCache = Number(row.runs_with_cache);
const hits = Number(row.hits);
const readTokens = Number(row.read_tokens);
const writeTokens = Number(row.write_tokens);
const noCacheTokens = Number(row.no_cache_tokens);
const billed = readTokens + writeTokens + noCacheTokens;
return {
actualModelId: row.actual_model_id,
runsWithCache,
hitRate: runsWithCache > 0 ? hits / runsWithCache : null,
readTokens,
writeTokens,
noCacheTokens,
cacheShare: billed > 0 ? readTokens / billed : null,
};
}
export async function GET() { export async function GET() {
try { try {
await requirePermission("billing.orders.read"); await requirePermission("billing.orders.read");
@@ -99,6 +127,26 @@ export async function GET() {
group by f.feature_key group by f.feature_key
order by f.feature_key order by f.feature_key
`, [FEATURE_KEYS]); `, [FEATURE_KEYS]);
const cacheRows = await queryAdminRows<CacheRow>(`
with windows(days) as (
select 7
union all
select 30
)
select w.days::text as days,
l.actual_model_id,
count(*)::text as runs_with_cache,
count(*) filter (where coalesce((l.metadata->'cache'->>'readTokens')::numeric, 0) > 0)::text as hits,
coalesce(sum(coalesce((l.metadata->'cache'->>'readTokens')::numeric, 0)), 0)::text as read_tokens,
coalesce(sum(coalesce((l.metadata->'cache'->>'writeTokens')::numeric, 0)), 0)::text as write_tokens,
coalesce(sum(coalesce((l.metadata->'cache'->>'noCacheTokens')::numeric, 0)), 0)::text as no_cache_tokens
from windows w
join public.usage_ledger l
on l.created_at >= now() - make_interval(days => w.days)
and l.metadata ? 'cache'
group by w.days, l.actual_model_id
order by w.days, l.actual_model_id
`);
return NextResponse.json({ return NextResponse.json({
window: { days: 30, since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() }, window: { days: 30, since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() },
@@ -112,6 +160,10 @@ export async function GET() {
durationMs: metric(row, "avg_duration_ms", "p50_duration_ms", "p95_duration_ms", "max_duration_ms"), durationMs: metric(row, "avg_duration_ms", "p50_duration_ms", "p95_duration_ms", "max_duration_ms"),
}, },
})), })),
cache: {
days7: cacheRows.filter((row) => row.days === "7").map(cacheEntry),
days30: cacheRows.filter((row) => row.days === "30").map(cacheEntry),
},
}); });
} catch (error) { } catch (error) {
return adminErrorResponse(error); return adminErrorResponse(error);
+2 -2
View File
@@ -3,5 +3,5 @@ import { requirePermission } from "@/lib/admin/auth";
import { pageOffset, queryAdminRows } from "@/lib/admin/database"; import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, invalidQueryResponse, parseListQuery } from "@/lib/admin/http"; import { adminErrorResponse, invalidQueryResponse, parseListQuery } from "@/lib/admin/http";
export const runtime="nodejs"; export const runtime="nodejs";
type Row={id:string;user_id:string;email:string|null;request_id:string;feature_key:string;source:string;requested_model_id:string|null;actual_model_id:string|null;model_config_version:number|null;input_tokens:number;output_tokens:number;cost_microusd:string;duration_ms:number|null;created_at:Date;total_count:string}; type Row={id:string;user_id:string;email:string|null;request_id:string;feature_key:string;source:string;requested_model_id:string|null;actual_model_id:string|null;model_config_version:number|null;input_tokens:number;output_tokens:number;cost_microusd:string;duration_ms:number|null;created_at:Date;cache_read_tokens:string|null;total_count:string};
export async function GET(request:Request){try{await requirePermission("billing.orders.read");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,userId:r.user_id,email:r.email,requestId:r.request_id,featureKey:r.feature_key,source:r.source,requestedModelId:r.requested_model_id,actualModelId:r.actual_model_id,modelConfigVersion:r.model_config_version,inputTokens:r.input_tokens,outputTokens:r.output_tokens,costMicrousd:Number(r.cost_microusd),durationMs:r.duration_ms,createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}} export async function GET(request:Request){try{await requirePermission("billing.orders.read");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,l.metadata->'cache'->>'readTokens' as cache_read_tokens,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,userId:r.user_id,email:r.email,requestId:r.request_id,featureKey:r.feature_key,source:r.source,requestedModelId:r.requested_model_id,actualModelId:r.actual_model_id,modelConfigVersion:r.model_config_version,inputTokens:r.input_tokens,outputTokens:r.output_tokens,costMicrousd:Number(r.cost_microusd),durationMs:r.duration_ms,cacheReadTokens:r.cache_read_tokens==null?null:Number(r.cache_read_tokens),createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
+168 -57
View File
@@ -29,10 +29,10 @@ import {
shouldLoadGeneralDailyPanchanga, shouldLoadGeneralDailyPanchanga,
} from "@/lib/consultation-entrypoint"; } from "@/lib/consultation-entrypoint";
import { CreditRpcError } from "@/lib/consultation-billing"; import { CreditRpcError } from "@/lib/consultation-billing";
import { cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage } from "@/lib/agent-generation-settings"; import { cachedHistoryMessage, cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage } from "@/lib/agent-generation-settings";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing"; import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
import { reserveConsultationModel } from "@/lib/consultation-model-selection"; import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { resolveSessionLanguageModel } from "@/lib/model-catalog"; import { resolveSessionLanguageModel, loadLanguageModelCatalog } from "@/lib/model-catalog";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server"; import { createServerSupabaseClient } from "@/lib/supabase/server";
@@ -72,7 +72,17 @@ import {
loadGeneralDailyPanchangaContext, loadGeneralDailyPanchangaContext,
type GeneralDailyPanchangaContext, type GeneralDailyPanchangaContext,
} from "@/lib/general-daily-panchanga"; } from "@/lib/general-daily-panchanga";
import { consultationHistoryFromStoredMessages } from "@/lib/consultation-session-history"; import {
consultationHistoryWindow,
consultationUserTurnContent,
isContextOverflowError,
lastConsultationPair,
parseSessionContextSummary,
} from "@/lib/consultation-session-history";
import {
checkpointSessionContextSummary,
generateSessionContextSummaryText,
} from "@/lib/session-context-summary";
import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent"; import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent";
import { z } from "zod"; import { z } from "zod";
@@ -279,7 +289,7 @@ export async function POST(request: Request) {
const { data: chatSession, error: chatSessionError } = await supabase const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions") .from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role") .select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role,context_summary")
.eq("id", parsed.data.sessionId) .eq("id", parsed.data.sessionId)
.eq("user_id", user.id) .eq("user_id", user.id)
.maybeSingle(); .maybeSingle();
@@ -336,11 +346,16 @@ export async function POST(request: Request) {
const visibleQuestion = parsed.data.question; const visibleQuestion = parsed.data.question;
// Client `history` stays in the request schema for old bundles and is not read. // Client `history` stays in the request schema for old bundles and is not read.
const storedHistory = consultationHistoryFromStoredMessages(chatSession.messages); const contextSummary = parseSessionContextSummary(chatSession.context_summary);
const historyWindow = consultationHistoryWindow(chatSession.messages, contextSummary, {
contextWindow: sessionModel.contextWindow,
});
const storedHistory = historyWindow.tail;
const userControlledPrompt = [ const userControlledPrompt = [
parsed.data.question, parsed.data.question,
historyWindow.summaryText,
...storedHistory.filter((message) => message.role === "user").map((message) => message.text), ...storedHistory.filter((message) => message.role === "user").map((message) => message.text),
].join("\n"); ].filter(Boolean).join("\n");
if (blocksPromptExtraction(userControlledPrompt)) { if (blocksPromptExtraction(userControlledPrompt)) {
return NextResponse.json( return NextResponse.json(
{ {
@@ -551,6 +566,40 @@ export async function POST(request: Request) {
} }
const usageStartedAt = Date.now(); const usageStartedAt = Date.now();
async function checkpointConsultationContext() {
try {
const { data: sessionRow, error } = await supabase
.from("chat_sessions")
.select("messages, context_summary")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
if (error || !sessionRow) return;
const catalog = await loadLanguageModelCatalog();
const summaryModel = catalog.defaultModelId
? catalog.models.find((model) => model.id === catalog.defaultModelId) ?? selectedModel
: selectedModel;
await checkpointSessionContextSummary({
messages: sessionRow.messages,
summary: sessionRow.context_summary,
generateText: (prompt, signal) => generateSessionContextSummaryText(summaryModel, prompt, signal),
update: async (summary, seenUpdatedAt) => {
let query = supabase.from("chat_sessions")
.update({ context_summary: summary })
.eq("id", sessionId)
.eq("user_id", userId);
query = seenUpdatedAt
? query.eq("context_summary->>updatedAt", seenUpdatedAt)
: query.is("context_summary", null);
const { data, error: writeError } = await query.select("id");
if (writeError) throw writeError;
return Boolean(data?.length);
},
});
} catch (error) {
console.warn("session context summary failed", error);
}
}
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) { async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const resolved = await usage; const resolved = await usage;
const usageRecord = resolved as Record<string, unknown>; const usageRecord = resolved as Record<string, unknown>;
@@ -615,6 +664,9 @@ export async function POST(request: Request) {
if (!completion.success && completion.error_code !== "request_cancelled") { if (!completion.success && completion.error_code !== "request_cancelled") {
throw new CreditRpcError(completion.error_code || "completion_rejected"); throw new CreditRpcError(completion.error_code || "completion_rejected");
} }
if (completion.success) {
void checkpointConsultationContext();
}
return "completed"; return "completed";
} catch (error) { } catch (error) {
await cancel(); await cancel();
@@ -775,26 +827,36 @@ export async function POST(request: Request) {
} }
}; };
const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model);
const baseMessages = [ const modeInstruction = consultationMode === "general_no_birth_time" || (consultationMode === "declared_birth_window" && generalDailyContext)
...(cacheBoundary ? [cacheBoundary] : []), ? generalNoMinuteInstruction(Boolean(generalDailyContext))
...history.map((message) => message.role === "user" : consultationMode === "declared_birth_window"
? declaredWindowInstruction()
: "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。事业/财富/婚恋/家庭按 skill Level 2 模板写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,然后才是现代生活措辞。不要复述内部 JSON 字段。";
const consultationBaseMessages = (overflow: boolean) => {
const tail = overflow ? lastConsultationPair(history) : history;
const mapped = tail.map((message) => message.role === "user"
? { role: "user" as const, content: message.text } ? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }), : { role: "assistant" as const, content: message.text });
{ const cachedTail = mapped.length > 0
role: "user" as const, ? [...mapped.slice(0, -1), cachedHistoryMessage(mapped[mapped.length - 1]!, selectedModel.model)]
content: [ : mapped;
currentTimeContext(requestTime), return [
name ? `用户称呼:${name}` : "", ...(cacheBoundary ? [cacheBoundary] : []),
consultationMode === "general_no_birth_time" || (consultationMode === "declared_birth_window" && generalDailyContext) ...cachedTail,
? generalNoMinuteInstruction(Boolean(generalDailyContext)) {
: consultationMode === "declared_birth_window" role: "user" as const,
? declaredWindowInstruction() content: consultationUserTurnContent({
: "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。事业/财富/婚恋/家庭按 skill Level 2 模板写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,然后才是现代生活措辞。不要复述内部 JSON 字段。", currentTime: currentTimeContext(requestTime),
generalDailyContextPrompt(generalDailyContext), name,
resolvedQuestion.modelQuestion, instruction: modeInstruction,
].filter(Boolean).join("\n"), extra: generalDailyContextPrompt(generalDailyContext),
}, summaryText: historyWindow.summaryText,
]; question: resolvedQuestion.modelQuestion,
}),
},
];
};
let baseMessages = consultationBaseMessages(false);
const agentAbortSignal = AbortSignal.timeout(AGENT_TIMEOUT_MS); const agentAbortSignal = AbortSignal.timeout(AGENT_TIMEOUT_MS);
const streamOptions = { const streamOptions = {
runId: requestId, runId: requestId,
@@ -803,6 +865,27 @@ export async function POST(request: Request) {
hooks, hooks,
...consultationGenerationSettings(selectedModel.model), ...consultationGenerationSettings(selectedModel.model),
}; };
async function streamWithOverflowRetry(agent: {
stream: (
messages: typeof baseMessages,
options: typeof streamOptions,
) => Promise<{ fullStream: AsyncIterable<unknown> | ReadableStream<unknown>; totalUsage: Promise<Usage> }>;
}) {
try {
const result = await agent.stream(baseMessages, streamOptions);
usages.push(result.totalUsage);
return result;
} catch (error) {
if (!isContextOverflowError(error)) throw error;
// Same request, same wait: shrink to summary + last pair. Consultation
// clients cannot parse rectification `attempt.reset`, so the retry stays
// server-side and never opens a second user-visible wait.
baseMessages = consultationBaseMessages(true);
const overflow = await agent.stream(baseMessages, streamOptions);
usages.push(overflow.totalUsage);
return overflow;
}
}
const workflowReceipt: WorkflowReceipt = usesPublicDailyGeneralAgent(consultationMode, generalDailyContext) const workflowReceipt: WorkflowReceipt = usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)
? { ? {
route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time", route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time",
@@ -822,8 +905,7 @@ export async function POST(request: Request) {
if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) { if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) {
state.workflowReceipt = workflowReceipt; state.workflowReceipt = workflowReceipt;
const agent = getGeneralJyotishAgent(selectedModel); const agent = getGeneralJyotishAgent(selectedModel);
const result = await agent.stream(baseMessages, streamOptions); const result = await streamWithOverflowRetry(agent);
usages.push(result.totalUsage);
// This mode has no calculation to require and no chart method to bind, // This mode has no calculation to require and no chart method to bind,
// so there is no contract for a retry to repair. // so there is no contract for a retry to repair.
const retryForAnswer = async () => { const retryForAnswer = async () => {
@@ -913,8 +995,7 @@ export async function POST(request: Request) {
state, state,
}); });
const agent = getWindowJyotishAgent(selectedModel, agentContext); const agent = getWindowJyotishAgent(selectedModel, agentContext);
const result = await agent.stream(baseMessages, streamOptions); const result = await streamWithOverflowRetry(agent);
usages.push(result.totalUsage);
const retry = async () => { const retry = async () => {
const retried = await agent.stream([ const retried = await agent.stream([
...baseMessages, ...baseMessages,
@@ -1013,8 +1094,7 @@ export async function POST(request: Request) {
state, state,
}); });
const agent = getJyotishAgent(selectedModel, agentContext); const agent = getJyotishAgent(selectedModel, agentContext);
const result = await agent.stream(baseMessages, streamOptions); const result = await streamWithOverflowRetry(agent);
usages.push(result.totalUsage);
const retry = async () => { const retry = async () => {
const retried = await agent.stream([ const retried = await agent.stream([
...baseMessages, ...baseMessages,
@@ -1138,20 +1218,21 @@ export async function POST(request: Request) {
return await runAgenticConsultation(consultationMode, history, name, generalDailyContext); return await runAgenticConsultation(consultationMode, history, name, generalDailyContext);
} }
if (!shouldRunBirthChartWorkflow(consultationMode)) { if (!shouldRunBirthChartWorkflow(consultationMode)) {
const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model);
const result = await getGeneralJyotishAgent(selectedModel).stream([ const result = await getGeneralJyotishAgent(selectedModel).stream([
...(cacheBoundary ? [cacheBoundary] : []), ...(cacheBoundary ? [cacheBoundary] : []),
{ {
role: "user", role: "user",
content: [ content: consultationUserTurnContent({
currentTimeContext(requestTime), currentTime: currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "", name,
generalNoMinuteInstruction(Boolean(generalDailyContext)), instruction: generalNoMinuteInstruction(Boolean(generalDailyContext)),
generalDailyContextPrompt(generalDailyContext), extra: generalDailyContextPrompt(generalDailyContext),
resolvedQuestion.modelQuestion, summaryText: historyWindow.summaryText,
].filter(Boolean).join("\n"), question: resolvedQuestion.modelQuestion,
}, }),
]); },
]);
const workflowReceipt: WorkflowReceipt = { const workflowReceipt: WorkflowReceipt = {
route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time", route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time",
status: "ready", status: "ready",
@@ -1216,21 +1297,51 @@ export async function POST(request: Request) {
const workflowReceipt = consultationWorkflowReceipt(workflowContext); const workflowReceipt = consultationWorkflowReceipt(workflowContext);
const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model);
const result = await getLegacyJyotishAgent(selectedModel, workflowContext).stream([ const legacyHistory = history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text });
const cachedLegacyHistory = legacyHistory.length > 0
? [...legacyHistory.slice(0, -1), cachedHistoryMessage(legacyHistory[legacyHistory.length - 1]!, selectedModel.model)]
: legacyHistory;
const legacyMessages = [
...(cacheBoundary ? [cacheBoundary] : []), ...(cacheBoundary ? [cacheBoundary] : []),
...history.map((message) => message.role === "user" ...cachedLegacyHistory,
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
{ {
role: "user", role: "user" as const,
content: [ content: consultationUserTurnContent({
currentTimeContext(requestTime), currentTime: currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "", name,
"先用 3–6 句口语直接回答下面的问题,不要加标题;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
resolvedQuestion.modelQuestion, summaryText: historyWindow.summaryText,
].filter(Boolean).join("\n"), question: resolvedQuestion.modelQuestion,
}),
}, },
]); ];
const legacyAgent = getLegacyJyotishAgent(selectedModel, workflowContext);
let result;
try {
result = await legacyAgent.stream(legacyMessages);
} catch (error) {
if (!isContextOverflowError(error)) throw error;
const overflowHistory = lastConsultationPair(legacyHistory);
const overflowCached = overflowHistory.length > 0
? [...overflowHistory.slice(0, -1), cachedHistoryMessage(overflowHistory[overflowHistory.length - 1]!, selectedModel.model)]
: overflowHistory;
result = await legacyAgent.stream([
...(cacheBoundary ? [cacheBoundary] : []),
...overflowCached,
{
role: "user" as const,
content: consultationUserTurnContent({
currentTime: currentTimeContext(requestTime),
name,
instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
summaryText: historyWindow.summaryText,
question: resolvedQuestion.modelQuestion,
}),
},
]);
}
const responseWorkflowReceipt = { const responseWorkflowReceipt = {
route: workflowReceipt.route, route: workflowReceipt.route,
status: workflowReceipt.status, status: workflowReceipt.status,
@@ -415,6 +415,7 @@ type Usage = {
outputTokens: number; outputTokens: number;
costMicrousd: number; costMicrousd: number;
durationMs: number | null; durationMs: number | null;
cacheReadTokens: number | null;
createdAt: string; createdAt: string;
}; };
@@ -452,6 +453,11 @@ export function UsageResource() {
render: (_, item) => render: (_, item) =>
`${item.inputTokens.toLocaleString()} / ${item.outputTokens.toLocaleString()}`, `${item.inputTokens.toLocaleString()} / ${item.outputTokens.toLocaleString()}`,
}, },
{
title: "缓存读 tokens",
dataIndex: "cacheReadTokens",
render: (value: number | null) => value == null ? "—" : value.toLocaleString(),
},
{ {
title: "成本", title: "成本",
dataIndex: "costMicrousd", dataIndex: "costMicrousd",
@@ -27,7 +27,19 @@ const BANDS: DistributionBand[] = [
type ModelRow = ModelPrice & { modelId: string; status: string; enabled: boolean }; type ModelRow = ModelPrice & { modelId: string; status: string; enabled: boolean };
type PricingRow = { featureKey: string; modelTier: string; creditCost: number; status: string; enabled: boolean }; type PricingRow = { featureKey: string; modelTier: string; creditCost: number; status: string; enabled: boolean };
type Product = { code: string; priceCents: number; enabled: boolean; status: string; entitlements: Array<{ featureKey: string; metadata?: { minuteLimit?: number | null; dayLimit?: number | null; billingLimit?: number | null } }> }; type Product = { code: string; priceCents: number; enabled: boolean; status: string; entitlements: Array<{ featureKey: string; metadata?: { minuteLimit?: number | null; dayLimit?: number | null; billingLimit?: number | null } }> };
type UsagePayload = { data: Array<{ featureKey: string; hasData: boolean; metrics: { costMicrousd: Metric; inputTokens: Metric; outputTokens: Metric; durationMs: Metric } }> }; type CacheModelRow = {
actualModelId: string | null;
runsWithCache: number;
hitRate: number | null;
readTokens: number;
writeTokens: number;
noCacheTokens: number;
cacheShare: number | null;
};
type UsagePayload = {
data: Array<{ featureKey: string; hasData: boolean; metrics: { costMicrousd: Metric; inputTokens: Metric; outputTokens: Metric; durationMs: Metric } }>;
cache?: { days7: CacheModelRow[]; days30: CacheModelRow[] };
};
type LoadState = { models?: ModelRow[]; pricing?: PricingRow[]; products?: Product[]; usage?: UsagePayload; errors: string[] }; type LoadState = { models?: ModelRow[]; pricing?: PricingRow[]; products?: Product[]; usage?: UsagePayload; errors: string[] };
@@ -48,6 +60,10 @@ function errorText(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback; return error instanceof Error ? error.message : fallback;
} }
function formatPercent(value: number | null) {
return value === null || !Number.isFinite(value) ? "—" : `${(value * 100).toFixed(1)}%`;
}
export function PricingSimulator() { export function PricingSimulator() {
const [state, setState] = useState<LoadState>({ errors: [] }); const [state, setState] = useState<LoadState>({ errors: [] });
const [usdToCny, setUsdToCny] = useState<number | null>(null); const [usdToCny, setUsdToCny] = useState<number | null>(null);
@@ -158,6 +174,36 @@ export function PricingSimulator() {
<Col xs={24} md={8}><Statistic title="画像下可承载会员数" value={capacity ?? "—"} /></Col> <Col xs={24} md={8}><Statistic title="画像下可承载会员数" value={capacity ?? "—"} /></Col>
</Row> </Row>
</Card> </Card>
<Card title="缓存命中">
<Typography.Paragraph type="secondary">
`usage_ledger.metadata.cache` = tokens &gt; 0
</Typography.Paragraph>
{([
["近 7 天", state.usage?.cache?.days7 ?? []],
["近 30 天", state.usage?.cache?.days30 ?? []],
] as const).map(([title, rows]) => (
<div key={title} style={{ marginBottom: 16 }}>
<Typography.Text strong>{title}</Typography.Text>
<Table<CacheModelRow>
size="small"
pagination={false}
rowKey={(row, index) => `${title}-${row.actualModelId ?? "none"}-${index}`}
dataSource={rows}
locale={{ emptyText: "暂无缓存数据" }}
columns={[
{ title: "模型", dataIndex: "actualModelId", render: (value) => value ?? "—" },
{ title: "运行数", dataIndex: "runsWithCache" },
{ title: "命中率", dataIndex: "hitRate", render: formatPercent },
{ title: "缓存占比", dataIndex: "cacheShare", render: formatPercent },
{ title: "读", dataIndex: "readTokens", render: (value: number) => value.toLocaleString() },
{ title: "写", dataIndex: "writeTokens", render: (value: number) => value.toLocaleString() },
{ title: "未命中", dataIndex: "noCacheTokens", render: (value: number) => value.toLocaleString() },
]}
/>
</div>
))}
</Card>
</Space> </Space>
); );
} }
@@ -73,6 +73,17 @@ export function cachedSystemMessage(content: string, model?: unknown) {
}; };
} }
export function cachedHistoryMessage<T extends { role: "user" | "assistant"; content: string }>(
message: T,
model?: unknown,
): T {
if (modelProviderId(model) !== "anthropic") return message;
return {
...message,
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" as const } } },
};
}
export function agentOutputTokenBudget( export function agentOutputTokenBudget(
thinking: ThinkingMode, thinking: ThinkingMode,
options: { answerTokens?: number; thinkingTokens?: number } = {}, options: { answerTokens?: number; thinkingTokens?: number } = {},
+8 -2
View File
@@ -157,8 +157,6 @@ export function consultationMethodologyForDomains(
}; };
push("Shared mandatory baseline", ROUTER_FILE, markdownSection(router, SHARED_BASELINE_HEADING)); push("Shared mandatory baseline", ROUTER_FILE, markdownSection(router, SHARED_BASELINE_HEADING));
push("Full-spectrum invocation", ROUTER_FILE, markdownSection(router, "Full-Spectrum Invocation Contract"));
push("Event judgment skeleton", SKELETON_FILE, packageFile(SKELETON_FILE));
const withoutChecklist: ConsultationDomain[] = []; const withoutChecklist: ConsultationDomain[] = [];
for (const domain of unique) { for (const domain of unique) {
@@ -189,3 +187,11 @@ export function consultationMethodologyForDomains(
planCache.set(key, methodology); planCache.set(key, methodology);
return methodology; return methodology;
} }
/** Cross-domain method that belongs in the cached system block, not the tool result. */
export function sharedConsultationMethodMarkdown(): string {
const router = packageFile(ROUTER_FILE);
const fullSpectrum = router ? markdownSection(router, "Full-Spectrum Invocation Contract") : null;
const skeleton = packageFile(SKELETON_FILE);
return [fullSpectrum, skeleton].filter((part): part is string => Boolean(part)).join("\n\n");
}
+215 -12
View File
@@ -1,27 +1,230 @@
export const CONSULTATION_HISTORY_LIMIT = 12; export const CONSULTATION_HISTORY_LIMIT = 12;
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 4_000; export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000;
export const CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000;
export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000;
export const CONSULTATION_HISTORY_CHAR_PER_TOKEN = 1.5;
export const CONSULTATION_HISTORY_BUDGET_MIN_CHARS = 4_000;
export const CONSULTATION_HISTORY_BUDGET_MAX_CHARS = 40_000;
export const DEFAULT_MODEL_CONTEXT_WINDOW = 128_000;
export const SESSION_CONTEXT_SUMMARY_HEADING = "【会话摘要(服务端维护)】";
export type ConsultationHistoryMessage = Readonly<{ export type ConsultationHistoryMessage = Readonly<{
role: "user" | "assistant"; role: "user" | "assistant";
text: string; text: string;
}>; }>;
export function consultationHistoryFromStoredMessages( export type SessionContextSummaryV1 = Readonly<{
version: 1;
text: string;
throughRequestId: string;
throughMessageIndex: number;
messageCount: number;
updatedAt: string;
}>;
export type ConsultationHistoryWindow = Readonly<{
tail: ConsultationHistoryMessage[];
summaryText: string | null;
droppedCount: number;
}>;
type StoredTurn = Readonly<{
index: number;
role: "user" | "assistant";
text: string;
requestId: string | null;
}>;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export function historyBudgetChars(contextWindow: number | null | undefined): number {
const window = typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
? contextWindow
: DEFAULT_MODEL_CONTEXT_WINDOW;
return clamp(
(window - CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS) * CONSULTATION_HISTORY_CHAR_PER_TOKEN,
CONSULTATION_HISTORY_BUDGET_MIN_CHARS,
CONSULTATION_HISTORY_BUDGET_MAX_CHARS,
);
}
export function parseSessionContextSummary(value: unknown): SessionContextSummaryV1 | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (row.version !== 1) return null;
if (typeof row.text !== "string" || !row.text.trim()) return null;
if (typeof row.throughRequestId !== "string") return null;
if (!Number.isInteger(row.throughMessageIndex) || (row.throughMessageIndex as number) < 0) return null;
if (!Number.isInteger(row.messageCount) || (row.messageCount as number) < 0) return null;
if (typeof row.updatedAt !== "string" || !row.updatedAt) return null;
return {
version: 1,
text: row.text,
throughRequestId: row.throughRequestId,
throughMessageIndex: row.throughMessageIndex as number,
messageCount: row.messageCount as number,
updatedAt: row.updatedAt,
};
}
export function omissionMarker(omittedChars: number): string {
return `……(以下省略 ${omittedChars} 字,结论已并入会话摘要)`;
}
export function clipConsultationHistoryText(text: string): string {
if (text.length <= CONSULTATION_HISTORY_MESSAGE_CHARS) return text;
const omitted = text.length - CONSULTATION_HISTORY_MESSAGE_CHARS;
return `${text.slice(0, CONSULTATION_HISTORY_MESSAGE_CHARS)}${omissionMarker(omitted)}`;
}
export function storedConsultationTurns(
messages: unknown, messages: unknown,
options: { excludeRequestId?: string } = {}, options: { excludeRequestId?: string } = {},
): ConsultationHistoryMessage[] { ): StoredTurn[] {
if (!Array.isArray(messages)) return []; if (!Array.isArray(messages)) return [];
const rows: ConsultationHistoryMessage[] = []; const rows: StoredTurn[] = [];
for (const message of messages) { messages.forEach((message, index) => {
if (!message || typeof message !== "object") continue; if (!message || typeof message !== "object") return;
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown }; const stored = message as { role?: unknown; text?: unknown; requestId?: unknown };
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) continue; if (options.excludeRequestId && stored.requestId === options.excludeRequestId) return;
if (stored.role !== "user" && stored.role !== "assistant") continue; if (stored.role !== "user" && stored.role !== "assistant") return;
if (typeof stored.text !== "string" || !stored.text) continue; if (typeof stored.text !== "string" || !stored.text) return;
rows.push({ rows.push({
index,
role: stored.role, role: stored.role,
text: stored.text.slice(0, CONSULTATION_HISTORY_MESSAGE_CHARS), text: stored.text,
requestId: typeof stored.requestId === "string" ? stored.requestId : null,
}); });
} });
return rows.slice(-CONSULTATION_HISTORY_LIMIT); return rows;
}
export function lastConsultationPair<T extends { role: "user" | "assistant" }>(
rows: readonly T[],
): T[] {
if (rows.length <= 2) return [...rows];
for (let index = rows.length - 1; index >= 1; index -= 1) {
if (rows[index]?.role === "assistant" && rows[index - 1]?.role === "user") {
return rows.slice(index - 1, index + 1);
}
}
return rows.slice(-2);
}
function turnsAfterSummary(
turns: readonly StoredTurn[],
summary: SessionContextSummaryV1 | null,
): StoredTurn[] {
if (!summary) return [...turns];
return turns.filter((turn) => turn.index > summary.throughMessageIndex);
}
export function consultationHistoryWindow(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: {
contextWindow?: number | null;
excludeRequestId?: string;
overflow?: boolean;
} = {},
): ConsultationHistoryWindow {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const afterSummary = turnsAfterSummary(turns, summary);
const selected = options.overflow ? lastConsultationPair(afterSummary) : afterSummary;
const clipped = selected.map((turn) => ({
role: turn.role,
text: clipConsultationHistoryText(turn.text),
}));
const budget = historyBudgetChars(options.contextWindow);
let droppedCount = 0;
let kept = clipped;
while (kept.length > 0 && kept.reduce((sum, message) => sum + message.text.length, 0) > budget) {
kept = kept.slice(1);
droppedCount += 1;
}
const summaryText = summary?.text.trim() || null;
return { tail: kept, summaryText, droppedCount };
}
export function consultationHistoryFromStoredMessages(
messages: unknown,
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
): ConsultationHistoryMessage[] {
return consultationHistoryWindow(messages, null, {
contextWindow: options.contextWindow,
excludeRequestId: options.excludeRequestId,
}).tail;
}
export function consultationUserTurnContent(input: {
currentTime: string;
name?: string;
instruction: string;
extra?: string;
summaryText?: string | null;
question: string;
}): string {
return [
input.currentTime,
input.name ? `用户称呼:${input.name}` : "",
input.instruction,
input.extra ?? "",
input.summaryText?.trim()
? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${input.summaryText.trim()}`
: "",
input.question,
].filter(Boolean).join("\n");
}
const CONTEXT_OVERFLOW_MARKERS = [
"context_length_exceeded",
"maximum context length",
"prompt is too long",
"input is too long",
"too many tokens",
] as const;
function errorHaystack(error: unknown): { hay: string; status: number | null } {
if (typeof error === "string") return { hay: error.toLowerCase(), status: null };
if (!error || typeof error !== "object") return { hay: String(error).toLowerCase(), status: null };
const row = error as Record<string, unknown>;
const status = typeof row.status === "number"
? row.status
: typeof row.statusCode === "number"
? row.statusCode
: null;
const parts = [
typeof row.message === "string" ? row.message : "",
typeof row.code === "string" ? row.code : "",
typeof row.type === "string" ? row.type : "",
error instanceof Error ? error.message : "",
error instanceof Error ? error.name : "",
];
const nested = row.data && typeof row.data === "object" ? row.data as Record<string, unknown> : null;
if (nested) {
if (typeof nested.message === "string") parts.push(nested.message);
if (typeof nested.code === "string") parts.push(nested.code);
}
const cause = "cause" in row ? row.cause : null;
if (cause && typeof cause === "object") {
const nestedCause = cause as Record<string, unknown>;
if (typeof nestedCause.message === "string") parts.push(nestedCause.message);
if (typeof nestedCause.code === "string") parts.push(nestedCause.code);
}
return { hay: parts.join(" ").toLowerCase(), status };
}
export function isContextOverflowError(error: unknown): boolean {
const { hay, status } = errorHaystack(error);
if (CONTEXT_OVERFLOW_MARKERS.some((marker) => hay.includes(marker))) return true;
if (hay.includes("max_tokens") && (hay.includes("context") || hay.includes("prompt") || hay.includes("input") || status === 400)) {
return true;
}
if (status === 400 && (hay.includes("context") || hay.includes("prompt") || hay.includes("token"))) {
return true;
}
return false;
} }
+4 -1
View File
@@ -14,6 +14,7 @@ type PublishedModelRow = {
model_id: string; version: number; label: string; description: string; provider_model: string; model_id: string; version: number; label: string; description: string; provider_model: string;
credit_cost: number; is_default: boolean; provider_code: string; provider_type: ModelProviderType; credit_cost: number; is_default: boolean; provider_code: string; provider_type: ModelProviderType;
base_url: string | null; encrypted_api_key: string | null; input_cost: string | number; output_cost: string | number; base_url: string | null; encrypted_api_key: string | null; input_cost: string | number; output_cost: string | number;
context_window: number | null;
}; };
type Cache = { expiresAt: number; catalog: LanguageModelCatalog }; type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
const state = globalThis as typeof globalThis & { jyotishaModelCatalogCache?: Cache }; const state = globalThis as typeof globalThis & { jyotishaModelCatalogCache?: Cache };
@@ -50,13 +51,15 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
id: row.model_id, label: row.label, description: row.description, creditCost: row.credit_cost, id: row.model_id, label: row.label, description: row.description, creditCost: row.credit_cost,
isDefault: row.is_default, mode: row.provider_type === "openai-compatible" ? "compatible" : row.provider_type, isDefault: row.is_default, mode: row.provider_type === "openai-compatible" ? "compatible" : row.provider_type,
model, configVersion: row.version, inputCostMicrousdPerMillion: Number(row.input_cost), outputCostMicrousdPerMillion: Number(row.output_cost), model, configVersion: row.version, inputCostMicrousdPerMillion: Number(row.input_cost), outputCostMicrousdPerMillion: Number(row.output_cost),
contextWindow: typeof row.context_window === "number" && Number.isFinite(row.context_window) ? row.context_window : null,
}; };
} }
async function queryCatalog(where: string, values: readonly unknown[] = []) { async function queryCatalog(where: string, values: readonly unknown[] = []) {
return queryAdminRows<PublishedModelRow>(` return queryAdminRows<PublishedModelRow>(`
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default, select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
p.code provider_code,p.provider_type,p.base_url,p.encrypted_api_key, p.code provider_code,p.provider_type,p.base_url,p.encrypted_api_key,
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost,
v.context_window
from public.model_config_versions v join public.model_configs c on c.id=v.config_id from public.model_config_versions v join public.model_configs c on c.id=v.config_id
join public.model_providers p on p.id=v.provider_id where ${where} join public.model_providers p on p.id=v.provider_id where ${where}
`, values); `, values);
+265
View File
@@ -0,0 +1,265 @@
import { Agent } from "@mastra/core/agent";
import {
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
lastConsultationPair,
parseSessionContextSummary,
storedConsultationTurns,
type SessionContextSummaryV1,
} from "@/lib/consultation-session-history";
import type { ResolvedLanguageModel } from "@/mastra/model";
export const SESSION_CONTEXT_SUMMARY_TIMEOUT_MS = 15_000;
export const SESSION_CONTEXT_SUMMARY_MAX_HAN = 800;
export const SESSION_CONTEXT_SUMMARY_MAX_TOKENS = 600;
export const SESSION_CONTEXT_SUMMARY_INSTRUCTIONS = `你在维护咨询会话的滚动摘要,只供下一轮模型使用。
只根据给定的问答文本写作,不要发明没出现过的事实。
输出不超过 800 个汉字,必须使用下面四个标题,每个标题下用短句:
已问过的问题
已给出的结论(含应期、置信度、blocked 项)
用户补充的事实
未决与待追问
不要写出生日期、出生时间、出生地、姓名、邮箱。`;
type DisposableAbort = Readonly<{
signal: AbortSignal;
dispose: () => void;
}>;
function composedAbortSignal(signal: AbortSignal | undefined, timeoutMs: number): DisposableAbort {
const controller = new AbortController();
// Must stay ref'd. The platform timeout helper uses an unref timer (BUG-523).
const timeoutId = globalThis.setTimeout(() => {
if (!controller.signal.aborted) {
controller.abort(new DOMException("session context summary timed out", "TimeoutError"));
}
}, timeoutMs);
const onExternalAbort = () => {
if (!controller.signal.aborted) {
controller.abort(signal?.reason ?? new DOMException("aborted", "AbortError"));
}
};
if (signal) {
if (signal.aborted) onExternalAbort();
else signal.addEventListener("abort", onExternalAbort);
}
return {
signal: controller.signal,
dispose: () => {
globalThis.clearTimeout(timeoutId);
signal?.removeEventListener("abort", onExternalAbort);
},
};
}
function whenAborted(signal: AbortSignal): { promise: Promise<never>; dispose: () => void } {
let onAbort: (() => void) | undefined;
const promise = new Promise<never>((_, reject) => {
const fail = () => {
reject(signal.reason ?? new Error("aborted"));
};
if (signal.aborted) {
fail();
return;
}
onAbort = fail;
signal.addEventListener("abort", fail, { once: true });
});
return {
promise,
dispose: () => {
if (onAbort) signal.removeEventListener("abort", onAbort);
},
};
}
function clipHan(value: string, maxChars: number): string {
const characters = Array.from(value);
return characters.length > maxChars ? characters.slice(0, maxChars).join("") : value;
}
const ISO_DATE = /\d{4}-\d{2}-\d{2}/g;
const CLOCK_TIME = /\b\d{1,2}:\d{2}(?::\d{2})?\b/g;
const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
export function sanitizeSessionContextSummary(raw: string): string | null {
const stripped = raw
.replace(EMAIL, "")
.replace(ISO_DATE, "")
.replace(CLOCK_TIME, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!stripped) return null;
return clipHan(stripped, SESSION_CONTEXT_SUMMARY_MAX_HAN);
}
export function tailCharCount(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
): number {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const tail = summary
? turns.filter((turn) => turn.index > summary.throughMessageIndex)
: turns;
return tail.reduce((sum, turn) => sum + turn.text.length, 0);
}
export function shouldCheckpoint(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
): boolean {
return tailCharCount(messages, summary, options) > CONSULTATION_HISTORY_TAIL_MAX_CHARS;
}
export function messagesForSummaryInput(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
): Array<{ role: "user" | "assistant"; text: string; index: number; requestId: string | null }> {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const tail = summary
? turns.filter((turn) => turn.index > summary.throughMessageIndex)
: turns;
const lastPair = lastConsultationPair(tail);
const pairStart = lastPair[0];
if (!pairStart) return [];
return tail.filter((turn) => turn.index < pairStart.index);
}
export function buildSummaryPrompt(
previous: SessionContextSummaryV1 | null,
messages: unknown,
options: { excludeRequestId?: string } = {},
): string {
const input = messagesForSummaryInput(messages, previous, options);
const lines = input.map((turn) => `${turn.role === "user" ? "用户" : "助手"}${turn.text}`);
return [
previous?.text.trim() ? `上一份摘要:\n${previous.text.trim()}` : "上一份摘要:无",
"需要并入摘要的问答(不含最后一对):",
lines.join("\n") || "(无)",
].join("\n\n");
}
export async function generateSessionContextSummaryText(
model: ResolvedLanguageModel,
prompt: string,
signal?: AbortSignal,
): Promise<string> {
const agent = new Agent({
id: `session-context-summary-${model.id}`,
name: "Session Context Summary",
model: model.model,
instructions: SESSION_CONTEXT_SUMMARY_INSTRUCTIONS,
});
const result = await agent.generate([{ role: "user", content: prompt }], {
abortSignal: signal,
modelSettings: { maxOutputTokens: SESSION_CONTEXT_SUMMARY_MAX_TOKENS },
});
return typeof result.text === "string" ? result.text : "";
}
export async function generateSessionContextSummary(input: {
model?: ResolvedLanguageModel | null;
previous: SessionContextSummaryV1 | null;
messages: unknown;
excludeRequestId?: string;
signal?: AbortSignal;
timeoutMs?: number;
generateText?: (prompt: string, signal?: AbortSignal) => Promise<string>;
}): Promise<string | null> {
const prompt = buildSummaryPrompt(input.previous, input.messages, {
excludeRequestId: input.excludeRequestId,
});
const generate = input.generateText ?? (input.model
? (nextPrompt: string, signal?: AbortSignal) => generateSessionContextSummaryText(
input.model as ResolvedLanguageModel,
nextPrompt,
signal,
)
: null);
if (!generate) return null;
const composed = composedAbortSignal(input.signal, input.timeoutMs ?? SESSION_CONTEXT_SUMMARY_TIMEOUT_MS);
const aborted = whenAborted(composed.signal);
try {
const raw = await Promise.race([generate(prompt, composed.signal), aborted.promise]);
return sanitizeSessionContextSummary(raw);
} catch {
return null;
} finally {
aborted.dispose();
composed.dispose();
}
}
export function nextSessionContextSummary(
messages: unknown,
previous: SessionContextSummaryV1 | null,
text: string,
updatedAt: string,
options: { excludeRequestId?: string } = {},
): SessionContextSummaryV1 | null {
const covered = messagesForSummaryInput(messages, previous, options);
const last = covered.at(-1);
if (!last) return null;
return {
version: 1,
text,
throughRequestId: last.requestId ?? previous?.throughRequestId ?? "",
throughMessageIndex: last.index,
messageCount: last.index + 1,
updatedAt,
};
}
export async function writeSessionContextSummary(input: {
seenUpdatedAt: string | null;
summary: SessionContextSummaryV1;
update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise<boolean>;
}): Promise<"written" | "abandoned"> {
const written = await input.update(input.summary, input.seenUpdatedAt);
return written ? "written" : "abandoned";
}
export async function checkpointSessionContextSummary(input: {
messages: unknown;
summary: unknown;
excludeRequestId?: string;
now?: () => Date;
generateText: (prompt: string, signal?: AbortSignal) => Promise<string>;
update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise<boolean>;
timeoutMs?: number;
}): Promise<"written" | "skipped" | "abandoned" | "failed"> {
const previous = parseSessionContextSummary(input.summary);
if (!shouldCheckpoint(input.messages, previous, { excludeRequestId: input.excludeRequestId })) {
return "skipped";
}
try {
const text = await generateSessionContextSummary({
previous,
messages: input.messages,
excludeRequestId: input.excludeRequestId,
generateText: input.generateText,
timeoutMs: input.timeoutMs,
});
if (!text) return "failed";
const next = nextSessionContextSummary(
input.messages,
previous,
text,
(input.now ?? (() => new Date()))().toISOString(),
{ excludeRequestId: input.excludeRequestId },
);
if (!next) return "skipped";
return writeSessionContextSummary({
seenUpdatedAt: previous?.updatedAt ?? null,
summary: next,
update: input.update,
});
} catch {
return "failed";
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ ${jyotishSkillMethodBlock}
The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat. The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat.
For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context. For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context.
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. At most ${MAX_CONSULTATION_DOMAINS} domains may be requested in one run, because they are calculated one after another inside a fixed time budget: list every domain the question actually needs, in priority order. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum, and omitting a domain omits that route's checklist from the answer. The server canonicalizes aliases, rejects unsupported/product domains, executes each accepted domain, and returns the actual domains in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters. Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. At most ${MAX_CONSULTATION_DOMAINS} domains may be requested in one run, because they are calculated one after another inside a fixed time budget: list every domain the question actually needs, in priority order. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum, and omitting a domain omits that route's checklist from the answer. The server canonicalizes aliases, rejects unsupported/product domains, executes each accepted domain, and returns the actual domains in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
The tool result's methodology field is the skill's own strict checklist for the routes that actually ran, quoted from the live skill. Treat it as the method for this answer, not as background: work through its mandatory modules against the evidence you were given, and obey its output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the delivered Full-spectrum invocation and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above. The tool result's methodology field is the domain checklist for the routes that actually ran, quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract. The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract.
When omitted_domains is non-empty, do not answer those domains and never present the reply as covering the whole plan. Stay with what was calculated. Do not announce a skipped-domain inventory or say this round was incomplete unless the user asked about coverage. When omitted_domains is non-empty, do not answer those domains and never present the reply as covering the whole plan. Stay with what was calculated. Do not announce a skipped-domain inventory or say this round was incomplete unless the user asked about coverage.
Activity, progress, tool status, and execution receipts are server-owned. Never imitate data-jyotish-activity, activity events, tool-started/tool-completed messages, or receipts in the answer text. Activity, progress, tool status, and execution receipts are server-owned. Never imitate data-jyotish-activity, activity events, tool-started/tool-completed messages, or receipts in the answer text.
+1
View File
@@ -16,6 +16,7 @@ export type ResolvedLanguageModel = PublicLanguageModel & {
readonly configVersion?: number; readonly configVersion?: number;
readonly inputCostMicrousdPerMillion?: number; readonly inputCostMicrousdPerMillion?: number;
readonly outputCostMicrousdPerMillion?: number; readonly outputCostMicrousdPerMillion?: number;
readonly contextWindow?: number | null;
}; };
export type LanguageModelCatalog = { export type LanguageModelCatalog = {
+5 -1
View File
@@ -5,6 +5,7 @@ import {
resolveLiveJyotishSkill, resolveLiveJyotishSkill,
resolveLiveJyotishSkillRuntimePath, resolveLiveJyotishSkillRuntimePath,
} from "../lib/skill-package-registry.ts"; } from "../lib/skill-package-registry.ts";
import { sharedConsultationMethodMarkdown } from "../lib/consultation-methodology.ts";
const skill = resolveLiveJyotishSkill(); const skill = resolveLiveJyotishSkill();
@@ -113,7 +114,10 @@ const BOUND_METHOD_MARKER = `<jyotish-skill name="${skill.name}">`;
export const jyotishSkillMethodBlock = `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block. export const jyotishSkillMethodBlock = `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block.
<jyotish-skill name="${skill.name}"> <jyotish-skill name="${skill.name}">
${boundMethod()} ${boundMethod()}
</jyotish-skill>`; </jyotish-skill>
<jyotish-shared-method>
${sharedConsultationMethodMarkdown()}
</jyotish-shared-method>`;
/** /**
* Withdraw the activation tools while keeping `skill_read`. * Withdraw the activation tools while keeping `skill_read`.
@@ -0,0 +1,10 @@
begin;
alter table public.chat_sessions
add column if not exists context_summary jsonb null
check (context_summary is null or jsonb_typeof(context_summary) = 'object');
grant update (context_summary)
on table public.chat_sessions to authenticated;
commit;
@@ -4,6 +4,9 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
const route = readFileSync(resolve("src/app/api/admin/usage/aggregate/route.ts"), "utf8"); const route = readFileSync(resolve("src/app/api/admin/usage/aggregate/route.ts"), "utf8");
const listRoute = readFileSync(resolve("src/app/api/admin/usage/route.ts"), "utf8");
const simulator = readFileSync(resolve("src/components/admin/pricing-simulator.tsx"), "utf8");
const usageResource = readFileSync(resolve("src/components/admin/billing-operations-resources.tsx"), "utf8");
test("admin usage aggregate is a read-only 30-day billing report", () => { test("admin usage aggregate is a read-only 30-day billing report", () => {
assert.match(route, /requirePermission\("billing\.orders\.read"\)/); assert.match(route, /requirePermission\("billing\.orders\.read"\)/);
@@ -14,3 +17,24 @@ test("admin usage aggregate is a read-only 30-day billing report", () => {
assert.match(route, /report\.full/); assert.match(route, /report\.full/);
assert.doesNotMatch(route, /export async function POST/); assert.doesNotMatch(route, /export async function POST/);
}); });
test("admin usage aggregate exposes cache hit rate from metadata without mixing windows", () => {
assert.match(route, /metadata \? 'cache'/);
assert.match(route, /readTokens/);
assert.match(route, /writeTokens/);
assert.match(route, /noCacheTokens/);
assert.match(route, /days7/);
assert.match(route, /days30/);
assert.match(route, /hitRate/);
assert.match(route, /cacheShare/);
assert.match(route, /make_interval\(days => w\.days\)/);
assert.doesNotMatch(route, /create index|alter table public\.usage_ledger/);
});
test("admin usage list returns cache read tokens from ledger metadata", () => {
assert.match(listRoute, /requirePermission\("billing\.orders\.read"\)/);
assert.match(listRoute, /metadata->'cache'->>'readTokens'/);
assert.match(listRoute, /cacheReadTokens/);
assert.match(usageResource, /缓存读 tokens/);
assert.match(simulator, /缓存命中/);
});
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
cachedHistoryMessage,
cachedSystemMessage, cachedSystemMessage,
mergePromptCacheUsage, mergePromptCacheUsage,
promptCacheUsage, promptCacheUsage,
@@ -43,3 +44,14 @@ test("adds an Anthropic cache boundary and safely degrades for other providers",
assert.equal(cachedSystemMessage("stable", "openai/gpt-5"), null); assert.equal(cachedSystemMessage("stable", "openai/gpt-5"), null);
assert.equal(cachedSystemMessage("stable", { providerId: "deepseek" }), null); assert.equal(cachedSystemMessage("stable", { providerId: "deepseek" }), null);
}); });
test("marks only the last Anthropic history message for cache", () => {
const last = { role: "assistant" as const, content: "先前结论" };
assert.deepEqual(cachedHistoryMessage(last, "anthropic/claude-sonnet"), {
role: "assistant",
content: "先前结论",
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
});
assert.equal(cachedHistoryMessage(last, "openai/gpt-5"), last);
assert.equal(cachedHistoryMessage(last, { providerId: "deepseek" }), last);
});
@@ -73,7 +73,7 @@ test("standard consultation resolves and settles the session-pinned model versio
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/); assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
// Former value: select("id,model_id,model_config_version,session_type,messages"). // Former value: select("id,model_id,model_config_version,session_type,messages").
// First-round session titles need the current title, theme, and chart role. // First-round session titles need the current title, theme, and chart role.
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role"\)/); assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role,context_summary"\)/);
assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/); assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
assert.match(consultRoute, /actualModelId: selectedModel\.id/); assert.match(consultRoute, /actualModelId: selectedModel\.id/);
assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/); assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/);
@@ -86,7 +86,13 @@ test("standard consultation awaits real usage before durable response settlement
assert.match(consultRoute, /const resolved = await usage;/); assert.match(consultRoute, /const resolved = await usage;/);
assert.match(consultRoute, /const actualUsage = await usagePayload\(usage\);[\s\S]*p_actual_usage: actualUsage/); assert.match(consultRoute, /const actualUsage = await usagePayload\(usage\);[\s\S]*p_actual_usage: actualUsage/);
assert.match(consultRoute, /function mergeUsage\(usages: Promise<Usage>\[\]\): Promise<Usage> \{[\s\S]*Promise\.all\(usages\)/); assert.match(consultRoute, /function mergeUsage\(usages: Promise<Usage>\[\]\): Promise<Usage> \{[\s\S]*Promise\.all\(usages\)/);
assert.equal(consultRoute.match(/usages\.push\(result\.totalUsage\)/g)?.length, 3); // Former: three agentic first streams each had `usages.push(result.totalUsage)`.
// New: the three first streams share streamWithOverflowRetry, which pushes the
// successful first attempt (and the overflow retry under a different name).
// Reason: context overflow must retry once on the same request.
assert.equal(consultRoute.match(/usages\.push\(result\.totalUsage\)/g)?.length, 1);
assert.match(consultRoute, /streamWithOverflowRetry/);
assert.match(consultRoute, /isContextOverflowError/);
// Three agentic first streams: public/general, declared-window, and natal. // Three agentic first streams: public/general, declared-window, and natal.
// Window and natal each have a contract retry plus an answer retry; general // Window and natal each have a contract retry plus an answer retry; general
// has only an answer retry. Every one of those model calls spends tokens. // has only an answer retry. Every one of those model calls spends tokens.
@@ -33,7 +33,7 @@ test("session list GET omits messages while detail GET returns them", () => {
test("consult reads stored history and never applies the client history field", () => { test("consult reads stored history and never applies the client history field", () => {
assert.match(consultRoute, /history: z\s*\.array\([\s\S]*?\)\s*\.max\(20\)\s*\.optional\(\)\s*\.default\(\[\]\)/); assert.match(consultRoute, /history: z\s*\.array\([\s\S]*?\)\s*\.max\(20\)\s*\.optional\(\)\s*\.default\(\[\]\)/);
assert.match(consultRoute, /const storedHistory = consultationHistoryFromStoredMessages\(chatSession\.messages\)/); assert.match(consultRoute, /const storedHistory = historyWindow\.tail/);
assert.match(consultRoute, /const history = storedHistory/); assert.match(consultRoute, /const history = storedHistory/);
assert.doesNotMatch(consultRoute, /parsed\.data\.history/); assert.doesNotMatch(consultRoute, /parsed\.data\.history/);
assert.match(sendSource, /history: currentSession\.messages\.slice\(-12\)/); assert.match(sendSource, /history: currentSession\.messages\.slice\(-12\)/);
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const history = readFileSync(new URL("../src/lib/consultation-session-history.ts", import.meta.url), "utf8");
const migration = readFileSync(
new URL("../supabase/migrations/20260906010000_chat_session_context_summary.sql", import.meta.url),
"utf8",
);
const generation = readFileSync(new URL("../src/lib/agent-generation-settings.ts", import.meta.url), "utf8");
test("consult history uses the checkpoint tail and puts the summary after the time line", () => {
assert.match(consultRoute, /consultationHistoryWindow\(chatSession\.messages, contextSummary/);
assert.match(consultRoute, /SESSION_CONTEXT_SUMMARY_HEADING|consultationUserTurnContent/);
assert.match(consultRoute, /summaryText: historyWindow\.summaryText/);
assert.match(history, /【会话摘要(服务端维护)】/);
const helper = history.slice(history.indexOf("export function consultationUserTurnContent"));
const timeLine = helper.indexOf("input.currentTime");
const summaryLine = helper.indexOf("SESSION_CONTEXT_SUMMARY_HEADING");
const questionLine = helper.indexOf("input.question");
assert.ok(timeLine >= 0 && summaryLine > timeLine && questionLine > summaryLine);
});
test("consult retries once on context overflow with summary plus the last pair", () => {
assert.match(consultRoute, /isContextOverflowError/);
assert.match(consultRoute, /streamWithOverflowRetry/);
assert.match(consultRoute, /consultationBaseMessages\(true\)/);
assert.match(consultRoute, /lastConsultationPair\(history\)/);
assert.match(history, /context_length_exceeded/);
});
test("consult checkpoints the session summary after a successful completion", () => {
assert.match(consultRoute, /void checkpointConsultationContext\(\)/);
assert.match(consultRoute, /checkpointSessionContextSummary/);
assert.match(consultRoute, /context_summary ->>updatedAt|context_summary->>updatedAt/);
assert.match(consultRoute, /console\.warn\("session context summary failed"/);
assert.doesNotMatch(consultRoute, /AbortSignal\.timeout\(\s*15/);
});
test("the context summary migration only adds one jsonb column", () => {
assert.match(migration, /add column if not exists context_summary jsonb null/);
assert.match(migration, /jsonb_typeof\(context_summary\) = 'object'/);
assert.match(migration, /grant update \(context_summary\)/);
assert.doesNotMatch(migration, /create index|backfill|update public\.chat_sessions set/);
});
test("Anthropic history cache is applied only to the last history message", () => {
assert.match(consultRoute, /cachedHistoryMessage/);
assert.match(generation, /export function cachedHistoryMessage/);
const helper = consultRoute.slice(consultRoute.indexOf("const consultationBaseMessages"));
assert.match(helper, /mapped\.length > 0/);
assert.match(helper, /cachedHistoryMessage\(mapped\[mapped\.length - 1\]/);
});
test("the published model catalog reads context_window", () => {
const catalog = readFileSync(new URL("../src/lib/model-catalog.ts", import.meta.url), "utf8");
assert.match(catalog, /v\.context_window/);
assert.match(catalog, /contextWindow:/);
});
@@ -22,10 +22,17 @@ test("the route's own strict checklist reaches the answer, not just the package
methodology.sections.some((section) => section.title.includes("Shared mandatory baseline")), methodology.sections.some((section) => section.title.includes("Shared mandatory baseline")),
"every route is read against the shared baseline", "every route is read against the shared baseline",
); );
assert.ok( // Former: Full-spectrum invocation and Event judgment skeleton were tool-result sections.
methodology.sections.some((section) => /Full-Spectrum Invocation Contract/.test(section.text)), // New: those two live in the cached system block; sections keep the domain checklists.
"the skill's full-spectrum contract must reach the web model", // Reason: they were resent with every tool result outside any cache prefix.
assert.equal(
methodology.sections.filter((section) => (
section.title.includes("Full-spectrum") || section.title.includes("Event judgment")
)).length,
0,
); );
const domainSections = methodology.sections.filter((section) => !section.title.includes("Shared mandatory baseline"));
assert.equal(domainSections.length, 2);
assert.deepEqual(methodology.domains_without_strict_checklist, []); assert.deepEqual(methodology.domains_without_strict_checklist, []);
}); });
@@ -41,9 +48,11 @@ test("a route the skill declares no checklist for is reported, not filled in wit
methodology.sections.some((section) => section.title.includes("Shared mandatory baseline")), methodology.sections.some((section) => section.title.includes("Shared mandatory baseline")),
"the baseline still applies", "the baseline still applies",
); );
assert.ok( // Former: full-spectrum still arrived as a methodology section for domains with no checklist.
methodology.sections.some((section) => section.title.includes("Full-spectrum")), // New: full-spectrum is bound in the system block; this plan only reports the missing checklist.
"full-spectrum invocation still applies when a domain has no named checklist", assert.equal(
methodology.sections.filter((section) => section.title.includes("Full-spectrum")).length,
0,
); );
}); });
@@ -1,24 +1,80 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { consultationHistoryFromStoredMessages } from "../src/lib/consultation-session-history.ts"; import {
CONSULTATION_HISTORY_MESSAGE_CHARS,
clipConsultationHistoryText,
consultationHistoryFromStoredMessages,
consultationHistoryWindow,
consultationUserTurnContent,
historyBudgetChars,
isContextOverflowError,
omissionMarker,
SESSION_CONTEXT_SUMMARY_HEADING,
} from "../src/lib/consultation-session-history.ts";
test("stored consultation history keeps the last 12 role/text pairs and clips text", () => { function numberedMessages(count: number, text: (index: number) => string) {
const history = consultationHistoryFromStoredMessages([ return Array.from({ length: count }, (_, index) => ({
{ role: "system", text: "ignore" }, role: index % 2 === 0 ? "user" : "assistant",
{ role: "user", text: "first" }, text: text(index),
...Array.from({ length: 12 }, (_, index) => ({ requestId: `req-${index}`,
role: index % 2 === 0 ? "assistant" : "user", }));
text: `keep-${index}`, }
requestId: `req-${index}`,
})), test("20 unspecialized messages at 128k drop whole oldest turns to fit the budget", () => {
{ role: "assistant", text: "x".repeat(4001), thinkingText: "secret" }, const body = "x".repeat(3_000);
const history = consultationHistoryWindow(numberedMessages(20, () => body), null, {
contextWindow: 128_000,
});
const budget = historyBudgetChars(128_000);
assert.equal(budget, 40_000);
assert.ok(history.droppedCount > 0);
assert.equal(history.tail.length, 20 - history.droppedCount);
assert.equal(history.tail[0]?.text, body);
assert.ok(history.tail.reduce((sum, message) => sum + message.text.length, 0) <= budget);
assert.equal(history.summaryText, null);
});
test("a summary throughMessageIndex keeps only later turns in the tail", () => {
const messages = numberedMessages(20, (index) => `keep-${index}`);
const history = consultationHistoryWindow(messages, {
version: 1,
text: "先前结论",
throughRequestId: "req-14",
throughMessageIndex: 14,
messageCount: 15,
updatedAt: "2026-09-06T00:00:00.000Z",
}, { contextWindow: 128_000 });
assert.deepEqual(history.tail.map((message) => message.text), [
"keep-15",
"keep-16",
"keep-17",
"keep-18",
"keep-19",
]); ]);
assert.equal(history.summaryText, "先前结论");
assert.equal(history.droppedCount, 0);
});
assert.equal(history.length, 12); test("a 20_000 character message is clipped to 12_000 with an omission count", () => {
assert.equal(history[0]?.text, "keep-1"); const text = "y".repeat(20_000);
assert.equal(history.at(-1)?.text.length, 4000); const clipped = clipConsultationHistoryText(text);
assert.deepEqual(history.at(-2), { role: "user", text: "keep-11" }); assert.equal(clipped.startsWith("y".repeat(CONSULTATION_HISTORY_MESSAGE_CHARS)), true);
assert.equal(clipped.includes(omissionMarker(8_000)), true);
assert.equal(clipped.length, CONSULTATION_HISTORY_MESSAGE_CHARS + omissionMarker(8_000).length);
const history = consultationHistoryWindow([
{ role: "assistant", text },
], null, { contextWindow: 128_000 });
assert.equal(history.tail[0]?.text, clipped);
});
test("historyBudgetChars uses 64k, 32k, and null-as-128k windows", () => {
assert.equal(historyBudgetChars(64_000), 6_000);
assert.equal(historyBudgetChars(32_000), 4_000);
assert.equal(historyBudgetChars(null), historyBudgetChars(128_000));
assert.equal(historyBudgetChars(undefined), 40_000);
}); });
test("stored consultation history can skip the in-flight request id", () => { test("stored consultation history can skip the in-flight request id", () => {
@@ -29,3 +85,28 @@ test("stored consultation history can skip the in-flight request id", () => {
assert.deepEqual(history, [{ role: "user", text: "old" }]); assert.deepEqual(history, [{ role: "user", text: "old" }]);
}); });
test("the summary block sits after the time line and before the question", () => {
const content = consultationUserTurnContent({
currentTime: "当前时间:2026-09-06 14:00(中国)",
name: "测",
instruction: "先加载 Jyotish Skill",
summaryText: "上次应期在 2027 年",
question: "你前面说的应期是哪年",
});
const timeAt = content.indexOf("当前时间:");
const summaryAt = content.indexOf(SESSION_CONTEXT_SUMMARY_HEADING);
const questionAt = content.indexOf("你前面说的应期是哪年");
assert.ok(timeAt >= 0 && summaryAt > timeAt && questionAt > summaryAt);
});
test("context overflow detection covers provider codes and related 400s", () => {
assert.equal(isContextOverflowError({ code: "context_length_exceeded" }), true);
assert.equal(isContextOverflowError(new Error("maximum context length exceeded")), true);
assert.equal(isContextOverflowError({ message: "prompt is too long" }), true);
assert.equal(isContextOverflowError({ message: "input is too long" }), true);
assert.equal(isContextOverflowError({ message: "too many tokens in the prompt" }), true);
assert.equal(isContextOverflowError({ status: 400, message: "max_tokens for this context" }), true);
assert.equal(isContextOverflowError({ status: 400, message: "invalid json" }), false);
assert.equal(isContextOverflowError(new Error("rate limit")), false);
});
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
} from "../src/lib/consultation-session-history.ts";
import {
buildSummaryPrompt,
checkpointSessionContextSummary,
generateSessionContextSummary,
messagesForSummaryInput,
sanitizeSessionContextSummary,
shouldCheckpoint,
writeSessionContextSummary,
} from "../src/lib/session-context-summary.ts";
function overBudgetConversation() {
return [
{ role: "user" as const, text: `${"x".repeat(8_000)}`, requestId: "u1" },
{ role: "assistant" as const, text: `${"y".repeat(8_000)}`, requestId: "a1" },
{ role: "user" as const, text: "追问应期", requestId: "u2" },
{ role: "assistant" as const, text: "2027 年", requestId: "a2" },
];
}
test("checkpoint triggers only when the tail exceeds 16_000 characters", () => {
assert.equal(CONSULTATION_HISTORY_TAIL_MAX_CHARS, 16_000);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(15_999) }], null), false);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(16_001) }], null), true);
});
test("checkpoint prompt omits the last question-answer pair", () => {
const messages = overBudgetConversation();
const input = messagesForSummaryInput(messages, null);
assert.equal(input.length, 2);
assert.equal(input[0]?.requestId, "u1");
assert.equal(input[1]?.requestId, "a1");
assert.equal(input.some((turn) => turn.text === "追问应期"), false);
assert.equal(input.some((turn) => turn.text === "2027 年"), false);
const prompt = buildSummaryPrompt(null, messages);
assert.equal(prompt.includes("追问应期"), false);
assert.equal(prompt.includes("2027 年"), false);
assert.equal(prompt.includes("上一份摘要:无"), true);
});
test("summary sanitizer strips ISO dates, clock times, and emails", () => {
const cleaned = sanitizeSessionContextSummary(
"已问过的问题\n事业时机,邮箱 user@example.com,日期 1990-01-01 08:30\n已给出的结论\nblocked 项无",
);
assert.ok(cleaned);
assert.equal(cleaned.includes("1990-01-01"), false);
assert.equal(cleaned.includes("08:30"), false);
assert.equal(cleaned.includes("user@example.com"), false);
assert.equal(cleaned.includes("事业时机"), true);
});
test("generateSessionContextSummary times out to null and keeps the timer ref'd", async () => {
const source = readFileSync(new URL("../src/lib/session-context-summary.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /AbortSignal\.timeout\(/);
assert.doesNotMatch(source, /\.unref\(/);
assert.match(source, /clearTimeout/);
const pending = new Set<unknown>();
let created = 0;
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
globalThis.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
created += 1;
const id = realSetTimeout(handler, delay, ...args);
pending.add(id);
return id;
}) as typeof setTimeout;
globalThis.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
pending.delete(id);
realClearTimeout(id);
}) as typeof clearTimeout;
try {
const text = await generateSessionContextSummary({
previous: null,
messages: overBudgetConversation(),
timeoutMs: 20,
generateText: () => new Promise(() => {}),
});
assert.equal(text, null);
assert.ok(created >= 1);
assert.equal(pending.size, 0);
} finally {
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
}
});
test("writeSessionContextSummary abandons when updatedAt does not match", async () => {
const result = await writeSessionContextSummary({
seenUpdatedAt: "2026-09-06T00:00:00.000Z",
summary: {
version: 1,
text: "新摘要",
throughRequestId: "a1",
throughMessageIndex: 1,
messageCount: 2,
updatedAt: "2026-09-06T01:00:00.000Z",
},
update: async () => false,
});
assert.equal(result, "abandoned");
});
test("checkpoint writes a new summary when the tail is over budget", async () => {
const result = await checkpointSessionContextSummary({
messages: overBudgetConversation(),
summary: null,
generateText: async () => "已问过的问题\n事业\n已给出的结论\n应期 blocked\n用户补充的事实\n无\n未决与待追问\n继续",
update: async () => true,
now: () => new Date("2026-09-06T02:00:00.000Z"),
});
assert.equal(result, "written");
});
+15
View File
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import test from "node:test"; import test from "node:test";
import { Agent } from "@mastra/core/agent"; import { Agent } from "@mastra/core/agent";
import { sharedConsultationMethodMarkdown } from "../src/lib/consultation-methodology.ts";
import { import {
jyotishSkillBinding, jyotishSkillBinding,
jyotishSkillBoundProcessor, jyotishSkillBoundProcessor,
@@ -155,3 +156,17 @@ test("only chart-answering agents bind the method", () => {
assert.doesNotMatch(source, /jyotishSkillBinding|jyotishSkillMethodBlock/, name); assert.doesNotMatch(source, /jyotishSkillBinding|jyotishSkillMethodBlock/, name);
} }
}); });
test("the system block binds the shared method sections verbatim from the package", () => {
const shared = sharedConsultationMethodMarkdown();
const opened = jyotishSkillMethodBlock.indexOf("<jyotish-shared-method>");
const closed = jyotishSkillMethodBlock.indexOf("</jyotish-shared-method>");
assert.ok(opened >= 0 && closed > opened);
const boundShared = jyotishSkillMethodBlock.slice(
opened + "<jyotish-shared-method>".length,
closed,
).trim();
assert.equal(boundShared, shared.trim());
assert.match(shared, /Full-Spectrum Invocation Contract/);
assert.ok(shared.includes(readFileSync(resolve(jyotishSkillPackage.resolvedPath, "references/event_judgment_skeleton.md"), "utf8").trim()));
});