From 95f585126175875cf8a37fd37f181f1a91559493 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 30 Aug 2026 10:01:32 +0800 Subject: [PATCH] fix(rectification): rewrite D9/D10 style options and stem spacing Choice cards were showing type-table tokens as options, dating long-term style questions, and stacking the stem on the first option because legend is outside the fieldset grid. Co-authored-by: Cursor --- contracts/probe-question-v1.json | 6 +- docs/BUG_HISTORY.md | 16 + frontend/src/app/birth-time-choice.css | 2 +- frontend/src/app/globals.css | 2 +- .../core/candidate-contrast-packet.ts | 4 +- .../rectification-agentic/v9/choice-card.ts | 17 +- .../v9/probe-question-contract.ts | 11 +- .../v9/varga-type-tables.ts | 189 ++++++++-- ...tification-probe-question-contract.test.ts | 4 +- .../rectification-varga-style-copy.test.ts | 350 ++++++++++++++++++ .../rectification/probe_question_contract.py | 6 +- tests/test_probe_question_contract.py | 3 +- 12 files changed, 563 insertions(+), 47 deletions(-) create mode 100644 frontend/tests/rectification-varga-style-copy.test.ts diff --git a/contracts/probe-question-v1.json b/contracts/probe-question-v1.json index 0b52a35d..8fe43dcf 100644 --- a/contracts/probe-question-v1.json +++ b/contracts/probe-question-v1.json @@ -54,9 +54,13 @@ } ], "varga_none_style_option": { - "label": "都不是这些特质", + "label": "都不太像我", "answer_class": "no" }, + "varga_unsure_style_option": { + "label": "一时说不好", + "answer_class": "unsure" + }, "unsure_style_option": { "label": "这段记不清楚", "answer_class": "unsure" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 169e9dc5..2362c823 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6783,3 +6783,19 @@ - 复发自:BUG-440(覆盖未齐时的收口分支存在,但 occupation 记账域错误导致永远走不到;无年份卡仍被同一覆盖门锁死) - 修复版本:待发布 +## BUG-443 | D9/D10 风格卡用了类型表原文、题干贴了年份、题干贴着选项 + +- 状态:resolved +- 首次发现:2026-08-30 +- 最近更新:2026-08-30 +- 影响面:`varga-type-tables` 的 `d9StyleLabel` / `d10StyleLabel`、`completeStyleOptions` 风格题固定项、`choice-card` 题干、`birth-time-choice.css` legend、`.rectification-choice-why` +- 用户现象:D9/D10 风格点选卡选项是「和谐、美感、合作」「照顾、情感」这类类型表词条,三个抽象名词并列、维度不统一,无法对号入座。题干出现「2024–2026 年这段里,做事风格更接近其中一种?」;风格题没有「这段」可指。题干与第一个选项几乎没有间隔。 +- 触发条件:候选在 D9 或 D10 上升上分开,出 `choice_kind=varga_style` 点选卡。 +- 根因:三段同时成立。(1) `d9StyleLabel` / `d10StyleLabel` 直接输出 `D9_TYPE_TABLE[sign].trait` / `D10_TYPE_TABLE[sign].style`,解读用的占星类型表字段被当成用户选项。(2) `choice-card.ts` 的 `periodFor` 对 `varga_style` 走 `lifePeriodLabel(evidence, domain)`,`eventQuestionPrompt` 再把时间段贴到长期风格题干上;风格描述的是平时怎么做 / 怎么相处,不随年份变化。(3) `.birth-time-choice-question` 用 grid `gap` 分隔子项,但题干是 ``,legend 不参与 fieldset 的 grid 布局,gap 对题干与选项不生效。 +- 修复:类型表为 D9/D10 每行新增面向用户的 `userChoice`,不改 `trait` / `occupation` / `style` / `spouse` / `marriage`。标签函数改输出 `userChoice`;`answer_class` 与 sign 的映射不变。风格题 `no` 改为「都不太像我」,unsure 单独为「一时说不好」;existence / event_quality 固定选项文案不变。`varga_style` 不再返回时间段,题干写成长期表述。legend 与 `.rectification-choice-why` 用 `--space-3` 下外边距,不把 legend 改成 `p`、不新增视觉容器。Skill 保持 10.0.13。未改计分、探针身份或 hash。 +- 验证:`frontend/tests/rectification-varga-style-copy.test.ts` 锁:12×2 `userChoice` 非空且不等于 `trait`/`style`;sign → `answer_class` 仍按组序;风格卡题干无年份/「这段」;existence / event_quality 仍锁年份且固定选项不变;四选项 `isRenderableStyleOptions`;legend / why 源码具有非零下边距。修复前:标签等于类型表原文、题干带 `lifePeriodLabel` 年份、「这段记不清楚」用在风格题、legend 仅 `margin-bottom: var(--space-1)`。合同金标 `contracts/probe-question-v1.json` 同步。 +- 防复发:面向用户的选项文案不得直接复用占星类型表字段。长期风格题不得锁定时间段。卡片内题干与选项必须有独立于 grid gap 的间距。existence / event_quality 的年份月份锁定(BUG-408、BUG-412)不得被风格题改动带回。 +- 相关记录:BUG-408、BUG-412 +- 复发自:无 +- 修复版本:待发布 + diff --git a/frontend/src/app/birth-time-choice.css b/frontend/src/app/birth-time-choice.css index fdc9d7c2..298ce126 100644 --- a/frontend/src/app/birth-time-choice.css +++ b/frontend/src/app/birth-time-choice.css @@ -2,7 +2,7 @@ .birth-time-question-progress b { color: var(--color-ink); font-weight: 600; } .birth-time-choice-surface { min-width: 0; display: grid; gap: var(--space-4); } .birth-time-choice-question { min-width: 0; display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; } -.birth-time-choice-question legend { width: 100%; margin: 0 0 var(--space-1); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; } +.birth-time-choice-question legend { width: 100%; margin: 0 0 var(--space-3); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; } .birth-time-primary-choices { display: grid; gap: var(--space-2); } .birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); } .birth-time-choice-option { min-height: 52px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.5; text-align: left; text-wrap: pretty; word-break: auto-phrase; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f9b32476..19771bc2 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2897,7 +2897,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin-inline-start: var(--assistant-content-inset); } .rectification-choice-why { - margin: 0; + margin: 0 0 var(--space-3); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index 27c7c8f9..011043de 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -881,8 +881,8 @@ function remainingDomain(layer: string): string { } function remainingQuestion(layer: string): string { - if (layer === "d10") return "平时做事更接近下面哪一种职责风格?"; - if (layer === "d9") return "亲密关系里更接近下面哪一种相处方式?"; + if (layer === "d10") return "平时做事,你更接近下面哪一种?"; + if (layer === "d9") return "亲密关系里,你更接近哪一种相处方式?"; if (layer === "d24" || layer === "d5") return "有没有学业或考试发挥明显失常、压力特别大的时候?"; if (layer === "d4") return "有没有搬家或长期住到外地?"; if (layer === "d7" || layer === "d12") return "家里有没有结婚、添丁或住院这类事?"; diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index 9941f35d..f1d07f71 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -245,17 +245,17 @@ function pickProbe( } function periodFor( - evidence: readonly ChoiceCardEvidence[] | undefined, + _evidence: readonly ChoiceCardEvidence[] | undefined, domain: string | null, probes: readonly DiscriminatingEventProbe[] | undefined, _birthDate?: string | null, followup?: ChoiceCardFollowup, ): string { const probe = pickProbe(probes, domain, followup); + const kind = followup?.choice_kind ?? probe?.choice_kind ?? "existence"; + if (kind === "varga_style") return ""; const fromProbe = concreteProbePeriod(probe); if (fromProbe) return fromProbe; - const kind = followup?.choice_kind ?? probe?.choice_kind ?? "existence"; - if (kind === "varga_style") return lifePeriodLabel(evidence ?? [], domain); return "那段时间"; } @@ -274,17 +274,20 @@ function eventQuestionPrompt( period: string, family: string, kind: EventProbeChoiceKind, + domain?: string | null, ): string { const time = period.trim(); const topic = family.replace(/[??。]+$/g, "").trim(); + if (kind === "varga_style") { + return domain === "relationship" + ? "亲密关系里,你更接近哪一种相处方式?" + : "平时做事,你更接近下面哪一种?"; + } if (!topic) return time; const dated = isConcreteChoicePeriod(period); if (kind === "event_quality") { return dated ? `${time},有没有${topic}的时候?` : `有没有${topic}的时候?`; } - if (kind === "varga_style") { - return dated ? `${time},${topic}?` : `${topic}?`; - } return dated ? `${time},有没有${topic}?` : `有没有${topic}?`; } @@ -336,7 +339,7 @@ function hypothesisFor( const kind = followup.choice_kind ?? probe.choice_kind ?? "existence"; const yearlessVarga = (followup.semantic_key ?? probe.semantic_key ?? "").startsWith("varga."); if (kind !== "varga_style" && !isConcreteChoicePeriod(period) && !yearlessVarga) return null; - const prompt = eventQuestionPrompt(period, probe.event_family, kind); + const prompt = eventQuestionPrompt(period, probe.event_family, kind, domain); const why = probe.user_meaning?.trim() || followup.user_prompt_hint.trim(); if (!why) return null; return withStyleOptionLabels(prompt, why, null, styleOptions.options); diff --git a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts index 983898cf..519d3f89 100644 --- a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts +++ b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts @@ -63,7 +63,7 @@ export const QUALITY_STYLE_OPTIONS: readonly ProbeStyleOption[] = [ ]; export const VARGA_NONE_STYLE_OPTION: ProbeStyleOption = { - label: "都不是这些特质", + label: "都不太像我", answer_class: "no", }; @@ -72,6 +72,11 @@ export const UNSURE_STYLE_OPTION: ProbeStyleOption = { answer_class: "unsure", }; +export const VARGA_UNSURE_STYLE_OPTION: ProbeStyleOption = { + label: "一时说不好", + answer_class: "unsure", +}; + export const FORBIDDEN_COPY_TOKENS = ["外貌", "体质", "胎记", "疤痕", "伤疤", "身高", "体型"] as const; export const LABEL_MIN = 4; export const LABEL_MAX = 80; @@ -105,6 +110,7 @@ export function probeQuestionContractPayload(): Readonly<{ existence_style_options: typeof EXISTENCE_STYLE_OPTIONS; quality_style_options: typeof QUALITY_STYLE_OPTIONS; varga_none_style_option: typeof VARGA_NONE_STYLE_OPTION; + varga_unsure_style_option: typeof VARGA_UNSURE_STYLE_OPTION; unsure_style_option: typeof UNSURE_STYLE_OPTION; }> { return { @@ -116,6 +122,7 @@ export function probeQuestionContractPayload(): Readonly<{ existence_style_options: EXISTENCE_STYLE_OPTIONS, quality_style_options: QUALITY_STYLE_OPTIONS, varga_none_style_option: VARGA_NONE_STYLE_OPTION, + varga_unsure_style_option: VARGA_UNSURE_STYLE_OPTION, unsure_style_option: UNSURE_STYLE_OPTION, }; } @@ -186,7 +193,7 @@ export function completeStyleOptions(input: { const byClass = new Map(); if (kind === "varga_style") { for (const option of incoming) byClass.set(option.answer_class, option); - if (!byClass.has("unsure")) byClass.set("unsure", UNSURE_STYLE_OPTION); + if (!byClass.has("unsure")) byClass.set("unsure", VARGA_UNSURE_STYLE_OPTION); const scoring = ANSWER_CLASSES.filter((item) => item !== "unsure" && byClass.has(item)); if (scoring.length < 2) { return { ok: false, reason: incomingReason ?? "varga_insufficient_scoring" }; diff --git a/frontend/src/lib/rectification-agentic/v9/varga-type-tables.ts b/frontend/src/lib/rectification-agentic/v9/varga-type-tables.ts index bbf37303..23950327 100644 --- a/frontend/src/lib/rectification-agentic/v9/varga-type-tables.ts +++ b/frontend/src/lib/rectification-agentic/v9/varga-type-tables.ts @@ -1,38 +1,171 @@ /** * D9/D10 type tables for rectification contrast, not fate promises. + * + * `userChoice` is the only field shown on choice cards. trait / spouse / + * marriage / occupation / style stay on the interpretation path. */ -export type D9TypeRow = Readonly<{ trait: string; spouse: string; marriage: string }>; -export type D10TypeRow = Readonly<{ trait: string; occupation: string; style: string }>; +export type D9TypeRow = Readonly<{ + trait: string; + spouse: string; + marriage: string; + userChoice: string; +}>; +export type D10TypeRow = Readonly<{ + trait: string; + occupation: string; + style: string; + userChoice: string; +}>; export const D9_TYPE_TABLE: Readonly> = { - 白羊座: { trait: "主动、热情、冲动", spouse: "独立、有活力", marriage: "早期婚姻、激情型" }, - 金牛座: { trait: "稳定、务实、占有欲强", spouse: "稳重、有经济基础", marriage: "晚婚、稳定型" }, - 双子座: { trait: "沟通、多变、好奇心强", spouse: "聪明、善交流", marriage: "多次恋爱、友谊型" }, - 巨蟹座: { trait: "情感丰富、家庭导向", spouse: "温柔、顾家", marriage: "家庭型" }, - 狮子座: { trait: "骄傲、戏剧性、领导欲", spouse: "有魅力、有地位", marriage: "戏剧性" }, - 处女座: { trait: "完美主义、挑剔、服务型", spouse: "细致、有技能", marriage: "晚婚、服务型" }, - 天秤座: { trait: "和谐、美感、合作", spouse: "优雅、有艺术气质", marriage: "美满、合作型" }, - 天蝎座: { trait: "深刻、占有欲、转化", spouse: "神秘、有深度", marriage: "深刻、转化型" }, - 射手座: { trait: "自由、哲学、冒险", spouse: "开放、有学识", marriage: "自由型、精神伴侣" }, - 摩羯座: { trait: "务实、责任、延迟", spouse: "成熟、有事业", marriage: "晚婚、责任型" }, - 水瓶座: { trait: "独立、非传统、友谊", spouse: "独特、有理想", marriage: "非传统、友谊型" }, - 双鱼座: { trait: "浪漫、牺牲、灵性", spouse: "灵性、有艺术天赋", marriage: "灵性、牺牲型" }, + 白羊座: { + trait: "主动、热情、冲动", + spouse: "独立、有活力", + marriage: "早期婚姻、激情型", + userChoice: "相处里爱先开口推进,来得快也容易急", + }, + 金牛座: { + trait: "稳定、务实、占有欲强", + spouse: "稳重、有经济基础", + marriage: "晚婚、稳定型", + userChoice: "把日子过稳当,也在意关系里的安全感", + }, + 双子座: { + trait: "沟通、多变、好奇心强", + spouse: "聪明、善交流", + marriage: "多次恋爱、友谊型", + userChoice: "爱聊、爱换话题,新鲜感一过就容易飘", + }, + 巨蟹座: { + trait: "情感丰富、家庭导向", + spouse: "温柔、顾家", + marriage: "家庭型", + userChoice: "把对方当家人养,情绪来了也藏不住", + }, + 狮子座: { + trait: "骄傲、戏剧性、领导欲", + spouse: "有魅力、有地位", + marriage: "戏剧性", + userChoice: "相处里要被看见,也爱把气氛带起来", + }, + 处女座: { + trait: "完美主义、挑剔、服务型", + spouse: "细致、有技能", + marriage: "晚婚、服务型", + userChoice: "对细节很较真,也习惯把对方的事揽下来", + }, + 天秤座: { + trait: "和谐、美感、合作", + spouse: "优雅、有艺术气质", + marriage: "美满、合作型", + userChoice: "尽量避免冲突,凡事想商量着来", + }, + 天蝎座: { + trait: "深刻、占有欲、转化", + spouse: "神秘、有深度", + marriage: "深刻、转化型", + userChoice: "投入很深、在意独占,也容易大起大落", + }, + 射手座: { + trait: "自由、哲学、冒险", + spouse: "开放、有学识", + marriage: "自由型、精神伴侣", + userChoice: "看重各自的空间,不喜欢被绑太紧", + }, + 摩羯座: { + trait: "务实、责任、延迟", + spouse: "成熟、有事业", + marriage: "晚婚、责任型", + userChoice: "把责任看得很重,进展也愿意慢慢来", + }, + 水瓶座: { + trait: "独立、非传统、友谊", + spouse: "独特、有理想", + marriage: "非传统、友谊型", + userChoice: "更像朋友那样处,各自留足独立空间", + }, + 双鱼座: { + trait: "浪漫、牺牲、灵性", + spouse: "灵性、有艺术天赋", + marriage: "灵性、牺牲型", + userChoice: "容易把感情理想化,也愿意为对方让步", + }, }; export const D10_TYPE_TABLE: Readonly> = { - 白羊座: { trait: "领导、创业、竞争", occupation: "创业者、运动员、军人", style: "主动、竞争" }, - 金牛座: { trait: "稳定、财富、艺术", occupation: "金融、艺术、农业", style: "稳定、务实" }, - 双子座: { trait: "沟通、写作、教育", occupation: "教师、作家、销售", style: "多变、沟通" }, - 巨蟹座: { trait: "照顾、家庭、情感", occupation: "护理、餐饮、房地产", style: "照顾、情感" }, - 狮子座: { trait: "领导、表演、创意", occupation: "管理、娱乐、政治", style: "领导、表演" }, - 处女座: { trait: "服务、分析、健康", occupation: "医疗、分析、服务", style: "细致、服务" }, - 天秤座: { trait: "合作、美学、法律", occupation: "法律、艺术、咨询", style: "合作、和谐" }, - 天蝎座: { trait: "研究、转化、危机", occupation: "研究、心理学、危机管理", style: "深度、转化" }, - 射手座: { trait: "教育、哲学、国际", occupation: "教育、出版、国际事务", style: "自由、哲学" }, - 摩羯座: { trait: "管理、责任、延迟", occupation: "管理、政府、建筑", style: "务实、责任" }, - 水瓶座: { trait: "创新、科技、人道", occupation: "科技、人道主义、创新", style: "创新、独立" }, - 双鱼座: { trait: "灵性、艺术、服务", occupation: "艺术、灵性、医疗", style: "灵性、服务" }, + 白羊座: { + trait: "领导、创业、竞争", + occupation: "创业者、运动员、军人", + style: "主动、竞争", + userChoice: "做事爱抢先上手,也爱跟别人比一把", + }, + 金牛座: { + trait: "稳定、财富、艺术", + occupation: "金融、艺术、农业", + style: "稳定、务实", + userChoice: "做事求稳、把基础打牢,不爱来回变", + }, + 双子座: { + trait: "沟通、写作、教育", + occupation: "教师、作家、销售", + style: "多变、沟通", + userChoice: "靠说话和换思路推进,一件事待不久", + }, + 巨蟹座: { + trait: "照顾、家庭、情感", + occupation: "护理、餐饮、房地产", + style: "照顾、情感", + userChoice: "做事以照顾人为主,在意团队里的感受", + }, + 狮子座: { + trait: "领导、表演、创意", + occupation: "管理、娱乐、政治", + style: "领导、表演", + userChoice: "习惯带头,也不排斥站到台前", + }, + 处女座: { + trait: "服务、分析、健康", + occupation: "医疗、分析、服务", + style: "细致、服务", + userChoice: "盯细节、把事做扎实,偏后端支持", + }, + 天秤座: { + trait: "合作、美学、法律", + occupation: "法律、艺术、咨询", + style: "合作、和谐", + userChoice: "推进事情时爱找人配合,尽量不得罪人", + }, + 天蝎座: { + trait: "研究、转化、危机", + occupation: "研究、心理学、危机管理", + style: "深度、转化", + userChoice: "爱把问题挖到底,也扛得住推倒重来", + }, + 射手座: { + trait: "教育、哲学、国际", + occupation: "教育、出版、国际事务", + style: "自由、哲学", + userChoice: "不爱被流程绑死,更想把事情做开去", + }, + 摩羯座: { + trait: "管理、责任、延迟", + occupation: "管理、政府、建筑", + style: "务实、责任", + userChoice: "把职责扛到底,进度宁可慢也要稳", + }, + 水瓶座: { + trait: "创新、科技、人道", + occupation: "科技、人道主义、创新", + style: "创新、独立", + userChoice: "爱走自己的路,也爱试还没人做过的办法", + }, + 双鱼座: { + trait: "灵性、艺术、服务", + occupation: "艺术、灵性、医疗", + style: "灵性、服务", + userChoice: "凭感觉做事,也常把别人的需要放前面", + }, }; export function signKey(value: string): string { @@ -43,10 +176,10 @@ export function signKey(value: string): string { export function d9StyleLabel(sign: string): string { const row = D9_TYPE_TABLE[signKey(sign)]; - return row?.trait ?? `${signKey(sign)}相处`; + return row?.userChoice ?? `${signKey(sign)}相处`; } export function d10StyleLabel(sign: string): string { const row = D10_TYPE_TABLE[signKey(sign)]; - return row?.style ?? `${signKey(sign)}职责`; + return row?.userChoice ?? `${signKey(sign)}职责`; } diff --git a/frontend/tests/rectification-probe-question-contract.test.ts b/frontend/tests/rectification-probe-question-contract.test.ts index 9d2a85b2..f8778fcc 100644 --- a/frontend/tests/rectification-probe-question-contract.test.ts +++ b/frontend/tests/rectification-probe-question-contract.test.ts @@ -45,8 +45,8 @@ test("varga-style probes stay dynamic and fail closed without two scoring labels }); assert.equal(completed.ok, true); assert.equal(completed.ok ? completed.options.length : 0, 4); - assert.equal(completed.ok ? completed.options.find((item) => item.answer_class === "no")?.label : null, "都不是这些特质"); - assert.equal(completed.ok ? completed.options.find((item) => item.answer_class === "unsure")?.label : null, "这段记不清楚"); + assert.equal(completed.ok ? completed.options.find((item) => item.answer_class === "no")?.label : null, "都不太像我"); + assert.equal(completed.ok ? completed.options.find((item) => item.answer_class === "unsure")?.label : null, "一时说不好"); }); test("illegal clock or appearance copy cannot become a renderable probe", () => { diff --git a/frontend/tests/rectification-varga-style-copy.test.ts b/frontend/tests/rectification-varga-style-copy.test.ts new file mode 100644 index 00000000..67b8f304 --- /dev/null +++ b/frontend/tests/rectification-varga-style-copy.test.ts @@ -0,0 +1,350 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { buildCandidateContrastPacket, selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; +import { buildChoiceFrame, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { + EXISTENCE_STYLE_OPTIONS, + LABEL_MAX, + LABEL_MIN, + QUALITY_STYLE_OPTIONS, + completeStyleOptions, + isRenderableStyleOptions, +} from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; +import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; +import { + D9_TYPE_TABLE, + D10_TYPE_TABLE, + d9StyleLabel, + d10StyleLabel, +} from "../src/lib/rectification-agentic/v9/varga-type-tables.ts"; + +const SIGNS = [ + "白羊座", "金牛座", "双子座", "巨蟹座", "狮子座", "处女座", + "天秤座", "天蝎座", "射手座", "摩羯座", "水瓶座", "双鱼座", +] as const; + +const FROZEN_D9 = { + 白羊座: { trait: "主动、热情、冲动", spouse: "独立、有活力", marriage: "早期婚姻、激情型" }, + 金牛座: { trait: "稳定、务实、占有欲强", spouse: "稳重、有经济基础", marriage: "晚婚、稳定型" }, + 双子座: { trait: "沟通、多变、好奇心强", spouse: "聪明、善交流", marriage: "多次恋爱、友谊型" }, + 巨蟹座: { trait: "情感丰富、家庭导向", spouse: "温柔、顾家", marriage: "家庭型" }, + 狮子座: { trait: "骄傲、戏剧性、领导欲", spouse: "有魅力、有地位", marriage: "戏剧性" }, + 处女座: { trait: "完美主义、挑剔、服务型", spouse: "细致、有技能", marriage: "晚婚、服务型" }, + 天秤座: { trait: "和谐、美感、合作", spouse: "优雅、有艺术气质", marriage: "美满、合作型" }, + 天蝎座: { trait: "深刻、占有欲、转化", spouse: "神秘、有深度", marriage: "深刻、转化型" }, + 射手座: { trait: "自由、哲学、冒险", spouse: "开放、有学识", marriage: "自由型、精神伴侣" }, + 摩羯座: { trait: "务实、责任、延迟", spouse: "成熟、有事业", marriage: "晚婚、责任型" }, + 水瓶座: { trait: "独立、非传统、友谊", spouse: "独特、有理想", marriage: "非传统、友谊型" }, + 双鱼座: { trait: "浪漫、牺牲、灵性", spouse: "灵性、有艺术天赋", marriage: "灵性、牺牲型" }, +} as const; + +const FROZEN_D10 = { + 白羊座: { trait: "领导、创业、竞争", occupation: "创业者、运动员、军人", style: "主动、竞争" }, + 金牛座: { trait: "稳定、财富、艺术", occupation: "金融、艺术、农业", style: "稳定、务实" }, + 双子座: { trait: "沟通、写作、教育", occupation: "教师、作家、销售", style: "多变、沟通" }, + 巨蟹座: { trait: "照顾、家庭、情感", occupation: "护理、餐饮、房地产", style: "照顾、情感" }, + 狮子座: { trait: "领导、表演、创意", occupation: "管理、娱乐、政治", style: "领导、表演" }, + 处女座: { trait: "服务、分析、健康", occupation: "医疗、分析、服务", style: "细致、服务" }, + 天秤座: { trait: "合作、美学、法律", occupation: "法律、艺术、咨询", style: "合作、和谐" }, + 天蝎座: { trait: "研究、转化、危机", occupation: "研究、心理学、危机管理", style: "深度、转化" }, + 射手座: { trait: "教育、哲学、国际", occupation: "教育、出版、国际事务", style: "自由、哲学" }, + 摩羯座: { trait: "管理、责任、延迟", occupation: "管理、政府、建筑", style: "务实、责任" }, + 水瓶座: { trait: "创新、科技、人道", occupation: "科技、人道主义、创新", style: "创新、独立" }, + 双鱼座: { trait: "灵性、艺术、服务", occupation: "艺术、灵性、医疗", style: "灵性、服务" }, +} as const; + +const FROZEN_EXISTENCE = [ + { label: "明确发生且时间吻合", answer_class: "yes" }, + { label: "发生过但程度较弱", answer_class: "weak_yes" }, + { label: "明确没有发生", answer_class: "no" }, + { label: "这段记不清楚", answer_class: "unsure" }, +] as const; + +const FROZEN_QUALITY = [ + { label: "发挥明显失常或压力很大", answer_class: "yes" }, + { label: "有压力但不算明显失常", answer_class: "weak_yes" }, + { label: "发挥正常、没有明显失常", answer_class: "no" }, + { label: "这段记不清楚", answer_class: "unsure" }, +] as const; + +const YEAR_OR_SPAN = /\d{4}|这段/; + +function styleCard( + domain: "relationship" | "career", + signs: readonly [string, string, string], + labelFor: (sign: string) => string, +): ReturnType { + const probe: DiscriminatingEventProbe = { + year: 2024, + year_label: "2024–2026 年这段里", + domain, + event_family: domain === "relationship" ? "相处方式更接近其中一种" : "做事风格更接近其中一种", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "对照类型表只作校时方法,不是命运承诺。", + role: "distinguish", + information_gain: 1.2, + candidate_ids: ["05:00", "05:03", "05:04"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:03", "05:04"] }, + { answer_class: "weak_yes", supports: ["05:03"], conflicts: ["05:00", "05:04"] }, + { answer_class: "no", supports: ["05:04"], conflicts: ["05:00", "05:03"] }, + ], + choice_kind: "varga_style", + semantic_key: domain === "relationship" ? "varga.d9.05:00|05:03/05:04" : "varga.d10.05:00|05:03/05:04", + style_options: [ + { sign: signs[0], label: labelFor(signs[0]), answer_class: "yes" }, + { sign: signs[1], label: labelFor(signs[1]), answer_class: "weak_yes" }, + { sign: signs[2], label: labelFor(signs[2]), answer_class: "no" }, + ], + }; + return buildChoiceFrame({ + method_id: domain === "relationship" ? "d9_refine" : "d10_career", + ask_theme: domain === "relationship" ? "relationship_style" : "career_style", + domain, + user_prompt_hint: "unused", + choice_kind: "varga_style", + semantic_key: probe.semantic_key, + style_options: probe.style_options, + }, { + evidence: [ + { status: "confirmed", domain, datePrecision: "year", occurredFrom: "2024-01-01", occurredTo: null }, + { status: "confirmed", domain, datePrecision: "year", occurredFrom: "2026-01-01", occurredTo: null }, + ], + probes: [probe], + }); +} + +test("D9/D10 interpretation fields stay frozen; userChoice is the card label", () => { + assert.deepEqual(Object.keys(D9_TYPE_TABLE), [...SIGNS]); + assert.deepEqual(Object.keys(D10_TYPE_TABLE), [...SIGNS]); + for (const sign of SIGNS) { + const d9 = D9_TYPE_TABLE[sign]; + const d10 = D10_TYPE_TABLE[sign]; + assert.ok(d9); + assert.ok(d10); + assert.deepEqual({ trait: d9.trait, spouse: d9.spouse, marriage: d9.marriage }, FROZEN_D9[sign]); + assert.deepEqual( + { trait: d10.trait, occupation: d10.occupation, style: d10.style }, + FROZEN_D10[sign], + ); + assert.ok(d9.userChoice.trim().length >= LABEL_MIN && d9.userChoice.length <= LABEL_MAX); + assert.ok(d10.userChoice.trim().length >= LABEL_MIN && d10.userChoice.length <= LABEL_MAX); + assert.notEqual(d9.userChoice, d9.trait); + assert.notEqual(d10.userChoice, d10.style); + assert.notEqual(d10.userChoice, d10.trait); + assert.equal(d9StyleLabel(sign), d9.userChoice); + assert.equal(d10StyleLabel(sign), d10.userChoice); + assert.equal(d9StyleLabel(sign.replace(/座$/, "")), d9.userChoice); + assert.equal(d10StyleLabel(sign.replace(/座$/, "")), d10.userChoice); + } + assert.equal(d9StyleLabel("天秤座"), "尽量避免冲突,凡事想商量着来"); + assert.equal(d9StyleLabel("天蝎座"), "投入很深、在意独占,也容易大起大落"); + assert.equal(d9StyleLabel("射手座"), "看重各自的空间,不喜欢被绑太紧"); + assert.equal(d10StyleLabel("巨蟹座"), "做事以照顾人为主,在意团队里的感受"); + assert.equal(d10StyleLabel("狮子座"), "习惯带头,也不排斥站到台前"); + assert.equal(d10StyleLabel("处女座"), "盯细节、把事做扎实,偏后端支持"); +}); + +test("varga_style options keep sign → answer_class mapping and are renderable", () => { + const d9 = completeStyleOptions({ + choiceKind: "varga_style", + styleOptions: [ + { sign: "天秤座", label: d9StyleLabel("天秤座"), answer_class: "yes" }, + { sign: "天蝎座", label: d9StyleLabel("天蝎座"), answer_class: "weak_yes" }, + { sign: "射手座", label: d9StyleLabel("射手座"), answer_class: "no" }, + ], + }); + assert.equal(d9.ok, true); + if (!d9.ok) return; + assert.equal(isRenderableStyleOptions(d9.options), true); + assert.equal(d9.options.find((item) => item.answer_class === "yes")?.sign, "天秤座"); + assert.equal(d9.options.find((item) => item.answer_class === "weak_yes")?.sign, "天蝎座"); + assert.equal(d9.options.find((item) => item.answer_class === "no")?.sign, "射手座"); + assert.equal(d9.options.find((item) => item.answer_class === "yes")?.label, D9_TYPE_TABLE.天秤座.userChoice); + assert.notEqual(d9.options.find((item) => item.answer_class === "yes")?.label, D9_TYPE_TABLE.天秤座.trait); + const no = d9.options.find((item) => item.answer_class === "no")?.label ?? ""; + const unsure = d9.options.find((item) => item.answer_class === "unsure")?.label ?? ""; + assert.doesNotMatch(no, /特质/); + assert.doesNotMatch(unsure, /特质|这段/); + assert.equal(unsure, "一时说不好"); + + const d10 = completeStyleOptions({ + choiceKind: "varga_style", + styleOptions: [ + { sign: "巨蟹座", label: d10StyleLabel("巨蟹座"), answer_class: "yes" }, + { sign: "狮子座", label: d10StyleLabel("狮子座"), answer_class: "weak_yes" }, + { sign: "处女座", label: d10StyleLabel("处女座"), answer_class: "no" }, + ], + }); + assert.equal(d10.ok, true); + if (!d10.ok) return; + assert.equal(isRenderableStyleOptions(d10.options), true); + assert.equal(d10.options.find((item) => item.answer_class === "yes")?.sign, "巨蟹座"); + assert.equal(d10.options.find((item) => item.answer_class === "weak_yes")?.sign, "狮子座"); + assert.equal(d10.options.find((item) => item.answer_class === "no")?.sign, "处女座"); + assert.notEqual(d10.options.find((item) => item.answer_class === "yes")?.label, D10_TYPE_TABLE.巨蟹座.style); + + const filled = completeStyleOptions({ + choiceKind: "varga_style", + styleOptions: [ + { sign: "天秤座", label: d9StyleLabel("天秤座"), answer_class: "yes" }, + { sign: "天蝎座", label: d9StyleLabel("天蝎座"), answer_class: "weak_yes" }, + ], + }); + assert.equal(filled.ok, true); + if (!filled.ok) return; + assert.equal(filled.options.find((item) => item.answer_class === "no")?.label, "都不太像我"); + assert.equal(filled.options.find((item) => item.answer_class === "unsure")?.label, "一时说不好"); + assert.doesNotMatch(filled.options.find((item) => item.answer_class === "no")?.label ?? "", /特质/); + assert.doesNotMatch(filled.options.find((item) => item.answer_class === "unsure")?.label ?? "", /这段/); +}); + +test("existence and event_quality fixed options stay on the old copy", () => { + assert.deepEqual(EXISTENCE_STYLE_OPTIONS, [...FROZEN_EXISTENCE]); + assert.deepEqual(QUALITY_STYLE_OPTIONS, [...FROZEN_QUALITY]); + const existence = completeStyleOptions({ choiceKind: "existence" }); + const quality = completeStyleOptions({ choiceKind: "event_quality" }); + assert.equal(existence.ok && quality.ok, true); + if (!existence.ok || !quality.ok) return; + assert.deepEqual(existence.options, [...FROZEN_EXISTENCE]); + assert.deepEqual(quality.options, [...FROZEN_QUALITY]); + assert.equal(existence.options.find((item) => item.answer_class === "unsure")?.label, "这段记不清楚"); + assert.equal(quality.options.find((item) => item.answer_class === "unsure")?.label, "这段记不清楚"); +}); + +test("varga_style stems are long-term; existence and quality still lock the year", () => { + const relationship = styleCard("relationship", ["天秤座", "天蝎座", "射手座"], d9StyleLabel); + const career = styleCard("career", ["巨蟹座", "狮子座", "处女座"], d10StyleLabel); + assert.ok(relationship); + assert.ok(career); + const relationshipCopy = relationship ? serverOwnedChoiceCopy(relationship) : null; + const careerCopy = career ? serverOwnedChoiceCopy(career) : null; + assert.ok(relationshipCopy); + assert.ok(careerCopy); + assert.doesNotMatch(relationship!.prompt, YEAR_OR_SPAN); + assert.doesNotMatch(career!.prompt, YEAR_OR_SPAN); + assert.doesNotMatch(relationship!.period, YEAR_OR_SPAN); + assert.doesNotMatch(career!.period, YEAR_OR_SPAN); + assert.equal(relationship!.prompt, "亲密关系里,你更接近哪一种相处方式?"); + assert.equal(career!.prompt, "平时做事,你更接近下面哪一种?"); + assert.doesNotMatch(relationshipCopy!.prompt, YEAR_OR_SPAN); + assert.doesNotMatch(careerCopy!.prompt, YEAR_OR_SPAN); + assert.match(relationship!.why, /校时方法/); + assert.match(career!.why, /校时方法/); + + const existence = buildChoiceFrame({ + method_id: "d4_home", + ask_theme: "home_change", + domain: "relocation", + user_prompt_hint: "unused", + choice_kind: "existence", + }, { + probes: [{ + year: 2016, + year_label: "2016 年前后", + domain: "relocation", + event_family: "搬家、离乡或长期异地", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "年份锁定 2016 年前后。", + role: "distinguish", + information_gain: 0.4, + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + }], + }); + assert.ok(existence); + assert.match(existence!.prompt, /2016 年前后/); + assert.match(existence!.prompt, /搬家、离乡或长期异地/); + assert.equal(existence!.unsure_label, "这段记不清楚"); + + const quality = buildChoiceFrame({ + method_id: "d24_learn", + ask_theme: "education_style", + domain: "education", + user_prompt_hint: "unused", + choice_kind: "event_quality", + }, { + probes: [{ + year: 2020, + year_label: "2020 年前后", + domain: "education", + event_family: "学业或考试发挥失常、压力特别大", + source: "known_event_quality", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "年份锁定 2020 年前后。", + role: "distinguish", + information_gain: 0.5, + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + choice_kind: "event_quality", + }], + }); + assert.ok(quality); + assert.match(quality!.prompt, /2020 年前后/); + assert.equal(quality!.unsure_label, "这段记不清楚"); +}); + +test("remaining D9/D10 packets use userChoice labels and long-term questions", () => { + const d9 = selectDiscriminatorProbe(buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: ["05:00", "05:03", "05:04"], + transitions: [ + { layer: "d9", at: "05:03", from_sign: "天秤座", to_sign: "天蝎座" }, + { layer: "d9", at: "05:04", from_sign: "天蝎座", to_sign: "射手座" }, + ], + })); + assert.ok(d9); + assert.equal(d9!.choiceKind, "varga_style"); + assert.doesNotMatch(d9!.question, YEAR_OR_SPAN); + assert.equal(d9!.question, "亲密关系里,你更接近哪一种相处方式?"); + assert.equal(d9!.styleOptions?.find((item) => item.answerClass === "yes")?.sign, "天秤座"); + assert.equal(d9!.styleOptions?.find((item) => item.answerClass === "weak_yes")?.sign, "天蝎座"); + assert.equal(d9!.styleOptions?.find((item) => item.answerClass === "no")?.sign, "射手座"); + assert.equal(d9!.styleOptions?.find((item) => item.answerClass === "yes")?.label, d9StyleLabel("天秤座")); + assert.notEqual(d9!.styleOptions?.find((item) => item.answerClass === "yes")?.label, D9_TYPE_TABLE.天秤座.trait); + + const d10 = selectDiscriminatorProbe(buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: ["05:00", "05:03", "05:04"], + transitions: [ + { layer: "d10", at: "05:03", from_sign: "巨蟹座", to_sign: "狮子座" }, + { layer: "d10", at: "05:04", from_sign: "狮子座", to_sign: "处女座" }, + ], + })); + assert.ok(d10); + assert.equal(d10!.choiceKind, "varga_style"); + assert.doesNotMatch(d10!.question, YEAR_OR_SPAN); + assert.equal(d10!.question, "平时做事,你更接近下面哪一种?"); + assert.equal(d10!.styleOptions?.find((item) => item.answerClass === "yes")?.label, d10StyleLabel("巨蟹座")); + assert.notEqual(d10!.styleOptions?.find((item) => item.answerClass === "yes")?.label, D10_TYPE_TABLE.巨蟹座.style); +}); + +test("choice-card legend spacing does not depend on fieldset grid gap", () => { + const choiceCss = readFileSync(new URL("../src/app/birth-time-choice.css", import.meta.url), "utf8"); + const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + const card = readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8"); + const legendRule = choiceCss.match(/\.birth-time-choice-question legend \{[^}]+\}/)?.[0] ?? ""; + const whyRule = globals.match(/\.rectification-choice-why \{[^}]+\}/)?.[0] ?? ""; + assert.match(legendRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/); + assert.match(whyRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/); + assert.match(card, /\{props\.card\.prompt\}<\/legend>/); + assert.match(card, /className="rectification-choice-why"/); + assert.doesNotMatch(card, /choice-question-wrap|choice-stem-wrap|legend-spacer/); + assert.match(card, /\{props\.card\.prompt\}<\/legend>\s*\{props\.card\.why \?

/); +}); diff --git a/scripts/rectification/probe_question_contract.py b/scripts/rectification/probe_question_contract.py index 0c27ce45..1377a9cf 100644 --- a/scripts/rectification/probe_question_contract.py +++ b/scripts/rectification/probe_question_contract.py @@ -25,8 +25,9 @@ QUALITY_STYLE_OPTIONS: tuple[dict[str, str], ...] = ( {"label": "发挥正常、没有明显失常", "answer_class": "no"}, {"label": "这段记不清楚", "answer_class": "unsure"}, ) -VARGA_NONE_STYLE_OPTION = {"label": "都不是这些特质", "answer_class": "no"} +VARGA_NONE_STYLE_OPTION = {"label": "都不太像我", "answer_class": "no"} UNSURE_STYLE_OPTION = {"label": "这段记不清楚", "answer_class": "unsure"} +VARGA_UNSURE_STYLE_OPTION = {"label": "一时说不好", "answer_class": "unsure"} FORBIDDEN_COPY_TOKENS = ("外貌", "体质", "胎记", "疤痕", "伤疤", "身高", "体型") LABEL_MIN = 4 LABEL_MAX = 80 @@ -82,6 +83,7 @@ def probe_question_contract_payload() -> dict[str, Any]: "existence_style_options": [dict(item) for item in EXISTENCE_STYLE_OPTIONS], "quality_style_options": [dict(item) for item in QUALITY_STYLE_OPTIONS], "varga_none_style_option": dict(VARGA_NONE_STYLE_OPTION), + "varga_unsure_style_option": dict(VARGA_UNSURE_STYLE_OPTION), "unsure_style_option": dict(UNSURE_STYLE_OPTION), } @@ -154,7 +156,7 @@ def complete_style_options( if kind == "varga_style": for option in incoming: by_class[option["answer_class"]] = option - by_class.setdefault("unsure", dict(UNSURE_STYLE_OPTION)) + by_class.setdefault("unsure", dict(VARGA_UNSURE_STYLE_OPTION)) scoring = [item for item in ANSWER_CLASSES if item != "unsure" and item in by_class] if len(scoring) < 2: return {"ok": False, "reason": incoming_reason or "varga_insufficient_scoring"} diff --git a/tests/test_probe_question_contract.py b/tests/test_probe_question_contract.py index c42e72d8..fc7a5485 100644 --- a/tests/test_probe_question_contract.py +++ b/tests/test_probe_question_contract.py @@ -38,7 +38,8 @@ class ProbeQuestionContractTests(unittest.TestCase): self.assertTrue(completed["ok"]) options = completed.get("options") or [] self.assertEqual(len(options), 4) - self.assertEqual(next(item["label"] for item in options if item["answer_class"] == "no"), "都不是这些特质") + self.assertEqual(next(item["label"] for item in options if item["answer_class"] == "no"), "都不太像我") + self.assertEqual(next(item["label"] for item in options if item["answer_class"] == "unsure"), "一时说不好") def test_clock_copy_is_dropped_and_catalog_fills_existence(self) -> None: completed = complete_style_options("existence", [