merge: resolve staging history conflict and record #177 validation
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m50s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 6m53s
Independent Staging Quality Gate / publish (pull_request) Skipped

This commit is contained in:
jesse-ux
2026-11-11 15:27:21 +08:00
14 changed files with 992 additions and 11 deletions
+17
View File
@@ -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(待独立验收)
+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 不出现。
@@ -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 沿用原地址。
@@ -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,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";
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 }));
@@ -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<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 +754,7 @@ 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,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,「不只报一个条数」被读成可以连条数一起报。 -->