diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7a124719..37d1bc1d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -24518,3 +24518,17 @@ - 防复发:上述测试;合同 docstring 写明后续 #174–#178 的注册步骤。 - 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;FIX-ondemand-transit-date-1(产品拍板 C 方案) - 修复版本:分支 codex/ondemand-transit-date-20261109 + +## BUG-1824 | 「解读依据」把候选格局显示成「成立」 + +- 状态:fixed-pending-verify(分支 codex/basis-candidate-status-20261109) +- 首次发现 / 最近更新:2026-11-09(第七轮验收包,页面阶段)/ 2026-11-09(修复) +- 影响面:frontend/src/lib/consultation-technique-evidence.ts 的 yogaLine;frontend/src/lib/consult-answer-basis.ts 的「格局」兜底文案;新增 frontend/src/lib/yoga-status-words.ts。 +- 现象:简明解读的「解读依据」里,「格局」一行写「成立:Sunaphaa Yoga、Vosi Yoga」,而卡上这些格局的状态是 candidate / computed,卡上的规则写明 none is confirmed。 +- 触发条件:任何带数据卡的简明解读回答。 +- 根因:yogaLine 只读领域段的 yogas.hits(检测器匹配到的规则名,BUG-1157),一律写成「成立:」;真正的状态在 card.base.natal.yogas_detail.rows[] 的 status / formation.status 里,从来没被这一行读过。 +- 修复:新增 yoga-status-words.ts(conditions_not_met → 不成立;candidate → 候选;computed → 未确认;其他 → 未标状态;结构观察不进这一行);yogaLine 改为按名字到 yogas_detail.rows 取状态词并按状态分组(候选:A、B;未确认:C),没有可列的写「本题没有检测到相关格局」;兜底文案由「盘上成立和落空的格局已核对」改为「盘上检测到的格局已列出各自状态」。行状态(已算 / 有条件)与数据卡事实记录不动。 +- 验证:frontend/tests/consult-basis-candidate-status-1824.test.ts(8 条:三种状态词、formed_under_source 不写成成立、结构观察不出现、未标状态、灰字与兜底都不含「成立:」和「已确认」、计算状态与解读状态分开、空 hits 文案、词表本身不提供「成立」);consult-technique-evidence-1793(2)、consult-answer-basis-receipt-20261108(4)、consult-answer-mode-ui-20261108(14)继续通过。 +- 防复发:上述测试;词表单独成文件,后续 #182 复用同一套词。 +- 关联记录:BUG-1793、BUG-1157、#182 +- 修复版本:分支 codex/basis-candidate-status-20261109 diff --git a/docs/tasks/PROGRESS-basis-candidate-status-20261109.md b/docs/tasks/PROGRESS-basis-candidate-status-20261109.md new file mode 100644 index 00000000..0cb614a0 --- /dev/null +++ b/docs/tasks/PROGRESS-basis-candidate-status-20261109.md @@ -0,0 +1,45 @@ +# PROGRESS · 「解读依据」候选格局不再写成「成立」(#181,BUG-1824,2026-11-09) + +- 分支:`codex/basis-candidate-status-20261109`(基线 `afac4ea9`) +- 任务书:`docs/tasks/TASK-basis-candidate-status-20261109.md` +- Bug:`docs/BUG_HISTORY.md` 末尾追加 BUG-1824 + +## 改了什么 + +| 文件 | 内容 | +| --- | --- | +| `frontend/src/lib/yoga-status-words.ts`(新) | `YOGA_STATUS_WORDS` 与 `yogaStatusWord(row)`:`formation.status = conditions_not_met` → 不成立;`status = candidate` → 候选;`status = computed` → 未确认;其他 → 未标状态。另导出 `isStructuralObservation(row)` | +| `frontend/src/lib/consultation-technique-evidence.ts` | 只改 `yogaLine` 与一行 import:仍取领域段 `yogas.hits` 的前 3 个名字,逐个到 `card.base.natal.yogas_detail.rows` 按 `name` 找状态词;结构观察跳过;按状态词分组(组按首次出现排、组内保持原顺序);找不到行 → 未标状态;没有可列的 → 「本题没有检测到相关格局」 | +| `frontend/src/lib/consult-answer-basis.ts` | 「格局」的兜底文案改为「盘上检测到的格局已列出各自状态」,改动处写三栏注释(原因:BUG-1824:候选不得显示为成立) | + +没动:数据卡(`yogas.hits` / `yogas_detail` 事实记录一字未改)、回执 schema、`display_status` 取值、页面组件、答题提示词、`references/**`、`scripts/**`。 + +## 状态词表 + +| 卡上的值 | 显示 | +| --- | --- | +| `formation.status = "conditions_not_met"` | 不成立 | +| `status = "candidate"` | 候选 | +| `status = "computed"` | 未确认 | +| 其他 / 缺失 | 未标状态 | + +`formed_under_source` 不单独显示为「成立」——卡上规则是 `none is confirmed`,按 `status` 显示。 +词表里没有「成立」「已确认」这两个词(「不成立」是否定式,是任务书要求的)。 + +## 自测 + +| 命令 | 结果 | +| --- | --- | +| `node ... --test tests/consult-basis-candidate-status-1824.test.ts` | 8 过 / 0 败 | +| `tests/consult-technique-evidence-1793.test.ts` | 2 过 / 0 败 | +| `tests/consult-answer-basis-receipt-20261108.test.tsx` | 4 过 / 0 败 | +| `tests/consult-answer-mode-ui-20261108.test.tsx` | 14 过 / 0 败 | +| `tsc --noEmit -p frontend` | 0 错 | +| `npm run lint` | 0 error | + +新测试覆盖任务书的 7 条:三种状态词各对各;`formed_under_source` 显示候选、不写成立;结构观察不出现;卡上没有对应行的名字 → 未标状态;灰字与兜底都不含「成立:」「已确认」;`consultAnswerBasisRows` 走一遍时「格局」行 `status === "done"` 且 `result` 是分组灰字(计算状态与解读状态分开);空 `hits` → 「本题没有检测到相关格局」。既有断言没有改动,所以没有三栏注释。 + +## 说明 + +- 「格局」行要显示成 `已算`,测试里的审计行用 `interpretation_status: "supporting"`(引擎把 yoga 行标为 observation/supporting 时 `display_status` 才是 firm)。这是既有判定,本单没有改。 +- 页面组件 `consult-answer-basis.tsx` 不用改:它只渲染 `result` 与状态标签。 diff --git a/frontend/src/lib/consult-answer-basis.ts b/frontend/src/lib/consult-answer-basis.ts index 85e123e2..e6113ff3 100644 --- a/frontend/src/lib/consult-answer-basis.ts +++ b/frontend/src/lib/consult-answer-basis.ts @@ -55,7 +55,10 @@ const BASIS_ENTRIES: readonly BasisEntry[] = [ name: "功能吉凶", result: (profile) => `按 ${FUNCTIONAL_PROFILE_LABELS[profile]} 判断每颗星在这张盘里起的作用`, }, - { match: /^Yogas?\b|^格局/i, name: "格局", result: () => "盘上成立和落空的格局已核对" }, + // 原值:`盘上成立和落空的格局已核对` + // 新值:`盘上检测到的格局已列出各自状态` + // 原因:BUG-1824:候选不得显示为成立(卡上规则是 none is confirmed) + { match: /^Yogas?\b|^格局/i, name: "格局", result: () => "盘上检测到的格局已列出各自状态" }, { match: /^Shadbala\b/i, name: "行星力量", result: () => "每颗星的强弱已算" }, { match: /^Ashtakavarga$/i, name: "宫位点数", result: () => "每个宫得到的点数已算" }, { match: /^Arudha\b/i, name: "Arudha 点位", result: () => "事业、婚姻相关的点位落在哪里" }, diff --git a/frontend/src/lib/consultation-technique-evidence.ts b/frontend/src/lib/consultation-technique-evidence.ts index b0905049..3cdbf193 100644 --- a/frontend/src/lib/consultation-technique-evidence.ts +++ b/frontend/src/lib/consultation-technique-evidence.ts @@ -11,6 +11,7 @@ */ import type { TechniqueAuditRow } from "./consultation-agent-events.ts"; import type { EvidenceCard } from "./consultation-evidence-card.ts"; +import { isStructuralObservation, yogaStatusWord, YOGA_STATUS_WORDS } from "./yoga-status-words.ts"; type Rec = Record; const rec = (value: unknown): Rec => (value && typeof value === "object" && !Array.isArray(value) ? value as Rec : {}); @@ -136,11 +137,31 @@ function savLine(card: EvidenceCard) { }); } +// 原值:`成立:${hits}` / `本题没有成立的格局` +// 新值:按卡上 yogas_detail 的状态分组写「候选 / 未确认 / 不成立」,没有可列的写「本题没有检测到相关格局」 +// 原因:BUG-1824:候选不得显示为成立(卡上规则是 none is confirmed) function yogaLine(card: EvidenceCard) { + const detail = rec(rec(rec(card.base).natal).yogas_detail); + const rows = Array.isArray(detail.rows) ? detail.rows.map(rec) : []; + const byName = new Map(); + for (const row of rows) { + const name = str(row.name); + if (name) byName.set(name, row); + } return firstOf(card, (section) => { if (!("yogas" in section)) return undefined; - const hits = list(rec(section.yogas).hits); - return hits.length ? `成立:${hits.slice(0, 3).join("、")}` : "本题没有成立的格局"; + const hits = list(rec(section.yogas).hits).slice(0, 3); + const groups = new Map(); + for (const name of hits) { + const row = byName.get(name); + if (row && isStructuralObservation(row)) continue; + const word = row ? yogaStatusWord(row) : YOGA_STATUS_WORDS.unknown; + const bucket = groups.get(word) ?? []; + bucket.push(name); + groups.set(word, bucket); + } + if (!groups.size) return "本题没有检测到相关格局"; + return [...groups.entries()].map(([word, names]) => `${word}:${names.join("、")}`).join(";"); }); } diff --git a/frontend/src/lib/yoga-status-words.ts b/frontend/src/lib/yoga-status-words.ts new file mode 100644 index 00000000..b5221b51 --- /dev/null +++ b/frontend/src/lib/yoga-status-words.ts @@ -0,0 +1,39 @@ +/** + * BUG-1824:「解读依据」的「格局」一行以前把「检测器匹配到的规则名」写成「成立」。 + * 卡上的规则是 `none is confirmed`,所以这一行只能照卡上的状态说话: + * + * | 卡上的值 | 显示 | + * | --- | --- | + * | `formation.status = "conditions_not_met"` | 不成立 | + * | `status = "candidate"` | 候选 | + * | `status = "computed"` | 未确认 | + * | 其他 / 缺失 | 未标状态 | + * + * `formed_under_source` 不单独显示为“成立”(卡上规则是 none is confirmed)。 + * 词表里刻意不含“成立”“已确认”这两个词。 + */ +export const YOGA_STATUS_WORDS = { + notFormed: "不成立", + candidate: "候选", + computed: "未确认", + unknown: "未标状态", +} as const; + +export type YogaStatusWord = typeof YOGA_STATUS_WORDS[keyof typeof YOGA_STATUS_WORDS]; + +type Rec = Record; +const rec = (value: unknown): Rec => (value && typeof value === "object" && !Array.isArray(value) ? value as Rec : {}); + +/** 一个格局行(`card.base.natal.yogas_detail.rows[]`)的解读状态词。 */ +export function yogaStatusWord(row: Rec): YogaStatusWord { + const formation = rec(row.formation); + if (formation.status === "conditions_not_met") return YOGA_STATUS_WORDS.notFormed; + if (row.status === "candidate") return YOGA_STATUS_WORDS.candidate; + if (row.status === "computed") return YOGA_STATUS_WORDS.computed; + return YOGA_STATUS_WORDS.unknown; +} + +/** 卡上这一行是不是“结构观察”(不是命名格局),不进「格局」这一行。 */ +export function isStructuralObservation(row: Rec): boolean { + return row.claim_kind === "structural_observation" || row.status === "structural_observation"; +} diff --git a/frontend/tests/consult-basis-candidate-status-1824.test.ts b/frontend/tests/consult-basis-candidate-status-1824.test.ts new file mode 100644 index 00000000..189e3509 --- /dev/null +++ b/frontend/tests/consult-basis-candidate-status-1824.test.ts @@ -0,0 +1,109 @@ +/** + * BUG-1824:「解读依据」的「格局」一行不得把候选格局写成「成立」。 + * 合成卡,不含任何真人资料。 + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { TechniqueAuditRow } from "../src/lib/consultation-agent-events.ts"; +import { consultAnswerBasisRows } from "../src/lib/consult-answer-basis.ts"; +import type { EvidenceCard } from "../src/lib/consultation-evidence-card.ts"; +import { withTechniqueEvidence } from "../src/lib/consultation-technique-evidence.ts"; +import { yogaStatusWord, YOGA_STATUS_WORDS } from "../src/lib/yoga-status-words.ts"; + +type Row = Record; + +function cardWith(rows: Row[], hits: string[]): EvidenceCard { + return { + domains: ["career"], + domain_sections: { career: { yogas: { hits } } }, + base: { natal: { yogas_detail: { rows } } }, + } as unknown as EvidenceCard; +} + +function yogaResult(card: EvidenceCard): string | undefined { + const audit: TechniqueAuditRow[] = [{ + technique: "Yogas", + calculation_status: "executed", + interpretation_status: "observation_only", + } as unknown as TechniqueAuditRow]; + const enriched = withTechniqueEvidence(audit, card) ?? []; + const rows = consultAnswerBasisRows(enriched); + return rows.find((row) => row.name === "格局")?.result; +} + +test("candidate, computed and not-formed rows each show their own status word", () => { + const card = cardWith([ + { name: "Sunaphaa Yoga", status: "candidate" }, + { name: "Vosi Yoga", status: "computed" }, + { name: "Dharidhra Yoga", status: "candidate", formation: { status: "conditions_not_met" } }, + ], ["Sunaphaa Yoga", "Vosi Yoga", "Dharidhra Yoga"]); + assert.equal(yogaResult(card), "候选:Sunaphaa Yoga;未确认:Vosi Yoga;不成立:Dharidhra Yoga"); +}); + +test("formed_under_source still shows the status word, never 成立", () => { + const card = cardWith([ + { name: "Amala Yoga", status: "candidate", formation: { status: "formed_under_source" } }, + ], ["Amala Yoga"]); + const line = yogaResult(card) ?? ""; + assert.equal(line, "候选:Amala Yoga"); + assert.doesNotMatch(line, /成立/u); +}); + +test("structural observations stay out of the 格局 row", () => { + const card = cardWith([ + { name: "Neecha Bhanga witness", status: "structural_observation" }, + { name: "Sunaphaa Yoga", status: "candidate" }, + ], ["Neecha Bhanga witness", "Sunaphaa Yoga"]); + assert.equal(yogaResult(card), "候选:Sunaphaa Yoga"); +}); + +test("a hit without a card row is 未标状态", () => { + const card = cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], ["Sunaphaa Yoga", "Unknown Yoga"]); + assert.equal(yogaResult(card), "候选:Sunaphaa Yoga;未标状态:Unknown Yoga"); +}); + +test("neither the grey line nor the fallback copy claims 成立 or 已确认", () => { + const card = cardWith([ + { name: "Sunaphaa Yoga", status: "candidate" }, + { name: "Vosi Yoga", status: "computed" }, + ], ["Sunaphaa Yoga", "Vosi Yoga"]); + const line = yogaResult(card) ?? ""; + const fallback = consultAnswerBasisRows([{ + technique: "Yogas", + calculation_status: "executed", + } as unknown as TechniqueAuditRow]).find((row) => row.name === "格局")?.result ?? ""; + for (const text of [line, fallback]) { + assert.doesNotMatch(text, /成立:/u); + assert.doesNotMatch(text, /已确认/u); + } + assert.equal(fallback, "盘上检测到的格局已列出各自状态"); +}); + +test("the 格局 row keeps its calculation status while the grey line carries the reading status", () => { + const card = cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], ["Sunaphaa Yoga"]); + const enriched = withTechniqueEvidence([{ + technique: "Yogas", + calculation_status: "executed", + interpretation_status: "supporting", + } as unknown as TechniqueAuditRow], card) ?? []; + const row = consultAnswerBasisRows(enriched).find((item) => item.name === "格局"); + assert.ok(row); + assert.equal(row.status, "done"); + assert.equal(row.result, "候选:Sunaphaa Yoga"); +}); + +test("no hits at all says nothing was detected, not that nothing formed", () => { + const card = cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], []); + assert.equal(yogaResult(card), "本题没有检测到相关格局"); +}); + +test("the status vocabulary never offers a word that claims confirmation", () => { + for (const word of Object.values(YOGA_STATUS_WORDS)) { + // 「不成立」是允许的否定式;不允许的是把候选说成「成立」或「已确认」。 + assert.notEqual(word, "成立"); + assert.notEqual(word, "已确认"); + assert.equal(word.startsWith("成立"), false, word); + } + assert.equal(yogaStatusWord({}), YOGA_STATUS_WORDS.unknown); +});