diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 67c24f31..35f31263 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -24750,6 +24750,23 @@ - 关联记录: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-1820 | 按需运限缺少指定日期的原生双轨计算 - 状态:fixed-pending-verify(待独立验收) diff --git a/docs/tasks/FIX-yoga-status-wording-1.md b/docs/tasks/FIX-yoga-status-wording-1.md new file mode 100644 index 00000000..073956a5 --- /dev/null +++ b/docs/tasks/FIX-yoga-status-wording-1.md @@ -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 不出现。 diff --git a/docs/tasks/PROGRESS-ondemand-dasha-date-20261111.md b/docs/tasks/PROGRESS-ondemand-dasha-date-20261111.md index edbb4db5..5f6c67a6 100644 --- a/docs/tasks/PROGRESS-ondemand-dasha-date-20261111.md +++ b/docs/tasks/PROGRESS-ondemand-dasha-date-20261111.md @@ -55,4 +55,17 @@ fixture 或引擎边界确定。 - 另跑出生时刻 Vimshottari、原生运限日期和 Narayana 第二轮三套回归,22 项通过。 - `python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 验证了 Gitea 可见性与外部适配器检查。碎片扫描和定向目标超时,预检整体退出 1。 -- 本机快速门与完整 pytest 已启动。提交代码以便 Gitea 同时运行独立门禁,完整结果待补。 +- 本机 `python scripts/run_quality_gate.py --profile quick` 的 Python 阶段为 1463 通过、 + 3 失败、2 跳过、29 个子测试通过。失败均出自 `tests/test_upstream_technique_layers.py`, + 用未改动的 staging `ecfc01083` 同机复跑同一文件时三个断言均失败。 + - `test_public_chart_values_are_deterministic_and_pinned` + - `test_dasha_registry_serves_or_fails_closed` + - `test_kaala_matches_pyjhora_seed_and_periods` +- 本机完整 `pytest tests` 已启动。记录交接时仍在运行,定向与快速门是目前已结算的本机范围。 +- Gitea [运行 7130](https://git.copse.top/root/Jyotisha/actions/runs/7130) 对初版提交 + `d92cad065` 的 Python 校验通过。前端测试 3615 通过、1 失败、0 取消,失败项为 + `a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase`。 + 在 staging `ecfc01083` 单独复跑同一测试同样得到 `run.failed:empty_answer`。 +- 合入更新后的 staging `150564528` 到工单分支,解决 `BUG_HISTORY.md` 两侧在文末新增 + 条目造成的冲突,保留两侧记录。合并后重新执行本单回归、三套原生日期回归与隐私 + 检查,119 项通过。PR #193 沿用原地址。 diff --git a/docs/tasks/PROGRESS-yoga-status-wording-20261109.md b/docs/tasks/PROGRESS-yoga-status-wording-20261109.md new file mode 100644 index 00000000..95de26d9 --- /dev/null +++ b/docs/tasks/PROGRESS-yoga-status-wording-20261109.md @@ -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 不出现(有测试)。正文不跨句继承。 diff --git a/docs/tasks/TASK-yoga-status-wording-20261109.md b/docs/tasks/TASK-yoga-status-wording-20261109.md new file mode 100644 index 00000000..32948447 --- /dev/null +++ b/docs/tasks/TASK-yoga-status-wording-20261109.md @@ -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 的是待检规则,不得说成本人的格局;没有完整条件检查,不写「不成立」「非成立」「落空」。` + +- 注释行改为(逐字照写): + + `` + + 注释必须保持单行(`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:<卡上名字>@"`,`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 条以外的任何文字才能做到时。 +- 任务书没写到的任何文案或取舍。 diff --git a/frontend/src/lib/consultation-answer-fact-guard.ts b/frontend/src/lib/consultation-answer-fact-guard.ts index 04afdcbd..31580ca5 100644 --- a/frontend/src/lib/consultation-answer-fact-guard.ts +++ b/frontend/src/lib/consultation-answer-fact-guard.ts @@ -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; @@ -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"; 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; path: string }>; karakamsha?: { sign?: Sign; lord?: string; house?: number }; arudha: Record; + /** 格局:归一化名字 → 卡上的状态(或“有歧义,不检查”)。 */ + yogas: Record; }; +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 = {}; 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(); + 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(`(? { @@ -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; 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 })); @@ -384,6 +592,9 @@ function checkLine(line: string, offset: number, view: CardView, push: Emit) { 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, ok = false) => push({ ...item, quote: match[0], start: offset + match.index!, end: offset + match.index! + match[0].length }, ok ? "match" : "mismatch"); @@ -543,6 +754,7 @@ export function consultationFactRepairPrompt(units: readonly FactGuardUnit[]): s "下面这些句子引用的盘面事实与本轮证据卡的层、实体或参照不符,已被拒收。逐条重写这一句(表格里的只重写这一格):", "- 只改与卡不符的部分,换成下面给出的卡上值;其余内容、语气和判断保持不变;如果改了值以后原来的推论不成立,就删掉那半句推论,不另找理由。", "- 分清层与实体:婚姻分盘(D9)里的木星、Karakamsha 星座所在的宫、Karakamsha 主星的本命落宫,是三件不同的事,不能互相照抄。", + "- 格局状态:照卡上给出的状态文字改写,不写「成立」「不成立」「非成立」「落空」,除非卡上的状态文字本身就是这个意思。", "- 不新增计算、技法或执行状态:不写 executed / blocked / 已执行 / 已核验一类的词,不加审计表行,不换行,不写「|」。", "- 只输出 JSON 数组:[{\"id\": 1, \"text\": \"重写后的整句\"}],不要别的文字。", "", diff --git a/frontend/src/lib/consultation-condensed-checklist.ts b/frontend/src/lib/consultation-condensed-checklist.ts index 2ccdb7e2..a687766a 100644 --- a/frontend/src/lib/consultation-condensed-checklist.ts +++ b/frontend/src/lib/consultation-condensed-checklist.ts @@ -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") diff --git a/frontend/src/lib/yoga-status-words.ts b/frontend/src/lib/yoga-status-words.ts index 304bc207..e02071b7 100644 --- a/frontend/src/lib/yoga-status-words.ts +++ b/frontend/src/lib/yoga-status-words.ts @@ -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", "候选", "待核实", "检出", "未确认", "待定"], +}; diff --git a/frontend/src/mastra/product-voice.ts b/frontend/src/mastra/product-voice.ts index bede4222..a94d8d36 100644 --- a/frontend/src/mastra/product-voice.ts +++ b/frontend/src/mastra/product-voice.ts @@ -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 没有写明出生时间已校正时,大运、子运、细运和行运的日期都是名义计算边界,几分钟的出生误差就能移动几天;第一次写日期时注明「名义计算边界」,结论和行动建议里的时间写到月或旬,不写「某日起」。 - 用户点名的技法、宫位、分盘每项都写到;用户的写法要求照做:不让排除的,不写排除或圈定某一类的句子;要分明显和可能的,按证据等级分。 - 用户纠正你的判断时,先认下,直说原来哪句读偏了,再只说盘上真正对得上的部分,并重新标等级;不为原判断辩护,也不把原因推到用户身上。 diff --git a/frontend/tests/consult-affliction-reading-20261001.test.ts b/frontend/tests/consult-affliction-reading-20261001.test.ts index 2cc86f37..3894d13f 100644 --- a/frontend/tests/consult-affliction-reading-20261001.test.ts +++ b/frontend/tests/consult-affliction-reading-20261001.test.ts @@ -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(/(? match[0]).filter((word) => !prose.has(word))); // 原值: required 含 "functional"(通用读法 ③ 写「父母行 functional」) // 新值: "functional_malefics" 与 "pressure" 代替 "functional"(父母行由服务端数,③ 只指 functional_malefics) diff --git a/frontend/tests/consult-chat-reopt-20261107.test.ts b/frontend/tests/consult-chat-reopt-20261107.test.ts index 387f0e30..325b96f3 100644 --- a/frontend/tests/consult-chat-reopt-20261107.test.ts +++ b/frontend/tests/consult-chat-reopt-20261107.test.ts @@ -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(" 6. 不断言死亡、离婚、疾病诊断、流产、不孕。 - -7. 瑜伽只认 yogas.hits:hits 里的才是这张盘成立的格局;只在 names 里、没进 hits 的是待检规则,不得说成本人的格局。 + +7. 格局只按卡上 yogas_detail 每一行的状态说,状态文字用这张表:{{YOGA_STATUS_TABLE}}。yogas.hits 只是检测器匹配到的规则名,不能据此写「成立」;只在 names 里、没进 hits 的是待检规则,不得说成本人的格局;没有完整条件检查,不写「不成立」「非成立」「落空」。 8. `traditional_yogas` 只用来说明力量,不单独下结论,不算受冲,也不抵消受冲。