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