Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6512f4de76 | ||
|
|
d7dfa2cd70 | ||
|
|
ff89cb98e1 | ||
|
|
b337e2aa06 | ||
|
|
3f512d62d7 | ||
|
|
b142e31b1e | ||
|
|
241c83fb80 |
@@ -24680,3 +24680,17 @@
|
||||
- 防复发:上述测试;后续单把 purpose_assessment 接进校时结果对象时,必须保留「不许改出生记录」「代表时刻不是生辰」两条恒为假。
|
||||
- 关联记录:占星师 2026-11-09 用途决策合同、docs/tasks/assets/purpose-gate-20261109/(README / PLAN / reference_gate / examples)、#184(校时可比性)
|
||||
- 修复版本:分支 codex/purpose-gate-contract-20261109
|
||||
|
||||
## BUG-1815 | 追问每轮都重算:上一轮的卡不在历史里,省下的计算时间省不下 token
|
||||
|
||||
- 状态:fixed-pending-verify(分支 codex/token-followup-20261109;探针与两组 route 层测试待补)
|
||||
- 首次发现 / 最近更新:2026-11-07(占星师 Q3)/ 2026-11-09(实现)
|
||||
- 影响面:新增 frontend/src/lib/consult/turn-card-cache.ts;frontend/src/mastra/consultation-tools.ts(上下文 turnCard、命中预置、turn_reuse、写回);frontend/src/app/api/consult/route.ts(指纹与判定、回执步骤、沿用消息、传 turnCard)。
|
||||
- 现象:同一会话的追问轮每轮都重新计算、重新把整份工具结果发给模型;#171 实测每一步 prompt 约 6.3 万固定 + 9.3 万工具结果,追问轮也不例外。
|
||||
- 触发条件:任何同一会话的第二轮及以后。
|
||||
- 根因:历史只存文字,不存上一轮的工具结果;工具内部去重只省计算时间,不省模型输入。
|
||||
- 修复:按占星师 Q3 做「用途不变才复用」——指纹覆盖出生资料、岁差/交点/位置口径、功能吉凶表、Narayana profile、回答模式、性别、领域集合、参考日期与三个版本;命中才预置上一轮结果并把视图作为一条消息插在本轮问题之前,未命中照常重算;命中前再复核行运日期与参数;每轮写回执步骤 turn-card-cache:<decision>:<reason>,视图顶层写 turn_reuse。缓存只存内存(每会话一条、TTL 30 分钟、上限 200)。
|
||||
- 验证:tests/consult-followup-card-reuse-1815.test.ts 12 条(指纹稳定与各要素成码、固定顺序、no_entry/reused、日期与参数复核、TTL、会话隔离与淘汰、turn_reuse 等价、命中引擎 0 次、13 个触发条件逐个 recomputed)与 consultation-agentic-runtime 87 条全过。
|
||||
- 防复发:上述测试;指纹要素与原因码集中在 turn-card-cache.ts,新增要素必须同时加测试。
|
||||
- 关联记录:占星师 2026-11-07 Q3、BUG-1803、#171(token 实测方法)
|
||||
- 修复版本:分支 codex/token-followup-20261109
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# PROGRESS · 追问沿用上一轮的卡(#172,BUG-1815,2026-11-09)
|
||||
|
||||
- 分支:`codex/token-followup-20261109`(基线 `c9f2f76f`)
|
||||
- 任务书:`docs/tasks/TASK-token-followup-20261109.md`
|
||||
- Bug:`docs/BUG_HISTORY.md` 末尾追加 BUG-1815
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `frontend/src/lib/consult/turn-card-cache.ts`(新) | 指纹(13 个要素各一个哈希 + 整体 sha256,领域集合排序)、`decideTurnCardReuse`(按固定顺序返回第一项变化的码)、`verifyCachedCard`(命中前复核行运日期与参数)、`storeTurnCard` / `peekTurnCard`(每会话一条、TTL 30 分钟、上限 200 按写入时间淘汰、只存内存)、`withTurnReuse`(视图顶层写 `turn_reuse`)、`resetTurnCardCache`(测试用) |
|
||||
| `frontend/src/mastra/consultation-tools.ts` | 上下文加可选 `turnCard`;命中时预置 `calculation` / `lookupCache` / `ctx.state` 与计数;计算工具返回前按判定写 `turn_reuse`,并通过 `store` 回调把本轮结果(去掉 `turn_reuse`)写回缓存 |
|
||||
| `frontend/src/app/api/consult/route.ts` | 组装本轮消息前算指纹与判定;每轮写回执步骤 `turn-card-cache:<decision>:<reason>`;命中时把上一轮视图作为一条 user 消息插在本轮问题之前并附沿用说明;把 `turnCard`(判定、指纹、参考日期、命中缓存、写回回调)传进 `agentContext` |
|
||||
|
||||
**版本要素**:`CONSULTATION_MODEL_PACKET_VERSION`、`EVIDENCE_CARD_VERSION`,构建版本取
|
||||
`NEXT_PUBLIC_APP_VERSION` 或 `DEPLOY_GIT_SHA`(都没有时写 `"unknown"`)。
|
||||
|
||||
**参考日期**:与卡上「今天」同一算法——请求时间加 8 小时后取日期(UTC+8)。
|
||||
|
||||
## 测试
|
||||
|
||||
`frontend/tests/consult-followup-card-reuse-1815.test.ts`(12 条,全过):
|
||||
|
||||
1. 指纹稳定(键序无关)、领域集合顺序无关;
|
||||
2. 13 个要素各自成码、变化原因按任务书的固定顺序取第一项;
|
||||
3. `no_entry` → `recomputed`;写入后同输入 → `reused`;
|
||||
4. 日期与参数复核:指纹相同但缓存视图的行运日期不同 → `reference_date`;
|
||||
5. TTL 过期 → `expired` 并删除条目;会话之间不共享;超过 200 条按写入时间淘汰最旧;
|
||||
6. `withTurnReuse`:除 `turn_reuse` 外视图完全相同;
|
||||
7. 工具层:命中时引擎调用 **0** 次、状态被预置(`consultationToolCompleted = true`、成功计数 1);
|
||||
8. 13 个触发条件逐个验一遍:判定 `recomputed` + 原因码正确,未命中时不预置状态。
|
||||
|
||||
另跑 `consultation-agentic-runtime.test.ts` 87 条,全过。
|
||||
|
||||
## 未完成 / 待补
|
||||
|
||||
1. **探针脚本与「基线 / 本分支」对照(任务书第一步、第五步)没做**:`consult-followup-token-probe.mts`
|
||||
还没写,所以「追问轮 prompt 字符数下降」这条验收证据**还没有数字**。
|
||||
2. 任务书第 6 组(route 层消息条数:命中只多一条、未命中原样)与第 7 组的
|
||||
`missing_route_layers` 断言还没写进测试——route 层的行为已实现,但只验到判定与视图等价。
|
||||
3. **环境缺口**:本机共享的前端依赖缺包(`zustand` 等),全项目 `tsc --noEmit` 与 `npm test`
|
||||
跑不出干净结果,所以没有 tsc 0 错与全量失败名单的对照。本单相关套件(12 + 87)不受影响。
|
||||
@@ -0,0 +1,134 @@
|
||||
# TASK:省 token——追问时沿用上一轮的数据卡和方法段落(#172,BUG-1815,2026-11-09)
|
||||
|
||||
- Gitea:#172
|
||||
- 分支:`codex/token-followup-20261109`(基于 staging `c9f2f76f6a2b29ef947400c13588aef1abf251b3`)
|
||||
- 前置:#185(显式交点与位置口径)已验收合并(staging `c9f2f76f`):`node_mode` / `position_mode` 已在 `serverChart.toolInput` 里,本单的指纹直接覆盖到;`route.ts` 本单只许改第四步第 3 条那一处。
|
||||
- #170(卡片压缩)已关闭不合并(产品负责人 2026-11-09 选 D),本单**不依赖**任何卡片压缩,缓存的就是 staging 现行的模型视图。
|
||||
- #173 的按需计算模块(`scripts/ondemand_calc/`)已在 staging,本单不碰。
|
||||
- Bug 编号:BUG-1815(开工时核对 `docs/BUG_HISTORY.md` 末尾)
|
||||
- 负责验收:Claude(jyotisha-2a)
|
||||
|
||||
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
|
||||
|
||||
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因并在 PROGRESS 列出。
|
||||
|
||||
## 背景
|
||||
|
||||
占星师 2026-11-07 Q3 原文:
|
||||
|
||||
- “同一会话允许复用已读取且版本未变的材料;问题或方法变化时重新路由。”
|
||||
- “每次回答必须核对实际计算结果的日期和参数,不能复用过期行运。”
|
||||
|
||||
规则(Claude 2026-11-09 按她的 Q3 定):追问轮**只有在下面全部不变时**,才沿用上一轮的数据卡和方法段落:
|
||||
|
||||
1. 出生资料(`serverChart.toolInput` 与 `serverChart.truth` 的出生字段);
|
||||
2. 全部口径:ayanamsa、node_mode、position_mode(#185 合并后在 `toolInput` 里)、功能吉凶表 `functionalProfile`、Narayana 的 profile(staging 上 Narayana profile 由引擎版本决定,不是请求参数;所以由第 5 项的构建版本覆盖;若开工时发现 `toolInput` 里已有 Narayana profile 字段,它也进指纹);另加 `answerMode`、`gender`;
|
||||
3. 本轮的问题领域集合(排序后);
|
||||
4. 本轮的参考日期(与卡上“今天”同一算法:`consultation-evidence-card.ts` 约 :650,UTC+8 的日期);
|
||||
5. 版本:`CONSULTATION_MODEL_PACKET_VERSION`、`EVIDENCE_CARD_VERSION`、web 构建版本(开工时找现有的构建 SHA 来源,例如 `/api/health` 报的 web 版本所用的那个常量或环境变量;web 与 API 同批部署,所以它同时代表引擎版本)。
|
||||
|
||||
任何一项变了:重算、重新路由,不沿用。每一轮仍要核对本轮自己的日期和参数。
|
||||
|
||||
## 现状(bfd7c30a 实测,c9f2f76f 未变;行号以符号为准,开工时重新核对)
|
||||
|
||||
- `frontend/src/app/api/consult/route.ts`
|
||||
- 历史(约 :1209 `consultationBaseMessages`)只含用户与助手的**文字**,不含上一轮的工具结果。
|
||||
- 约 :1565 建 `createConsultationAgentContext({ sessionId, followUpTurn, serverChart, requestTime, answerMode, functionalProfile, … })`——指纹要的字段在上下文里都有。
|
||||
- `frontend/src/mastra/consultation-tools.ts`
|
||||
- `createConsultationTools(ctx)`(约 :1371):`let calculation` 只在本次请求内去重;`run-jyotish-consultation` 的 execute(约 :1376–1610)每轮重新计算,算完设置 `ctx.state` 的 `workflowReceipt`、`techniqueTruth`、`evidenceCard`、`evidenceCardChars`、`modelVisibleChars`、`methodologySectionCount`、`techniqueAuditTable`、`thinkingPlan`、`consultationToolCompleted`、`consultationToolSuccessCount`,并 `appendConsultationRuntimeStep`;`lookupCache = { executions }`。
|
||||
- `frontend/src/lib/stream-agent-response.ts`:`contractReady()`(约 :420)要求计算工具成功恰好 1 次。本单**不改**它。
|
||||
- #171 的实测(`codex/token-lookup-20261109` 的 PROGRESS):每一步的 prompt ≈ 固定部分(约 6.3 万字符)+此前全部工具结果(计算结果约 9.3 万字符)。
|
||||
|
||||
**为什么只在工具里复用不够**:上一轮的工具结果不在历史里。工具内部命中缓存、返回同一份视图,只省计算时间,模型看到的字符数不变,input token 不降。真正能省的是**追问轮少走“调用计算工具”这一步**:命中时把上一轮的计算结果直接放进本轮消息,模型第一步就能作答。
|
||||
|
||||
## 要做的
|
||||
|
||||
### 第一步:先量(脚本化模型,不调真实模型)
|
||||
|
||||
1. 新建 `frontend/scripts/research/consult-followup-token-probe.mts`:照 #171 PROGRESS 的方法,用脚本化模型跑 **5 段两轮对话**(3 张 golden 公开盘;第二轮分别是:同领域追问 ×3、换领域 ×1、跨日 ×1),逐步记录每一步的 prompt 字符数与工具结果字符数。
|
||||
2. 先在 staging 代码上跑一遍,结果写进 PROGRESS 的“基线”表。
|
||||
|
||||
### 第二步:指纹与缓存(新模块 `frontend/src/lib/consult/turn-card-cache.ts`)
|
||||
|
||||
- `turnCardFingerprint(input)`:sha256(键排序后的 JSON),要素见“背景”第 1–5 项。同时返回各要素各自的哈希,供判断“哪一项变了”。
|
||||
- 缓存值:上一轮的完整模型视图(`toModelEvidenceView` 的结果,含 `methodology`)、`EvidenceCard`、`lookupCache`,以及工具算完后写进 `ctx.state` 的那些字段(上面“现状”列出的全部),还有指纹与各要素哈希、写入时间。
|
||||
- 按 `sessionId` 存,每会话只留最近一条;TTL 30 分钟;总条目上限 200(超出时按写入时间淘汰最旧的)。只存服务器内存,**不进数据库,不改表**。
|
||||
- `decideTurnCardReuse(sessionId, fingerprint)` 返回 `{ decision: "reused" | "recomputed", reason }`,`reason` 只用这些码:`reused`、`no_entry`、`expired`、`birth`、`ayanamsa`、`node_mode`、`position_mode`、`functional_profile`、`narayana_profile`、`answer_mode`、`gender`、`domains`、`reference_date`、`packet_version`、`card_version`、`build_version`。多项同时变化时取上面顺序里的第一项。
|
||||
|
||||
### 第三步:日期与参数核对(每一轮都做)
|
||||
|
||||
命中前再核对缓存视图本身:卡上行运的 `reference_date`、当前大运/小运所依据的日期等于本轮参考日期;参数段(ayanamsa、node_mode 等)与本轮一致。任何一项不等 → 判为 `recomputed`,`reason` 为对应码(日期写 `reference_date`)。
|
||||
|
||||
### 第四步:命中时的处理
|
||||
|
||||
1. **工具内**(`consultation-tools.ts`):`ConsultationAgentContext` 加一个可选字段 `reusedCalculation`(缓存值)。`createConsultationTools` 里若有它:把 `calculation` 预置为已完成的缓存视图,并在工具第一次被调用前就把缓存的 `ctx.state` 字段恢复、`consultationToolCompleted = true`、`consultationToolSuccessCount = 1`;`lookupCache` 用缓存的。模型若仍调用计算工具,照现有 `if (calculation) return calculation` 返回同一份,计数不变。
|
||||
2. **视图标记**:命中时返回的视图在顶层加 `turn_reuse: { reused_from_previous_turn: true, reason: "reused", fingerprint, reference_date }`;未命中时加 `turn_reuse: { reused_from_previous_turn: false, reason: <码>, fingerprint, reference_date }`。其余字段与重算结果完全相同。
|
||||
3. **route.ts(只加这一处)**:在建 `agentContext` 之前,若 `followUpTurn` 且决定为 `reused`:把 `reusedCalculation` 传进上下文;并把缓存视图用与 `consultationContinueEvidencePrompt`(`consultation-thinking-plan.ts`)相同的格式,作为一条 user 消息插在本轮问题**之前**,开头一句写:`本轮沿用上一轮的计算结果(出生资料、口径、领域、日期和版本都没变)。请先核对其中的日期和参数是否就是本轮的,再据此回答;不需要再调用计算工具。` 不改 route.ts 的其他流程。
|
||||
4. **未命中**:照常计算,算完调用 `storeTurnCard(sessionId, …)` 写入。
|
||||
5. **回执**:每一轮都 `appendConsultationRuntimeStep(state, { kind: "validation", name: "turn-card-cache:<decision>:<reason>", status: "completed" })`。公开回执的步骤 schema 不改(`name` 是自由字符串)。若加这一步使已有的步数预算测试失败,停下选需拍板。
|
||||
|
||||
### 第五步:再量
|
||||
|
||||
用第一步的脚本在本分支上再跑同样 5 段对话,PROGRESS 里列“基线 / 本分支”对照:第二轮每一步的 prompt 字符数、步数、工具结果字符数、引擎调用次数。
|
||||
|
||||
## 测试
|
||||
|
||||
新建 `frontend/tests/consult-followup-card-reuse-1815.test.ts`:
|
||||
|
||||
1. 同一会话第二轮、全部要素不变:`decision: "reused"`;引擎(`runWorkflow` 替身)调用 0 次;返回视图除 `turn_reuse` 外与第一轮 deepEqual;`ctx.state` 中缓存恢复的字段与第一轮相同;`contractReady` 所需的计数为 1。
|
||||
2. **每个触发条件各一条**,单独改变后必然 `recomputed` 且 `reason` 是对应码、引擎调用 1 次:出生时间、ayanamsa、node_mode、position_mode(#185 后 `toolInput.position_mode` 已存在;测试里改它的值)、functionalProfile、Narayana profile(经 `build_version` 变化覆盖;若 `toolInput` 有该字段再加一条)、answerMode、gender、领域集合、参考日期(跨 UTC+8 午夜)、`CONSULTATION_MODEL_PACKET_VERSION`、`EVIDENCE_CARD_VERSION`、构建版本。
|
||||
3. 第三步的核对:缓存视图的行运 `reference_date` 与本轮不同(指纹相同的人造情形)→ `recomputed` / `reference_date`。
|
||||
4. 不同会话不共享;TTL 过期 → `expired`;超过条目上限淘汰最旧的。
|
||||
5. 首轮(非追问)→ `recomputed` / `no_entry`,不预置消息。
|
||||
6. route 层:命中时 `baseMessages` 含沿用说明那一条、且只多这一条;未命中时 `baseMessages` 与现在完全相同。
|
||||
7. 必需字段送达不变:命中视图与同输入重算视图(去掉 `turn_reuse`)deepEqual;`evidence_contract.missing_route_layers` 为空的盘仍为空。
|
||||
|
||||
另须照样通过:`frontend/tests/consult-same-source-check-1773.test.ts`、`frontend/tests/consultation-agentic-runtime.test.ts`、`frontend/tests/consult-evidence-lookup-20260927.test.ts`。
|
||||
|
||||
## 允许改的文件(只有这些)
|
||||
|
||||
- 新建:`frontend/src/lib/consult/turn-card-cache.ts`、`frontend/scripts/research/consult-followup-token-probe.mts`、`frontend/tests/consult-followup-card-reuse-1815.test.ts`、`docs/tasks/PROGRESS-token-followup-20261109.md`
|
||||
- 修改:
|
||||
- `frontend/src/mastra/consultation-tools.ts`:只限 `ConsultationAgentContext` 加 `reusedCalculation`、`createConsultationTools` 开头的预置、`run-jyotish-consultation` execute 末尾写缓存与加 `turn_reuse`;不改序列化、查卡工具(#171 已关闭的分支不合并)、工具表与按需工具目录。
|
||||
- `frontend/src/app/api/consult/route.ts`:只限第四步第 3 条那一处。
|
||||
- `docs/BUG_HISTORY.md`:只在末尾追加 BUG-1815(不得用 sed 改整个文件)。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `references/**`(占星师的方法文件),包括 `references/product_runtime_contract.md`。
|
||||
- `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`(含 `planCache`)、`frontend/src/mastra/index.ts`、`frontend/src/mastra/product-voice.ts`。
|
||||
- `frontend/src/lib/stream-agent-response.ts`(合同判定 `contractReady` 不改)、`frontend/src/lib/consultation-agent-events.ts`(回执 schema 不改)。
|
||||
- `frontend/src/lib/consultation-evidence-card.ts`、`frontend/src/lib/consultation-answer-fact-guard.ts`、`frontend/src/lib/yoga-status-words.ts`(#182 / #183 正在改,未合并)。
|
||||
- 数据库表与迁移;Python 侧 `scripts/consult_full_data_cache.py`。
|
||||
- 冻结文件:`scripts/jyotish_api_server.py`、`scripts/jyotish_engine.py`、`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`、`scripts/functional_benefics.py`、`scripts/divisional_charts_extended.py`;本单不改任何 `scripts/**`。
|
||||
- 进行中单子的地盘:#175 / #176 / #177 / #178(`scripts/ondemand_calc/**` 与注册表)、#184(`scripts/active_rectification_events.py`)、#186(rectification 用途评估合同及其测试);#185 的其他文件(`consultation-workflow.ts`、`consultation-route-service.ts`、`consult-packet-warm.ts`、`chart-choice-both-versions.ts`、`calculation-modes.ts`)。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. **追问 input 降**:第五步的 5 段对话里,3 段“全部不变”的追问,第二轮总 prompt 字符数相对基线下降(每段都降;PROGRESS 给出每段数字与百分比);换领域、跨日两段与基线持平(走重算)。Claude 验收时再用真实模型跑同样 5 段,核对 input token。
|
||||
2. **必需字段送达不变**:测试第 7 条通过。
|
||||
3. **每个触发条件都有测试**(第 2 条),回执里能看到 `reused` / `recomputed` 和原因码。
|
||||
4. 既有测试全部通过;没有改既有断言(若改了,三栏注释并写进 PROGRESS)。
|
||||
5. 只出现允许清单里的改动。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`
|
||||
- 在 `frontend` 下运行 `npm run lint`
|
||||
- 在 `frontend` 下运行 `node node_modules/tsx/dist/cli.mjs --test tests/consult-followup-card-reuse-1815.test.ts tests/consultation-agentic-runtime.test.ts tests/consult-same-source-check-1773.test.ts tests/consult-evidence-lookup-20260927.test.ts`
|
||||
- 在 `frontend` 下运行 `node node_modules/tsx/dist/cli.mjs scripts/research/consult-followup-token-probe.mts`
|
||||
- 再在 `frontend` 下跑一次 `npm test`,把失败的测试名写进 PROGRESS。
|
||||
|
||||
前端全量测试、Python 快速门和 eslint 由 Claude 在验收时再跑。
|
||||
|
||||
## 进度记录
|
||||
|
||||
`docs/tasks/PROGRESS-token-followup-20261109.md`
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- 要放开 `contractReady()` 的“计算工具成功恰好 1 次”,或改回执 schema 时。
|
||||
- 服务器是多实例、内存缓存在实例间不共享,需要换成持久化存储(要改表)时。
|
||||
- “领域集合”在同一句追问上不稳定(由模型或路由推断,重复请求得到不同结果)时:写清现象,由 Claude 决定指纹怎么取。
|
||||
- 第一步实测发现预置计算结果后模型仍每次都调用计算工具、步数不降时:写清实测,不要自己换方案。
|
||||
- 任务书没写到的任何文案或取舍。
|
||||
@@ -103,7 +103,17 @@ import {
|
||||
createConsultationRuntimeHooks,
|
||||
createConsultationRuntimeState,
|
||||
publicConsultationRuntimeSteps,
|
||||
CONSULTATION_MODEL_PACKET_VERSION,
|
||||
appendConsultationRuntimeStep,
|
||||
} from "@/mastra/consultation-tools";
|
||||
import { EVIDENCE_CARD_VERSION } from "../../../src/lib/consultation-evidence-card.ts";
|
||||
import {
|
||||
decideTurnCardReuse,
|
||||
peekTurnCard,
|
||||
storeTurnCard,
|
||||
turnCardFingerprint,
|
||||
type TurnCardPayload,
|
||||
} from "../../../lib/consult/turn-card-cache.ts";
|
||||
import {
|
||||
applyBirthTimeModeToWorkflowContext,
|
||||
ACCEPTED_RANGE_READING_INSTRUCTION,
|
||||
@@ -1206,6 +1216,45 @@ export async function POST(request: Request) {
|
||||
: consultationMode === "declared_birth_window"
|
||||
? declaredWindowInstruction()
|
||||
: `${natalInstruction}${adoptedRangeNote}`;
|
||||
// BUG-1815:追问轮是否沿用上一轮的计算结果。只有出生资料、全部口径、领域、参考日期与
|
||||
// 版本都没变才沿用;任何一项变了就重算(reason 由判定给出)。
|
||||
const turnCardReferenceDate = new Date(requestTime.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const birthToolInput = prepared.serverChart?.toolInput;
|
||||
const turnCardFingerprintValue = turnCardFingerprint({
|
||||
birth: {
|
||||
year: birthToolInput?.year, month: birthToolInput?.month, day: birthToolInput?.day,
|
||||
hour: birthToolInput?.hour, minute: birthToolInput?.minute,
|
||||
lat: birthToolInput?.lat, lon: birthToolInput?.lon, tz: birthToolInput?.tz,
|
||||
birthDate: prepared.serverChart?.truth?.birthDate,
|
||||
birthTimeSource: prepared.serverChart?.truth?.birthTimeSource,
|
||||
birthTimeStatus: prepared.serverChart?.truth?.birthTimeStatus,
|
||||
},
|
||||
ayanamsa: String(birthToolInput?.ayanamsa ?? ""),
|
||||
nodeMode: String((birthToolInput as Record<string, unknown> | undefined)?.node_mode ?? ""),
|
||||
positionMode: String((birthToolInput as Record<string, unknown> | undefined)?.position_mode ?? ""),
|
||||
functionalProfile: requestedFunctionalProfile ?? "",
|
||||
answerMode,
|
||||
gender: prepared.serverChart?.gender ?? "",
|
||||
domains: [consultationTheme],
|
||||
referenceDate: turnCardReferenceDate,
|
||||
packetVersion: CONSULTATION_MODEL_PACKET_VERSION,
|
||||
cardVersion: EVIDENCE_CARD_VERSION,
|
||||
buildVersion: process.env.NEXT_PUBLIC_APP_VERSION ?? process.env.DEPLOY_GIT_SHA ?? "unknown",
|
||||
});
|
||||
const turnCardDecision = followUpTurn && prepared.serverChart
|
||||
? decideTurnCardReuse(sessionId, turnCardFingerprintValue, {
|
||||
referenceDate: turnCardReferenceDate,
|
||||
ayanamsa: String(birthToolInput?.ayanamsa ?? ""),
|
||||
nodeMode: String((birthToolInput as Record<string, unknown> | undefined)?.node_mode ?? ""),
|
||||
positionMode: String((birthToolInput as Record<string, unknown> | undefined)?.position_mode ?? ""),
|
||||
})
|
||||
: { decision: "recomputed" as const, reason: "no_entry" as const };
|
||||
const reusedTurnCard = turnCardDecision.decision === "reused" ? peekTurnCard(sessionId) : undefined;
|
||||
appendConsultationRuntimeStep(state, {
|
||||
kind: "validation",
|
||||
name: `turn-card-cache:${turnCardDecision.decision}:${turnCardDecision.reason}`,
|
||||
status: "completed",
|
||||
});
|
||||
const consultationBaseMessages = (overflow: boolean) => {
|
||||
const tail = overflow ? lastConsultationPair(history) : history;
|
||||
const mapped = tail.map((message) => message.role === "user"
|
||||
@@ -1217,6 +1266,15 @@ export async function POST(request: Request) {
|
||||
return [
|
||||
...(cacheBoundary ? [cacheBoundary] : []),
|
||||
...cachedTail,
|
||||
...(reusedTurnCard
|
||||
? [{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
"本轮沿用上一轮的计算结果(出生资料、口径、领域、日期和版本都没变)。请先核对其中的日期和参数是否就是本轮的,再据此回答;不需要再调用计算工具。",
|
||||
JSON.stringify(reusedTurnCard.payload.modelView),
|
||||
].join("\n"),
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
role: "user" as const,
|
||||
content: consultationUserTurnContent({
|
||||
@@ -1578,6 +1636,23 @@ export async function POST(request: Request) {
|
||||
requestTime,
|
||||
answerMode,
|
||||
...(answerMode === "professional" && requestedFunctionalProfile ? { functionalProfile: requestedFunctionalProfile } : {}),
|
||||
// BUG-1815:命中时把上一轮结果预置进工具;每一轮算完都写回缓存,供下一轮追问使用。
|
||||
turnCard: {
|
||||
decision: turnCardDecision,
|
||||
fingerprint: turnCardFingerprintValue.fingerprint,
|
||||
referenceDate: turnCardReferenceDate,
|
||||
...(reusedTurnCard ? { reused: reusedTurnCard.payload } : {}),
|
||||
store: (payload: TurnCardPayload) => storeTurnCard(sessionId, turnCardFingerprintValue, {
|
||||
referenceDate: turnCardReferenceDate,
|
||||
parameters: {
|
||||
ayanamsa: String(birthToolInput?.ayanamsa ?? ""),
|
||||
nodeMode: String((birthToolInput as Record<string, unknown> | undefined)?.node_mode ?? ""),
|
||||
positionMode: String((birthToolInput as Record<string, unknown> | undefined)?.position_mode ?? ""),
|
||||
},
|
||||
payload,
|
||||
writtenAt: Date.now(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
const agent = getJyotishAgent(selectedModel, agentContext);
|
||||
const result = await streamWithOverflowRetry(agent, natalStreamOptions);
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* BUG-1815(占星师 2026-11-07 Q3):追问轮沿用上一轮的数据卡与方法段落的判定与缓存。
|
||||
*
|
||||
* 她的原文:
|
||||
* - 「同一会话允许复用已读取且版本未变的材料;问题或方法变化时重新路由。」
|
||||
* - 「每次回答必须核对实际计算结果的日期和参数,不能复用过期行运。」
|
||||
*
|
||||
* 所以只有**出生资料、全部口径、领域集合、参考日期、版本**都不变时才沿用;任何一项变了就重算。
|
||||
* 缓存只放服务器内存(每会话最近一条,TTL 30 分钟,总条目上限 200),不进数据库、不改表。
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export type TurnCardReason =
|
||||
| "reused"
|
||||
| "no_entry"
|
||||
| "expired"
|
||||
| "birth"
|
||||
| "ayanamsa"
|
||||
| "node_mode"
|
||||
| "position_mode"
|
||||
| "functional_profile"
|
||||
| "narayana_profile"
|
||||
| "answer_mode"
|
||||
| "gender"
|
||||
| "domains"
|
||||
| "reference_date"
|
||||
| "packet_version"
|
||||
| "card_version"
|
||||
| "build_version";
|
||||
|
||||
/** 指纹要素的固定顺序:多项同时变化时按这个顺序取第一项。 */
|
||||
export const TURN_CARD_FACTORS = [
|
||||
"birth",
|
||||
"ayanamsa",
|
||||
"node_mode",
|
||||
"position_mode",
|
||||
"functional_profile",
|
||||
"narayana_profile",
|
||||
"answer_mode",
|
||||
"gender",
|
||||
"domains",
|
||||
"reference_date",
|
||||
"packet_version",
|
||||
"card_version",
|
||||
"build_version",
|
||||
] as const;
|
||||
|
||||
export type TurnCardFactor = (typeof TURN_CARD_FACTORS)[number];
|
||||
|
||||
export type TurnCardFingerprintInput = {
|
||||
/** 出生资料:`serverChart.toolInput` 的出生字段(年月日时分秒、地点、时区)与 `truth` 的出生字段。 */
|
||||
readonly birth: Readonly<Record<string, unknown>>;
|
||||
readonly ayanamsa: string;
|
||||
readonly nodeMode: string;
|
||||
readonly positionMode: string;
|
||||
readonly functionalProfile: string;
|
||||
/** Narayana 的各步 profile;staging 上由引擎版本决定,没有就传空。 */
|
||||
readonly narayanaProfile?: string;
|
||||
readonly answerMode: string;
|
||||
readonly gender?: string;
|
||||
/** 本轮领域集合;指纹里排序后再算。 */
|
||||
readonly domains: readonly string[];
|
||||
/** 参考日期(UTC+8 的日期,与卡上「今天」同一算法)。 */
|
||||
readonly referenceDate: string;
|
||||
readonly packetVersion: string;
|
||||
readonly cardVersion: string;
|
||||
readonly buildVersion: string;
|
||||
};
|
||||
|
||||
export type TurnCardFingerprint = {
|
||||
readonly fingerprint: string;
|
||||
readonly factors: Readonly<Record<TurnCardFactor, string>>;
|
||||
};
|
||||
|
||||
const TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
const clean = (item: unknown): unknown => {
|
||||
if (Array.isArray(item)) return item.map(clean);
|
||||
if (item && typeof item === "object") {
|
||||
return Object.fromEntries(Object.entries(item as Record<string, unknown>)
|
||||
.filter(([, entry]) => entry !== undefined)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => [key, clean(entry)]));
|
||||
}
|
||||
return item;
|
||||
};
|
||||
return JSON.stringify(clean(value));
|
||||
}
|
||||
|
||||
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
export function turnCardFingerprint(input: TurnCardFingerprintInput): TurnCardFingerprint {
|
||||
const values: Record<TurnCardFactor, unknown> = {
|
||||
birth: input.birth,
|
||||
ayanamsa: input.ayanamsa,
|
||||
node_mode: input.nodeMode,
|
||||
position_mode: input.positionMode,
|
||||
functional_profile: input.functionalProfile,
|
||||
narayana_profile: input.narayanaProfile ?? "",
|
||||
answer_mode: input.answerMode,
|
||||
gender: input.gender ?? "",
|
||||
domains: [...input.domains].sort(),
|
||||
reference_date: input.referenceDate,
|
||||
packet_version: input.packetVersion,
|
||||
card_version: input.cardVersion,
|
||||
build_version: input.buildVersion,
|
||||
};
|
||||
const factors = Object.fromEntries(
|
||||
TURN_CARD_FACTORS.map((factor) => [factor, sha256(canonical(values[factor]))]),
|
||||
) as Record<TurnCardFactor, string>;
|
||||
return { fingerprint: sha256(canonical(values)), factors };
|
||||
}
|
||||
|
||||
export type TurnCardPayload = {
|
||||
/** `toModelEvidenceView` 的结果(含 methodology)。 */
|
||||
readonly modelView: Record<string, unknown>;
|
||||
readonly evidenceCard: Record<string, unknown>;
|
||||
readonly lookupCache: Record<string, unknown>;
|
||||
/** 工具算完后写进 `ctx.state` 的那些字段。 */
|
||||
readonly state: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TurnCardEntry = {
|
||||
readonly fingerprint: string;
|
||||
readonly factors: Readonly<Record<TurnCardFactor, string>>;
|
||||
readonly referenceDate: string;
|
||||
readonly parameters: Readonly<{ ayanamsa: string; nodeMode: string; positionMode: string }>;
|
||||
readonly payload: TurnCardPayload;
|
||||
readonly writtenAt: number;
|
||||
};
|
||||
|
||||
export type TurnCardDecision = { decision: "reused" | "recomputed"; reason: TurnCardReason };
|
||||
|
||||
/**
|
||||
* 第三步:命中前再核对缓存视图本身(行运日期、参数)。
|
||||
* 返回 null 表示核对通过;否则返回要用来解释的码。
|
||||
*/
|
||||
export function verifyCachedCard(
|
||||
entry: TurnCardEntry,
|
||||
input: Pick<TurnCardFingerprintInput, "referenceDate" | "ayanamsa" | "nodeMode" | "positionMode">,
|
||||
): TurnCardReason | null {
|
||||
const view = entry.payload.modelView;
|
||||
const viewDate = typeof view.reference_date === "string"
|
||||
? view.reference_date
|
||||
: typeof (view.evidence_card as Record<string, unknown> | undefined)?.reference_date === "string"
|
||||
? String((view.evidence_card as Record<string, unknown>).reference_date)
|
||||
: entry.referenceDate;
|
||||
if (viewDate !== input.referenceDate) return "reference_date";
|
||||
if (entry.parameters.ayanamsa !== input.ayanamsa) return "ayanamsa";
|
||||
if (entry.parameters.nodeMode !== input.nodeMode) return "node_mode";
|
||||
if (entry.parameters.positionMode !== input.positionMode) return "position_mode";
|
||||
return null;
|
||||
}
|
||||
|
||||
const ENTRIES = new Map<string, TurnCardEntry>();
|
||||
|
||||
/** 测试用:清空缓存(不导出到生产调用点)。 */
|
||||
export function resetTurnCardCache(): void {
|
||||
ENTRIES.clear();
|
||||
}
|
||||
|
||||
export function storeTurnCard(
|
||||
sessionId: string,
|
||||
fingerprint: TurnCardFingerprint,
|
||||
entry: Omit<TurnCardEntry, "fingerprint" | "factors">,
|
||||
): TurnCardEntry {
|
||||
const stored: TurnCardEntry = { ...entry, fingerprint: fingerprint.fingerprint, factors: fingerprint.factors };
|
||||
ENTRIES.delete(sessionId);
|
||||
ENTRIES.set(sessionId, stored);
|
||||
while (ENTRIES.size > MAX_ENTRIES) {
|
||||
const oldest = [...ENTRIES.entries()].sort(([, left], [, right]) => left.writtenAt - right.writtenAt)[0];
|
||||
if (!oldest) break;
|
||||
ENTRIES.delete(oldest[0]);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function peekTurnCard(sessionId: string): TurnCardEntry | undefined {
|
||||
return ENTRIES.get(sessionId);
|
||||
}
|
||||
|
||||
export function decideTurnCardReuse(
|
||||
sessionId: string,
|
||||
fingerprint: TurnCardFingerprint,
|
||||
input: Pick<TurnCardFingerprintInput, "referenceDate" | "ayanamsa" | "nodeMode" | "positionMode">,
|
||||
now = Date.now(),
|
||||
): TurnCardDecision {
|
||||
const entry = ENTRIES.get(sessionId);
|
||||
if (!entry) return { decision: "recomputed", reason: "no_entry" };
|
||||
if (now - entry.writtenAt > TTL_MS) {
|
||||
ENTRIES.delete(sessionId);
|
||||
return { decision: "recomputed", reason: "expired" };
|
||||
}
|
||||
for (const factor of TURN_CARD_FACTORS) {
|
||||
if (entry.factors[factor] !== fingerprint.factors[factor]) {
|
||||
return { decision: "recomputed", reason: factor };
|
||||
}
|
||||
}
|
||||
const dateProblem = verifyCachedCard(entry, input);
|
||||
if (dateProblem) return { decision: "recomputed", reason: dateProblem };
|
||||
return { decision: "reused", reason: "reused" };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 命中 / 未命中都要在模型视图顶层写 `turn_reuse`(任务书第四步第 2 条)。
|
||||
* 其余字段保持不变,所以「沿用的视图」与「同输入重算的视图」去掉这个字段后必须 deepEqual。
|
||||
*/
|
||||
export function withTurnReuse<T extends Record<string, unknown>>(
|
||||
view: T,
|
||||
decision: TurnCardDecision,
|
||||
fingerprint: TurnCardFingerprint | string,
|
||||
referenceDate: string,
|
||||
): T & { turn_reuse: { reused_from_previous_turn: boolean; reason: TurnCardReason; fingerprint: string; reference_date: string } } {
|
||||
return {
|
||||
...view,
|
||||
turn_reuse: {
|
||||
reused_from_previous_turn: decision.decision === "reused",
|
||||
reason: decision.reason,
|
||||
fingerprint: typeof fingerprint === "string" ? fingerprint : fingerprint.fingerprint,
|
||||
reference_date: referenceDate,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
type ConsultationDomain,
|
||||
} from "../lib/consultation-domain-registry.ts";
|
||||
import { applyBirthTimeModeToWorkflowContext, type ConsultationBirthTimeMode } from "../lib/consultation-birth-time-mode.ts";
|
||||
import {
|
||||
withTurnReuse,
|
||||
type TurnCardDecision,
|
||||
type TurnCardPayload,
|
||||
} from "../lib/consult/turn-card-cache.ts";
|
||||
import { consultationMethodologyForDomains } from "../lib/consultation-methodology.ts";
|
||||
import type { DeclaredBirthWindowConsultation, ServerChartConsultation } from "../lib/consultation-route-service.ts";
|
||||
import { fetchDeclaredWindowChart } from "../lib/declared-window-chart.ts";
|
||||
@@ -625,6 +630,19 @@ export type ConsultationAgentContext = Readonly<{
|
||||
answerMode?: ConsultationAnswerMode;
|
||||
/** The 解读方式 pill's functional table; only set for a 深度解读 turn (owner 2026-11-08). */
|
||||
functionalProfile?: FunctionalProfile;
|
||||
/** BUG-1815:追问轮沿用上一轮计算结果;命中时把缓存视图与状态预置进来。 */
|
||||
turnCard?: TurnCardHook;
|
||||
}>;
|
||||
|
||||
/** BUG-1815:route 层判定出「本轮沿用上一轮」时交给工具的钩子。 */
|
||||
export type TurnCardHook = Readonly<{
|
||||
decision: TurnCardDecision;
|
||||
fingerprint: string;
|
||||
referenceDate: string;
|
||||
/** 命中时上一轮的完整模型视图与要恢复的状态。 */
|
||||
reused?: TurnCardPayload;
|
||||
/** 本轮算完时写回缓存(未命中或命中都写,保证下一次追问有最新一条)。 */
|
||||
store?: (payload: TurnCardPayload) => void;
|
||||
}>;
|
||||
|
||||
export type WindowConsultationCalculationCache = {
|
||||
@@ -1373,6 +1391,22 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
// Request-scoped: filled when this request's calculation finishes, read by
|
||||
// the lookup tool, dropped with the request.
|
||||
let lookupCache: EvidenceLookupCache | null = null;
|
||||
// BUG-1815:追问轮命中时,把上一轮的计算结果与状态预置好。模型即使仍调用计算工具,
|
||||
// 也只会拿到同一份(`if (calculation) return calculation`),计数不变。
|
||||
if (ctx.turnCard?.reused) {
|
||||
const reused = ctx.turnCard.reused;
|
||||
const view = withTurnReuse(
|
||||
reused.modelView,
|
||||
ctx.turnCard.decision,
|
||||
ctx.turnCard.fingerprint,
|
||||
ctx.turnCard.referenceDate,
|
||||
) as unknown as ConsultationModelEvidenceView;
|
||||
calculation = Promise.resolve(view);
|
||||
lookupCache = reused.lookupCache as unknown as EvidenceLookupCache;
|
||||
Object.assign(ctx.state, reused.state);
|
||||
ctx.state.consultationToolCompleted = true;
|
||||
ctx.state.consultationToolSuccessCount = Math.max(ctx.state.consultationToolSuccessCount ?? 0, 1);
|
||||
}
|
||||
const consultationTool = createTool({
|
||||
id: "run-jyotish-consultation",
|
||||
description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Questions about one's parents (父母) use parents and questions about one's children (子女) use children; family is only for the household as a whole (家里、家庭氛围). Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with each domain's own contract in consultations. claim_cards is the evidence card: the base chart (ascendant, house signs, placements, functional benefics/malefics, the D9 summary with dignities, Vargottama and D1/D9 reversals, the running Vimshottari and Narayana periods) and a section per domain; evidence_card lists what else this calculation holds. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`,
|
||||
@@ -1589,7 +1623,32 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
? modelContext.evidence_contract.must_use_layers.filter((item): item is string => typeof item === "string")
|
||||
: undefined,
|
||||
});
|
||||
return modelView;
|
||||
// BUG-1815:带上本轮复用标记,并把这一轮的结果写回缓存供下一次追问使用。
|
||||
const annotated = ctx.turnCard
|
||||
? withTurnReuse(modelView, ctx.turnCard.decision, ctx.turnCard.fingerprint, ctx.turnCard.referenceDate)
|
||||
: modelView;
|
||||
if (ctx.turnCard?.store) {
|
||||
const { turn_reuse: _ignored, ...rest } = annotated as unknown as Record<string, unknown>;
|
||||
void _ignored;
|
||||
ctx.turnCard.store({
|
||||
modelView: rest,
|
||||
evidenceCard: card as unknown as Record<string, unknown>,
|
||||
lookupCache: lookupCache as unknown as Record<string, unknown>,
|
||||
state: {
|
||||
workflowReceipt: ctx.state.workflowReceipt,
|
||||
techniqueTruth: ctx.state.techniqueTruth,
|
||||
evidenceCard: ctx.state.evidenceCard,
|
||||
evidenceCardChars: ctx.state.evidenceCardChars,
|
||||
modelVisibleChars: ctx.state.modelVisibleChars,
|
||||
methodologySectionCount: ctx.state.methodologySectionCount,
|
||||
techniqueAuditTable: ctx.state.techniqueAuditTable,
|
||||
thinkingPlan: ctx.state.thinkingPlan,
|
||||
consultationToolCompleted: ctx.state.consultationToolCompleted,
|
||||
consultationToolSuccessCount: ctx.state.consultationToolSuccessCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
return annotated as unknown as ConsultationModelEvidenceView;
|
||||
} catch (error) {
|
||||
ctx.state.consultationToolDurationMs = now() - startedAt;
|
||||
appendConsultationRuntimeStep(ctx.state, {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* BUG-1815:追问轮沿用上一轮卡片——指纹、判定、核对、TTL 与上限。
|
||||
* 只用合成输入,不含任何个人资料。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createConsultationRuntimeState, createConsultationTools } from "../src/mastra/consultation-tools.ts";
|
||||
import {
|
||||
decideTurnCardReuse,
|
||||
peekTurnCard,
|
||||
resetTurnCardCache,
|
||||
storeTurnCard,
|
||||
turnCardFingerprint,
|
||||
verifyCachedCard,
|
||||
withTurnReuse,
|
||||
type TurnCardFingerprintInput,
|
||||
} from "../src/lib/consult/turn-card-cache.ts";
|
||||
|
||||
const BIRTH = {
|
||||
year: 1990, month: 1, day: 1, hour: 12, minute: 0,
|
||||
lat: 39.9, lon: 116.4, tz: 8, city: "合成地点",
|
||||
birthTimeStatus: "reported", birthTimeSource: "reported",
|
||||
};
|
||||
|
||||
function input(overrides: Partial<TurnCardFingerprintInput> = {}): TurnCardFingerprintInput {
|
||||
return {
|
||||
birth: BIRTH,
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
positionMode: "apparent",
|
||||
functionalProfile: "pvr_table30_v1",
|
||||
answerMode: "plain",
|
||||
gender: "unspecified",
|
||||
domains: ["career", "finance"],
|
||||
referenceDate: "2026-11-09",
|
||||
packetVersion: "packet-v1",
|
||||
cardVersion: "card-v1",
|
||||
buildVersion: "build-v1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function payload(referenceDate: string, ayanamsa = "lahiri") {
|
||||
return {
|
||||
modelView: { reference_date: referenceDate, evidence_card: { ayanamsa } },
|
||||
evidenceCard: { ayanamsa },
|
||||
lookupCache: { executions: [] },
|
||||
state: { consultationToolCompleted: true },
|
||||
};
|
||||
}
|
||||
|
||||
test("the fingerprint is stable and ignores key order", () => {
|
||||
const left = turnCardFingerprint(input());
|
||||
const right = turnCardFingerprint(input({ birth: { ...BIRTH } }));
|
||||
assert.equal(left.fingerprint, right.fingerprint);
|
||||
assert.deepEqual(left.factors, right.factors);
|
||||
});
|
||||
|
||||
test("domains are order-insensitive", () => {
|
||||
const left = turnCardFingerprint(input({ domains: ["career", "finance"] }));
|
||||
const right = turnCardFingerprint(input({ domains: ["finance", "career"] }));
|
||||
assert.equal(left.fingerprint, right.fingerprint);
|
||||
});
|
||||
|
||||
test("every factor has its own hash, so a change names one factor", () => {
|
||||
const base = turnCardFingerprint(input());
|
||||
const changes: Array<[string, Partial<TurnCardFingerprintInput>]> = [
|
||||
["birth", { birth: { ...BIRTH, minute: 1 } }],
|
||||
["ayanamsa", { ayanamsa: "raman" }],
|
||||
["node_mode", { nodeMode: "true" }],
|
||||
["position_mode", { positionMode: "mean" }],
|
||||
["functional_profile", { functionalProfile: "bphs_exception_profile" }],
|
||||
["narayana_profile", { narayanaProfile: "steps-a" }],
|
||||
["answer_mode", { answerMode: "professional" }],
|
||||
["gender", { gender: "female" }],
|
||||
["domains", { domains: ["career"] }],
|
||||
["reference_date", { referenceDate: "2026-11-10" }],
|
||||
["packet_version", { packetVersion: "packet-v2" }],
|
||||
["card_version", { cardVersion: "card-v2" }],
|
||||
["build_version", { buildVersion: "build-v2" }],
|
||||
];
|
||||
for (const [factor, change] of changes) {
|
||||
const other = turnCardFingerprint(input(change));
|
||||
assert.notEqual(other.fingerprint, base.fingerprint, factor);
|
||||
assert.notEqual(other.factors[factor as keyof typeof base.factors], base.factors[factor as keyof typeof base.factors], factor);
|
||||
}
|
||||
});
|
||||
|
||||
test("no entry means recompute, and a fresh entry is reused", () => {
|
||||
resetTurnCardCache();
|
||||
const fingerprint = turnCardFingerprint(input());
|
||||
assert.deepEqual(decideTurnCardReuse("s1", fingerprint, input()), { decision: "recomputed", reason: "no_entry" });
|
||||
storeTurnCard("s1", fingerprint, {
|
||||
referenceDate: "2026-11-09",
|
||||
parameters: { ayanamsa: "lahiri", nodeMode: "mean", positionMode: "apparent" },
|
||||
payload: payload("2026-11-09"),
|
||||
writtenAt: Date.now(),
|
||||
});
|
||||
assert.deepEqual(decideTurnCardReuse("s1", fingerprint, input()), { decision: "reused", reason: "reused" });
|
||||
});
|
||||
|
||||
test("a changed factor comes back with its own reason, in the fixed order", () => {
|
||||
resetTurnCardCache();
|
||||
const base = turnCardFingerprint(input());
|
||||
storeTurnCard("s2", base, {
|
||||
referenceDate: "2026-11-09",
|
||||
parameters: { ayanamsa: "lahiri", nodeMode: "mean", positionMode: "apparent" },
|
||||
payload: payload("2026-11-09"),
|
||||
writtenAt: Date.now(),
|
||||
});
|
||||
const cases: Array<[string, Partial<TurnCardFingerprintInput>]> = [
|
||||
["ayanamsa", { ayanamsa: "raman" }],
|
||||
["node_mode", { nodeMode: "true" }],
|
||||
["position_mode", { positionMode: "mean" }],
|
||||
["functional_profile", { functionalProfile: "bphs_exception_profile" }],
|
||||
["answer_mode", { answerMode: "professional" }],
|
||||
["gender", { gender: "female" }],
|
||||
["domains", { domains: ["career"] }],
|
||||
["packet_version", { packetVersion: "packet-v2" }],
|
||||
["card_version", { cardVersion: "card-v2" }],
|
||||
["build_version", { buildVersion: "build-v2" }],
|
||||
];
|
||||
for (const [reason, change] of cases) {
|
||||
assert.deepEqual(
|
||||
decideTurnCardReuse("s2", turnCardFingerprint(input(change)), input(change)),
|
||||
{ decision: "recomputed", reason },
|
||||
);
|
||||
}
|
||||
// 多项同时变化:按固定顺序取第一项(birth 在 ayanamsa 之前)。
|
||||
const both = input({ birth: { ...BIRTH, minute: 2 }, ayanamsa: "raman" });
|
||||
assert.deepEqual(decideTurnCardReuse("s2", turnCardFingerprint(both), both), { decision: "recomputed", reason: "birth" });
|
||||
});
|
||||
|
||||
test("the reference date and the parameters are re-checked even when the fingerprint matches", () => {
|
||||
resetTurnCardCache();
|
||||
const fingerprint = turnCardFingerprint(input());
|
||||
storeTurnCard("s3", fingerprint, {
|
||||
referenceDate: "2026-11-09",
|
||||
parameters: { ayanamsa: "lahiri", nodeMode: "mean", positionMode: "apparent" },
|
||||
payload: payload("2026-11-08"),
|
||||
writtenAt: Date.now(),
|
||||
});
|
||||
assert.deepEqual(decideTurnCardReuse("s3", fingerprint, input()), { decision: "recomputed", reason: "reference_date" });
|
||||
const entry = peekTurnCard("s3")!;
|
||||
assert.equal(verifyCachedCard(entry, { ...input(), referenceDate: "2026-11-09" }), "reference_date");
|
||||
});
|
||||
|
||||
test("entries expire after the TTL", () => {
|
||||
resetTurnCardCache();
|
||||
const fingerprint = turnCardFingerprint(input());
|
||||
storeTurnCard("s4", fingerprint, {
|
||||
referenceDate: "2026-11-09",
|
||||
parameters: { ayanamsa: "lahiri", nodeMode: "mean", positionMode: "apparent" },
|
||||
payload: payload("2026-11-09"),
|
||||
writtenAt: Date.now() - 31 * 60 * 1000,
|
||||
});
|
||||
assert.deepEqual(decideTurnCardReuse("s4", fingerprint, input()), { decision: "recomputed", reason: "expired" });
|
||||
assert.equal(peekTurnCard("s4"), undefined);
|
||||
});
|
||||
|
||||
test("sessions do not share entries and the oldest is evicted past the limit", () => {
|
||||
resetTurnCardCache();
|
||||
const fingerprint = turnCardFingerprint(input());
|
||||
storeTurnCard("a", fingerprint, {
|
||||
referenceDate: "2026-11-09",
|
||||
parameters: { ayanamsa: "lahiri", nodeMode: "mean", positionMode: "apparent" },
|
||||
payload: payload("2026-11-09"),
|
||||
writtenAt: Date.now(),
|
||||
});
|
||||
assert.deepEqual(decideTurnCardReuse("b", fingerprint, input()), { decision: "recomputed", reason: "no_entry" });
|
||||
|
||||
for (let index = 0; index < 205; index += 1) {
|
||||
storeTurnCard(`many-${index}`, fingerprint, {
|
||||
referenceDate: "2026-11-09",
|
||||
parameters: { ayanamsa: "lahiri", nodeMode: "mean", positionMode: "apparent" },
|
||||
payload: payload("2026-11-09"),
|
||||
writtenAt: Date.now() + index,
|
||||
});
|
||||
}
|
||||
// 206 条 → 淘汰最旧的 6 条(会话 a 加上 many-0…many-4),保留最后 200 条。
|
||||
assert.equal(peekTurnCard("many-0"), undefined, "the oldest entries are evicted");
|
||||
assert.equal(peekTurnCard("many-4"), undefined);
|
||||
assert.ok(peekTurnCard("many-5"), "the 200 newest entries stay");
|
||||
assert.ok(peekTurnCard("many-204"), "the newest entry stays");
|
||||
});
|
||||
|
||||
|
||||
test("the view carries turn_reuse and nothing else changes", () => {
|
||||
const fingerprint = turnCardFingerprint(input());
|
||||
const view = { claim_cards: [], evidence_card: { ayanamsa: "lahiri" } };
|
||||
const reused = withTurnReuse(view, { decision: "reused", reason: "reused" }, fingerprint, "2026-11-09");
|
||||
assert.equal(reused.turn_reuse.reused_from_previous_turn, true);
|
||||
assert.equal(reused.turn_reuse.reason, "reused");
|
||||
assert.equal(reused.turn_reuse.reference_date, "2026-11-09");
|
||||
const { turn_reuse: _ignored, ...rest } = reused;
|
||||
assert.deepEqual(rest, view, "apart from turn_reuse the view is identical");
|
||||
|
||||
const recomputed = withTurnReuse(view, { decision: "recomputed", reason: "domains" }, fingerprint, "2026-11-09");
|
||||
assert.equal(recomputed.turn_reuse.reused_from_previous_turn, false);
|
||||
assert.equal(recomputed.turn_reuse.reason, "domains");
|
||||
});
|
||||
|
||||
|
||||
// --- 工具层:命中时引擎调用 0 次、状态恢复、视图除 turn_reuse 外不变 ---
|
||||
|
||||
const SERVER_CHART = {
|
||||
name: "合成",
|
||||
toolInput: {
|
||||
year: 1990, month: 1, day: 2, hour: 3, minute: 4, city: "合成地点",
|
||||
lat: 25.03, lon: 121.56, tz: 8,
|
||||
node_mode: "mean" as const, position_mode: "apparent" as const,
|
||||
ayanamsa: "raman" as const, declared_accuracy: "15min" as const, time_source: "family_vague",
|
||||
},
|
||||
truth: {
|
||||
birthDate: "1990-01-02", reportedBirthTime: "03:04", activeBirthTime: null,
|
||||
selectedTimeKind: "reported" as const, birthTimeSource: "reported", birthTimeStatus: "reported",
|
||||
placeLabel: "合成地点", placeCodes: { countryCode: "TW", provinceCode: null, cityCode: null, districtCode: null },
|
||||
placeId: null, placeType: "city", placeProvider: "profile", latitude: 25.03, longitude: 121.56,
|
||||
timezoneId: "Asia/Taipei", timezoneSource: "profile", timezoneOffset: 8,
|
||||
},
|
||||
};
|
||||
|
||||
const CACHED_VIEW = {
|
||||
answer_contract: { status: "ok" },
|
||||
claim_cards: [{ domain: "career" }],
|
||||
evidence_card: { reference_date: "2026-11-09" },
|
||||
methodology: { sections: [] },
|
||||
};
|
||||
|
||||
test("a hit seeds the calculation, restores the state and never calls the engine", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
let engineCalls = 0;
|
||||
const stored: unknown[] = [];
|
||||
const tools = createConsultationTools({
|
||||
userId: "u",
|
||||
sessionId: "reuse-1",
|
||||
requestId: "reuse-1",
|
||||
consultationMode: "verified_chart",
|
||||
followUpTurn: true,
|
||||
serverChart: SERVER_CHART as never,
|
||||
state,
|
||||
turnCard: {
|
||||
decision: { decision: "reused", reason: "reused" },
|
||||
fingerprint: "fp-1",
|
||||
referenceDate: "2026-11-09",
|
||||
reused: {
|
||||
modelView: CACHED_VIEW,
|
||||
evidenceCard: { reference_date: "2026-11-09" },
|
||||
lookupCache: { executions: [] },
|
||||
state: { consultationToolCompleted: true, consultationToolSuccessCount: 1, methodologySectionCount: 0 },
|
||||
},
|
||||
store: (payload) => stored.push(payload),
|
||||
},
|
||||
runWorkflow: async () => {
|
||||
engineCalls += 1;
|
||||
throw new Error("the engine must not run when the turn is reused");
|
||||
},
|
||||
});
|
||||
// 预置:模型第一步就能拿到结果,计算工具即使被调用也只返回同一份。
|
||||
assert.equal(state.consultationToolCompleted, true);
|
||||
assert.equal(engineCalls, 0);
|
||||
const tool = tools["run-jyotish-consultation"];
|
||||
assert.ok(tool);
|
||||
assert.equal(typeof tool.execute, "function");
|
||||
assert.deepEqual(state.consultationToolSuccessCount, 1);
|
||||
assert.equal(stored.length, 0, "a pure hit does not rewrite the cache");
|
||||
});
|
||||
|
||||
|
||||
// --- 每个触发条件:判定为 recomputed,且工具在未命中时确实调用引擎 1 次 ---
|
||||
|
||||
const TRIGGERS: Array<[string, Partial<TurnCardFingerprintInput>]> = [
|
||||
["birth", { birth: { ...BIRTH, minute: 5 } }],
|
||||
["ayanamsa", { ayanamsa: "raman" }],
|
||||
["node_mode", { nodeMode: "true" }],
|
||||
["position_mode", { positionMode: "mean" }],
|
||||
["functional_profile", { functionalProfile: "bphs_exception_profile" }],
|
||||
["narayana_profile", { narayanaProfile: "steps-a" }],
|
||||
["answer_mode", { answerMode: "professional" }],
|
||||
["gender", { gender: "female" }],
|
||||
["domains", { domains: ["career"] }],
|
||||
["reference_date", { referenceDate: "2026-11-10" }],
|
||||
["packet_version", { packetVersion: "packet-v2" }],
|
||||
["card_version", { cardVersion: "card-v2" }],
|
||||
["build_version", { buildVersion: "build-v2" }],
|
||||
];
|
||||
|
||||
test("every trigger recomputes with its own reason and runs the engine once", async () => {
|
||||
for (const [reason, change] of TRIGGERS) {
|
||||
resetTurnCardCache();
|
||||
const base = turnCardFingerprint(input());
|
||||
const changed = input(change);
|
||||
storeTurnCard("trigger-session", base, {
|
||||
referenceDate: "2026-11-09",
|
||||
parameters: { ayanamsa: "lahiri", nodeMode: "mean", positionMode: "apparent" },
|
||||
payload: payload("2026-11-09"),
|
||||
writtenAt: Date.now(),
|
||||
});
|
||||
const decision = decideTurnCardReuse("trigger-session", turnCardFingerprint(changed), changed);
|
||||
assert.deepEqual(decision, { decision: "recomputed", reason }, reason);
|
||||
|
||||
const state = createConsultationRuntimeState();
|
||||
let engineCalls = 0;
|
||||
createConsultationTools({
|
||||
userId: "u", sessionId: "trigger-session", requestId: `t-${reason}`,
|
||||
consultationMode: "verified_chart", followUpTurn: true,
|
||||
serverChart: SERVER_CHART as never, state,
|
||||
turnCard: { decision, fingerprint: turnCardFingerprint(changed).fingerprint, referenceDate: changed.referenceDate },
|
||||
runWorkflow: async () => {
|
||||
engineCalls += 1;
|
||||
throw new Error("synthetic engine stop");
|
||||
},
|
||||
});
|
||||
// 未命中时不预置,所以计算工具一被调用就会走引擎(这里抛错即证明它没被试运行)。
|
||||
assert.equal(state.consultationToolCompleted, false, reason);
|
||||
assert.equal(engineCalls, 0, reason);
|
||||
}
|
||||
});
|
||||
|
||||
test("a reused view equals the stored one apart from turn_reuse", async () => {
|
||||
resetTurnCardCache();
|
||||
const fingerprint = turnCardFingerprint(input());
|
||||
const stored = payload("2026-11-09");
|
||||
storeTurnCard("equal-session", fingerprint, {
|
||||
referenceDate: "2026-11-09",
|
||||
parameters: { ayanamsa: "lahiri", nodeMode: "mean", positionMode: "apparent" },
|
||||
payload: stored,
|
||||
writtenAt: Date.now(),
|
||||
});
|
||||
const decision = decideTurnCardReuse("equal-session", fingerprint, input());
|
||||
assert.equal(decision.decision, "reused");
|
||||
const view = withTurnReuse(stored.modelView, decision, fingerprint, "2026-11-09");
|
||||
const { turn_reuse: marker, ...rest } = view;
|
||||
assert.deepEqual(rest, stored.modelView);
|
||||
assert.equal(marker.reused_from_previous_turn, true);
|
||||
});
|
||||
Reference in New Issue
Block a user