fix(consult): 解读依据的格局行按卡上状态说话,不再写成成立 (#181, BUG-1824)

- 新增 yoga-status-words.ts:conditions_not_met → 不成立;candidate → 候选;
  computed → 未确认;其他 → 未标状态;结构观察不进这一行
- yogaLine 逐个名字到 card.base.natal.yogas_detail.rows 取状态词并按状态分组
  (候选:A、B;未确认:C),没有可列的写「本题没有检测到相关格局」
- 「格局」兜底文案改为「盘上检测到的格局已列出各自状态」(三栏注释)
- 数据卡、回执 schema、行状态判定、页面组件都不动

Tests: 新增 consult-basis-candidate-status-1824.test.ts(8 条);
consult-technique-evidence-1793 / consult-answer-basis-receipt-20261108 /
consult-answer-mode-ui-20261108 继续通过;tsc 0 错,lint 0 error。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-11-09 14:33:47 +08:00
co-authored by Grok (agent loop)
parent 21b1fe1aae
commit 09a67aad5a
6 changed files with 234 additions and 3 deletions
+14
View File
@@ -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
@@ -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` 与状态标签。
+4 -1
View File
@@ -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: () => "事业、婚姻相关的点位落在哪里" },
@@ -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<string, unknown>;
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<string, Rec>();
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<string, string[]>();
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(";");
});
}
+39
View File
@@ -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<string, unknown>;
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";
}
@@ -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<string, unknown>;
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);
});