fix(rectification): rewrite D9/D10 style options and stem spacing
Independent Staging Quality Gate / validate (push) Successful in 9m28s
Independent Staging Quality Gate / publish (push) Successful in 7m26s

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 <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-30 10:01:32 +08:00
parent ea0fbd4406
commit 95f5851261
12 changed files with 563 additions and 47 deletions
+5 -1
View File
@@ -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"
+16
View File
@@ -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>`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
- 复发自:无
- 修复版本:待发布
+1 -1
View File
@@ -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; }
+1 -1
View File
@@ -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;
@@ -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 "家里有没有结婚、添丁或住院这类事?";
@@ -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);
@@ -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<AnswerClass, ProbeStyleOption>();
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" };
@@ -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<Record<string, D9TypeRow>> = {
: { 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<Record<string, D10TypeRow>> = {
: { 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)}职责`;
}
@@ -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", () => {
@@ -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<typeof buildChoiceFrame> {
const probe: DiscriminatingEventProbe = {
year: 2024,
year_label: "20242026 年这段里",
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, /<legend>\{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, /<legend>\{props\.card\.prompt\}<\/legend>\s*\{props\.card\.why \? <p className="rectification-choice-why">/);
});
@@ -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"}
+2 -1
View File
@@ -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", [