Compare commits

..
Author SHA1 Message Date
jesse-ux 4c9e25ce58 docs(task): record varga guard validation and baseline
Independent Staging Quality Gate / validate-python (pull_request) Successful in 7m35s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 8m30s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-11 16:12:06 +08:00
jesse-ux fafabf8e48 fix(consult): verify explicit varga house reference points
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m39s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m10s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-11 15:50:54 +08:00
ClaudeandClaude Opus 5.5 3fe65bfc23 docs(task): brief for d9-reference-guard (#183, BUG-1826, 2026-11-11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-11 14:54:53 +08:00
ClaudeandClaude Opus 5.5 1505645287 merge: #182 option A - Claude prototype codex/yoga-status-wording-optB-1111 (855cc71c), owner ruling 2026-11-11 (BUG-1825)
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m9s
Independent Staging Quality Gate / validate-python (push) Successful in 6m48s
Independent Staging Quality Gate / publish (push) Successful in 2m54s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-11 14:46:55 +08:00
ClaudeandClaude Opus 5.5 855cc71ca9 fix(consult): option B for #182 - cells split on 。!?; only, no name carry (#182 needs ruling, BUG-1825)
be9b2e91 carried names across cells and past semicolons, which flagged
9 phrases in staging answers the human check rated correct (b4yoga spot
set). Option B: inside a table cell split on 。!?; only (not commas)
and never carry names. Spot set: new arm 0 mismatches, old arm keeps the
2 real ones (Amala, Saraswati). Also fixes the 5 tsc TS2339 errors.
Prototype for the owner's ruling; not merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-11 14:15:55 +08:00
jesse-uxandGrok (agent loop) be9b2e9131 fix(consult): split table cells by sentence and carry names inside a cell (#182 round 2, BUG-1825)
抽查补充:表格行先按 | 分格,格内再按同一套句读分句;同一格里后面的子句出现状态词时
沿用本格上文的名字(正文不跨句继承)。

验收那格「Sunaphaa、Vosi 状态为 candidate(…);Amala、Saraswati 等为 computed,不成立」
现在只有 Amala、Saraswati 两条不符。

Tests: 本单 14 条;fact-guard 13、fact-guard-stream 2、chat-reopt 4、
affliction-reading 6、professional-mode 10 全过。

Co-Authored-By: Grok (agent loop)
2026-11-09 20:03:50 +08:00
jesse-ux 84f2f29f87 Merge remote-tracking branch 'origin/codex/yoga-status-wording-20261109' into codex/yoga-status-wording-20261109 2026-11-09 20:02:44 +08:00
jesse-uxandGrok (agent loop) 1a4ba3fc47 fix(consult): the three red tests and the 已 false positive (#182 round 2, BUG-1825)
- consult-chat-reopt(BUG-1728):shared-reading 取值补 YOGA_STATUS_TABLE: "x"
- consult-affliction-reading T1:旧的「瑜伽只认 yogas.hits」换成新第 7 条开头,
  并断言整张状态表;hard red line 3 把映射表的状态值加进「不是字段名」例外(从模块取)
- 用法否定现在接受「成立」前多一个「已」(不当作已成立),补 1 条误拦测试
- Windows 上「只有一张表」测试的路径统一成正斜杠(文件集合不变)

未做成的一步:全量 npm test 与 staging 逐条对比——本机共享 node_modules 被清空/缺包
(zustand 等 Missing),整跑 140 条几乎全是模块加载失败,与本单无关;已在 PROGRESS 写明。

Tests: 两个被点名文件 4+6、本单 13、fact-guard 13、fact-guard-stream 2、
professional-mode 10 全过。

Co-Authored-By: Grok (agent loop)
2026-11-09 20:02:30 +08:00
ClaudeandClaude Opus 5.5 46ea6a35c9 docs(task): FIX-yoga-status-wording-1 — table cells split by sentence too (spot check)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 19:59:28 +08:00
ClaudeandClaude Opus 5.5 cb1339ef2e docs(task): FIX-yoga-status-wording-1.md — #182 review fail: 3 red tests, one false positive
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 19:33:08 +08:00
ClaudeandClaude Opus 5.5 c9bac6fe0e fix(consult): initialise CardView.yogas so tsc passes (#182 acceptance, BUG-1825)
Grok could not run tsc (shared node_modules was emptied); the new required
field was missing from the view literal (TS2741).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 19:32:48 +08:00
ClaudeandClaude Opus 5.5 8f25693456 test(consult): stream path, single-table scan and 只有…才 for yoga status (#182 acceptance, BUG-1825)
Brief items 8 and 9 (last bullet) were not written into tests. The
conditional list had 只有 alone, which also skipped plain statements
such as 卡上只有 X 成立; brief 3d lists 只有…才, so require both in
order before 成立.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 19:32:48 +08:00
jesse-uxandGrok (agent loop) 185c92bbe7 feat(consult): reject yoga status wording that the card contradicts (#182, BUG-1825)
- 拦截检查新增 yoga_status:卡上每行状态(hits-only 按 blocked)、名字归一
  (去括号 / 去末尾 yoga / 去分隔符)、歧义名跳过、按句判状态词类别
  (括号不断句、表格按 | 分格、排除写法与条件句不算),candidate / computed /
  blocked 写「成立」或「不成立」都算不符,formed 只认肯定、not_met 只认否定
- 调用点放在 checkLine 的两处早退之前(没有 Karakamsha / Arudha 的卡也要检查)
- 重写提示加一条;仍不符就删句(既有逻辑)
- 合同第 7 条与 product-voice 那一行改由 yogaStatusContractTable() 生成

Tests: 新增 consult-yoga-status-wording-1825.test.ts 9 条;fact-guard 13、
fact-guard-stream 2、professional-mode 10、answer-mode-ui 14、
candidate-status 12、limitation-line 12 全过。

Co-Authored-By: Grok (agent loop)
2026-11-09 18:49:10 +08:00
jesse-uxandGrok (agent loop) ece76a2e8e wip(#182): one yoga status table drives the contract and the professional voice
- yoga-status-words.ts:新增 yogaStatusContractTable()(遍历 YOGA_STATUS_MAPPING 生成,
  不手写文字)与 YOGA_STATUS_WORD_CLASSES(拦截检查用的三张词表 + 括号里照抄状态的词)
- references/product_runtime_contract.md:shared-reading 第 7 条与它上面的注释改成
  「按 yogas_detail 每行状态 + {{YOGA_STATUS_TABLE}}」,别的行一字未动
- consultation-condensed-checklist.ts:sharedReadingBlock() 的取值里补 YOGA_STATUS_TABLE
- product-voice.ts:只把「状态词照卡上原词抄」那一行换成按同一张表写
- consult-professional-mode-20261107.test.ts:按任务书改那一处断言(三栏注释)

未完成(下一轮):consultation-answer-fact-guard.ts 的格局状态检查与 9 组测试。

Tests: consult-professional-mode 10/10、consult-basis-candidate-status-1824 12/12 通过。

Co-Authored-By: Grok (agent loop)
2026-11-09 18:39:56 +08:00
ClaudeandClaude Opus 5.5 0d114c9ca9 docs(task): brief for yoga-status-wording (#182, BUG-1825, 2026-11-09)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 18:18:16 +08:00
16 changed files with 1546 additions and 10 deletions
+28
View File
@@ -24749,3 +24749,31 @@
- 防复发:上述测试;以后重封必须新建标签,不覆盖旧标签。
- 关联记录:BUG-1809、BUG-1827、BUG-1829、BUG-1830、BUG-1801、BUG-1644
- 修复版本:staging(codex/accept-grok-batch5-1111)
## BUG-1825 | 回答正文把卡上的 computed 写成「非成立」、candidate 写成「成立」
- 状态:fixed-pending-verify(staging,待真机)
- 首次发现 / 最近更新:2026-11-09(占星师第七轮回复 §5 P0.2)/ 2026-11-09(修复)
- 影响面:frontend/src/lib/yoga-status-words.ts(新增合同文字与词表类)、consultation-answer-fact-guard.ts(新增 yoga_status 检查)、consultation-condensed-checklist.ts、product-voice.ts、references/product_runtime_contract.md(shared-reading 第 7 条与其注释)。
- 现象:父母题深度回答把卡上 computed 的五条格局写成「非成立状态的格局」;事业题把 candidate 的 Sunaphaa / Vosi 写成「成立:…」。页面侧(#181)已修,回答正文没有拦截。
- 触发条件:任何在正文里给格局下「成立 / 不成立」结论的回答。
- 根因:唯一状态映射表只被页面用;合同第 7 条仍写「hits 里的才是成立的格局」;专业模式 voice 把 computed 写成「已计算,未验证」;回答拦截检查不检查格局状态。
- 修复:yoga-status-words.ts 新增 yogaStatusContractTable()(遍历映射表生成文字,不手写)与 YOGA_STATUS_WORD_CLASSES(肯定 / 否定 / 排除写法 / 条件词 / 括号内照抄状态的词);合同第 7 条与 product-voice 同一行改成按该表说;拦截检查新增 yoga_status:卡上每行状态(hits-only 按 blocked)→ 名字归一(去括号、去末尾 yoga、去分隔符)→ 歧义名跳过 → 按句判状态词类别(括号不断句、表格按 | 分格、否定与排除写法不误判、条件句不算)→ candidate / computed / blocked 写成立或否定都算不符、formed 只认肯定、not_met 只认否定;重写提示加一条,仍不符则删句。
- 验证:frontend/tests/consult-yoga-status-wording-1825.test.ts 9 条(她的父母例 5 条不符、事业例 2 条不符、hits-only 按 blocked、四种正确写法不拦、五行映射文字各一句不拦、formed/not_met 两向、歧义与结构观察与混句不查、重写拒绝与删句、合同与 voice 同源);consult-answer-fact-guard 13、fact-guard-stream 2、professional-mode 10、answer-mode-ui 14、candidate-status 12、limitation-line 12 全过。
- 防复发:上述测试;状态文字只有一份(yoga-status-words.ts),合同与 voice 都由它生成。
- 关联记录:BUG-1824(页面侧同一张表)、BUG-1789(回答拦截检查)、占星师 2026-11-09 第七轮回复 §5 P0.2
- 验收退回(2026-11-09,FIX-yoga-status-wording-1):前端全量 3 条既有测试变红(shared-reading 取值缺 YOGA_STATUS_TABLE、T1 断言旧第 7 条、hard red line 3 把状态值当字段名),另有 `不当作已成立` 被误拦。已按三栏注释改那三处断言与取值、让用法否定接受「成立」前多一个「已」,并补 1 条误拦测试;被点名的两个测试文件 4+6 条通过,本单 13 条与相关 25 条通过。
- 验收退回(2026-11-11,第 2 次 → 需拍板):be9b2e91 在表格格内按逗号断句并在格内沿用格局名,名字跨格、跨分号被带走,b4yoga 抽查集新检查 9 条误拦(人工判为正确的写法)。Claude 原型 855cc71c(分支 codex/yoga-status-wording-optB-1111):格内只按 。!?; 与换行断句、不沿用名字;抽查集新检查 0 条误拦,旧回答仍拦下 2 条真错(Amala、Saraswati);另修 5 个 tsc TS2339。
- 产品负责人 2026-11-11 选 A:采用 Claude 原型分支(855cc71c)合入 staging;Grok 分支 codex/yoga-status-wording-20261109 不合并。
- 修复版本:staging(合并 codex/yoga-status-wording-optB-1111 @ 855cc71c)
## BUG-1826 | 回答拦截漏查分盘宫位的起算点
- 状态:fixed-pending-verify(待独立验收)
- 首次发现 / 最近更新:2026-11-09 / 2026-11-11
- 现象与触发条件:回答把从分盘另一星座数出的宫数写为从该分盘上升数,证据卡虽有行星与上升的星座,拦截未拒收该句。
- 根因:原规则只检查分盘星座、上升和宫主。显式起算措辞也会阻止通用位置规则将其当作本命宫位处理。
- 修复:新增 `varga_house`,在同句同格绑定层、实体、起算点、整宫关系与句中值,给出现有重写流程可用的卡上值。
- 验证与防复发:`frontend/tests/consult-varga-house-reference-1826.test.ts` 覆盖原反例、不同起算点、分盘冲突、表格和重写。实测及边界见 `docs/tasks/PROGRESS-d9-reference-guard-20261111.md`。
- 关联记录:BUG-1789、BUG-1825、#183。
- 修复版本:`codex/d9-reference-guard-20261111`。
+45
View File
@@ -0,0 +1,45 @@
# #182 验收退回(第 1 次):前端全量 3 条既有测试变红,另有一处误拦(2026-11-09,Claude)
本文件补充 `TASK-yoga-status-wording-20261109.md`,冲突时以本文件为准。验收的是 `185c92bb`。
## 已核对通过(保留)
- 合同第 7 条与注释、`product-voice.ts` 一行、`sharedReadingBlock()` 取值,逐字符合任务书;三张词表来自 `YOGA_STATUS_WORD_CLASSES`;她的两个反例被拦(5 条、2 条);professional-mode 那一处断言改法正确。
## Claude 已在本分支补上的(不要回退)
- `8f256934`:任务书第 8 条(流式路径)和第 9 条最后一项(五句文字只在 `yoga-status-words.ts`)原来没写进测试,已补;条件词 `只有` 单独出现会把「卡上只有 X 成立」也放过,任务书 3d 写的是 `只有…才`,已改成两段按顺序出现才算,并加测试。
- `c9bac6fe`:`factGuardCardView` 的 `view` 字面量缺 `yogas: {}`,`tsc --noEmit` 报 TS2741,已补。
## 问题 1:前端全量有 3 条既有测试因本单变红(staging 上都通过)
| 文件 | 测试 | 原因 |
| --- | --- | --- |
| `tests/consult-chat-reopt-20261107.test.ts` | BUG-1728: product rules load from the announced contract file… | 调 `productContractBlock("shared-reading", {...})` 的取值里没有 `YOGA_STATUS_TABLE`,现在抛 `needs a value` |
| `tests/consult-affliction-reading-20261001.test.ts` | T1: the shared reading is the first section… | 断言旧第 7 条原文 `/瑜伽只认 yogas\.hits/` |
| 同上 | hard red line 3: every field name the lists cite is a key on a real card | 新第 7 条里的状态值(`candidate`、`computed` 等)被当成字段名,在卡上找不到 |
要做的(只许这样改,每处写「原值 / 新值 / 原因」三栏注释,原因写 `BUG-1825:shared-reading 第 7 条改用 yoga-status-words.ts 的映射表(任务书第 2a 节)`,并列进 PROGRESS):
1. BUG-1728 那条:只在取值对象里加 `YOGA_STATUS_TABLE: "x"`,其他不动。
2. T1:把 `/瑜伽只认 yogas\.hits/` 换成断言新第 7 条的开头 `格局只按卡上 yogas_detail 每一行的状态说`,并断言输出包含 `yogaStatusContractTable()` 整串(同等或更严)。
3. hard red line 3:只把 `YOGA_STATUS_MAPPING` 各项 `evidence` 里的状态值(从模块 import,不手写)加进“不是字段名”的例外集合;`required` 清单与其余检查不变。
4. 在 `frontend` 下跑一次完整 `npm test`(有依赖的机器上),把失败名单和 staging 的逐条对比(集合差为空)写进 PROGRESS。
## 问题 2:一处误拦(验收时在 staging 旧回答上发现)
staging 的一份综合题回答写「Saraswati、Amala 等为 `computed`,不当作已成立格局」,新检查把 4 个格局都判为不符:排除写法只有 `不当作成立`,没有中间带「已」的写法。
要做的:`exempt` 里对用法否定那一组(`不作…`、`不当作…`、`不算…`、`不视为…`、`不写成…`、`不说成…`、`未列为…`、`没有列为…`),同时接受 `成立` 前多一个 `已` 的写法(例如 `不当作已成立`、`不视为已成立`)。这是 Claude 对任务书 3d 的补充,不是新的产品决定;除此之外不增删词。加 1 条测试,用上面那句原文,结果为 0 条不符。
## 不要碰
原任务书“允许改的文件”加上面三个测试文件中列出的那几处;其余“不要碰”清单继续有效。不得自行写“产品决定/裁定”;任务书没写到的取舍一律选需拍板。
BUG-1825(在原条目补一行“验收退回:全量 3 条变红、`不当作已成立` 误拦”,只改这一条)。
## 问题 3(抽查补充):表格格内不再按句号 / 分号断句,名字被错配
抽查(2 张公开盘 × 7 题,staging 回答)里有一格写「Sunaphaa、Vosi 状态为 candidate(support_only_candidate);Amala、Saraswati 等为 computed,不成立」。表格行现在只按 `|` 分格,所以整格算一句,前半句的 Sunaphaa、Vosi 也被判成「不成立」。
要做的:表格行先按 `|` 分格,每一格内部再按 3c 的句读规则(`。!?;,,`,括号内不断)分句。加 1 条测试,用上面这格原文:只有 Amala、Saraswati 两条不符,Sunaphaa、Vosi 不出现。
@@ -0,0 +1,138 @@
# PROGRESS · 分盘宫位起算点检查(#183,BUG-1826,2026-11-11)
- 基线为 staging `1505645287c59d53bc0c608c2c10b2da32d61f6a`。
- 工单分支为 `codex/d9-reference-guard-20261111`。
- 实施依据见 `docs/tasks/TASK-d9-reference-guard-20261111.md`。
## 实现
在既有回答拦截末尾新增 `varga_house` 检查。它先在一句或一格内绑定显式分盘、宫数、
起算点和该宫数前最近的实体,再用卡片的星座从指定起算点按整宫计数。修正提示给出正确
宫数,卡上有该盘上升时也给出原宫数对应的起算星座。
基线原句里的「月亮在 D9 金牛」因分盘名插在行星和星座之间,旧星座检查没有识别。
新检查在核实这种内联星座与卡一致后,同时记录已确认的 `varga_sign`,保留原句要求的
星座证据。原有星座规则、格局状态规则、`BREAKER`、`layerSpans`、去重和重写判定保留。
当宫数在同一括号的起算点前面,新宫位事实的定位落在起算点,使原星座事实与宫位事实
各有自己的文本范围。
## 覆盖范围
| 句式 | 验证 |
| --- | --- |
| D9 原反例 | 恰好一条宫位不符,绑定 D9、Moon、D9 上升、11 宫与卡上 5 宫。提示巨蟹为原值对应的起算星座 |
| 起算点变更 | D9 上升、上升起算、Lagna、巨蟹数/起/为第 1 宫、D9 金星数/起 |
| 值的位置 | 紧接起算点,或位于同一括号的起算点前面 |
| 其他分盘 | D10 例检查实体星座来源。卡片跨领域给出冲突 D10 时跳过 |
| 分句 | 正文按 。!?; 和换行断句,表格按格处理,格内同样保留逗号 |
| 修复 | 原值再次出现返回 `still_mismatched`,改为正确宫数后通过 |
本次覆盖范围限于阿拉伯数字 1–12、任务书列明的三类起算点及已有显式分盘范围。中文
数字、未写起算点、分开的括号、跨句跨格的实体或起算点,以及 Karakamsha、AL/UL、
月亮盘、D1 参照、行运、年盘和 KP 等其他体系不进入该检查。星座有歧义或与卡不符、卡
缺层或缺值,以及无法唯一配对的宫数与参照也跳过。
真实回答抽查与精度统计由独立验收侧完成。
## 验证
- 四份定向套件共 39 项通过,0 失败、0 取消。原 `consult-answer-fact-guard-1789`、
流式重写与 `consult-yoga-status-wording-1825` 的断言均原样保留。
- TypeScript `--noEmit` 通过。ESLint 0 error、62 warnings。隐私检查 62 项通过。
- 两份生产构建退出码均为 0,首页 `/` 均为 Static。以首页 HTML 直接引用的 36 份
JS 资源按路径去重后分别 gzip,staging 和本单均为 515086 字节,变动 0%。基线构建
另有 Windows 文件跟踪复制警告。
- staging 基线 `150564528` 的 Gitea 全量前端门禁 3631 通过、0 失败、0 取消。
PR 代码提交 `fafabf8e4` 的 Gitea 全量前端测试 3640 通过、0 失败、0 取消。
- Windows 本机 `npm test -- --test-concurrency=4` 退出码 1,3231 通过、76 失败、0
取消。失败包含 Windows 路径和 Shell 兼容、Docker 夹具、启动子进程失败及断言。
将 32 个文件级失败单独以并发 1 重跑,365 项全部通过。管理员兑换码的单条失败
定向重跑也通过。
- 同机 staging 基线全量退出码 1,3599 通过、32 失败、0 取消。以两边 TAP 的顶层
测试名逐项对照,将本单 32 个文件级失败的占位行替换为重跑名录后,staging 的
3611 个测试名全部保留,新增 9 个。本机失败名有 30 个在两边重合,含两条回答
时钟断言。其余 13 个本单首轮失败未在基线复现,下文标出。同机基线另有
`synastry reports reuse a pair, drop with the person, and settle the synastry.full reservation`
和 `database roles have no cluster privileges` 两项本单通过的失败。两组 Gitea
全量前端测试均通过,Windows 首轮失败保留在此供验收核查。
### Windows 本机首次全量失败名
以下为 TAP 失败行去重后的测试名或测试文件。
- `admin code functions reject immutable codes, revoked redemption, and roll back on audit failure`
- `operation-level admin email reauthentication is removed from routes and UI`
- `a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase`
- `the answer clock cutting the answer step mid-sentence is truncated, recorded and not charged`
- `dated adoption 23:59 persists date/spec atomically and rejects malformed candidates`(基线通过)
- `staging backups are encrypted, atomic, private, and retain the newest three`
- `rejects destructive backup directory aliases and symlink components before mutation`
- `rejects unsafe writable backup parents before creating the target`
- `rejects a direct canonical sticky shared backup directory before chmod`
- `creates every absent backup path component privately despite a permissive caller umask`
- `same-second backups publish once without overwriting the completed archive`
- `find enumeration failures preserve existing backups and do not report completion`
- `refuses full disks and removes a failed-pipeline partial file`
- `actual subject deletion releases queued and running reservations once but never refunds delivered reports`(基线通过)
- `database env validator accepts punctuated literal required secrets`
- `database env validator enforces an explicit staging owner uid without printing values`
- `database env validator accepts a private valid file without printing values`
- `migration runner is serialized, atomic, drift-safe, and read-only in check mode`(基线通过)
- `foundation grants no direct runtime table DML and exposes only reviewed functions`(基线通过)
- `personal report job migration is atomic, lease-bound, recoverable, and owner read-only`
- `personal report sections enforce owner-read RLS and service-owned durable transitions`
- `rectification session titles follow the result and do not bump updated_at`(基线通过)
- `a delivered report found by recovery is never refunded`(基线通过)
- `self-hosted identity migration creates Better Auth tables with least privilege`(基线通过)
- `session list query drops retired rectification rows and empty consultations`(基线通过)
- `retire archive migration clears archived_at without bumping updated_at`(基线通过)
- `createSynastryFullReport reserves synastry.full and enqueues a job on the real client`
- `synastry chat binds an owned section, rejects another report, and stays readable after delete`(基线通过)
- `staging model provider env preparation removes legacy settings and keeps one stable key`
- `staging env validator rejects selector drift, duplicates, and unsafe permissions`
- `production env validators accept only self-hosted production selectors and role URLs`
- `Better Auth supports shared user OTP/password sessions for admins`(基线通过)
- `BUG-1526: outside self-hosted auth the profile read does not ask for avatar columns`(基线通过)
- `tests/notify-toasts-20260930.test.ts`
- `tests/people-archive-order.test.ts`
- `tests/people-archive-regressions.test.ts`
- `tests/people-archive-view.test.tsx`
- `tests/people-pagination-lifecycle.test.tsx`
- `tests/personal-report-agent-telemetry.test.ts`
- `tests/personal-report-api.test.ts`
- `tests/personal-report-contract.test.ts`
- `tests/personal-report-density-retry.test.ts`
- `tests/personal-report-entitlement.test.ts`
- `tests/personal-report-entry.test.ts`
- `tests/personal-report-export.test.ts`
- `tests/personal-report-generation-v2.test.ts`
- `tests/personal-report-generation.test.ts`
- `tests/personal-report-job-service.test.ts`
- `tests/personal-report-job-state.test.ts`
- `tests/personal-report-longform-appendix.test.ts`
- `tests/personal-report-longform-md.test.ts`
- `tests/personal-report-markdown-view.test.ts`
- `tests/personal-report-markdown-view.test.tsx`
- `tests/personal-report-migration.test.ts`
- `tests/personal-report-plan.test.ts`
- `tests/personal-report-progress.test.ts`
- `tests/personal-report-theme-contract.test.ts`
- `tests/personal-report-view.test.ts`
- `tests/personal-report-worker-sleep-listener.test.ts`
- `tests/postgres-fixture-contract.test.ts`
- `tests/pricing-simulation.test.ts`
- `tests/product-domain-registry.test.ts`
- `tests/profile-gender-20260927.test.ts`
- `tests/profile-gender-ui-20260927.test.tsx`
- `immutable Skill migration enforces RPC-only identity, legacy adoption, and cascade-safe receipts`
- `importing the registry creates no runtime directory`
- `a used runtime root is removed when the process exits`
- `changed staging workflows are syntactically valid YAML`
- `live staging sync preserves env, state, incoming files, and encrypted backups`
- `live staging sync repairs nested deploy-tree drift without preserving foreign ownership`
- `first immutable deployment rolls back to validated local image IDs`
- `deployment scripts pass shell syntax validation`
- `is-docs-only-range.sh decides from local history and refuses non-ancestor ranges`
- `bounded npm install distinguishes forced timeout, OOM, and unknown failure and cleans its container`
- `session and report lists keep legacy self rows and hide another person`(基线通过)
- `tests/synastry-report-chat.test.ts`
@@ -0,0 +1,86 @@
# PROGRESS · 回答里的格局状态词要和卡一致(#182,BUG-1825,2026-11-09)
- 分支:`codex/yoga-status-wording-20261109`(基线 `bfd7c30a`)
- 任务书:`docs/tasks/TASK-yoga-status-wording-20261109.md`
- Bug:`docs/BUG_HISTORY.md` 末尾追加 BUG-1825
## 做了什么
| 文件 | 改动 |
| --- | --- |
| `frontend/src/lib/yoga-status-words.ts` | 只新增:`yogaStatusContractTable()`(遍历 `YOGA_STATUS_MAPPING` 生成一行文字,不手写)与 `YOGA_STATUS_WORD_CLASSES`(肯定 / 否定 / 排除写法 / 条件词 / 括号内照抄状态的词)。已有导出一字未改 |
| `references/product_runtime_contract.md` | 只改 `shared-reading` 第 7 条和它上面的注释(逐字照任务书),其他行与所有 product-block 未动 |
| `frontend/src/lib/consultation-condensed-checklist.ts` | `sharedReadingBlock()` 的取值里补 `YOGA_STATUS_TABLE` |
| `frontend/src/mastra/product-voice.ts` | 只改「状态词照卡上原词抄」那一行 |
| `frontend/src/lib/consultation-answer-fact-guard.ts` | 只加:`CardView.yogas`、`yogaCardEntries()`、`normalizeYogaName()`、`yogaNamePattern()`、`yogaSentences()`、`yogaWordClass()`、`checkYogaStatus()`;`checkLine` 里的调用放在两处早退之前;重写提示加一条 |
| `frontend/tests/consult-professional-mode-20261107.test.ts` | 按任务书改那一处断言(三栏注释,改严不改弱) |
| `frontend/tests/consult-yoga-status-wording-1825.test.ts`(新) | 9 条 |
## 两处实现选择(任务书没写死的细节)
1. **`checkYogaStatus` 的调用位置**:任务书说「在 `checkLine` 末尾调用」。`checkLine` 里有两处早退
(`if (!kkm) return;`、`if (!Object.keys(view.arudha).length) return;`),放在末尾会让**没有
Karakamsha / Arudha 的卡完全不检查格局状态**——我第一次就是踩了这个(测试 0 条不符)。
所以调用放在这两处早退**之前**,其余判定一行未改。
2. **名字末尾的 `Yoga` 视为可省**:`Budhaditya Yoga` 在正文里常写成 `Budhaditya`。生成匹配时去掉
末尾的 `yoga` 词,否则只匹配带 Yoga 的写法(第二次踩到的就是这个)。
## 测试
| 命令 | 结果 |
| --- | --- |
| `node --test tests/consult-yoga-status-wording-1825.test.ts` | 9 过 / 0 败 |
| `tests/consult-answer-fact-guard-1789.test.ts` | 13 过 |
| `tests/consult-fact-guard-stream-1789.test.ts` | 2 过 |
| `tests/consult-professional-mode-20261107.test.ts` | 10 过 |
| `tests/consult-answer-mode-ui-20261108.test.tsx` | 14 过 |
| `tests/consult-basis-candidate-status-1824.test.ts` | 12 过 |
| `tests/consult-limitation-line-1834.test.tsx` | 12 过 |
9 条测试覆盖任务书的 9 组:她的父母例(5 条 `yoga_status`、`claimed=非成立`、`card` 用常量)、
事业例(「成立:…」与「…均已成立。」各 2 条)、只在 hits 里(`card` 以 `YOGA_BLOCKED_PREFIX` 开头)、
四种正确写法 0 条、五行映射文字各一句 0 条、formed / not_met 两向、歧义与结构观察与同句混类不查、
重写拒绝(`still_mismatched`)与合格替换、合同与 voice 同源且不再有旧措辞。
## 已知未覆盖
- 中文意译的格局名(例如「日水合相格」这类写法)不在本单范围:拦截只认拉丁字母写的名字。
- 句内出现「成立」但主语不是格局(例如「结论成立」)时,若同一句里也点了格局名,仍可能被判不符;
任务书的三张词表就是这个粒度,要更细需要新的裁定。
## 第 1 次打回(FIX-yoga-status-wording-1,2026-11-09)
**问题 1:前端全量 3 条既有测试变红(staging 上通过)。**
| 文件 | 改法(都写了「原值 / 新值 / 原因」三栏) |
| --- | --- |
| `tests/consult-chat-reopt-20261107.test.ts`(BUG-1728 那条) | 取值对象里加 `YOGA_STATUS_TABLE: "x"`,其他不动 |
| `tests/consult-affliction-reading-20261001.test.ts` T1 | 旧的 `/瑜伽只认 yogas\.hits/` 换成断言新第 7 条开头,并断言输出包含 `yogaStatusContractTable()` 整串(更严) |
| 同上「hard red line 3」 | 把 `YOGA_STATUS_MAPPING` 各项 `evidence` 里的状态值(从模块 import,不手写)加进「不是字段名」的例外集合;`required` 清单与其余检查不变 |
改后:`consult-chat-reopt` **4/4**、`consult-affliction-reading` **6/6** 通过。
**问题 2:`不当作已成立` 被误拦。** `exempt` 的用法否定组现在接受「成立」前多一个「已」
(`不当作已成立`、`不视为已成立`…);只改这一处,没增删词。新增 1 条测试用验收时那句原文
(`Saraswati、Amala 等为 computed,不当作已成立格局。`),结果 0 条不符。
**另外修了 Claude 补的那条「只有一张表」测试在 Windows 上的路径写法**:`relative()` 在 Windows
返回反斜杠,与断言的 `src/lib/...` 不匹配;比较前统一成正斜杠(只改路径写法,文件集合不变)。
### 全量对比这一步没做成(环境缺口)
Claude 在本分支上已跑过一次全量、指出 3 条红;我这一轮把它们修好后,本机**共享的
`frontend/node_modules` 已被别的会话清空/缺包**(`zustand` 等直接 `Cannot find package`),
现在整跑 `npm test` 有 140 条失败,几乎全是模块加载失败,**不是本单引入**,因此无法在这一轮
给出「与 staging 逐条对比、集合差为空」的证据。等完整依赖装回来我会补跑并把名单对比写进来。
本轮能给的替代证据:3 条被点名的测试所在的两个文件(4 条 + 6 条)全部通过;
本单新增的 13 条、以及 fact-guard 13、fact-guard-stream 2、professional-mode 10 全部通过。
### 抽查补充:表格格内按句读分句
表格行先按 `|` 分格,**每一格内部再按同一套句读规则分句**(`。!?;,,`,括号内不断)。
另外,同一格里后面的子句出现状态词时,沿用本格上文提到的格局名——验收给的那格
「Sunaphaa、Vosi 状态为 candidate(…);Amala、Saraswati 等为 computed,不成立」现在只有
**Amala、Saraswati 两条不符**,Sunaphaa、Vosi 不出现(有测试)。正文不跨句继承。
@@ -0,0 +1,131 @@
# TASK:拦截检查要查分盘宫位的起算点(#183,BUG-1826,2026-11-11)
- Gitea:#183
- 分支:`codex/d9-reference-guard-20261111`(基于 staging `1505645287c59d53bc0c608c2c10b2da32d61f6a`,已含 #182)
- 前置:#182(格局状态词)已于 2026-11-11 合入 staging(产品负责人选 A:采用 Claude 原型 `855cc71c`,见 #182 评论)。本单**在同一个拦截检查上扩展**:`frontend/src/lib/consultation-answer-fact-guard.ts` 里 #182 加的格局状态检查保持原样,本单只在旁边新增一个检查。
- Bug 编号:BUG-1826(1809–1830 已为这一批预留;开工时核对 `docs/BUG_HISTORY.md` 末尾)
- 负责验收:Claude(jyotisha-2a)
- 依据:占星师 2026-11-09 第七轮回复 §5 P0.1
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因并在 PROGRESS 列出。
## 现象(她已确认的一例)
婚恋题深度回答第 38 行:「D9 第 7 宫为巨蟹,宫内无星,宫主月亮在 D9 金牛入旺(从 D9 上升数第 11 宫)。」
- 卡上:`base.natal.d9.lagna = Capricorn`,`base.natal.d9.planets.Moon.d9_sign = Taurus`。
- 从 D9 上升(摩羯)数,金牛是第 **5** 宫;第 11 宫是从巨蟹(D9 第 7 宫)数的。句子写明了“从 D9 上升数”,所以是错的。
- 现有检查没拦住:句中的「从 … 数」命中 `BREAKER`,整句被跳过;而且现在没有任何“分盘宫位”检查。
她的要求原文:修复必须绑定 `chart_layer + entity + reference + relation + value`,不是只检查星座文本是否出现。
## 现状(staging `15056452` 实测,已含 #182)
- `frontend/src/lib/consultation-answer-fact-guard.ts`
- `factGuardCardView`:`view.vargas[layer] = { lagna, planets: {行星: 星座}, path }`;D9 来自 `base.natal.d9`,其他分盘来自 `domain_sections.<领域>.vargas`;各领域给出的同一分盘不一致时,这个分盘整个不进视图。
- `layerSpans` / `spanAt`:句子里显式写出的层(D9 / 婚姻分盘 / D10 …)管到哪里。
- `BREAKER`(约 :80):含 `起算|起第|起数|从…起/数|月亮起|月亮盘…`,命中后各检查跳过该句。
- 已有检查:分盘行星星座(`varga_sign`)、分盘上升(`varga_lagna`)、分盘宫主(`varga_house_lord`)、D1 的星座/宫位/上升/宫主、Karakamsha、Arudha;**没有分盘宫位**。
- `push` 会按“同实体、文本重叠”合并,不符优先。
- #182 已加(**只读,不改**):`FactGuardKind` 里的 `yoga_status`、`CardView.yogas` 与 `yogaCardEntries`、`normalizeYogaName`、`yogaNamePattern`、`splitProse`(约 :357)、`yogaSentences`(约 :375)、`yogaWordClass`、`checkYogaStatus`(约 :430,在 `checkKarakamsha` 开头调用)、重写提示里的「格局状态」一条。
- `checkLine`(约 :469)末尾依次调用 `checkKarakamsha`、`checkArudha`。
## 要做的
只**新增**一个检查,不改已有检查的任何正则、`BREAKER`、`layerSpans` 和判定(包括 #182 的格局状态检查)。新检查的入口单独写(例如 `checkVargaHouse(line, offset, view, push)`),在 `checkLine` 末尾 `checkArudha(...)` 之后调用。
### 1. 新的事实种类 `varga_house`
加进 `FactGuardKind`。一条记录绑定五样:
| 字段 | 含义 | 本例 |
|---|---|---|
| `layer` | 分盘(D2…D60,不含 D1) | `D9` |
| `entity` | 行星 | `Moon` |
| `reference` | 起算点,写成 `from D9 lagna (Capricorn)`、`from sign Cancer` 或 `from D9 Venus (Aquarius)` | `from D9 lagna (Capricorn)` |
| 关系 | 整宫制,含起点计数:`((实体星座序号 − 起点星座序号 + 12) % 12) + 1` | — |
| `claimed` / `card` | 句中的宫数 / 按卡算出的宫数 | `11 宫` / `5 宫(从摩羯数;第 11 宫是从巨蟹数)` |
`fact_id` 写 `<layer>:<entity>@<星座>/from:<起点星座>`,`source_path` 写实体星座的卡上路径(D9 是 `base.natal.d9.planets.<行星>.d9_sign`,其他分盘是 `<varga.path>.planets.<行星>`)。
### 2. 只查“写全了”的句子(高精度子集)
一句(正文按 `。!?;` 与换行断句,括号内不断;表格行先按 `|` 分格,格内同样只按 `。!?;` 断句,不跨格、不跨句沿用行星名或起算点——与 #182 验收采用的分句口径一致;可以调用 `splitProse(line, base, /[。!?;\n]/u)`,但不得改它的默认参数或实现)只有同时满足下面四条才检查,否则不检查(也不记 `not_on_card`):
1. **层**:句子在某个显式分盘的范围里(`spanAt` 给出的层不是 `D1`),且视图里有这一层。
2. **实体**:同一句里,宫数之前最近的一个行星名,且这颗行星在这一层的星座在句中写出、并与卡一致(不一致的已由 `varga_sign` 报告,这里不重复检查);或句中没写星座、但卡上这一层有这颗行星。
3. **起算点**:句中明确写出,且属于下面三种之一:
- 这一层的上升:`从 D9 上升数`、`从 D9 上升起`、`D9 上升起算`、`从 D9 Lagna 数` 以及去掉“D9”、但在 D9 范围内的同样写法(`从上升数`);视图里这一层要有 `lagna`;
- 一个星座:`从巨蟹数`、`从巨蟹座起`、`以巨蟹为第 1 宫`;
- 这一层里的一颗行星:`从金星数`、`从 D9 金星起`;视图里这一层要有这颗行星。
- 其他起算点(Karakamsha、AL / UL、月亮盘、D1 的宫、行运、年盘等)一律不检查。
4. **值**:`第 N 宫`(`N` 为 1–12),紧跟在起算点写法之后,或与起算点在同一对括号里。
`BREAKER` 对本检查不起作用(本检查自己识别起算点);但句中若还有 `行运|过境|Transit|流年|年盘|Varsha|Tajika|KP|宫头|Chalit` 这类词,仍不检查。
### 3. 判定与输出
- 按第 1 节的关系算出宫数,等于句中值 → `push(…, "match")`;不等 → 不符。
- 不符时 `card` 文字:先写按句中起算点算出的宫数;若句中的数恰好等于从“这一层上升”或“这一层某个宫的星座”数出来的值,补一句是从哪里数的(本例:`5 宫(从摩羯数;第 11 宫是从巨蟹数)`)。只补一处,找不到就不补。
- `consultationFactRepairPrompt` 的“分清层与实体”那一条后面(即 #182 加的“格局状态”那一条之前)加一条(逐字照写):`- 分盘宫位:从哪里起算就按哪里数,写明起算点;从上升数和从某一宫数是两件事,不能互相照抄。` 其余提示不改。
## 测试
新建 `frontend/tests/consult-varga-house-reference-1826.test.ts`,用**本文件内新建的合成卡**(不写真人资料、不改 `tests/fixtures/fact-guard-minimal-card.ts`):
1. **她的最小反例**:D9 `lagna: "Capricorn"`、`Moon.d9_sign: "Taurus"`;答案用原句「D9 第 7 宫为巨蟹,宫内无星,宫主月亮在 D9 金牛入旺(从 D9 上升数第 11 宫)。」→ 恰好 1 条 `varga_house` 不符:`layer: "D9"`、`entity: "Moon"`、`reference` 含 `lagna`、`claimed: "11 宫"`、`card` 以 `5 宫` 开头并含 `巨蟹`。
2. **同值不同体系(负例)**:同一张卡,
- 「宫主月亮在 D9 金牛(从巨蟹数第 11 宫)。」→ 0 条不符(起算点是巨蟹,11 是对的),1 条 `match`;
- 「宫主月亮在 D9 金牛(从 D9 上升数第 5 宫)。」→ 0 条不符;
- D1 句「本命月亮在第 11 宫」,卡上 D1 月亮在 11 宫 → 本检查不产生任何 `varga_house` 记录;
- 没写起算点的「月亮在 D9 第 11 宫」→ 本检查不检查(0 条 `varga_house`)。
3. **其他起算点**:从 D9 金星数(卡上 D9 金星在水瓶,月亮金牛 → 第 4 宫)写成第 4 宫相符、写成第 5 宫不符;从 Karakamsha / 月亮盘 / AL 起算 → 不检查。
4. **其他分盘**:D10 一例(上升、某行星、写错宫数)不符;该分盘在两个领域段里不一致(视图里没有)→ 不检查。
5. **排除词**:句中有「行运」「年盘」「KP」→ 不检查。
6. **已有检查不变**(不改其中任何断言):`frontend/tests/consult-answer-fact-guard-1789.test.ts` 与 #182 的 `consult-yoga-status-wording-1825.test.ts` 全部照样通过;在新测试里再加一条:她的原句中「D9 金牛」这一处的 `varga_sign` 记录(相符)仍然存在。
7. **重写**:第 1 条的单元,替换句仍写「从 D9 上升数第 11 宫」→ `factRepairRejection` 返回 `still_mismatched`;改成「从 D9 上升数第 5 宫」→ `null`。
## 允许改的文件(只有这些)
- 新建:`frontend/tests/consult-varga-house-reference-1826.test.ts`、`docs/tasks/PROGRESS-d9-reference-guard-20261111.md`
- 修改:`frontend/src/lib/consultation-answer-fact-guard.ts`(只加第 1–3 节的内容和 repair 提示一行)、`docs/BUG_HISTORY.md`(只在末尾追加 BUG-1826,不得用 sed 改整个文件)
## 不要碰
- `references/**`(占星师的方法文件),包括 `references/product_runtime_contract.md`:本单不改合同文字。
- `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`、`frontend/src/mastra/index.ts`、`frontend/src/mastra/product-voice.ts`。
- `frontend/src/lib/yoga-status-words.ts`,以及 #182 加进 fact guard 的格局状态检查:`yoga_status`、`CardView.yogas`、`yogaCardEntries`、`normalizeYogaName`、`yogaNamePattern`、`splitProse`、`yogaSentences`、`yogaWordClass`、`checkYogaStatus`、`checkKarakamsha` 开头那次调用、重写提示里的「格局状态」一条。
- 既有测试文件:`frontend/tests/consult-answer-fact-guard-1789.test.ts`、`consult-fact-guard-stream-1789.test.ts`、`consult-yoga-status-wording-1825.test.ts` 一行不改。
- `frontend/src/lib/stream-agent-response.ts`、`frontend/src/lib/consultation-evidence-card.ts`、`frontend/tests/fixtures/fact-guard-minimal-card.ts`。
- 冻结文件:`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/**`。
- 进行中单子的地盘:#177 / #178(`scripts/ondemand_calc/**` 与注册表)、#172(`frontend/src/mastra/consultation-tools.ts`、`frontend/src/app/api/consult/route.ts`);已验收的 #184 / #185 / #186 的文件本单同样不碰。
## 验收标准
1. 她的原句被拦下,记录五样齐全(层、实体、起算点、关系对应的宫数、句中值);`card` 指出第 11 宫是从巨蟹数的。
2. 同值不同体系的负例全部不报(第 2 条),没写起算点的不检查。
3. 已有检查的结果不变:`consult-answer-fact-guard-1789` 与 `consult-yoga-status-wording-1825` 全过,不改任何既有断言。
4. 只出现允许清单里的改动。
5. **精度**:Claude 验收时用第七轮验收包 10 份回答与第八轮盲测回答跑一遍,人工核对每一条 `varga_house`(不符与相符都看),报告精度;PROGRESS 里请列出测试覆盖的句式和“已知未覆盖”的句式。
## 自测命令(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-varga-house-reference-1826.test.ts tests/consult-answer-fact-guard-1789.test.ts tests/consult-fact-guard-stream-1789.test.ts tests/consult-yoga-status-wording-1825.test.ts`
- 再在 `frontend` 下跑一次 `npm test`,把失败的测试名写进 PROGRESS。
前端全量测试、Python 快速门和 eslint 由 Claude 在验收时再跑。
## 进度记录
`docs/tasks/PROGRESS-d9-reference-guard-20261111.md`
## 何时停下来选“需拍板”
- 要改 `BREAKER`、`layerSpans`、#182 的格局状态检查或任何已有检查才能做到时。
- 要支持第 2 节第 3 条以外的起算点时。
- 句中宫数的写法与第 2 节第 4 条不同、又想纳入检查时。
- 任务书没写到的任何文案或取舍。
@@ -0,0 +1,209 @@
# TASK:回答里的格局状态词要和卡上一致(#182,BUG-1825,2026-11-09)
- Gitea:#182
- 分支:`codex/yoga-status-wording-20261109`(基于 staging `bfd7c30a8563fe1aeabfd87db72dfc5121fb10a4`)
- Bug 编号:BUG-1825(1809–1830 已为这一批预留;开工时核对 `docs/BUG_HISTORY.md` 末尾)
- 负责验收:Claude(jyotisha-2a)
- 依据:占星师 2026-11-09 第七轮回复 §5 P0.2(下称“她的表”)
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因并在 PROGRESS 列出。
## 现象(第七轮验收包,她已确认的两例)
1. 父母题深度回答第 67 行:「其余为已计算、非成立状态的格局(Sasa Yoga、Budhaditya、Surya-Budha、Guru Yoga、Neecha Bhanga 等)」。卡上这 5 条的 `status` 都是 `computed`(检测器检出、未做来源条件检查),回答把它们降成了“非成立”。
2. 事业题简明解读的「解读依据」把 `candidate` 的 Sunaphaa Yoga、Vosi Yoga 写成「成立:Sunaphaa Yoga、Vosi Yoga」。页面这一处已由 #181 修好;本单要让**回答正文**写出同样的话时也被拦下。
她的规则原文:用 hits 列表不能推断“成立”;没有完整条件检查不能写“落空”。模型和页面必须用同一张状态映射。
## 现状(bfd7c30a 实测,行号以符号为准)
- 唯一映射表已存在:`frontend/src/lib/yoga-status-words.ts`
- `YOGA_STATUS_MAPPING`:她的五行表,文字逐字照抄;
- `yogaStatus(row, { notEvaluated })`:按卡上一行(`card.base.natal.yogas_detail.rows[]`)给出 `{ key, text }`;
- `isStructuralObservation(row)`。
- 本单**只能 import 它**,不得另写第二张表,也不得在别处再写这五句文字的字面量。
- 产品规则(送给模型的合同)里有两处和她的表冲突:
- `references/product_runtime_contract.md`,`product-block:shared-reading` 里的第 7 条(注释 `S-07 瑜伽只认 yogas.hits`):「hits 里的才是这张盘成立的格局」——这正是“从 hits 推出成立”。
- 这一块由 `frontend/src/lib/consultation-condensed-checklist.ts` 的 `sharedReadingBlock()` 用 `productContractBlock("shared-reading", { AFFLICTION_RANGE_RULE, CHECKLIST_ANALYSIS_ONLY_NOTE })` 读入;`{{TOKEN}}` 由代码填值,未填会抛错。
- `frontend/src/mastra/product-voice.ts`,`professionalConversationVoice` 的「怎么写」里一行:「状态词照卡上原词抄:candidate 写「候选」、computed 写「已计算,未验证」、strength_comparison_required 写「待定」;不升格为「成立」「确认」「命中」,标题里也不写「已成立」。」——computed 的写法与她的表不同。
- 回答拦截检查(BUG-1789):`frontend/src/lib/consultation-answer-fact-guard.ts`
- `findAnswerFactClaims` 按行调用 `checkLine`;`factGuardCardView` 从卡上取出要核对的事实;
- 不符的句子由 `factGuardUnits` → `consultationFactRepairPrompt` 重写一次,`factRepairRejection` 用同一套检查复核重写结果,仍不符就删句(`applyFactRepair`);
- 流式接入在 `frontend/src/lib/stream-agent-response.ts` 约 :1109(本单**不改**这个文件:只要新检查进了 `findAnswerFactMismatches`,流式路径自动生效)。
- 现在不检查任何格局状态。
## 要做的
### 1. 在 `yoga-status-words.ts` 里加一个合同文字函数(只加,不改已有导出)
```ts
/** 她的表,渲染成一行合同文字;合同和 product-voice 都用它,不各写一遍。 */
export function yogaStatusContractTable(): string
```
- 逐项遍历 `YOGA_STATUS_MAPPING` 生成,格式固定为:`candidate / support_only_candidate →「命中候选规则,尚未完整确认」;computed →「检测器检出,待核实」;formed_under_source →「按所选来源的形成条件成立;强度和结果另评」;conditions_not_met →「所选来源的必要条件未满足」;blocked / not_available_yet →「当前无法确认,列明缺项」`(左边用 `evidence` 数组以「 / 」连接,右边是 `text`;文字不得手写)。
- 再加一个导出常量 `YOGA_STATUS_WORD_CLASSES`(见第 3 节的三张词表),供拦截检查和测试共用。
### 2. 合同:两处改成从这个模块取文字
**2a. `references/product_runtime_contract.md`:只改 `product-block:shared-reading` 里第 7 条和它上面那行注释,别的字一个不动。**
- 第 7 条新文字(逐字照写):
`7. 格局只按卡上 yogas_detail 每一行的状态说,状态文字用这张表:{{YOGA_STATUS_TABLE}}。yogas.hits 只是检测器匹配到的规则名,不能据此写「成立」;只在 names 里、没进 hits 的是待检规则,不得说成本人的格局;没有完整条件检查,不写「不成立」「非成立」「落空」。`
- 注释行改为(逐字照写):
`<!-- 产品规则,不是占星师的方法:S-07 格局状态词。BUG-1825:原值「瑜伽只认 yogas.hits:hits 里的才是这张盘成立的格局;只在 names 里、没进 hits 的是待检规则,不得说成本人的格局。」;新值:按 yogas_detail 每行状态、用 yoga-status-words.ts 的映射表({{YOGA_STATUS_TABLE}});原因:占星师 2026-11-09 第七轮回复 §5 P0.2「使用hits列表不能推断成立;没有完整条件检查不能写落空」,模型与页面用同一张表。 -->`
注释必须保持单行(`NOTE_LINE` 只删整行注释)。`productContractBlock` 先删注释行、再填 `{{…}}`(bfd7c30a 已核对),所以注释里的 `{{YOGA_STATUS_TABLE}}` 不会抛错。
- `frontend/src/lib/consultation-condensed-checklist.ts`:`sharedReadingBlock()` 的取值对象里加 `YOGA_STATUS_TABLE: yogaStatusContractTable()`(import 自 `./yoga-status-words.ts`),其余不改。
- 如果还有别的代码也读 `shared-reading` 这一块(开工时 `grep -rn '"shared-reading"' frontend/src`),同样补这个值;只补值,不改别的。
**2b. `frontend/src/mastra/product-voice.ts`:只改「状态词照卡上原词抄」这一行。**
新行(`${…}` 处是插值,其余逐字照写):
`- 格局(Yoga)的状态只按卡上 yogas_detail 那一行写,用这张表:${yogaStatusContractTable()};只出现在 yogas.hits、卡上没有状态行的,按 blocked 写并写明缺什么。不从 hits 推出「成立」,没有完整条件检查不写「不成立」「非成立」「落空」。其他状态词照卡上原词抄:candidate 写「候选」、strength_comparison_required 写「待定」;不升格为「成立」「确认」「命中」,标题里也不写「已成立」。`
- 同一文件里 `## 实际执行的技法与原始数据` 那一行中的「再 Yoga(…状态照卡上原词写…)」**不改**。
- `productConversationVoice`(简明解读)**不改**。
### 3. 拦截检查:新增“格局状态词与卡不符”(`consultation-answer-fact-guard.ts`)
只加,不改已有检查的行为。
**3a. 卡上视图**:`CardView` 加 `yogas`,在 `factGuardCardView` 里从 `card.base.natal.yogas_detail.rows` 建:
- 每行用 `yogaStatus(row, { notEvaluated })`(`notEvaluated` = 名字在 `yogas_detail.not_evaluated` 里)得到 `key` 和 `text`;
- `isStructuralObservation(row)` 的行跳过(不是命名格局);
- 另外,各领域段 `yogas.hits` 里出现、但 `rows` 里找不到的名字,也进视图,状态取 `yogaStatus(undefined, …)`(即 blocked)——**用 hits 不能推出成立**;
- 名字归一:小写;去掉末尾括号内容(`Guru Yoga (Jupiter with Ascendant Lord)` → `guru yoga`);去掉末尾的 `yoga`;去掉空格、`-`、`_`。例如 `Surya-Budha Yoga`、`Surya Budha`、`surya-budha` 都归为 `suryabudha`;`Budhaditya` 与 `Budhaditya Yoga` 相同。
- 归一后同名、但状态 `key` 不同的多行(例如几条 `Putra Yoga (…)`),这个名字记为“有歧义”,**不检查**。
**3b. 在答案里找格局名**:按行处理(与现有 `checkLine` 一样逐行)。在一行里找拉丁字母写的名字(如 `Sunaphaa Yoga`、`Budhaditya`、`Neecha Bhanga`),归一后在视图里查;查不到就不管。中文意译的格局名不在本单范围(写进 PROGRESS 的“已知未覆盖”)。
**3c. 分句**:句子在 `。!?;,,` 和换行处断开;表格行按 `|` 分格;**全角/半角括号里面的标点不断句**(括号整体属于它所在的那一句)。
**3d. 三张词表(`YOGA_STATUS_WORD_CLASSES`,逐字照用;要增删词选需拍板)**
- 肯定词 `affirm`:`已成立`、`已经成立`、`确认成立`、`已形成`、`已经形成`、`形成了`,以及单独的 `成立`(但要先排除下面的否定词和排除写法)。
- 否定词 `deny`:`不成立`、`非成立`、`未成立`、`没有成立`、`并不成立`、`落空`、`不构成`、`未形成`、`没有形成`、`必要条件未满足`、`条件未满足`。
- 排除写法 `exempt`(出现时这一处不算肯定也不算否定):
- 用法否定:`不作成立`、`不作为成立`、`不作格局成立`、`不当作成立`、`不当成成立`、`不算成立`、`不算作成立`、`不写成成立`、`不说成成立`、`不能说成立`、`未列为成立`、`没有列为成立`、`不视为成立`;
- 问句和条件:`是否成立`、`能否成立`、`成立与否`、`成立条件`、`成立的条件`、`成立名单`;
- 同一句里 `成立` 之前出现 `若`、`如果`、`假如`、`一旦`、`只有…才`、`才能`、`才会` 时,这个 `成立` 不算。
- 一句里同时有肯定词和否定词:整句不检查(有歧义)。
**3e. 判定**:一句里的状态词类别,与这句里每个查到的格局名的卡上 `key` 比:
| 卡上 key | 写了肯定词 | 写了否定词 |
|---|---|---|
| candidate | 不符 | 不符 |
| computed | 不符 | 不符 |
| blocked | 不符 | 不符 |
| formed_under_source | 相符 | 不符 |
| conditions_not_met | 不符 | 相符 |
- 格局名后面紧跟的括号里写了 `candidate`、`computed`、`候选`、`待核实`、`检出`、`未确认`、`待定` 之一时,这一个格局名不检查(这是句子在照抄状态,不是在下结论)。
- 每个不符的格局出一条 `AnswerFactMismatch`:
- `kind: "yoga_status"`(加进 `FactGuardKind`);
- `layer: "D1"`,`entity`:卡上的完整名字,`reference: "yogas_detail status"`;
- `claimed`:句中的状态词原文;`card`:映射表的 `text`(blocked 时是 `yogaStatus` 给出的带缺项的文字);
- `fact_id: "yoga:<卡上名字>@<key>"`,`source_path: "base.natal.yogas_detail.rows[<下标>]"`(hits-only 的名字写 `domain_sections.<领域>.yogas.hits`);
- `quote` / `start` / `end`:状态词在答案里的位置。
- 相符的也要 `push(…, "match")`,供精度统计用(与现有检查一致)。
- 检查的入口函数单独写(例如 `checkYogaStatus(line, offset, view, push)`),在 `checkLine` 末尾调用;不改 `checkLine` 里已有的任何正则和判定。
**3f. 重写提示**:`consultationFactRepairPrompt` 里加一条说明(逐字照写):`- 格局状态:照卡上给出的状态文字改写,不写「成立」「不成立」「非成立」「落空」,除非卡上的状态文字本身就是这个意思。` 其余提示不改。重写结果仍由 `factRepairRejection` 用同一套检查复核:仍不符就删句——这一条已有逻辑覆盖,不需要改 `applyFactRepair`。
## 测试
新建 `frontend/tests/consult-yoga-status-wording-1825.test.ts`。用**本文件内新建的合成卡**(不写真人资料、不改 `tests/fixtures/fact-guard-minimal-card.ts`),至少覆盖:
1. **她的父母反例**:卡上 `Sasa Yoga`、`Budhaditya Yoga`、`Surya-Budha Yoga`、`Guru Yoga (Jupiter with Ascendant Lord)`、`Neecha Bhanga` 均为 `status: "computed"`;答案句用原文「其余为已计算、非成立状态的格局(Sasa Yoga、Budhaditya、Surya-Budha、Guru Yoga、Neecha Bhanga 等),其中 Sasa Yoga 与「水星在第 7 宫角宫」一类只作结构观察,本轮不用来支撑父母线的判断。」→ 5 条 `yoga_status` 不符,`claimed` 为 `非成立`,`card` 为 `YOGA_STATUS_MAPPING.computed.text`(测试里用常量,不写字面量)。
2. **她的事业反例**:卡上 `Sunaphaa Yoga`、`Vosi Yoga` 为 `status: "candidate"`、`claim_status: "support_only_candidate"`;答案写「成立:Sunaphaa Yoga、Vosi Yoga」和「Sunaphaa Yoga 与 Vosi Yoga 均已成立。」→ 各 2 条不符,`card` 为 candidate 的文字。
3. **只在 hits 里**:名字在领域段 `yogas.hits`、`rows` 里没有 → 写「X Yoga 成立」不符,`card` 以 `YOGA_BLOCKED_PREFIX` 开头。
4. **正确写法不拦**(负例,各一条断言为 0 条不符):
- 事业题实际回答原句:「卡上 yogas.hits 只有两条——Sunaphaa Yoga(candidate,support only,条件未复核)、Vosi Yoga(candidate,support only,当前细运层有激活);两条都是候选,只能当支持性描述,不作格局成立。」
- 「Amala Yoga、Saraswati Yoga 等 computed 项,是探测器识别、未经规则治理,不作成立。」
- 照映射表文字写的句子(五种状态各一句,用 `YOGA_STATUS_MAPPING` 拼出);
- 「若 Vosi Yoga 成立,……」(条件句);「Vosi Yoga 是否成立要看……」(问句)。
5. **formed / not_met 两向**:`formation.status: "formed_under_source"` 写「成立」相符、写「不成立」不符;`formation.status: "conditions_not_met"` 写「不成立」相符、写「已成立」不符。
6. **歧义**:两行 `Putra Yoga (A)` computed、`Putra Yoga (B)` 带 `formation.status: "formed_under_source"`,答案写「Putra Yoga 成立」→ 不检查(0 条)。结构观察行的名字 → 不检查。一句里同时有「成立」和「不成立」→ 不检查。
7. **重写**:对第 1 条的单元,替换句仍含「非成立」→ `factRepairRejection` 返回 `still_mismatched`;替换句改用映射表文字 → 返回 `null`;`applyFactRepair` 拿不到合格替换时删句。
8. **流式路径**:照 `frontend/tests/consult-fact-guard-stream-1789.test.ts` 的写法,造一个写了第 1 条原句的流,确认 `state.factGuard.kinds` 含 `yoga_status`、最终输出不含「非成立」。
9. **合同同源**:
- `productContractBlock("shared-reading", …)` 的输出(经 `CONDENSED_SHARED_READING_LINES` 或直接调用)包含 `yogaStatusContractTable()` 的整串,且对 `YOGA_STATUS_MAPPING` 的每一项 `text` 都能找到;
- `professionalConversationVoice` 同样包含 `yogaStatusContractTable()` 整串;
- 合同文字里不再有「hits 里的才是这张盘成立的格局」;
- **只有一张表**:读 `frontend/src` 下所有 `.ts/.tsx` 与 `references/product_runtime_contract.md`,五句 `text` 的字面量只出现在 `frontend/src/lib/yoga-status-words.ts` 里。
### 既有断言的一处必改(已知)
`frontend/tests/consult-professional-mode-20261107.test.ts` 约 :208 断言的是旧行「状态词照卡上原词抄:candidate 写「候选」、computed 写「已计算,未验证」……」。改成断言新行(包含 `yogaStatusContractTable()` 整串,且包含「其他状态词照卡上原词抄:candidate 写「候选」、strength_comparison_required 写「待定」;不升格为「成立」「确认」「命中」,标题里也不写「已成立」」),并在断言上方写三栏注释:
- 原值:旧行原文;
- 新值:新行;
- 原因:`BUG-1825:占星师 2026-11-09 第七轮回复 §5 P0.2,格局状态用 yoga-status-words.ts 的同一张映射表`。
断言只能换成同等或更严,不得删。其他任何既有断言若因本单失败,先停下在 PROGRESS 写明,再按同样三栏格式处理;不在本任务书列出的改动一律选需拍板。
## 允许改的文件(只有这些)
- 新建:`frontend/tests/consult-yoga-status-wording-1825.test.ts`、`docs/tasks/PROGRESS-yoga-status-wording-20261109.md`
- 修改:
- `frontend/src/lib/yoga-status-words.ts`:只**新增** `yogaStatusContractTable` 与 `YOGA_STATUS_WORD_CLASSES`;已有导出一字不改;
- `frontend/src/lib/consultation-answer-fact-guard.ts`:按第 3 节,只加不改;
- `frontend/src/lib/consultation-condensed-checklist.ts`:只在 `sharedReadingBlock()` 的取值里加 `YOGA_STATUS_TABLE` 和对应 import;
- `frontend/src/mastra/product-voice.ts`:只改第 2b 节那一行(和必要的 import);
- `references/product_runtime_contract.md`:**只改** `product-block:shared-reading` 第 7 条及其注释行(第 2a 节);
- `frontend/tests/consult-professional-mode-20261107.test.ts`:只改上面列出的那一处断言;
- `docs/BUG_HISTORY.md`:只在末尾追加 BUG-1825(不得用 sed 改整个文件)。
## 不要碰
- `references/**` 的其他任何内容(占星师的方法文件),包括 `product_runtime_contract.md` 中 shared-reading 第 7 条以外的每一行。
- `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`、`frontend/src/mastra/index.ts`。
- `frontend/src/lib/consultation-evidence-card.ts`(卡上事实一字不改)、`frontend/src/lib/consultation-technique-evidence.ts`、`frontend/src/lib/consult-answer-basis.ts`(#181 已验收的页面部分)。
- `frontend/src/lib/stream-agent-response.ts`(拦截接入与合同判定都不改)、`frontend/tests/fixtures/fact-guard-minimal-card.ts`。
- 冻结文件:`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/consultation_native_layers.py`;本单不改任何 `scripts/**`。
- 进行中单子的地盘:
- #175 / #176 / #177 / #178:`scripts/ondemand_calc/**` 及其注册表;
- #184:`scripts/active_rectification_events.py`;
- #185:`frontend/src/app/api/consult/route.ts`、`frontend/src/mastra/consultation-workflow.ts`、`frontend/src/lib/consultation-route-service.ts`、`frontend/src/lib/consultation-agent-events.ts`、`frontend/src/lib/consult-packet-warm.ts`、`frontend/src/lib/chart-choice-both-versions.ts`、`frontend/src/lib/calculation-modes.ts`;
- #186:用途决策合同相关文件(`scripts/**` 下的 rectification 用途评估、它的测试);
- `frontend/src/mastra/consultation-tools.ts`(#172 将改)。
- #183(D9 宫位参照)后面也要改 `consultation-answer-fact-guard.ts`:本单不做任何分盘宫位检查,不动 `BREAKER`、`layerSpans` 和已有的 placement / lagna / house / lord 检查。
## 验收标准
1. 第 1、2 条她的两个反例被拦下:每个格局一条 `yoga_status` 不符,`card` 文字来自 `YOGA_STATUS_MAPPING`。
2. 负例全部 0 条不符(第 4、6 条),特别是事业题实际回答里「不作格局成立」那句。
3. 重写复核:仍写错的替换被拒、照表写的被接受(第 7 条),流式路径生效(第 8 条)。
4. 合同两处都由 `yogaStatusContractTable()` 生成,五句文字在源码和合同文件里只有一份(第 9 条);合同不再说“hits 就是成立”。
5. 既有测试全部通过;改过的断言只有任务书列出的那一处,且有三栏注释、写进 PROGRESS。
6. 不出现允许清单之外的改动(验收时 `git diff --stat` 逐个核对)。
7. 精度:Claude 验收时用第七轮验收包 10 份回答跑一遍新检查,人工核对每一条 `yoga_status` 不符;PROGRESS 里请列出你在测试里覆盖的句式和“已知未覆盖”的句式(中文意译名、跨行标题「已成立的格局」下面的列表等)。
## 自测命令(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-yoga-status-wording-1825.test.ts tests/consult-answer-fact-guard-1789.test.ts tests/consult-fact-guard-stream-1789.test.ts tests/consult-professional-mode-20261107.test.ts tests/consult-basis-candidate-status-1824.test.ts tests/consultation-methodology.test.ts tests/consult-limitation-line-contract-1834.test.ts`
前端全量测试、Python 快速门和 eslint 由 Claude 在验收时跑(你只跑上面列出的也不够:请再在 `frontend` 下跑一次 `npm test`,把失败的测试名写进 PROGRESS)。
## 进度记录
`docs/tasks/PROGRESS-yoga-status-wording-20261109.md`:写改了哪些文件、每条验收标准的证据、改过的断言(原值/新值/原因)、已知未覆盖的句式。
## 何时停下来选“需拍板”
- 第 3d 节三张词表要增删任何一个词时。
- 卡上出现 `YOGA_STATUS_MAPPING` 之外的状态值、且 `yogaStatus` 不能归入 blocked 时。
- 要改 `stream-agent-response.ts`、数据卡、页面或合同第 7 条以外的任何文字才能做到时。
- 任务书没写到的任何文案或取舍。
@@ -15,6 +15,12 @@
* eval answers are in eval-blind-1107/phase2-chat/RESULTS-r8.md.
*/
import type { EvidenceCard } from "./consultation-evidence-card.ts";
import {
isStructuralObservation,
yogaStatus,
YOGA_STATUS_WORD_CLASSES,
type YogaStatusKey,
} from "./yoga-status-words.ts";
type Json = unknown;
type Rec = Record<string, Json>;
@@ -74,7 +80,7 @@ const TIME_CONTEXT = /\d{4}\s*年|\d{4}-\d{1,2}|\d{1,2}\s*月|现在|目前|眼
const BREAKER = /行运|过境|Gochara|[Tt]ransit|流年|年盘|Varsha|Tajika|Muntha|年主|KP|Placidus|宫头|起算|起第|起数|为第\s*1\s*宫|作为第?一宫|从\s*[^\s,。;]{0,10}\s*(起|数)|月亮起|月亮盘|Chandra|Sudar[sś]han|Chalit|D1\s*投射|投到/gu;
export type FactGuardKind = "varga_sign" | "varga_lagna" | "varga_house_lord" | "d1_sign" | "d1_house" | "d1_lagna" | "d1_house_lord"
| "karakamsha_sign" | "karakamsha_house" | "karakamsha_lord" | "karakamsha_lord_house" | "arudha_sign" | "arudha_lord" | "arudha_house" | "arudha_lord_house";
| "karakamsha_sign" | "karakamsha_house" | "karakamsha_lord" | "karakamsha_lord_house" | "arudha_sign" | "arudha_lord" | "arudha_house" | "arudha_lord_house" | "yoga_status" | "varga_house";
export type AnswerFactClaim = AnswerFactMismatch & { status: "match" | "mismatch" | "not_on_card" };
type Emit = (item: AnswerFactMismatch, status?: AnswerFactClaim["status"]) => void;
@@ -102,8 +108,19 @@ type CardView = {
vargas: Record<string, { lagna?: Sign; planets: Record<string, Sign>; path: string }>;
karakamsha?: { sign?: Sign; lord?: string; house?: number };
arudha: Record<string, { sign?: Sign; lord?: string; house?: number; lordHouse?: number; path: string }>;
/** 格局:归一化名字 → 卡上的状态(或“有歧义,不检查”)。 */
yogas: Record<string, YogaCardEntry>;
};
type YogaCardEntry = {
name: string;
key: YogaStatusKey;
text: string;
factId: string;
sourcePath: string;
pattern: RegExp;
} | { ambiguous: true; name: string };
const asSign = (value: Json): Sign | undefined => (typeof value === "string" && (FACT_GUARD_SIGNS as readonly string[]).includes(value) ? value as Sign : undefined);
export function factGuardCardView(card: EvidenceCard): CardView {
@@ -114,7 +131,7 @@ export function factGuardCardView(card: EvidenceCard): CardView {
const sign = asSign(row.sign);
if (sign && typeof row.house === "number") d1[name] = { sign, house: row.house };
}
const view: CardView = { d1, d1Lagna: asSign(rec(natal.ascendant).sign), vargas: {}, arudha: {} };
const view: CardView = { d1, d1Lagna: asSign(rec(natal.ascendant).sign), vargas: {}, arudha: {}, yogas: {} };
const d9 = rec(natal.d9);
const d9Planets: Record<string, Sign> = {};
for (const [name, raw] of Object.entries(rec(d9.planets))) {
@@ -159,9 +176,87 @@ export function factGuardCardView(card: EvidenceCard): CardView {
house: typeof kkm.karakamsha_natal_house === "number" ? kkm.karakamsha_natal_house : undefined,
};
}
view.yogas = yogaCardEntries(card);
return view;
}
/** 卡上每一行格局的状态,加上「只在 hits 里」的名字(状态按 blocked)。同名不同状态记为歧义。 */
function yogaCardEntries(card: EvidenceCard): CardView["yogas"] {
const entries: CardView["yogas"] = {};
const detail = rec(rec(rec(card.base).natal).yogas_detail);
const notEvaluated = new Set(
(Array.isArray(detail.not_evaluated) ? detail.not_evaluated : [])
.map((item) => (typeof item === "string" ? item : typeof rec(item).name === "string" ? rec(item).name : undefined))
.filter((item): item is string => typeof item === "string"),
);
const rows = Array.isArray(detail.rows) ? detail.rows : [];
const seen = new Map<string, { key: YogaStatusKey; count: number }>();
rows.forEach((raw, index) => {
const row = rec(raw);
const name = typeof row.name === "string" ? row.name : undefined;
if (!name || isStructuralObservation(row)) return;
const normalized = normalizeYogaName(name);
if (!normalized) return;
const status = yogaStatus(row, { notEvaluated: notEvaluated.has(name) });
const existing = seen.get(normalized);
if (existing) {
if (existing.key !== status.key) entries[normalized] = { ambiguous: true, name };
existing.count += 1;
return;
}
seen.set(normalized, { key: status.key, count: 1 });
entries[normalized] = {
name,
key: status.key,
text: status.text,
factId: `yoga:${name}@${status.key}`,
sourcePath: `base.natal.yogas_detail.rows[${index}]`,
pattern: yogaNamePattern(name),
};
});
for (const [domain, rawSection] of Object.entries(card.domain_sections)) {
const hits = rec(rec(rawSection).yogas).hits;
for (const name of Array.isArray(hits) ? hits : []) {
if (typeof name !== "string") continue;
const normalized = normalizeYogaName(name);
if (!normalized || entries[normalized]) continue;
const status = yogaStatus(undefined, { notEvaluated: notEvaluated.has(name) });
entries[normalized] = {
name,
key: status.key,
text: status.text,
factId: `yoga:${name}@${status.key}`,
sourcePath: `domain_sections.${domain}.yogas.hits`,
pattern: yogaNamePattern(name),
};
}
}
return entries;
}
/** 小写、去末尾括号内容、去末尾 yoga、去分隔符。`Surya-Budha Yoga` 与 `Surya Budha` 都是 `suryabudha`。 */
export function normalizeYogaName(name: string): string {
return name
.toLowerCase()
.replace(/\s*[((][^))]*[))]\s*$/u, "")
.replace(/\s*yoga\s*$/u, "")
.replace(/[\s\-_]/gu, "")
.trim();
}
/** 从卡上的名字生成一个宽松匹配(允许空格 / 连字符 / 下划线差异,末尾 Yoga 可省)。 */
function yogaNamePattern(name: string): RegExp {
const withoutParens = name.replace(/\s*[((][^))]*[))]\s*$/u, "").trim();
const tokens = withoutParens
.split(/[\s\-_]+/u)
.filter(Boolean)
// 名字末尾的 Yoga 是可省的(`Budhaditya Yoga` 与 `Budhaditya` 都算)。
.filter((token, index, list) => !(index === list.length - 1 && /^yoga$/iu.test(token)))
.map((token) => token.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"));
const body = tokens.join("[\\s\\-_]*");
return new RegExp(`(?<![A-Za-z])${body}(?:[\\s\\-_]+yoga)?(?![A-Za-z])`, "giu");
}
type Span = { layer: string; start: number; end: number; token: number; head: boolean };
const LAYER_ALIAS = (match: RegExpExecArray): string => {
@@ -258,6 +353,119 @@ export function findAnswerFactMismatches(answer: string, card: EvidenceCard): An
});
}
/** 分句:正文按 。!?;,, 与换行;表格行先按 | 分格,格内只按 。!?; 与换行分句(不按逗号,也不跨句沿用名字)。括号里的标点不断句。 */
function splitProse(line: string, base = 0, breaks = /[。!?;,,\n]/u): Array<{ start: number; end: number }> {
const out: Array<{ start: number; end: number }> = [];
let depth = 0;
let start = 0;
const push = (end: number) => out.push({ start: base + start, end: base + end });
for (let index = 0; index < line.length; index += 1) {
const char = line[index]!;
if (char === "(" || char === "(") depth += 1;
else if (char === ")" || char === ")") depth = Math.max(0, depth - 1);
else if (depth === 0 && breaks.test(char)) {
push(index);
start = index + 1;
}
}
push(line.length);
return out;
}
function yogaSentences(line: string): Array<{ start: number; end: number }> {
if (!line.trimStart().startsWith("|")) return splitProse(line);
const out: Array<{ start: number; end: number }> = [];
let base = 0;
for (const cell of line.split("|")) {
out.push(...splitProse(cell, base, /[。!?;\n]/u));
base += cell.length + 1;
}
return out;
}
/** 一句话里的状态词类别;同时有肯定与否定、或全是排除写法时返回 null(不检查)。 */
function yogaWordClass(sentence: string): { kind: "affirm" | "deny"; word: string; at: number } | null {
const { affirm, deny, exempt, conditional } = YOGA_STATUS_WORD_CLASSES;
const found: Array<{ kind: "affirm" | "deny"; word: string; at: number }> = [];
const exemptHits: Array<{ start: number; end: number }> = [];
// 用法否定里「成立」前多一个「已」(不当作已成立 / 不视为已成立)也算排除写法。
const exemptPattern = (phrase: string) =>
phrase.replace(/成立/u, "已?成立").replace(/[.*+?^${}()|[\]\\]/gu, "\\$&").replace("已\\?成立", "已?成立");
for (const phrase of exempt) {
for (const hit of sentence.matchAll(new RegExp(exemptPattern(phrase), "gu"))) {
exemptHits.push({ start: hit.index!, end: hit.index! + hit[0].length });
}
}
const insideExempt = (at: number) => exemptHits.some((hit) => at >= hit.start && at < hit.end);
for (const phrase of deny) {
for (const hit of sentence.matchAll(new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gu"))) {
if (!insideExempt(hit.index!)) found.push({ kind: "deny", word: phrase, at: hit.index! });
}
}
for (const phrase of affirm) {
for (const hit of sentence.matchAll(new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gu"))) {
const at = hit.index!;
if (insideExempt(at)) continue;
// 「不成立」里的「成立」不是肯定。
if (phrase === "成立" && /不|非|未|没有|并不|落/u.test(sentence.slice(Math.max(0, at - 1), at))) continue;
if (phrase === "成立" && /^\s*(?:条件|与否|名单)/u.test(sentence.slice(at + 2))) continue;
const before = sentence.slice(0, at);
if (phrase === "成立" && conditional.some((word) => {
// 「只有…才」:两段按顺序都出现在「成立」之前才算条件句。
const [head, tail] = word.split("…");
if (tail === undefined) return before.includes(word);
const at = before.indexOf(head!);
return at >= 0 && before.indexOf(tail, at + head!.length) >= 0;
})) continue;
found.push({ kind: "affirm", word: phrase, at });
}
}
const kinds = new Set(found.map((item) => item.kind));
if (kinds.size !== 1) return null;
const first = found.sort((left, right) => left.at - right.at)[0]!;
return first;
}
/** 「格局状态词与卡不符」(BUG-1825)。只加,不改 checkLine 里已有的任何判定。 */
function checkYogaStatus(line: string, offset: number, view: CardView, push: Emit) {
const entries = Object.entries(view.yogas ?? {});
if (!entries.length) return;
for (const sentence of yogaSentences(line)) {
const text = line.slice(sentence.start, sentence.end);
if (!text.trim()) continue;
const found: Array<{ entry: Exclude<YogaCardEntry, { ambiguous: true }>; name: string }> = [];
for (const [, entry] of entries) {
if ("ambiguous" in entry) continue;
for (const hit of text.matchAll(entry.pattern)) {
const after = text.slice(hit.index! + hit[0].length);
const bracket = /^\s*[((]([^))]*)[))]/u.exec(after);
if (bracket && YOGA_STATUS_WORD_CLASSES.literalStatus.some((word) => bracket[1]!.includes(word))) continue;
found.push({ entry, name: entry.name });
}
}
const wordClass = yogaWordClass(text);
if (!wordClass || !found.length) continue;
for (const { entry, name } of found) {
const matches = (entry.key === "formed_under_source" && wordClass.kind === "affirm")
|| (entry.key === "conditions_not_met" && wordClass.kind === "deny");
const at = offset + sentence.start + wordClass.at;
push({
kind: "yoga_status",
layer: "D1",
entity: name,
reference: "yogas_detail status",
claimed: wordClass.word,
card: entry.text,
fact_id: entry.factId,
source_path: entry.sourcePath,
quote: wordClass.word,
start: at,
end: at + wordClass.word.length,
}, matches ? "match" : "mismatch");
}
}
}
function checkLine(line: string, offset: number, view: CardView, push: Emit) {
const spans = layerSpans(line, offset);
const breakers = [...line.matchAll(BREAKER)].map((hit) => ({ start: hit.index!, end: hit.index! + hit[0].length }));
@@ -380,10 +588,147 @@ function checkLine(line: string, offset: number, view: CardView, push: Emit) {
checkKarakamsha(line, offset, view, push, brokenBetween);
checkArudha(line, offset, view, push, brokenBetween);
checkVargaHouse(line, offset, spans, view, push);
}
const VARGA_REFERENCE = new RegExp([
`从\\s*(?:(?<fromLayer>D\\d{1,2})\\s*)?(?:(?<lagna>上升|Lagna)|(?<sign>${S})座?|(?<planet>${P}))\\s*(?:起算|起数|数|起)`,
"(?<![\\p{L}\\p{N}])(?:(?<lagnaLayer>D\\d{1,2})\\s*)?(?<lagnaOnly>上升|Lagna)\\s*起算",
`以\\s*(?<firstSign>${S})座?\\s*为\\s*第\\s*1\\s*宫`,
].join("|"), "gu");
const VARGA_HOUSE_NUMBER = /第\s*(1[0-2]|[1-9])\s*宫/gu;
const VARGA_HOUSE_EXCLUDED = /行运|过境|Gochara|Transit|流年|年盘|Varsha|Tajika|Muntha|年主|KP|Placidus|宫头|Chalit|月亮盘|Chandra|Sudar[sś]han/iu;
const VARGA_HOUSE_BREAKS = /[。!?;\n]/u;
const VARGA_LAYER = /^D(?:[2-9]|[1-5]\d|60)$/u;
const VARGA_PLACEMENT_PREFIX = new RegExp(`^\\s*[((]?\\s*${PLACED}`, "u");
const VARGA_PLACEMENT_SIGN = new RegExp(`^\\s*(${S})座?`, "u");
const VARGA_SIGN_MENTION = new RegExp(S, "u");
const VARGA_PLANET_MENTION = new RegExp(P, "gu");
const VARGA_AMBIGUOUS_SIGN = /^\s*(?:或|或者|\/|~|~|到|至|宫?\s*的|对应|所对应|对宫|方向)/u;
function enclosingParenthesis(text: string, at: number): number | undefined {
const open: number[] = [];
for (let i = 0; i < at; i += 1) {
if (text[i] === "(" || text[i] === "(") open.push(i);
else if (text[i] === ")" || text[i] === ")") open.pop();
}
return open.at(-1);
}
function wholeSignHouse(sign: Sign, reference: Sign): number {
return (FACT_GUARD_SIGNS.indexOf(sign) - FACT_GUARD_SIGNS.indexOf(reference) + 12) % 12 + 1;
}
/** BUG-1826: only an explicit varga, entity, counting point and house form a claim. */
function checkVargaHouse(line: string, offset: number, spans: readonly Span[], view: CardView, push: Emit) {
const cells = line.trimStart().startsWith("|") ? line.split("|") : [line];
let cellOffset = 0;
for (const cell of cells) {
const sentences = splitProse(cell, cellOffset, VARGA_HOUSE_BREAKS);
cellOffset += cell.length + 1;
for (const scope of sentences) {
const sentence = line.slice(scope.start, scope.end);
if (VARGA_HOUSE_EXCLUDED.test(sentence)) continue;
const references = [...sentence.matchAll(VARGA_REFERENCE)].filter((hit) =>
!hit.groups?.lagnaOnly || !/(?:从|以)[^,,。!?;(())]*$/u.test(sentence.slice(0, hit.index)),
);
if (!references.length) continue;
const absolute = offset + scope.start;
const planets = [...sentence.matchAll(VARGA_PLANET_MENTION)].filter((planet) =>
!references.some((hit) => hit.index <= planet.index && planet.index < hit.index + hit[0].length),
);
const houses = [...sentence.matchAll(VARGA_HOUSE_NUMBER)].filter((house) =>
!references.some((hit) => hit.index <= house.index && house.index < hit.index + hit[0].length),
);
const bindings = references.map((reference) => {
const end = reference.index + reference[0].length;
const adjacent = houses.find((house) => end <= house.index && /^[\s,,::]*$/u.test(sentence.slice(end, house.index)));
if (adjacent) return { reference, house: adjacent };
const bracket = enclosingParenthesis(sentence, reference.index);
const nearby = bracket === undefined ? [] : houses.filter((house) => enclosingParenthesis(sentence, house.index) === bracket);
return { reference, house: nearby.length === 1 ? nearby[0] : undefined };
});
for (const house of houses) {
const compatible = bindings.filter((binding) => binding.house === house);
if (compatible.length !== 1) continue;
const reference = compatible[0]!.reference;
const groups = reference.groups!;
const namedLayer = groups.fromLayer ?? groups.lagnaLayer;
const referenceLayerAt = namedLayer ? reference.index + reference[0].indexOf(namedLayer) : undefined;
const start = absolute + house.index;
const end = start + house[0].length;
const span = spanAt(spans, start, end) ?? (referenceLayerAt === undefined ? undefined
: spanAt(spans, absolute + referenceLayerAt, absolute + referenceLayerAt + namedLayer!.length));
const layer = span?.layer;
if (!layer || !VARGA_LAYER.test(layer) || (namedLayer && namedLayer !== layer)) continue;
const varga = view.vargas[layer];
const entityHit = planets.findLast((planet) => planet.index < house.index);
if (!varga || !entityHit) continue;
const entity = PLANET_NAMES[entityHit[0]]!;
const sign = varga.planets[entity];
if (!sign) continue;
const placementStart = entityHit.index + entityHit[0].length;
const placementEnd = reference.index >= placementStart ? Math.min(reference.index, house.index) : house.index;
const tail = sentence.slice(placementStart, placementEnd);
const prefix = VARGA_PLACEMENT_PREFIX.exec(tail)?.[0].length ?? 0;
const placement = tail.slice(prefix);
const layerToken = [...placement.matchAll(LAYER_TOKEN)].find((hit) => hit.index === 0);
const explicitPlacementLayer = layerToken ? LAYER_ALIAS(layerToken) : undefined;
const entityLayer = explicitPlacementLayer
?? spanAt(spans, absolute + entityHit.index, absolute + placementStart)?.layer;
if (entityLayer && entityLayer !== layer) continue;
const signText = placement.slice(layerToken?.[0].length ?? 0);
const written = VARGA_PLACEMENT_SIGN.exec(signText);
if (written) {
const remainder = signText.slice(written[0].length);
if (SIGN_NAMES[written[1]!] !== sign || SIGN_THEN_LORD.test(remainder) || VARGA_AMBIGUOUS_SIGN.test(remainder)) continue;
}
if (!written && VARGA_SIGN_MENTION.test(signText)) continue;
const planetReference = groups.planet ? PLANET_NAMES[groups.planet] : undefined;
const signReference = groups.sign ?? groups.firstSign;
const referenceSign = signReference ? SIGN_NAMES[signReference]
: planetReference ? varga.planets[planetReference] : varga.lagna;
if (!referenceSign) continue;
const point = signReference ? `from sign ${referenceSign}`
: `from ${layer} ${planetReference ?? "lagna"} (${referenceSign})`;
const claimed = Number(house[1]);
const actual = wholeSignHouse(sign, referenceSign);
const alternative = FACT_GUARD_SIGNS[(FACT_GUARD_SIGNS.indexOf(sign) - claimed + 13) % 12]!;
const card = claimed !== actual && varga.lagna
? `${actual} 宫(从${SIGN_ZH[referenceSign]}数;第 ${claimed} 宫是从${SIGN_ZH[alternative]}数)`
: `${actual} 宫`;
const source_path = `${varga.path}.planets.${entity}${varga.path === "base.natal.d9" ? ".d9_sign" : ""}`;
// The legacy placement matcher cannot read 「月亮在 D9 金牛」. When the new
// rule verifies that inline sign, record it separately from the house claim.
if (explicitPlacementLayer && written) {
const signEnd = placementStart + prefix + (layerToken?.[0].length ?? 0) + written[0].length;
push({
kind: "varga_sign", layer, entity, reference: "sign", claimed: SIGN_ZH[sign], card: SIGN_ZH[sign],
fact_id: `${layer}:${entity}@${sign}`, source_path,
quote: sentence.slice(entityHit.index, signEnd), start: absolute + entityHit.index, end: absolute + signEnd,
}, "match");
}
// When the reference follows the house, the legacy sign quote may
// already include the number. Anchor the new claim at the reference.
const anchor = house.index < reference.index ? reference : house;
const anchorStart = absolute + anchor.index;
push({
kind: "varga_house", layer, entity, reference: point, claimed: `${claimed} 宫`, card,
fact_id: `${layer}:${entity}@${sign}/from:${referenceSign}`, source_path,
quote: anchor[0], start: anchorStart, end: anchorStart + anchor[0].length,
}, claimed === actual ? "match" : "mismatch");
}
}
}
}
function checkKarakamsha(line: string, offset: number, view: CardView, push: Emit, brokenBetween: (from: number, to: number) => boolean) {
const kkm = view.karakamsha;
// BUG-1825:格局状态词与卡不符。放在这两处早退之前——卡上没有 Karakamsha / Arudha 时
// 也要检查格局状态;它不依赖这一段的 make,也不会改变上面任何一条判定。
checkYogaStatus(line, offset, view, push);
if (!kkm) return;
const note = "(?:\\s*[((][^))]{0,24}[))])?";
const make = (match: RegExpExecArray, item: Omit<AnswerFactMismatch, "quote" | "start" | "end">, ok = false) => push({ ...item, quote: match[0], start: offset + match.index!, end: offset + match.index! + match[0].length }, ok ? "match" : "mismatch");
@@ -543,6 +888,8 @@ export function consultationFactRepairPrompt(units: readonly FactGuardUnit[]): s
"下面这些句子引用的盘面事实与本轮证据卡的层、实体或参照不符,已被拒收。逐条重写这一句(表格里的只重写这一格):",
"- 只改与卡不符的部分,换成下面给出的卡上值;其余内容、语气和判断保持不变;如果改了值以后原来的推论不成立,就删掉那半句推论,不另找理由。",
"- 分清层与实体:婚姻分盘(D9)里的木星、Karakamsha 星座所在的宫、Karakamsha 主星的本命落宫,是三件不同的事,不能互相照抄。",
"- 分盘宫位:从哪里起算就按哪里数,写明起算点;从上升数和从某一宫数是两件事,不能互相照抄。",
"- 格局状态:照卡上给出的状态文字改写,不写「成立」「不成立」「非成立」「落空」,除非卡上的状态文字本身就是这个意思。",
"- 不新增计算、技法或执行状态:不写 executed / blocked / 已执行 / 已核验一类的词,不加审计表行,不换行,不写「|」。",
"- 只输出 JSON 数组:[{\"id\": 1, \"text\": \"重写后的整句\"}],不要别的文字。",
"",
@@ -1,6 +1,7 @@
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
import type { ProfileGender } from "./profile-gender.ts";
import { AFFLICTION_RANGE_RULE, CAREER_FIELD_ASK_RULE, CHECKLIST_ANALYSIS_ONLY_NOTE } from "./consultation-thinking-plan.ts";
import { yogaStatusContractTable } from "./yoga-status-words.ts";
import { productContractBlock } from "./product-runtime-contract.ts";
/**
@@ -68,7 +69,12 @@ export const CONDENSED_SHARED_READING_SOURCE = "consultation shared reading (TAS
* items without their numbers.
*/
function sharedReadingBlock(): string {
return productContractBlock("shared-reading", { AFFLICTION_RANGE_RULE, CHECKLIST_ANALYSIS_ONLY_NOTE });
return productContractBlock("shared-reading", {
AFFLICTION_RANGE_RULE,
CHECKLIST_ANALYSIS_ONLY_NOTE,
// BUG-1825:格局状态表只从 yoga-status-words.ts 取,合同里不手写。
YOGA_STATUS_TABLE: yogaStatusContractTable(),
});
}
export const CONDENSED_SHARED_READING_LINES: readonly string[] = sharedReadingBlock()
.split("\n")
+39
View File
@@ -74,3 +74,42 @@ export function yogaStatus(row: Rec | undefined, options: { notEvaluated?: boole
export function isStructuralObservation(row: Rec): boolean {
return row.claim_kind === "structural_observation" || row.status === "structural_observation";
}
/**
* 她的表渲染成一行合同文字(合同与 product-voice 都用它,不各写一遍)。
* 左边是卡上可能出现的原始值(用「 / 」连接),右边是映射表里的文字;文字不手写。
*/
export function yogaStatusContractTable(): string {
return (Object.keys(YOGA_STATUS_MAPPING) as YogaStatusKey[])
.map((key) => {
const entry = YOGA_STATUS_MAPPING[key];
return `${entry.evidence.join(" / ")} →「${entry.text}」`;
})
.join(";");
}
/** 拦截检查用的三张词表(占星师第七轮回复 §5 P0.2 的口径)。 */
export const YOGA_STATUS_WORD_CLASSES: Readonly<{
affirm: readonly string[];
deny: readonly string[];
exempt: readonly string[];
conditional: readonly string[];
literalStatus: readonly string[];
}> = {
// 肯定:说这条格局已经成立。
affirm: ["已成立", "已经成立", "确认成立", "已形成", "已经形成", "形成了", "成立"],
// 否定:说这条格局不成立 / 落空。
deny: ["不成立", "非成立", "未成立", "没有成立", "并不成立", "落空", "不构成", "未形成", "没有形成", "必要条件未满足", "条件未满足"],
// 排除写法:出现时这一处既不算肯定也不算否定。
exempt: [
"不作成立", "不作为成立", "不作格局成立", "不当作成立", "不当成成立", "不算成立", "不算作成立",
"不写成成立", "不说成成立", "不能说成立", "未列为成立", "没有列为成立", "不视为成立",
"是否成立", "能否成立", "成立与否", "成立条件", "成立的条件", "成立名单",
],
// 条件句里出现这些词时,后面的「成立」不算。
// 「只有…才」照任务书 3d:「只有」后面要跟「才」才算条件句(「卡上只有 X 成立」不是条件句)。
conditional: ["若", "如果", "假如", "一旦", "只有…才", "才能", "才会"],
// 括号里照抄状态时,这条格局名不检查。
literalStatus: ["candidate", "computed", "候选", "待核实", "检出", "未确认", "待定"],
};
+2 -1
View File
@@ -1,3 +1,4 @@
import { yogaStatusContractTable } from "../lib/yoga-status-words.ts";
import {
DEFAULT_CONSULTATION_ANSWER_MODE,
INTERPRETATION_STATUS_RULE,
@@ -140,7 +141,7 @@ export const professionalConversationVoice = `VISIBLE VOICE
- 数值照卡上和补取结果原样引用,标出处层(D1、D9、卡上 timing 等);卡上没有、补取也没有的,写「未交付」,不从记忆补。
- 受冲信号写实际来源:哪一宫、哪颗星、什么关系(同宫、相照、宫主落 6 / 8 / 12、功能凶星、落陷或燃烧),不报条数,条数只在后台(占星师 2026-11-08 第五轮裁定 6)。
- 点位(A2、A11、AL、UL、Indu Lagna 等)和宫主、行星的星座、宫位、主星,只照卡上那个字段写;同一个点在正文、表格、审计表里只有一个值,不从别的点、宫或 Saham 推出另一个落点。卡上没给的大运层(比如下一个子运)写「未交付」,不拿下一个主运或别的体系顶替。
- 状态词照卡上原词抄:candidate 写「候选」、computed 写「已计算,未验证」、strength_comparison_required 写「待定」;不升格为「成立」「确认」「命中」,标题里也不写「已成立」。
- 格局(Yoga)的状态只按卡上 yogas_detail 那一行写,用这张表:${yogaStatusContractTable()};只出现在 yogas.hits、卡上没有状态行的,按 blocked 写并写明缺什么。不从 hits 推出「成立」,没有完整条件检查不写「不成立」「非成立」「落空」。其他状态词照卡上原词抄:candidate 写「候选」、strength_comparison_required 写「待定」;不升格为「成立」「确认」「命中」,标题里也不写「已成立」。
- 日期精度跟出生时间走:卡上 rectification 没有写明出生时间已校正时,大运、子运、细运和行运的日期都是名义计算边界,几分钟的出生误差就能移动几天;第一次写日期时注明「名义计算边界」,结论和行动建议里的时间写到月或旬,不写「某日起」。
- 用户点名的技法、宫位、分盘每项都写到;用户的写法要求照做:不让排除的,不写排除或圈定某一类的句子;要分明显和可能的,按证据等级分。
- 用户纠正你的判断时,先认下,直说原来哪句读偏了,再只说盘上真正对得上的部分,并重新标等级;不为原判断辩护,也不把原因推到用户身上。
@@ -6,6 +6,7 @@
// evidence card really carries (hard red line 3), checked against the cards
// built from the real-engine golden fixture.
import assert from "node:assert/strict";
import { YOGA_STATUS_MAPPING, yogaStatusContractTable } from "../src/lib/yoga-status-words.ts";
import { readFileSync } from "node:fs";
import test from "node:test";
@@ -64,8 +65,11 @@ test("T1: the shared reading is the first section, once per turn, whatever the p
assert.match(text, /受冲信号不按条数设门槛/);
assert.match(text, /支持、压力和证据缺口三样都写/);
assert.match(text, /逆行不等于「反复、打回来」/);
// The line added on top of the brief's six: only hits are yogas this chart has.
assert.match(text, /瑜伽只认 yogas\.hits/);
// 原值:/瑜伽只认 yogas\.hits/(旧第 7 条:从 hits 推成立)
// 新值:断言新第 7 条的开头,并断言整张状态表都在
// 原因:BUG-1825:shared-reading 第 7 条改用 yoga-status-words.ts 的映射表(任务书第 2a 节)
assert.match(text, /格局只按卡上 yogas_detail 每一行的状态说/);
assert.ok(text.includes(yogaStatusContractTable()), "the shared reading carries the whole status table");
// 原值: 7 行
// 新值: 8 行(第 8 行:traditional_yogas 只说明力量,不单独下结论,不算受冲也不抵消受冲)
// 原因: TASK-consult-career-yoga-functional-20261002 T3 / D3 定稿,通用读法加一行(BUG-1175)
@@ -232,7 +236,13 @@ test("hard red line 3: every field name the lists cite is a key on a real card",
// 新值: 再加上 skill_read、further_reading
// 原因: 清单要写「用 skill_read 读 further_reading 里的章节」,这两个词是工具和目录名,不是卡上的字段
// 原值(2): new Set(["blocked", "skill_read", "further_reading"]) / 新值(2): 再加 parameter_sensitive / 原因(2): 产品 2026-11-07 占星师方案:专业模式默认、完整运行 skill(item 1:按 skill_family_router 交付子 skill SKILL.md,排在路由全文之后、领域清单之前);item 7:KP 宫头改写为 parameter_sensitive(解读状态词,不是卡上字段)
const prose = new Set(["blocked", "skill_read", "further_reading", "parameter_sensitive"]);
// 原值:new Set(["blocked", "skill_read", "further_reading", "parameter_sensitive"])
// 新值:再把映射表里的状态值(candidate / computed / …)加进来,从模块取,不手写
// 原因:BUG-1825:shared-reading 第 7 条改用 yoga-status-words.ts 的映射表(任务书第 2a 节)
const prose = new Set([
"blocked", "skill_read", "further_reading", "parameter_sensitive",
...Object.values(YOGA_STATUS_MAPPING).flatMap((entry) => [...entry.evidence]),
]);
const cited = new Set([...texts.matchAll(/(?<![A-Za-z0-9_])[a-z][a-z0-9_]*/g)].map((match) => match[0]).filter((word) => !prose.has(word)));
// 原值: required 含 "functional"(通用读法 ③ 写「父母行 functional」)
// 新值: "functional_malefics" 与 "pressure" 代替 "functional"(父母行由服务端数,③ 只指 functional_malefics)
@@ -118,6 +118,10 @@ test("BUG-1728: product rules load from the announced contract file, each with i
assert.equal(productContractBlock(name, {
NATAL_SPOKEN_REPORT_CONTRACT: "x", JYOTISH_SKILL_METHOD_BLOCK: "x", PERIOD_FIRST_RULE: "x", INTERPRETATION_STATUS_RULE: "x",
MAX_CONSULTATION_DOMAINS: 1, SPOKEN_HEADING_RULE: "x", MEVG_BLOCKED_POLICY: "x", AFFLICTION_RANGE_RULE: "x", CHECKLIST_ANALYSIS_ONLY_NOTE: "x",
// 原值:取值对象到这里结束(没有 YOGA_STATUS_TABLE)
// 新值:加 YOGA_STATUS_TABLE: "x"
// 原因:BUG-1825:shared-reading 第 7 条改用 yoga-status-words.ts 的映射表(任务书第 2a 节)
YOGA_STATUS_TABLE: "x",
}).includes("<!--"), false, name);
}
assert.throws(() => productContractBlock("shared-reading"), /needs a value/);
@@ -6,6 +6,7 @@
// read"; sub-skills travel with the router; follow-ups and entries never
// invite a question the chart cannot answer.
import assert from "node:assert/strict";
import { yogaStatusContractTable } from "../src/lib/yoga-status-words.ts";
import { readFileSync } from "node:fs";
import test from "node:test";
@@ -205,7 +206,11 @@ test("claims come from the card, status words are copied, dates carry the birth-
const voice = professionalConversationVoice;
// 原值:「再 Yoga(只写 yogas.hits 里成立的,写成立条件……)」;新值:状态照卡上原词写;原因:占星师 2026-11-07 评测意见(candidate 格局被写成「成立」,原句本身就要求写「成立的」)。
assert.doesNotMatch(voice, /yogas\.hits 里成立的/);
assert.match(voice, /状态词照卡上原词抄:candidate 写「候选」、computed 写「已计算,未验证」、strength_comparison_required 写「待定」;不升格为「成立」「确认」「命中」,标题里也不写「已成立」/);
// 原值:/状态词照卡上原词抄:candidate 写「候选」、computed 写「已计算,未验证」、strength_comparison_required 写「待定」;不升格为「成立」「确认」「命中」,标题里也不写「已成立」/
// 新值:断言新的格局状态行(含映射表整串 + 其他状态词那句原文)
// 原因:BUG-1825:占星师 2026-11-09 第七轮回复 §5 P0.2,格局状态用 yoga-status-words.ts 的同一张映射表
assert.ok(voice.includes(`- 格局(Yoga)的状态只按卡上 yogas_detail 那一行写,用这张表:${yogaStatusContractTable()};`), voice);
assert.match(voice, /其他状态词照卡上原词抄:candidate 写「候选」、strength_comparison_required 写「待定」;不升格为「成立」「确认」「命中」,标题里也不写「已成立」/);
// 原值:无;新值:点位和宫主只照卡上字段写、一个点一个值,卡上没给的大运层写「未交付」;原因:占星师 2026-11-07 评测意见(A11 表格写狮子、正文写金牛/11 宫;三次运行把下一个 Narayana 主运处女写成下一个子运)。
assert.match(voice, /同一个点在正文、表格、审计表里只有一个值/);
assert.match(voice, /卡上没给的大运层(比如下一个子运)写「未交付」,不拿下一个主运或别的体系顶替/);
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
consultationFactRepairPrompt,
factGuardUnits,
factRepairRejection,
findAnswerFactClaims,
findAnswerFactMismatches,
} from "../src/lib/consultation-answer-fact-guard.ts";
import type { EvidenceCard } from "../src/lib/consultation-evidence-card.ts";
function syntheticCard(conflictingD10 = false): EvidenceCard {
return {
base: {
natal: {
planets: { Moon: { sign: "Aquarius", house: 11 } },
ascendant: { sign: "Aries" },
d9: {
lagna: "Capricorn",
planets: { Moon: { d9_sign: "Taurus" }, Venus: { d9_sign: "Aquarius" } },
},
},
},
domain_sections: {
career: { vargas: { D10: { lagna: "Aries", planets: { Mercury: "Gemini" } } } },
...(conflictingD10 ? { general: { vargas: { D10: { lagna: "Aries", planets: { Mercury: "Cancer" } } } } } : {}),
},
} as unknown as EvidenceCard;
}
const card = syntheticCard();
const original = "D9 第 7 宫为巨蟹,宫内无星,宫主月亮在 D9 金牛入旺(从 D9 上升数第 11 宫)。";
const claims = (text: string, evidence = card) =>
findAnswerFactClaims(text, evidence).filter((claim) => claim.kind === "varga_house");
test("the reported D9 sentence identifies the five house-claim parts and the alternative counting point", () => {
const matches = claims(original);
assert.equal(matches.length, 1);
const [claim] = matches;
assert.equal(claim?.status, "mismatch");
assert.equal(claim?.layer, "D9");
assert.equal(claim?.entity, "Moon");
assert.equal(claim?.reference, "from D9 lagna (Capricorn)");
assert.equal(claim?.claimed, "11 宫");
assert.equal(claim?.card, "5 宫(从摩羯数;第 11 宫是从巨蟹数)");
assert.equal(claim?.fact_id, "D9:Moon@Taurus/from:Capricorn");
assert.equal(claim?.source_path, "base.natal.d9.planets.Moon.d9_sign");
assert.equal(original.slice(claim?.start, claim?.end), "第 11 宫");
const sign = findAnswerFactClaims(original, card).find((claim) => claim.kind === "varga_sign");
assert.equal(sign?.status, "match");
assert.equal(sign?.card, "金牛");
assert.ok(findAnswerFactClaims("D9:月亮在金牛。", card).some((claim) =>
claim.kind === "varga_sign" && claim.status === "match"));
});
test("the same placement gives different correct houses under explicit lagna and sign references", () => {
for (const [text, reference, value] of [
["宫主月亮在 D9 金牛(从巨蟹数第 11 宫)。", "from sign Cancer", "11 宫"],
["宫主月亮在 D9 金牛(从巨蟹座起第 11 宫)。", "from sign Cancer", "11 宫"],
["宫主月亮在 D9 金牛(以巨蟹为第 1 宫,第 11 宫)。", "from sign Cancer", "11 宫"],
["宫主月亮在 D9 金牛(从 D9 上升数第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
["宫主月亮在 D9 金牛(从 D9 上升起第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
["宫主月亮在 D9 金牛(从 D9 Lagna 数第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
["宫主月亮在 D9 金牛(从上升数第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
["D9:月亮在金牛(第 5 宫,D9 上升起算)。", "from D9 lagna (Capricorn)", "5 宫"],
["D9:月亮(从上升数第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
]) {
assert.deepEqual(claims(text!).map((claim) => [claim.status, claim.reference, claim.claimed]),
[["match", reference, value]], text);
}
});
test("a reference planet is excluded from the entity search", () => {
for (const [point, number, status] of [
["从 D9 金星起", 4, "match"], ["从金星数", 5, "mismatch"],
] as const) {
const text = `宫主月亮在 D9 金牛(${point}第 ${number} 宫)。`;
const out = claims(text);
assert.equal(out.length, 1);
assert.equal(out[0]?.entity, "Moon");
assert.equal(out[0]?.reference, "from D9 Venus (Aquarius)");
assert.equal(out[0]?.status, status);
assert.equal(out[0]?.fact_id, "D9:Moon@Taurus/from:Aquarius");
}
});
test("unsupported, unbound and incomplete house claims are left alone", () => {
for (const text of [
"本命月亮在第 11 宫。",
"月亮在 D9 第 11 宫。",
"D9 月亮在金牛(从 Karakamsha 数第 11 宫)。",
"D9 月亮在金牛(从月亮盘数第 11 宫)。",
"D9 月亮在金牛(从月亮盘 上升起算第 11 宫)。",
"D9 月亮在金牛(从 AL 数第 11 宫)。",
"D9 月亮在金牛(从 D1 上升数第 11 宫)。",
"本命月亮在金牛(从 D9 上升数第 11 宫)。",
"D9 月亮在金牛(从上升数第 13 宫)。",
"D9 月亮在金牛(从火星数第 11 宫)。",
"D9 火星(从上升数第 11 宫)。",
"D7 月亮(从上升数第 11 宫)。",
"月亮在金牛(从上升数第 11 宫)。",
"D9 月亮在金牛第 11 宫(从上升数)。",
"D9:月亮在金牛或双子(从上升数第 11 宫)。",
"D9:月亮在金牛(第 5 宫或第 11 宫,从上升数)。",
]) assert.deepEqual(claims(text), [], text);
assert.equal(claims("D9:月亮在金牛(从上升数第 5 宫,第 11 宫是另一处)。").length, 1);
});
test("an explicitly wrong sign still belongs to the existing sign check", () => {
const text = "D9:月亮在双子(从上升数第 11 宫)。";
assert.deepEqual(claims(text), []);
assert.ok(findAnswerFactMismatches(text, card).some((claim) =>
claim.kind === "varga_sign" && claim.claimed === "双子"));
});
test("other available vargas are checked, and conflicting evidence for a layer is ignored", () => {
const text = "D10:水星在双子(从上升数第 4 宫)。";
const [claim] = claims(text);
assert.equal(claim?.status, "mismatch");
assert.equal(claim?.layer, "D10");
assert.equal(claim?.source_path, "domain_sections.career.vargas.D10.planets.Mercury");
assert.equal(claim?.card.startsWith("3 宫"), true);
assert.deepEqual(claims(text, syntheticCard(true)), []);
});
test("transit, annual and KP wording prevents a natal varga check in that sentence", () => {
for (const word of ["行运", "过境", "Transit", "流年", "年盘", "Varsha", "Tajika", "KP", "宫头", "Chalit"]) {
assert.deepEqual(claims(`D9:月亮在金牛(从上升数第 11 宫),这是${word}。`), [], word);
}
});
test("the new check keeps commas inside a sentence and never carries an entity or reference across cells", () => {
const checked = "| D9 | 月亮在金牛,位置写作(从上升数第 5 宫) |";
assert.equal(claims(checked)[0]?.status, "match");
for (const text of [
"| D9 | 月亮在金牛 | 从上升数第 5 宫 |",
"| D9 | 月亮在金牛,从上升数 | 第 5 宫 |",
"| D9 | 月亮在金牛;从上升数第 5 宫 |",
"D9:月亮在金牛。从上升数第 5 宫。",
"D9:月亮在金牛\n从上升数第 5 宫。",
]) assert.deepEqual(claims(text), [], text);
});
test("the repair path rejects the same wrong count and accepts the corrected sentence", () => {
const mismatches = findAnswerFactMismatches(original, card);
const [unit] = factGuardUnits(original, mismatches);
assert.ok(unit);
assert.equal(factRepairRejection(unit, original, card), "still_mismatched");
assert.equal(factRepairRejection(unit, original.replace("第 11 宫", "第 5 宫"), card), null);
assert.match(consultationFactRepairPrompt([unit]), /分盘宫位:从哪里起算就按哪里数/);
});
@@ -0,0 +1,334 @@
/**
* BUG-1825:回答正文里的格局状态词要和卡上一致。
* 合成卡;不含任何真人资料。
*/
import assert from "node:assert/strict";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import test from "node:test";
import { Agent } from "@mastra/core/agent";
import { createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
import { streamAgentResponse } from "../src/lib/stream-agent-response.ts";
import {
consultationStepBudgetReceipt,
createConsultationRuntimeState,
publicConsultationRuntimeSteps,
} from "../src/mastra/consultation-tools.ts";
import {
applyFactRepair,
factGuardUnits,
factRepairRejection,
findAnswerFactMismatches,
} from "../src/lib/consultation-answer-fact-guard.ts";
import type { EvidenceCard } from "../src/lib/consultation-evidence-card.ts";
import { CONDENSED_SHARED_READING_LINES } from "../src/lib/consultation-condensed-checklist.ts";
import { professionalConversationVoice } from "../src/mastra/product-voice.ts";
import {
YOGA_BLOCKED_PREFIX,
YOGA_STATUS_MAPPING,
yogaStatusContractTable,
} from "../src/lib/yoga-status-words.ts";
type Row = Record<string, unknown>;
function card(rows: Row[], hits: string[] = [], notEvaluated: string[] = []): EvidenceCard {
return {
domains: ["career"],
domain_sections: { career: { yogas: { hits } } },
base: {
natal: {
planets: {},
yogas_detail: { rows, hits, not_evaluated: notEvaluated },
},
timing: {},
},
} as unknown as EvidenceCard;
}
const mismatches = (answer: string, evidence: EvidenceCard) => findAnswerFactMismatches(answer, evidence);
const PARENTS_ROWS: Row[] = [
{ name: "Sasa Yoga", status: "computed" },
{ name: "Budhaditya Yoga", status: "computed" },
{ name: "Surya-Budha Yoga", status: "computed" },
{ name: "Guru Yoga (Jupiter with Ascendant Lord)", status: "computed" },
{ name: "Neecha Bhanga", status: "computed" },
];
test("her parents counterexample: computed written as 非成立 is rejected five times", () => {
const answer = "其余为已计算、非成立状态的格局(Sasa Yoga、Budhaditya、Surya-Budha、Guru Yoga、Neecha Bhanga 等),其中 Sasa Yoga 与「水星在第 7 宫角宫」一类只作结构观察,本轮不用来支撑父母线的判断。";
const found = mismatches(answer, card(PARENTS_ROWS, PARENTS_ROWS.map((row) => String(row.name))));
assert.equal(found.length, 5, JSON.stringify(found.map((item) => item.entity)));
for (const item of found) {
assert.equal(item.kind, "yoga_status");
assert.equal(item.claimed, "非成立");
assert.equal(item.card, YOGA_STATUS_MAPPING.computed.text);
assert.equal(item.layer, "D1");
assert.equal(item.reference, "yogas_detail status");
assert.match(item.source_path, /^base\.natal\.yogas_detail\.rows\[\d\]$/);
assert.equal(item.fact_id, `yoga:${item.entity}@computed`);
}
});
test("her career counterexample: candidate written as 成立 is rejected", () => {
const rows: Row[] = [
{ name: "Sunaphaa Yoga", status: "candidate", claim_status: "support_only_candidate" },
{ name: "Vosi Yoga", status: "candidate", claim_status: "support_only_candidate" },
];
const evidence = card(rows, ["Sunaphaa Yoga", "Vosi Yoga"]);
const listed = mismatches("成立:Sunaphaa Yoga、Vosi Yoga", evidence);
assert.equal(listed.length, 2);
assert.deepEqual(listed.map((item) => item.entity).sort(), ["Sunaphaa Yoga", "Vosi Yoga"]);
assert.equal(listed[0]!.card, YOGA_STATUS_MAPPING.candidate.text);
const prose = mismatches("Sunaphaa Yoga 与 Vosi Yoga 均已成立。", evidence);
assert.equal(prose.length, 2);
assert.equal(prose[0]!.claimed, "均已成立".includes("成立") ? "已成立" : "成立");
});
test("a name only in hits is blocked, never 成立", () => {
const found = mismatches("Kalanidhi Yoga 成立。", card([], ["Kalanidhi Yoga"]));
assert.equal(found.length, 1);
assert.ok(found[0]!.card.startsWith(YOGA_BLOCKED_PREFIX));
assert.equal(found[0]!.source_path, "domain_sections.career.yogas.hits");
});
test("the correct phrasings are not rejected", () => {
const rows: Row[] = [
{ name: "Sunaphaa Yoga", status: "candidate" },
{ name: "Vosi Yoga", status: "candidate" },
{ name: "Amala Yoga", status: "computed" },
{ name: "Saraswati Yoga", status: "computed" },
];
const evidence = card(rows, ["Sunaphaa Yoga", "Vosi Yoga", "Amala Yoga", "Saraswati Yoga"]);
const answers = [
"卡上 yogas.hits 只有两条——Sunaphaa Yoga(candidate,support only,条件未复核)、Vosi Yoga(candidate,support only,当前细运层有激活);两条都是候选,只能当支持性描述,不作格局成立。",
"Amala Yoga、Saraswati Yoga 等 computed 项,是探测器识别、未经规则治理,不作成立。",
"若 Vosi Yoga 成立,才轮到这条线。",
"Vosi Yoga 是否成立要看细运。",
];
for (const answer of answers) {
assert.deepEqual(mismatches(answer, evidence), [], answer);
}
});
test("the card's own words are accepted for every status", () => {
const rows: Row[] = [
{ name: "Alpha Yoga", status: "candidate" },
{ name: "Beta Yoga", status: "computed" },
{ name: "Gamma Yoga", status: "candidate", formation: { status: "formed_under_source" } },
{ name: "Delta Yoga", status: "computed", formation: { status: "conditions_not_met" } },
];
const evidence = card(rows);
const text = (key: keyof typeof YOGA_STATUS_MAPPING) => YOGA_STATUS_MAPPING[key].text;
assert.deepEqual(mismatches(`Alpha Yoga:${text("candidate")}。`, evidence), []);
assert.deepEqual(mismatches(`Beta Yoga:${text("computed")}。`, evidence), []);
assert.deepEqual(mismatches(`Gamma Yoga:${text("formed_under_source")}。`, evidence), []);
assert.deepEqual(mismatches(`Delta Yoga:${text("conditions_not_met")}。`, evidence), []);
});
test("formed and not-met go in opposite directions", () => {
const evidence = card([
{ name: "Gamma Yoga", status: "candidate", formation: { status: "formed_under_source" } },
{ name: "Delta Yoga", status: "computed", formation: { status: "conditions_not_met" } },
]);
assert.deepEqual(mismatches("Gamma Yoga 成立。", evidence), []);
assert.equal(mismatches("Gamma Yoga 不成立。", evidence).length, 1);
assert.deepEqual(mismatches("Delta Yoga 不成立。", evidence), []);
assert.equal(mismatches("Delta Yoga 已成立。", evidence).length, 1);
});
test("ambiguous, structural and mixed sentences are left alone", () => {
const evidence = card([
{ name: "Putra Yoga (A)", status: "computed" },
{ name: "Putra Yoga (B)", status: "candidate", formation: { status: "formed_under_source" } },
{ name: "Witness Yoga", status: "structural_observation" },
]);
assert.deepEqual(mismatches("Putra Yoga 成立。", evidence), []);
assert.deepEqual(mismatches("Witness Yoga 成立。", evidence), []);
// 同一句里既有肯定又有否定:有歧义,整句不检查(「,」是分句点,所以这里用同一句内的写法)。
assert.deepEqual(mismatches("Sunaphaa Yoga 成立又不成立。", card([{ name: "Sunaphaa Yoga", status: "candidate" }])), []);
});
test("the repair prompt rejects a rewrite that still says 非成立 and accepts the card text", () => {
const evidence = card(PARENTS_ROWS);
const [unit] = factGuardUnits(
"其余为已计算、非成立状态的格局(Sasa Yoga 等)。",
mismatches("其余为已计算、非成立状态的格局(Sasa Yoga 等)。", evidence),
);
assert.ok(unit);
assert.equal(factRepairRejection(unit!, "其余为已计算、非成立状态的格局(Sasa Yoga 等)。", evidence), "still_mismatched");
const fixed = `其余为已计算、${YOGA_STATUS_MAPPING.computed.text}的格局(Sasa Yoga 等)。`;
assert.equal(factRepairRejection(unit!, fixed, evidence), null);
const applied = applyFactRepair("其余为已计算、非成立状态的格局(Sasa Yoga 等)。", [unit!], new Map(), evidence);
assert.ok(applied.dropped >= 1, "no accepted rewrite means the sentence is dropped");
assert.doesNotMatch(applied.output, /非成立/u);
});
test("the contract and the professional voice both quote the one table", () => {
const table = yogaStatusContractTable();
const shared = CONDENSED_SHARED_READING_LINES.join("\n");
assert.ok(shared.includes(table), "shared-reading carries the whole table");
for (const entry of Object.values(YOGA_STATUS_MAPPING)) assert.ok(shared.includes(entry.text));
assert.ok(professionalConversationVoice.includes(table), "professional voice carries the whole table");
assert.doesNotMatch(shared, /hits 里的才是这张盘成立的格局/u);
});
// ---- 验收补充(Claude,2026-11-09):任务书第 8 条、第 9 条最后一项、第 3d 节「只有…才」 ----
test("「只有」without 「才」is not a conditional: 卡上只有 X 成立 is still checked", () => {
const evidence = card([{ name: "Vosi Yoga", status: "candidate" }]);
assert.equal(mismatches("卡上只有 Vosi Yoga 成立。", evidence).length, 1);
assert.deepEqual(mismatches("只有细运走到这里,Vosi Yoga 才能成立。", evidence), []);
assert.deepEqual(mismatches("只有细运激活时 Vosi Yoga 才算成立。", evidence), []);
});
test("only one table: the five status texts are written only in yoga-status-words.ts", () => {
const root = join(import.meta.dirname, "..");
const files: string[] = [];
const walk = (dir: string) => {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else if (/\.(ts|tsx)$/u.test(name)) files.push(path);
}
};
walk(join(root, "src"));
files.push(join(root, "..", "references", "product_runtime_contract.md"));
const owners = new Map<string, string[]>();
for (const file of files) {
const text = readFileSync(file, "utf8");
for (const entry of Object.values(YOGA_STATUS_MAPPING)) {
// Windows 上 relative() 用反斜杠,比较前统一成正斜杠(只影响路径写法,不影响集合)。
if (text.includes(entry.text)) {
owners.set(entry.text, [...(owners.get(entry.text) ?? []), relative(root, file).replace(/\\/gu, "/")]);
}
}
}
for (const entry of Object.values(YOGA_STATUS_MAPPING)) {
assert.deepEqual(owners.get(entry.text), ["src/lib/yoga-status-words.ts"], entry.text);
}
});
function fakeModel(parts: readonly string[]) {
return {
specificationVersion: "v2",
provider: "fake",
modelId: "fake-writer",
supportedUrls: {},
async doGenerate() {
throw new Error("not used");
},
async doStream() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue({ type: "stream-start", warnings: [] });
controller.enqueue({ type: "text-start", id: "1" });
for (const part of parts) controller.enqueue({ type: "text-delta", id: "1", delta: part });
controller.enqueue({ type: "text-end", id: "1" });
controller.enqueue({ type: "finish", finishReason: "stop", usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } });
controller.close();
},
});
return { stream };
},
};
}
let seq = 0;
async function realStream(text: string) {
seq += 1;
const agent = new Agent({
id: `yoga-writer-${seq}`,
name: `yoga-writer-${seq}`,
model: fakeModel(text.match(/[\s\S]{1,12}/g) ?? []) as never,
instructions: "fictional writer",
} as never);
const result = await agent.stream([{ role: "user", content: "fictional question" }] as never, { maxSteps: 1, toolChoice: "none" } as never);
return result.fullStream as ReadableStream<unknown>;
}
test("stream path: the parents counterexample is rejected and never delivered", async () => {
const wrong = "其余为已计算、非成立状态的格局(Sasa Yoga、Budhaditya、Surya-Budha、Guru Yoga、Neecha Bhanga 等),其中 Sasa Yoga 与「水星在第 7 宫角宫」一类只作结构观察,本轮不用来支撑父母线的判断。";
const answer = ["## 结论", "父母这条线的根基稳。", "", "## 依据", wrong, ""].join("\n");
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
state.consultationToolCallCount = 1;
state.consultationToolSuccessCount = 1;
state.consultationToolCompleted = true;
state.workflowReceipt = { route: "parents", status: "ready", preciseTiming: "blocked", missingLayers: [] };
state.evidenceCard = card(PARENTS_ROWS);
let completed: string | null = null;
const response = streamAgentResponse({
runId: "run",
requestId: "req",
state,
stream: () => realStream(answer),
requireTool: true,
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => ({
runId: "run",
runtime: "mastra-agentic",
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 },
steps: publicConsultationRuntimeSteps(state),
stepBudget: consultationStepBudgetReceipt(state),
workflow: state.workflowReceipt!,
techniqueTruth: "unknown",
}) as never,
continueAfterLength: async () => {
throw new Error("length continuation not expected");
},
regenerateFactSentences: async () => realStream("无法改写"),
onComplete: (output: string) => { completed = output; },
} as never);
const parser = createNdjsonParser(() => undefined);
parser.finish(await response.text());
assert.ok(completed, "delivered");
assert.ok(state.factGuard?.kinds.includes("yoga_status"), JSON.stringify(state.factGuard));
assert.doesNotMatch(completed!, /非成立/u);
assert.match(completed!, /父母这条线的根基稳。/u);
});
test("usage negations with 已 in the middle are not false positives", () => {
// 验收时在 staging 旧回答上发现的误拦:这句里的「不当作已成立」原先不认。
const evidence = card([
{ name: "Saraswati Yoga", status: "computed" },
{ name: "Amala Yoga", status: "computed" },
], ["Saraswati Yoga", "Amala Yoga"]);
const answer = "Saraswati、Amala 等为 `computed`,不当作已成立格局。";
assert.deepEqual(mismatches(answer, evidence), []);
assert.deepEqual(mismatches("Amala Yoga 不视为已成立。", evidence), []);
});
test("a table cell splits by sentence inside the cell, so neighbours are not blamed", () => {
const evidence = card([
{ name: "Sunaphaa Yoga", status: "candidate" },
{ name: "Vosi Yoga", status: "candidate" },
{ name: "Amala Yoga", status: "computed" },
{ name: "Saraswati Yoga", status: "computed" },
], ["Sunaphaa Yoga", "Vosi Yoga", "Amala Yoga", "Saraswati Yoga"]);
const cell = "Sunaphaa、Vosi 状态为 candidate(support_only_candidate);Amala、Saraswati 等为 computed,不成立";
const found = mismatches(`| 格局 | ${cell} |`, evidence);
assert.deepEqual(found.map((item) => item.entity).sort(), ["Amala Yoga", "Saraswati Yoga"]);
for (const item of found) assert.equal(item.claimed, "不成立");
});
// 验收第 2 次(Claude 2026-11-11):be9b2e91 的「同格沿用名字」跨格、跨分号沿用,
// 在 b4yoga 抽查的 staging 正确回答上误拦 9 条。下面三句都取自那批回答,均应 0 条不符。
test("names never carry into the next cell or past a semicolon", () => {
const evidence = card([
{ name: "Sunaphaa Yoga", status: "candidate" },
{ name: "Vosi Yoga", status: "candidate" },
{ name: "Amala Yoga", status: "computed" },
{ name: "Saraswati Yoga", status: "computed" },
], ["Sunaphaa Yoga", "Vosi Yoga", "Amala Yoga", "Saraswati Yoga"]);
assert.deepEqual(mismatches("| Yoga | Amala、Saraswati 为检测器检出、待核实;其余 computed 行同 | 都不升格为「成立」,只作观察 |", evidence), []);
assert.deepEqual(mismatches("| Yoga | Vosi、Sunaphaa 为候选;三条规则未实现 | 不升级为格局成立,只作方向观察 |", evidence), []);
assert.deepEqual(mismatches("| Yoga | conditional | hits 仅 Vosi、Sunaphaa,均 candidate;其余为 computed,不成立 | 只作支持层 |", evidence), []);
});
+2 -2
View File
@@ -192,8 +192,8 @@ evidence_card.backstage records, for the backend only, that global web evidence
5. 不写没有区分度的推论:逆行不等于「反复、打回来」,也不等于「拖得久、拖出来」,推论里不拿逆行当理由(依据里可以列);宫主不在本宫不等于「往外跑」;某星不在某宫不等于「离得远」;空宫不单独下结论。
<!-- 产品规则,不是占星师的方法:S-06 不断言死亡、离婚、疾病诊断、流产、不孕。 -->
6. 不断言死亡、离婚、疾病诊断、流产、不孕。
<!-- 产品规则,不是占星师的方法:S-07 瑜伽只认 yogas.hits。 -->
7. 瑜伽只认 yogas.hits:hits 里的才是这张盘成立的格局;只在 names 里、没进 hits 的是待检规则,不得说成本人的格局。
<!-- 产品规则,不是占星师的方法:S-07 格局状态词。BUG-1825:原值「瑜伽只认 yogas.hits:hits 里的才是这张盘成立的格局;只在 names 里、没进 hits 的是待检规则,不得说成本人的格局。」;新值:按 yogas_detail 每行状态、用 yoga-status-words.ts 的映射表({{YOGA_STATUS_TABLE}});原因:占星师 2026-11-09 第七轮回复 §5 P0.2「使用hits列表不能推断成立;没有完整条件检查不能写落空」,模型与页面用同一张表。 -->
7. 格局只按卡上 yogas_detail 每一行的状态说,状态文字用这张表:{{YOGA_STATUS_TABLE}}。yogas.hits 只是检测器匹配到的规则名,不能据此写「成立」;只在 names 里、没进 hits 的是待检规则,不得说成本人的格局;没有完整条件检查,不写「不成立」「非成立」「落空」。
<!-- 产品规则,不是占星师的方法:S-08 traditional_yogas 只说明力量。 -->
8. `traditional_yogas` 只用来说明力量,不单独下结论,不算受冲,也不抵消受冲。
<!-- 产品规则,不是占星师的方法:S-09 综合判断表受冲条数留在后台。第五轮裁定 6(BUG-1756):原值「只计数,不下结论、不报给用户」;新值:条数留作后台字段、不单独当结论,用户需要理解冲突时写出实际来源;原因:占星师 2026-11-08 第五轮裁定 6「保留后台字段,但用户需要理解冲突时必须展示实际来源」。BUG-1758:原值「留在后台作计数,不单独当结论,也不只报一个条数」;新值:卡上各宫只列来源(指向按事实去重的来源账,每条写输入与作用),条数只在后台引擎字段,回答写来源、不写条数;同源不算独立票(占星师 2026-11-08 验收);原因:r4 盲测念出受冲条数 6/70 → 13/70,「不只报一个条数」被读成可以连条数一起报。 -->