From f5e73ef326b65c1d3ba502849abd789155eed5ad Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 25 Aug 2026 20:44:55 +0800 Subject: [PATCH] fix(web): rank remaining-minute probes by split and match choice kind Choice cards used a hardcoded domain menu and always asked existence. Rank scoring layers by remaining-minute entropy, keep finance and health volunteer-only, and ask D9/D10 style or exam quality so taps match outcomes. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++ .../rectification-agentic/core/build-state.ts | 18 +- .../core/candidate-contrast-packet.ts | 205 +++++++++++++- .../rectification-agentic/v9/answer-choice.ts | 2 +- .../rectification-agentic/v9/choice-action.ts | 5 +- .../rectification-agentic/v9/choice-card.ts | 60 ++++- .../v9/inference-adapter.ts | 2 +- .../v9/interview-state.ts | 2 + .../v9/method-followup.ts | 20 ++ .../v9/refinement-packet.ts | 30 +++ .../rectification-agentic/v9/server-focus.ts | 1 + .../v9/skill-verification-report.ts | 37 +-- .../v9/varga-type-tables.ts | 52 ++++ frontend/src/mastra/rectification-v9-tools.ts | 3 + ...fication-candidate-contrast-packet.test.ts | 82 ++++++ .../tests/rectification-choice-card.test.ts | 88 +++++++ .../rectification-inference-machine.test.ts | 45 ++++ scripts/rectification/event_probes.py | 249 ++++++++++++++---- scripts/rectification/refinement_packet.py | 21 +- tests/test_rectification_event_probes.py | 95 +++++++ tests/test_rectification_refinement_packet.py | 2 + 21 files changed, 921 insertions(+), 114 deletions(-) create mode 100644 frontend/src/lib/rectification-agentic/v9/varga-type-tables.ts create mode 100644 frontend/tests/rectification-candidate-contrast-packet.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 4ae0bd5f..3130cc4c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5563,6 +5563,22 @@ - 复发自:BUG-376(关掉思考正文后,进度条只剩工具名,技法句不再出现;回到最新仍按咨询页浮层) - 修复版本:待发布 +## BUG-382 | 点选题与剩余切开不是同一题型,选题不是按剩余最大切开 + +- 状态:resolved +- 首次发现:2026-08-25 +- 最近更新:2026-08-25 +- 影响面:`discriminating_event_probes`、`candidate_contrast_packet`、`choice_card`、`method_followup_plan`、window_scan transitions +- 用户现象:生时纠正点选卡一律问「某年前后有没有这类事」。口语在问相处或职责时,卡仍问存在性。剩余分钟其实还能被家人盘切开时,下一问仍按写死的学业/事业/感情/搬家顺序出。 +- 触发条件:覆盖已齐、候选仍并列;剩余 D9/D10 换升,或 D12/D7 切开而 D9 已齐;或大运年界与剩余切开同时存在。 +- 根因:(1) 选择题走 `eventHypothesis` 存在性模板,D10 内部职责三分仍显示成有没有这件事。(2) Python 探针按整窗 `*_candidates_differ` 和精度阶段优先选题,每个领域取第一个能分开的年。(3) TS 剩余层写死 `D24→D5→D10→D9→D4`,并用假 IG 让学业压过事业/感情。 +- 修复:计分领域按当前剩余分钟仍切开的层进池,财务/健康仍要用户先提过。Python 对剩余分钟打分,按分组熵加置信度取最高年。TS 按分组熵选题。年界差问存在性;D9/D10 换升问类型表相处/职责;学业问考试质量。两路 style 的「都不像」计 `unsure`,不把另一组分钟当「没有这件事」。不改 Skill `10.0.11`。 +- 验证:`tests/test_rectification_event_probes.py`、`tests/test_rectification_refinement_packet.py`、`frontend/tests/rectification-candidate-contrast-packet.test.ts`、`frontend/tests/rectification-choice-card.test.ts`、`frontend/tests/rectification-inference-machine.test.ts`。 +- 防复发:剩余分钟选题不得写死学业优先。点选卡题型必须跟 `choice_kind` 与 `expected_outcomes` 一致。两路 style 的 C 不得淘汰另一组分钟。不得改已哈希 Skill `10.0.11`。 +- 相关记录:BUG-375、BUG-379、BUG-380 +- 复发自:BUG-375(区分卡已落到剩余分钟,但题型仍是存在性,选题顺序仍写死) +- 修复版本:待发布 + ## BUG-379 | 生时纠正已记入学后仍编造高考年并再问入学 - 状态:resolved diff --git a/frontend/src/lib/rectification-agentic/core/build-state.ts b/frontend/src/lib/rectification-agentic/core/build-state.ts index 6f4dcc3a..72e3f0bc 100644 --- a/frontend/src/lib/rectification-agentic/core/build-state.ts +++ b/frontend/src/lib/rectification-agentic/core/build-state.ts @@ -251,13 +251,27 @@ export function answersFromEvidence( }); } -export function classifyChoiceAnswer(key: string): AnswerClass { +export function classifyChoiceAnswer(key: string, schema?: unknown): AnswerClass { if (key === "A") return "yes"; if (key === "B") return "weak_yes"; - if (key === "C") return "no"; + if (key === "C") { + if (schemaMapsCToUnsure(schema)) return "unsure"; + return "no"; + } return "unsure"; } +function schemaMapsCToUnsure(schema: unknown): boolean { + if (!schema || typeof schema !== "object") return false; + const row = schema as Record; + if (row.choice_kind !== "varga_style") return false; + const choice = row.choice && typeof row.choice === "object" && !Array.isArray(row.choice) + ? row.choice as Record + : row; + const optionC = typeof choice.option_c === "string" ? choice.option_c : ""; + return optionC.includes("都不像"); +} + function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswer[]): InferenceState { return buildInferenceState({ range_start: state.range_start, 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 8f824d1d..485ba21a 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -4,6 +4,15 @@ */ import type { AnswerClass, ConflictProbe } from "./types.ts"; +import { d9StyleLabel, d10StyleLabel } from "../v9/varga-type-tables.ts"; + +export type ContrastChoiceKind = "existence" | "varga_style" | "event_quality"; + +export type ContrastStyleOption = Readonly<{ + label: string; + answerClass: AnswerClass; + sign?: string; +}>; export type ContrastExpectedOutcome = Readonly<{ outcomeId: string; @@ -27,6 +36,8 @@ export type CandidateDiscriminatorProbe = Readonly<{ domain: string | null; year: number | null; semanticKey: string; + choiceKind?: ContrastChoiceKind; + styleOptions?: readonly ContrastStyleOption[]; }>; export type VargaDifference = Readonly<{ @@ -37,11 +48,15 @@ export type VargaDifference = Readonly<{ export type WindowScanTransition = Readonly<{ layer: string; at: string; + from_sign?: string; + to_sign?: string; }>; export type RemainingVargaSplit = Readonly<{ layer: string; groups: readonly (readonly string[])[]; + signs: readonly (string | null)[]; + entropy: number; }>; export type CandidateContrastPacket = Readonly<{ @@ -65,12 +80,26 @@ export type EngineContrastProbe = Readonly<{ }>[]; left_time?: string; right_time?: string; + choice_kind?: ContrastChoiceKind; + style_options?: readonly Readonly<{ + label?: string; + answer_class?: string; + sign?: string; + }>[]; }>; -const REMAINING_LAYER_ORDER = ["d24", "d5", "d10", "d9", "d4"] as const; +const REMAINING_LAYERS = ["d24", "d5", "d10", "d9", "d4", "d7", "d12", "d2", "d11", "d30"] as const; +const VOLUNTEER_LAYER_DOMAIN: Readonly> = { + d2: "finance", + d11: "finance", + d30: "health_pressure", +}; const DUTY_ANSWERED_RE = /技术执行|算法|分析|数据处理|系统维护|组织型|第三个|照顾、家庭|台前|带人|公开担责|程序员|前端|工程师|开发/; const EXAM_QUALITY_RE = /失利|失常|复读|没考好|考砸|发挥不好|发挥失常|压力很大/; const RELOCATION_RE = /搬家|离乡|迁居|长期异地|离开家/; +const FAMILY_RE = /家人|父母|子女|兄弟|亲戚/; +const FINANCE_RE = /收入|资产|财务|欠债|投资/; +const HEALTH_RE = /健康|住院|手术|事故|持续压力/; const LIVE_EVIDENCE = new Set(["confirmed", "draft", "pending_confirmation"]); const ANSWER_CLASSES: ReadonlySet = new Set(["yes", "weak_yes", "no", "unsure"]); @@ -109,15 +138,55 @@ export function remainingLayerGroups( export function remainingVargaSplits( candidateTimes: readonly string[], transitions: readonly WindowScanTransition[], + volunteeredDomains: readonly string[] = [], ): readonly RemainingVargaSplit[] { if (candidateTimes.length < 2) return []; + const volunteered = new Set(volunteeredDomains); const rows: RemainingVargaSplit[] = []; - for (const layer of REMAINING_LAYER_ORDER) { + for (const layer of REMAINING_LAYERS) { + const volunteerDomain = VOLUNTEER_LAYER_DOMAIN[layer]; + if (volunteerDomain && !volunteered.has(volunteerDomain)) continue; const groups = remainingLayerGroups(candidateTimes, transitions, layer); if (groups.length < 2) continue; - rows.push({ layer, groups }); + rows.push({ + layer, + groups, + signs: remainingGroupSigns(groups, transitions, layer), + entropy: groupEntropy(groups), + }); } - return rows; + return rows.sort((left, right) => ( + right.entropy - left.entropy + || right.groups.length - left.groups.length + || left.layer.localeCompare(right.layer) + )); +} + +function groupEntropy(groups: readonly (readonly string[])[]): number { + const sizes = groups.map((group) => group.length); + const total = sizes.reduce((sum, size) => sum + size, 0); + if (total <= 0) return 0; + return -sizes.reduce((sum, size) => ( + size > 0 ? sum + (size / total) * Math.log2(size / total) : sum + ), 0); +} + +function remainingGroupSigns( + groups: readonly (readonly string[])[], + transitions: readonly WindowScanTransition[], + layer: string, +): readonly (string | null)[] { + const changes = transitions + .filter((item) => item.layer === layer) + .filter((item) => /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(item.at)) + .sort((left, right) => clockMinutes(left.at) - clockMinutes(right.at)); + return groups.map((group) => { + const time = group[0]; + if (!time) return null; + const crossed = changes.filter((item) => clockMinutes(item.at) <= clockMinutes(time)); + if (crossed.length === 0) return changes[0]?.from_sign ?? null; + return crossed[crossed.length - 1]?.to_sign ?? changes[0]?.from_sign ?? null; + }); } export function askedKeysFromLedgerEvidence( @@ -140,10 +209,34 @@ export function askedKeysFromLedgerEvidence( } const relocation = item.domain === "relocation" || item.eventKind === "home_change"; if (relocation || RELOCATION_RE.test(summary)) keys.add("varga.d4"); + const family = item.domain === "family" || FAMILY_RE.test(summary); + if (family) { + keys.add("varga.d7"); + keys.add("varga.d12"); + } + if (item.domain === "finance" || FINANCE_RE.test(summary)) { + keys.add("varga.d2"); + keys.add("varga.d11"); + } + if (item.domain === "health_pressure" || HEALTH_RE.test(summary)) keys.add("varga.d30"); } return [...keys]; } +export function volunteeredDomainsFromEvidence( + evidence: readonly Readonly<{ + status?: string | null; + domain?: string | null; + }>[], +): string[] { + const domains = new Set(); + for (const item of evidence) { + if (item.status && !LIVE_EVIDENCE.has(item.status)) continue; + if (item.domain) domains.add(item.domain); + } + return [...domains]; +} + export function askedKeysFromOccupationEvidence( evidence: readonly Readonly<{ status?: string | null; @@ -164,6 +257,7 @@ export function buildCandidateContrastPacket(input: { candidateTimes?: readonly string[]; transitions?: readonly WindowScanTransition[]; askedKeys?: readonly string[]; + volunteeredDomains?: readonly string[]; }): CandidateContrastPacket { const asked = new Set(input.askedKeys ?? []); const fromEngine = (input.engineProbes ?? []).flatMap((probe) => { @@ -175,7 +269,11 @@ export function buildCandidateContrastPacket(input: { return [built]; }); const remainingSplits = input.remainingSplits - ?? remainingVargaSplits(input.candidateTimes ?? [], input.transitions ?? []); + ?? remainingVargaSplits( + input.candidateTimes ?? [], + input.transitions ?? [], + input.volunteeredDomains ?? [], + ); const vargaDifferences = vargaDifferencesForPacket({ remainingSplits, windowDifferences: input.vargaDifferences ?? [], @@ -292,9 +390,30 @@ function probeFromEngine( domain: probe.domain ?? null, year: probe.year ?? null, semanticKey, + choiceKind: probe.choice_kind, + styleOptions: styleOptionsFromEngine(probe.style_options), }; } +function styleOptionsFromEngine( + rows: EngineContrastProbe["style_options"], +): readonly ContrastStyleOption[] | undefined { + if (!rows?.length) return undefined; + const parsed = rows.flatMap((row) => { + const label = typeof row.label === "string" ? row.label.trim() : ""; + const answerClass = typeof row.answer_class === "string" && ANSWER_CLASSES.has(row.answer_class) + ? row.answer_class as AnswerClass + : null; + if (!label || !answerClass) return []; + return [{ + label, + answerClass, + ...(typeof row.sign === "string" && row.sign.trim() ? { sign: row.sign.trim() } : {}), + }]; + }); + return parsed.length > 0 ? parsed : undefined; +} + function vargaProbe( remainingSplits: readonly RemainingVargaSplit[], candidateSetVersion: string, @@ -320,49 +439,115 @@ function vargaProbeFromRemaining( calculationResultId: string | null, ): CandidateDiscriminatorProbe { const allMinutes = split.groups.flat(); - const outcomes = remainingOutcomes(split.groups, allMinutes); + const choiceKind = remainingChoiceKind(split.layer); + const outcomes = remainingOutcomes(split.groups, allMinutes, choiceKind); const semanticKey = `varga.${split.layer}.${split.groups.map((group) => group.join("|")).join("/")}`; const layerLabel = split.layer.toUpperCase(); const domain = remainingDomain(split.layer); - const question = remainingQuestion(split.layer, layerLabel); + const styleOptions = remainingStyleOptions(split, choiceKind); + const question = remainingQuestion(split.layer, layerLabel, styleOptions); return { probeId: `contrast:${semanticKey}`, candidateSetVersion, question, expectedOutcomes: outcomes, candidateSplitHash: semanticKey, - informationGain: split.layer === "d24" || split.layer === "d5" ? 0.16 : 0.12, + informationGain: split.entropy, sourceFeatures: [{ technique: layerLabel, calculationResultId }], domain, year: null, semanticKey, + choiceKind, + ...(styleOptions ? { styleOptions } : {}), }; } +function remainingChoiceKind(layer: string): ContrastChoiceKind { + if (layer === "d10" || layer === "d9") return "varga_style"; + if (layer === "d24" || layer === "d5") return "event_quality"; + return "existence"; +} + +function remainingStyleOptions( + split: RemainingVargaSplit, + kind: ContrastChoiceKind, +): readonly ContrastStyleOption[] | undefined { + if (kind !== "varga_style") return undefined; + const classes = ["yes", "weak_yes", "no"] as const; + const options = split.groups.slice(0, 3).flatMap((group, index) => { + const sign = split.signs[index]; + if (!sign) return []; + const label = split.layer === "d10" ? d10StyleLabel(sign) : d9StyleLabel(sign); + return [{ label, answerClass: classes[index] ?? "unsure", sign }]; + }); + return options.length >= 2 ? uniquifyStyleLabels(options) : undefined; +} + +function uniquifyStyleLabels( + options: readonly ContrastStyleOption[], +): readonly ContrastStyleOption[] { + const seen = new Set(); + return options.map((option) => { + let label = option.label; + if (seen.has(label) && option.sign) label = `${label}(${option.sign})`; + seen.add(label); + return { ...option, label }; + }); +} + function remainingDomain(layer: string): string { if (layer === "d24" || layer === "d5") return "education"; if (layer === "d10") return "career"; if (layer === "d4") return "relocation"; + if (layer === "d7" || layer === "d12") return "family"; + if (layer === "d2" || layer === "d11") return "finance"; + if (layer === "d30") return "health_pressure"; return "relationship"; } -function remainingQuestion(layer: string, layerLabel: string): string { +function remainingQuestion( + layer: string, + layerLabel: string, + styleOptions?: readonly ContrastStyleOption[], +): string { if (layer === "d24" || layer === "d5") { return "当前几个候选在学业盘上还分得开。请核对一段还没用进评分的学业前事:那次高考或重要考试有没有发挥明显失常、压力很大?"; } if (layer === "d10") { - return "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:长期更接近照顾或家庭,还是台前带人,还是技术执行或分析?"; + return styleOptions?.length + ? "当前几个候选在事业盘上还分得开。长期工作更接近哪一类?" + : "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:长期更接近照顾或家庭,还是台前带人,还是技术执行或分析?"; + } + if (layer === "d9") { + return "当前几个候选在关系盘上还分得开。这段关系更接近哪一种相处?"; } if (layer === "d4") { return "当前几个候选在居所盘上还分得开。请核对一段还没用进评分的搬家或离乡:那几年有没有明显搬家、离乡或长期异地?"; } + if (layer === "d7" || layer === "d12") { + return "当前几个候选在家人盘上还分得开。那几年有没有家人相关的明显变化?"; + } + if (layer === "d2" || layer === "d11") { + return "当前几个候选在财帛盘上还分得开。那几年有没有收入、资产或财务明显变化?"; + } + if (layer === "d30") { + return "当前几个候选在健康盘上还分得开。那几年有没有健康、事故或持续压力明显变化?"; + } return `当前几个候选在关系盘上还分得开。请核对一段还没用进评分的感情前事,用来对照 ${layerLabel} 差异。`; } function remainingOutcomes( groups: readonly (readonly string[])[], allMinutes: readonly string[], + kind: ContrastChoiceKind, ): ContrastExpectedOutcome[] { + if (kind === "varga_style" && groups.length === 2) { + return [ + { outcomeId: "yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] }, + { outcomeId: "weak_yes", supportsCandidateIds: groups[1], conflictsCandidateIds: groups[0] }, + { outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] }, + ]; + } if (groups.length === 2) { return [ { outcomeId: "yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] }, diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index f38ba6fc..e07e0272 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -119,7 +119,7 @@ export async function applyRectificationChoice( optionId, scoring, appliedInference: false, - answerClass: outcomeIdForOption(optionId), + answerClass: outcomeIdForOption(optionId, schema), sourceQuote: optionQuoteFromSchema(schema, optionId), year: null, expectedRevision: command.expectedRevision, diff --git a/frontend/src/lib/rectification-agentic/v9/choice-action.ts b/frontend/src/lib/rectification-agentic/v9/choice-action.ts index 1e1e7677..f17c7d4a 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-action.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-action.ts @@ -6,6 +6,7 @@ */ import type { AnswerClass } from "../core/types"; +import { classifyChoiceAnswer } from "../core/build-state"; import type { ChoiceKey, RectificationChoiceCard } from "./choice-card"; export const CHOICE_ACTION = "answer_choice" as const; @@ -30,8 +31,8 @@ export type StructuredProbeDerivedContext = Readonly<{ answerClass: AnswerClass | null; }>; -export function outcomeIdForOption(optionId: ChoiceKey): AnswerClass { - return OPTION_ANSWER_CLASS[optionId]; +export function outcomeIdForOption(optionId: ChoiceKey, schema?: unknown): AnswerClass { + return classifyChoiceAnswer(optionId, schema); } export function focusStatusForOption(optionId: ChoiceOptionId): "resolved" | "declined" | "skipped" { diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index e4ab7616..63b2484a 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -7,7 +7,7 @@ * option copy, and never parses A/B/C/D out of assistant prose. */ -import type { DiscriminatingEventProbe } from "./refinement-packet"; +import type { DiscriminatingEventProbe, EventProbeChoiceKind, EventProbeStyleOption } from "./refinement-packet"; import type { InternalVargaObservation } from "./varga-observations"; export const CHOICE_MODE = "A/B/C/D"; @@ -39,6 +39,7 @@ export type RectificationChoiceFrame = Readonly<{ stop_label: string; stop_message: string; scoring: boolean; + choice_kind?: EventProbeChoiceKind; }>; export type AgentChoiceCopy = Readonly<{ @@ -63,6 +64,7 @@ export type RectificationChoiceCard = Readonly<{ probe_id: string | null; case_revision: number | null; focus_id: string | null; + choice_kind?: EventProbeChoiceKind; }>; export type ChoiceCardFollowup = Readonly<{ @@ -70,6 +72,8 @@ export type ChoiceCardFollowup = Readonly<{ ask_theme: string; domain: string | null; user_prompt_hint: string; + choice_kind?: EventProbeChoiceKind; + style_options?: readonly EventProbeStyleOption[]; }>; export type ChoiceCardEvidence = Readonly<{ @@ -84,6 +88,10 @@ const PRIMARY_C = "没有明显发生"; const SECONDARY_D = "不记得 / 不确定"; const OPTION_A = "是,大概就在那段时间"; const OPTION_B = "有类似,但年份不对或不够重大"; +const STYLE_NEITHER = "两边都不像"; +const QUALITY_A = "有,失常或压力很大"; +const QUALITY_B = "有压力,但不算失常"; +const QUALITY_C = "没有明显失常"; const AGE_BAND: Record = { education: { lo: 16, hi: 18, family: "升学、高考、转学或学习环境变化", varga: "D5 / D24" }, @@ -253,6 +261,8 @@ function hypothesisFor( } const domain = followupDomain(followup); const probe = pickProbe(probes, domain); + const kind = followup.choice_kind ?? probe?.choice_kind ?? "existence"; + const styleOptions = followup.style_options ?? probe?.style_options ?? []; const family = probe?.event_family ?? (domain ? AGE_BAND[domain]?.family : null) ?? "带大概年份的经历"; @@ -271,6 +281,40 @@ function hypothesisFor( : probe?.user_meaning ?? "用一件带年份的具体生平分开还在比的时间窗。题干自己写,年份不得发明。"; void observations; + if (kind === "varga_style" && styleOptions.length >= 2) { + const career = domain === "career" || followup.ask_theme === "career_style"; + if (styleOptions.length >= 3) { + return { + prompt: career ? "长期工作更接近哪一类?" : "这段关系更接近哪一种相处?", + why, + varga, + a: styleOptions[0].label, + b: styleOptions[1].label, + neither: styleOptions[2].label, + }; + } + return { + prompt: career ? "长期工作更接近哪一类?" : "这段关系更接近哪一种相处?", + why, + varga, + a: styleOptions[0].label, + b: styleOptions[1].label, + neither: STYLE_NEITHER, + }; + } + if (kind === "event_quality") { + const dated = probe?.year_label && probe.year_label !== "那段时间"; + return { + prompt: dated + ? `${probe.year_label},那次高考或重要考试有没有发挥明显失常、压力很大?` + : "那次高考或重要考试有没有发挥明显失常、压力很大?", + why, + varga: varga ?? "D5 / D24", + a: QUALITY_A, + b: QUALITY_B, + neither: QUALITY_C, + }; + } return eventHypothesis(period, family, why, varga); } @@ -308,9 +352,19 @@ export function buildChoiceFrame( stop_label: CHOICE_STOP_LABEL, stop_message: CHOICE_STOP_MESSAGE, scoring, + choice_kind: hypothesisKind(followup, input.probes), }; } +function hypothesisKind( + followup: ChoiceCardFollowup, + probes?: readonly DiscriminatingEventProbe[], +): EventProbeChoiceKind { + return followup.choice_kind + ?? pickProbe(probes, followupDomain(followup))?.choice_kind + ?? "existence"; +} + function clippedCopy(value: unknown, min: number, max: number): string | null { if (typeof value !== "string") return null; const text = value.trim().replace(/\s+/g, " "); @@ -391,6 +445,7 @@ export function mergeChoiceCard( probe_id: meta.probe_id ?? null, case_revision: meta.case_revision ?? null, focus_id: meta.focus_id ?? null, + choice_kind: frame.choice_kind, }; } @@ -454,5 +509,8 @@ export function parseRectificationChoiceCard(value: unknown): RectificationChoic ? row.case_revision : null, focus_id: typeof row.focus_id === "string" && row.focus_id.trim() ? row.focus_id : null, + ...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality" + ? { choice_kind: row.choice_kind } + : {}), }; } diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index 6d88dc91..ec804d78 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -315,7 +315,7 @@ export function applyChoiceWithoutEvidence( } const openProbeId = nextProbe(state)?.id ?? null; const lastAnsweredId = state.answered_probes.at(-1)?.probe_id ?? null; - const answerClass = classifyChoiceAnswer(choiceKey); + const answerClass = classifyChoiceAnswer(choiceKey, input.schema); if ( (submittedProbeId && submittedProbeId !== openProbeId && submittedProbeId !== lastAnsweredId && submittedProbeId !== probe.id && submittedProbeId !== probe.semantic_key && submittedProbeId !== `probe:${probe.semantic_key}`) || (openProbeId && probe.id !== openProbeId && probe.id !== lastAnsweredId) diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index 5e9e164c..3f68e0b3 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -8,6 +8,7 @@ import { askedKeysFromLedgerEvidence, buildCandidateContrastPacket, + volunteeredDomainsFromEvidence, } from "../core/candidate-contrast-packet.ts"; import { evaluateCandidateSeparation } from "../core/candidate-separation.ts"; import { askedProbeKeysFromReceipt, previousInferenceFromReceipt } from "./inference-adapter"; @@ -88,6 +89,7 @@ export function choiceCardFromCaseDossier(dossier: { candidateTimes: candidateScores.map((item) => item.time), transitions: windowScan?.transitions ?? [], askedKeys: askedProbeKeys, + volunteeredDomains: volunteeredDomainsFromEvidence(dossier.evidence), }); const userStopped = latestUserStoppedCollecting(dossier.turns ?? []); return projectRectificationChoiceCard({ diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 7a8266bf..9e0f8e0d 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -94,6 +94,12 @@ export type MethodFollowup = Readonly<{ semantic_key?: string; candidate_split_hash?: string; probe_year?: number; + choice_kind?: "existence" | "varga_style" | "event_quality"; + style_options?: readonly Readonly<{ + label: string; + answer_class: string; + sign?: string; + }>[]; }>; export type MethodFollowupPlan = Readonly<{ @@ -470,6 +476,12 @@ function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDi domain: followup.domain, year: followup.probe_year ?? null, semanticKey: followup.semantic_key ?? followup.method_id, + choiceKind: followup.choice_kind, + styleOptions: followup.style_options?.map((item) => ({ + label: item.label, + answerClass: item.answer_class as "yes" | "weak_yes" | "no" | "unsure", + ...(item.sign ? { sign: item.sign } : {}), + })), }; } @@ -785,6 +797,8 @@ export function buildMethodFollowupPlan(input: { semantic_key: conflictProbe.semantic_key ?? `${conflictProbe.domain}.${conflictProbe.year}`, candidate_split_hash: conflictProbe.candidate_split_hash, probe_year: conflictProbe.year, + choice_kind: conflictProbe.choice_kind ?? "existence", + style_options: conflictProbe.style_options, }, true, true); } else if (!relationshipCovered && !declined.has("relationship")) { next = makeFollowup({ @@ -860,6 +874,12 @@ export function buildMethodFollowupPlan(input: { semantic_key: contrastProbe.semanticKey, candidate_split_hash: contrastProbe.candidateSplitHash, probe_year: contrastProbe.year ?? undefined, + choice_kind: contrastProbe.choiceKind ?? "existence", + style_options: contrastProbe.styleOptions?.map((item) => ({ + label: item.label, + answer_class: item.answerClass, + ...(item.sign ? { sign: item.sign } : {}), + })), }, true, true); } else if (stage === "lagna_frame") { next = makeFollowup({ diff --git a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts index cb273eb7..f6f0eea7 100644 --- a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts +++ b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts @@ -92,6 +92,8 @@ export type WindowScanTransition = Readonly<{ layer: WindowScanLayer; at: string; user_meaning: string; + from_sign?: string; + to_sign?: string; }>; export type EventDashaLedgerRow = Readonly<{ @@ -179,6 +181,14 @@ export type EventProbeSource = export type EventProbeRole = "distinguish" | "reverse_verify"; +export type EventProbeChoiceKind = "existence" | "varga_style" | "event_quality"; + +export type EventProbeStyleOption = Readonly<{ + label: string; + answer_class: string; + sign?: string; +}>; + export type DiscriminatingEventProbe = Readonly<{ year: number; year_label: string; @@ -200,6 +210,8 @@ export type DiscriminatingEventProbe = Readonly<{ }>[]; left_time?: string; right_time?: string; + choice_kind?: EventProbeChoiceKind; + style_options?: readonly EventProbeStyleOption[]; }>; function asRecord(value: unknown): Readonly> | null { @@ -262,6 +274,8 @@ export function parseWindowScanTransitions(value: unknown): readonly WindowScanT layer, at, user_meaning: `${LAYER_LABEL[layer]} 在 ${at} 发生变化`, + ...(asText(row.from_sign, 12) ? { from_sign: asText(row.from_sign, 12)! } : {}), + ...(asText(row.to_sign, 12) ? { to_sign: asText(row.to_sign, 12)! } : {}), }); } return rows; @@ -474,6 +488,22 @@ export function parseDiscriminatingEventProbes(value: unknown): readonly Discrim ...(asText(row.candidate_split_hash, 120) ? { candidate_split_hash: asText(row.candidate_split_hash, 120)! } : {}), ...(asTime(row.left_time) ? { left_time: asTime(row.left_time)! } : {}), ...(asTime(row.right_time) ? { right_time: asTime(row.right_time)! } : {}), + ...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality" + ? { choice_kind: row.choice_kind } + : {}), + ...(Array.isArray(row.style_options) ? { + style_options: row.style_options.flatMap((item) => { + const option = asRecord(item); + const label = asText(option?.label, 80); + const answer = typeof option?.answer_class === "string" ? option.answer_class : ""; + if (!option || !label || !answer) return []; + return [{ + label, + answer_class: answer, + ...(asText(option.sign, 12) ? { sign: asText(option.sign, 12)! } : {}), + }]; + }), + } : {}), ...(Array.isArray(row.expected_outcomes) ? { expected_outcomes: row.expected_outcomes.flatMap((item) => { const outcome = asRecord(item); diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 3635f87a..fbfda0d8 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -69,6 +69,7 @@ function expectedAnswerSchemaFor( }, semantic_key: followup.semantic_key ?? null, candidate_split_hash: followup.candidate_split_hash ?? null, + choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence", }; return stampChoiceSchemaWithProbe( schema, diff --git a/frontend/src/lib/rectification-agentic/v9/skill-verification-report.ts b/frontend/src/lib/rectification-agentic/v9/skill-verification-report.ts index b7cfb00c..8e5eb7ac 100644 --- a/frontend/src/lib/rectification-agentic/v9/skill-verification-report.ts +++ b/frontend/src/lib/rectification-agentic/v9/skill-verification-report.ts @@ -9,42 +9,7 @@ import type { EventDashaLedgerRow } from "./refinement-packet"; import type { WindowScan } from "./varga-observations"; import type { CandidateSeparation } from "../core/candidate-separation"; - -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: "灵性、牺牲型" }, -}; - -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: "灵性、服务" }, -}; - -function signKey(value: string): string { - const trimmed = value.trim(); - if (trimmed.endsWith("座")) return trimmed; - return `${trimmed}座`; -} +import { D9_TYPE_TABLE, D10_TYPE_TABLE, signKey } from "./varga-type-tables"; function typeRow(sign: string, table: "d9" | "d10"): string { const key = signKey(sign); diff --git a/frontend/src/lib/rectification-agentic/v9/varga-type-tables.ts b/frontend/src/lib/rectification-agentic/v9/varga-type-tables.ts new file mode 100644 index 00000000..bbf37303 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/varga-type-tables.ts @@ -0,0 +1,52 @@ +/** + * D9/D10 type tables for rectification contrast, not fate promises. + */ + +export type D9TypeRow = Readonly<{ trait: string; spouse: string; marriage: string }>; +export type D10TypeRow = Readonly<{ trait: string; occupation: string; style: 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: "灵性、牺牲型" }, +}; + +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: "灵性、服务" }, +}; + +export function signKey(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + return trimmed.endsWith("座") ? trimmed : `${trimmed}座`; +} + +export function d9StyleLabel(sign: string): string { + const row = D9_TYPE_TABLE[signKey(sign)]; + return row?.trait ?? `${signKey(sign)}相处`; +} + +export function d10StyleLabel(sign: string): string { + const row = D10_TYPE_TABLE[signKey(sign)]; + return row?.style ?? `${signKey(sign)}职责`; +} diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 88c26f72..bffb6d19 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -85,6 +85,7 @@ import { buildCandidateContrastPacket, conflictProbesFromContrast, selectDiscriminatorProbe, + volunteeredDomainsFromEvidence, } from "@/lib/rectification-agentic/core/candidate-contrast-packet"; import { evaluateCandidateSeparation } from "@/lib/rectification-agentic/core/candidate-separation"; import { offerSessionKinds } from "@/lib/rectification-agentic/core/decide-next-action"; @@ -205,6 +206,7 @@ function contrastPacketFromLatest( candidateTimes, transitions: windowScan?.transitions ?? [], askedKeys: askedDiscriminatorKeys(latest?.decisionReceipt, evidence), + volunteeredDomains: volunteeredDomainsFromEvidence(evidence), }); } @@ -879,6 +881,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { candidateTimes: score.candidates.map((item) => item.time), transitions: windowScan?.transitions ?? [], askedKeys: askedDiscriminatorKeys(dossier.latestResult?.decisionReceipt, parsed.evidence), + volunteeredDomains: volunteeredDomainsFromEvidence(parsed.evidence), }); const inference = buildCaseInferenceState({ range: parsed.case.candidateRange, diff --git a/frontend/tests/rectification-candidate-contrast-packet.test.ts b/frontend/tests/rectification-candidate-contrast-packet.test.ts new file mode 100644 index 00000000..b97aba9b --- /dev/null +++ b/frontend/tests/rectification-candidate-contrast-packet.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildCandidateContrastPacket, + remainingVargaSplits, + selectDiscriminatorProbe, + volunteeredDomainsFromEvidence, +} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; + +test("remaining D10 three-way outranks a skewed D24 split", () => { + const packet = 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: "处女座" }, + { layer: "d24", at: "05:04", from_sign: "白羊座", to_sign: "金牛座" }, + ], + }); + const probe = selectDiscriminatorProbe(packet); + assert.ok(probe); + assert.equal(probe.domain, "career"); + assert.equal(probe.choiceKind, "varga_style"); + assert.match(probe.semanticKey, /varga\.d10/); + assert.doesNotMatch(probe.semanticKey, /varga\.d24/); + assert.equal(probe.styleOptions?.length, 3); + assert.equal(probe.expectedOutcomes.length, 3); + assert.deepEqual(probe.expectedOutcomes.map((row) => row.supportsCandidateIds), [ + ["05:00"], + ["05:03"], + ["05:04"], + ]); +}); + +test("two-way remaining D9 maps C to unsure, not the other minute group", () => { + const packet = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: ["05:00", "05:04"], + transitions: [ + { layer: "d9", at: "05:04", from_sign: "巨蟹座", to_sign: "狮子座" }, + ], + }); + const probe = selectDiscriminatorProbe(packet); + assert.ok(probe); + assert.equal(probe.choiceKind, "varga_style"); + assert.equal(probe.styleOptions?.length, 2); + assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "yes")?.supportsCandidateIds, ["05:00"]); + assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "weak_yes")?.supportsCandidateIds, ["05:04"]); + assert.equal(probe.expectedOutcomes.some((row) => row.outcomeId === "no"), false); +}); + +test("remaining D7 enters the pool as family existence", () => { + const splits = remainingVargaSplits( + ["05:00", "05:04"], + [{ layer: "d7", at: "05:04", from_sign: "金牛座", to_sign: "双子座" }], + ); + assert.equal(splits[0]?.layer, "d7"); + const packet = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: ["05:00", "05:04"], + remainingSplits: splits, + }); + const probe = selectDiscriminatorProbe(packet); + assert.equal(probe?.domain, "family"); + assert.equal(probe?.choiceKind, "existence"); + assert.match(probe?.question ?? "", /家人/); +}); + +test("finance remaining splits stay out unless volunteered", () => { + const hidden = remainingVargaSplits( + ["05:00", "05:04"], + [{ layer: "d2", at: "05:04" }], + ); + assert.equal(hidden.length, 0); + const shown = remainingVargaSplits( + ["05:00", "05:04"], + [{ layer: "d2", at: "05:04" }], + volunteeredDomainsFromEvidence([{ status: "confirmed", domain: "finance" }]), + ); + assert.equal(shown[0]?.layer, "d2"); +}); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 5f7c2883..bfa00f6f 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -685,3 +685,91 @@ test("GET choice_card stays hidden after 没有了 when selection is allowed", ( }); assert.equal(card, null); }); + +test("remaining D10 style card uses type-table labels instead of existence", () => { + const frame = buildChoiceFrame({ + method_id: "d10_career", + ask_theme: "career_style", + domain: "career", + user_prompt_hint: "unused", + choice_kind: "varga_style", + style_options: [ + { label: "照顾、情感", answer_class: "yes", sign: "巨蟹座" }, + { label: "领导、表演", answer_class: "weak_yes", sign: "狮子座" }, + { label: "细致、服务", answer_class: "no", sign: "处女座" }, + ], + }); + const copy = serverOwnedChoiceCopy(frame); + assert.ok(copy); + assert.equal(frame.choice_kind, "varga_style"); + assert.match(copy.prompt, /长期工作更接近哪一类/); + assert.doesNotMatch(copy.prompt, /有没有明显/); + assert.equal(copy.option_a, "照顾、情感"); + assert.equal(copy.option_b, "领导、表演"); + assert.equal(copy.option_c, "细致、服务"); +}); + +test("two-way remaining D9 puts 都不像 on C", () => { + const frame = buildChoiceFrame({ + method_id: "d9_relationship", + ask_theme: "relationship_style", + domain: "relationship", + user_prompt_hint: "unused", + choice_kind: "varga_style", + style_options: [ + { label: "情感丰富、家庭导向", answer_class: "yes", sign: "巨蟹座" }, + { label: "骄傲、戏剧性、领导欲", answer_class: "weak_yes", sign: "狮子座" }, + ], + }); + const copy = serverOwnedChoiceCopy(frame); + assert.ok(copy); + assert.match(copy.prompt, /相处/); + assert.match(copy.option_c, /都不像/); + assert.match(copy.option_d, /不确定/); +}); + +test("known exam quality asks 失常, not enrollment existence", () => { + const frame = buildChoiceFrame({ + method_id: "d5_education", + ask_theme: "education_style", + domain: "education", + user_prompt_hint: "unused", + choice_kind: "event_quality", + }, { + probes: [{ + year: 2015, + year_label: "2015 年前后", + domain: "education", + event_family: "高考或重要考试发挥明显失常、压力很大", + source: "known_event_quality", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "年份锁定 2015 年前后。已有高考或考试经历。请写成一句自然语言,问那次是否发挥失常或压力特别大。不得改年份。", + role: "distinguish", + choice_kind: "event_quality", + }], + }); + const copy = serverOwnedChoiceCopy(frame); + assert.ok(copy); + assert.match(copy.prompt, /2015 年前后/); + assert.match(copy.prompt, /失常/); + assert.doesNotMatch(copy.prompt, /升学、高考、转学/); + assert.match(copy.option_a, /失常或压力很大/); + assert.match(copy.option_c, /没有明显失常/); +}); + +test("dasha existence cards still ask whether the event happened", () => { + const frame = buildChoiceFrame({ + method_id: "d4_home", + ask_theme: "home_change", + domain: "relocation", + user_prompt_hint: "unused", + choice_kind: "existence", + }, { probes: [MOVE_PROBE] }); + const copy = serverOwnedChoiceCopy(frame); + assert.ok(copy); + assert.match(copy.prompt, /有没有明显搬家/); + assert.match(copy.option_a, /是,大概就在那段时间/); + assert.match(copy.option_c, /没有明显发生/); +}); diff --git a/frontend/tests/rectification-inference-machine.test.ts b/frontend/tests/rectification-inference-machine.test.ts index 0626004a..7ba4b7a2 100644 --- a/frontend/tests/rectification-inference-machine.test.ts +++ b/frontend/tests/rectification-inference-machine.test.ts @@ -461,6 +461,51 @@ test("A/B/C/D on a remaining-minute contrast probe moves the posterior", () => { > (applied.state.candidates.find((item) => item.time === "05:00")?.posterior_score ?? 0)); }); +test("two-way style C 都不像 does not promote the other minute group", () => { + const contrast = { + id: "contrast:varga.d9.05:00/05:04", + semantic_key: "varga.d9.05:00/05:04", + candidate_split_hash: "varga.d9.05:00/05:04", + domain: "relationship", + year: 0, + question: "这段关系更接近哪一种相处?", + candidate_ids: ["05:00", "05:04"], + expected_outcomes: [ + { answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:04"] }, + { answer_class: "weak_yes" as const, supports: ["05:04"], conflicts: ["05:00"] }, + { answer_class: "unsure" as const, supports: [] as string[], conflicts: [] as string[] }, + ], + information_gain: 1, + source: "varga_contrast", + }; + const state = buildInferenceState({ + range_start: "04:45", + range_end: "05:15", + candidates: [ + { id: "05:00", time: "05:00", relative_support: 34 }, + { id: "05:04", time: "05:04", relative_support: 33 }, + ], + events: [{ id: "e1", domain: "relationship", year: 2024, precision: "year" }], + probes: [contrast], + }); + const before = posteriorMap(state.candidates); + const applied = applyChoiceWithoutEvidence(state, { + choiceKey: "C", + schema: { + choice: { + prompt: "这段关系更接近哪一种相处?", + option_c: "两边都不像", + }, + choice_kind: "varga_style", + probe_id: contrast.id, + semantic_key: contrast.semantic_key, + }, + }); + assert.equal(applied.applied, true); + assert.equal(applied.answerClass, "unsure"); + assert.deepEqual(posteriorMap(applied.state.candidates), before); +}); + test("holdout and collection declines do not write a probe answer", () => { const conflict = probe({ id: "p-holdout", diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 2f122222..ecfb0f76 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -37,14 +37,19 @@ LAYER_DOMAIN = { "d11": "finance", "d30": "health_pressure", } -STAGE_DOMAIN = { - "d9_refine": "relationship", - "d10_refine": "career", - "d4_refine": "relocation", - "theme_refine": "relocation", - "d5_refine": "education", -} VOLUNTEER_ONLY = frozenset({"finance", "health_pressure"}) +LAYER_VARGA = { + "d9": "D9", + "d10": "D10", + "d4": "D4", + "d5": "D5", + "d24": "D24", + "d7": "D7", + "d12": "D12", + "d2": "D2", + "d11": "D11", + "d30": "D30", +} DOMAIN_CATALOG: dict[str, dict[str, Any]] = { "education": { "event_family": "升学、高考、转学或学习环境变化", @@ -158,10 +163,43 @@ def _age_band_year(birth_year: int, domain: str, today: date) -> int | None: return year +def _layer_value(context: dict[str, Any], layer: str) -> int | None: + feature = context.get("feature") if isinstance(context.get("feature"), dict) else {} + if layer == "d1": + raw = feature.get("ascendant_sign_index") + if isinstance(raw, int): + return raw + index = context.get("ascendant_index") + return index if isinstance(index, int) else None + name = LAYER_VARGA.get(layer) + if not name: + return None + vargas = feature.get("varga_ascendants") if isinstance(feature.get("varga_ascendants"), dict) else {} + raw = vargas.get(name) + if isinstance(raw, int): + return raw + charts = context.get("varga_charts") if isinstance(context.get("varga_charts"), dict) else {} + chart = charts.get(name) if isinstance(charts.get(name), dict) else {} + ascendant = chart.get("Ascendant") if isinstance(chart.get("Ascendant"), dict) else {} + index = ascendant.get("sign_idx") + return index if isinstance(index, int) else None + + +def _differing_layers(contexts: Sequence[dict[str, Any]]) -> set[str]: + values: dict[str, set[int]] = {layer: set() for layer in SCORING_LAYERS} + for context in contexts: + for layer in SCORING_LAYERS: + value = _layer_value(context, layer) + if isinstance(value, int): + values[layer].add(value) + return {layer for layer, bucket in values.items() if len(bucket) > 1} + + def _probe_domains( - scan: dict[str, Any], - precision_current: str | None, + remaining_layers: set[str], events: Sequence[dict[str, Any]], + *, + d1_differs: bool = False, ) -> list[str]: volunteered = { str(event.get("domain")) @@ -169,19 +207,16 @@ def _probe_domains( if isinstance(event, dict) and event.get("domain") } ordered: list[str] = [] - stage_domain = STAGE_DOMAIN.get(str(precision_current or "")) - if stage_domain: - ordered.append(stage_domain) for layer in SCORING_LAYERS: domain = LAYER_DOMAIN.get(layer) if not domain or domain in ordered: continue - if not scan.get(f"{layer}_candidates_differ"): + if layer not in remaining_layers: continue if domain in VOLUNTEER_ONLY and domain not in volunteered: continue ordered.append(domain) - if not ordered and scan.get("d1_candidates_differ"): + if not ordered and d1_differs: ordered.append("education") return ordered @@ -195,6 +230,20 @@ def _static_contexts(built: dict[str, Any]) -> list[dict[str, Any]]: return rows +def _remaining_contexts(built: dict[str, Any], candidate_times: Sequence[str]) -> list[dict[str, Any]]: + by_time = {_context_time(item): item for item in _static_contexts(built)} + remaining: list[dict[str, Any]] = [] + seen: set[str] = set() + for raw in candidate_times: + time = str(raw or "")[:5] + context = by_time.get(time) + if context is None or time in seen: + continue + seen.add(time) + remaining.append(context) + return remaining + + def _pick_representatives( built: dict[str, Any], scan: dict[str, Any], @@ -440,6 +489,13 @@ def _pair_entropy(left: float, right: float) -> float: return _binary_entropy(left / total) +def _group_entropy(sizes: Sequence[int]) -> float: + total = sum(int(item) for item in sizes) + if total <= 0: + return 0.0 + return round(-sum((item / total) * log2(item / total) for item in sizes if item > 0), 4) + + def _information_gain(left_level: str, right_level: str) -> float: left_p = LEVEL_P.get(left_level, 0.5) right_p = LEVEL_P.get(right_level, 0.5) @@ -448,6 +504,10 @@ def _information_gain(left_level: str, right_level: str) -> float: return round(max(0.0, 1.0 - after), 4) +def _year_activated(rule_ids: Sequence[str]) -> bool: + return _has_domain_activation(rule_ids) or LEVEL_RANK.get(match_level(rule_ids), 0) >= 2 + + def _public_probe( *, year: int, @@ -474,6 +534,7 @@ def _public_probe( "information_gain": 0.0, "candidate_split_hash": f"{domain}:{year}", "expected_outcomes": [], + "choice_kind": "event_quality" if source == "known_event_quality" else "existence", } payload.update(extra) return payload @@ -558,33 +619,54 @@ def _quality_probes( return rows -def _evaluate_year( - left: dict[str, Any], - right: dict[str, Any], +def _evaluate_contexts( + contexts: Sequence[dict[str, Any]], *, birth_date: str, domain: str, year: int, source: str, ) -> dict[str, Any] | None: - scored_left = _score_year(left, birth_date=birth_date, domain=domain, year=year) - scored_right = _score_year(right, birth_date=birth_date, domain=domain, year=year) - if scored_left is None or scored_right is None: + scored_rows: list[tuple[str, list[str]]] = [] + for context in contexts: + time = _context_time(context) + if not time: + continue + scored = _score_year(context, birth_date=birth_date, domain=domain, year=year) + if scored is None: + continue + scored_rows.append((time, list(scored.get("rule_ids") or []))) + if len(scored_rows) < 2: return None - left_rules = scored_left.get("rule_ids") or [] - right_rules = scored_right.get("rule_ids") or [] - if not _discriminates(left_rules, right_rules): + yes: list[tuple[str, list[str]]] = [] + no: list[tuple[str, list[str]]] = [] + for time, rules in scored_rows: + if _year_activated(rules): + yes.append((time, rules)) + else: + no.append((time, rules)) + if not yes or not no: + ranks = [ + (time, LEVEL_RANK.get(match_level(rules), 0), rules) + for time, rules in scored_rows + ] + highest = max(item[1] for item in ranks) + lowest = min(item[1] for item in ranks) + if highest - lowest < 2: + return None + yes = [(time, rules) for time, rank, rules in ranks if rank == highest] + no = [(time, rules) for time, rank, rules in ranks if rank < highest] + if not yes or not no: + return None + if not any(_discriminates(left, right) for _, left in yes for _, right in no): return None - left_level = match_level(left_rules) - right_level = match_level(right_rules) - stronger = left_rules if LEVEL_RANK[left_level] >= LEVEL_RANK[right_level] else right_rules + yes_times = sorted((time for time, _ in yes), key=_clock) + no_times = sorted((time for time, _ in no), key=_clock) + yes_level = max((match_level(rules) for _, rules in yes), key=lambda item: LEVEL_RANK[item]) + no_level = min((match_level(rules) for _, rules in no), key=lambda item: LEVEL_RANK[item]) + stronger = next(rules for _, rules in yes if match_level(rules) == yes_level) vim_hit, narayana_hit = _tracks_present(stronger) - left_time = _context_time(left) - right_time = _context_time(right) - left_stronger = LEVEL_RANK[left_level] >= LEVEL_RANK[right_level] - yes_supports = [time for time in ([left_time] if left_stronger else [right_time]) if time] - yes_conflicts = [time for time in ([right_time] if left_stronger else [left_time]) if time] - split = f"{domain}:{year}:{ '|'.join(sorted(yes_supports + yes_conflicts)) }" + split = f"{domain}:{year}:{ '|'.join(sorted(yes_times + no_times)) }" return _public_probe( year=year, domain=domain, @@ -596,16 +678,37 @@ def _evaluate_year( family=str(DOMAIN_CATALOG[domain]["event_family"]), ), event_family=str(DOMAIN_CATALOG[domain]["event_family"]), - information_gain=_information_gain(left_level, right_level), + information_gain=round( + _group_entropy([len(yes_times), len(no_times)]) + _information_gain(yes_level, no_level), + 4, + ), semantic_key=f"{domain}.{year}.{source}", candidate_split_hash=split, expected_outcomes=[ - {"answer_class": "yes", "supports": yes_supports, "conflicts": yes_conflicts}, - {"answer_class": "no", "supports": yes_conflicts, "conflicts": yes_supports}, + {"answer_class": "yes", "supports": yes_times, "conflicts": no_times}, + {"answer_class": "no", "supports": no_times, "conflicts": yes_times}, {"answer_class": "unsure", "supports": [], "conflicts": []}, ], - left_time=left_time, - right_time=right_time, + left_time=yes_times[0], + right_time=no_times[0], + ) + + +def _evaluate_year( + left: dict[str, Any], + right: dict[str, Any], + *, + birth_date: str, + domain: str, + year: int, + source: str, +) -> dict[str, Any] | None: + return _evaluate_contexts( + [left, right], + birth_date=birth_date, + domain=domain, + year=year, + source=source, ) @@ -619,6 +722,7 @@ def discriminating_event_probes( precision_current: str | None = None, today: date | None = None, ) -> list[dict[str, Any]]: + del precision_current birth_date = str(request.get("birth_date") or "").strip() birth_year = _birth_year(birth_date) if birth_year is None: @@ -629,21 +733,42 @@ def discriminating_event_probes( return [] now = today or date.today() events = [item for item in (request.get("events") or []) if isinstance(item, dict)] - domains = _probe_domains(scan, precision_current, events) - if not domains: + remaining = _remaining_contexts(built, candidate_times) + if len(remaining) < 2: + remaining = _static_contexts(built) + remaining_layers = _differing_layers(remaining) if len(remaining) >= 2 else set() + if not remaining_layers: + remaining_layers = { + layer for layer in SCORING_LAYERS + if scan.get(f"{layer}_candidates_differ") + } + domains = _probe_domains( + remaining_layers, + events, + d1_differs="d1" in remaining_layers or bool(scan.get("d1_candidates_differ")), + ) + known_domains = [ + str(event.get("domain")) + for event in events + if str(event.get("domain") or "") in DOMAIN_CATALOG + ] + if not domains and not known_domains: return [] probes: list[dict[str, Any]] = [] covered_domains: set[str] = set() pair = _pick_representatives(built, scan, candidate_times, representative_time) + scoreable_remaining = [item for item in remaining if _scoreable(item)] + if len(scoreable_remaining) >= 2: + score_contexts = scoreable_remaining + elif pair is not None and _scoreable(pair[0]) and _scoreable(pair[1]): + score_contexts = [pair[0], pair[1]] + else: + score_contexts = [] lo, hi = birth_year + 5, min(now.year, birth_year + 80) - can_score = ( - pair is not None - and _scoreable(pair[0]) - and _scoreable(pair[1]) - ) + can_score = len(score_contexts) >= 2 dasha_domains: set[str] = set() - if can_score and pair is not None: - left, right = pair + if can_score: + left, right = score_contexts[0], score_contexts[-1] left_moon = float(left["planet_longitudes"]["Moon"]) right_moon = float(right["planet_longitudes"]["Moon"]) vim_years = _boundary_years( @@ -661,26 +786,36 @@ def discriminating_event_probes( known_years = _event_years(events, domain) blocked_years = _existence_blocked_years(domain, known_years) boundary = sorted((vim_years | narayana_years) & set(range(lo, hi + 1))) - found = None + best = None for year in boundary: if year in blocked_years: continue - found = _evaluate_year( - left, right, birth_date=birth_date, domain=domain, year=year, source="dasha_boundary", + found = _evaluate_contexts( + score_contexts, + birth_date=birth_date, + domain=domain, + year=year, + source="dasha_boundary", ) - if found: - break - if found is None: + if found is None: + continue + if best is None or float(found["information_gain"]) > float(best["information_gain"]): + best = found + if best is None: midpoint = _age_band_year(birth_year, domain, now) if midpoint is not None and midpoint not in blocked_years: - found = _evaluate_year( - left, right, birth_date=birth_date, domain=domain, year=midpoint, source="dasha_activation", + best = _evaluate_contexts( + score_contexts, + birth_date=birth_date, + domain=domain, + year=midpoint, + source="dasha_activation", ) - if found: - probes.append(found) + if best: + probes.append(best) dasha_domains.add(domain) covered_domains.add(domain) - quality = _quality_probes(events, domains) + quality = _quality_probes(events, known_domains or domains) for row in quality: if row["domain"] in dasha_domains: continue diff --git a/scripts/rectification/refinement_packet.py b/scripts/rectification/refinement_packet.py index 58b2c1bc..bf89265e 100644 --- a/scripts/rectification/refinement_packet.py +++ b/scripts/rectification/refinement_packet.py @@ -77,11 +77,18 @@ def _features(built: dict[str, Any]) -> list[dict[str, Any]]: return rows +def _sign_name(index: int | None) -> str | None: + if isinstance(index, int) and 0 <= index <= 11: + return SIGNS_CN[SIGNS[index]] + return None + + def _sign_names(indices: set[int]) -> list[str]: names: list[str] = [] for idx in sorted(indices): - if isinstance(idx, int) and 0 <= idx <= 11: - names.append(SIGNS_CN[SIGNS[idx]]) + name = _sign_name(idx) + if name: + names.append(name) return names @@ -207,11 +214,17 @@ def window_scan( before = previous[layer] after = current[layer] if isinstance(before, int) and isinstance(after, int) and before != after: - transitions.append({ + row = { "layer": layer, "at": time, "user_meaning": f"{label} 在 {time} 发生变化", - }) + } + from_sign = _sign_name(before) + to_sign = _sign_name(after) + if from_sign and to_sign: + row["from_sign"] = from_sign + row["to_sign"] = to_sign + transitions.append(row) previous = current payload: dict[str, Any] = { "scanned": True, diff --git a/tests/test_rectification_event_probes.py b/tests/test_rectification_event_probes.py index 9bc9b94f..8d5cd8f4 100644 --- a/tests/test_rectification_event_probes.py +++ b/tests/test_rectification_event_probes.py @@ -345,6 +345,101 @@ class EventProbesTest(unittest.TestCase): self.assertTrue(times <= {"05:00", "05:06", "05:07"}) self.assertIn("05:00", times) self.assertTrue(times & {"05:06", "05:07"}) + covered = set(row["expected_outcomes"][0]["supports"] + row["expected_outcomes"][0]["conflicts"]) + self.assertEqual(covered, {"05:00", "05:06", "05:07"}) + + def test_remaining_family_layer_outranks_stable_relationship(self) -> None: + built = { + "static_contexts": [ + { + "feature": { + "time": "05:13", + "varga_ascendants": {"D9": 1, "D10": 1, "D4": 1, "D12": 1, "D7": 1}, + } + }, + { + "feature": { + "time": "05:14", + "varga_ascendants": {"D9": 1, "D10": 1, "D4": 1, "D12": 2, "D7": 2}, + } + }, + ] + } + probes = discriminating_event_probes( + _request(), + built, + scan=window_scan(built), + candidate_times=["05:13", "05:14"], + representative_time="05:13", + precision_current="d9_refine", + today=date(2026, 8, 22), + ) + self.assertTrue(probes) + self.assertEqual(probes[0]["domain"], "family") + self.assertFalse(any(item["domain"] == "relationship" for item in probes)) + self.assertFalse(any(item["domain"] == "finance" for item in probes)) + + def test_finance_layer_stays_volunteer_only(self) -> None: + built = { + "static_contexts": [ + {"feature": {"time": "05:13", "varga_ascendants": {"D2": 1, "D9": 1, "D10": 1}}}, + {"feature": {"time": "05:14", "varga_ascendants": {"D2": 2, "D9": 1, "D10": 1}}}, + ] + } + probes = discriminating_event_probes( + _request(), + built, + scan=window_scan(built), + candidate_times=["05:13", "05:14"], + representative_time="05:13", + today=date(2026, 8, 22), + ) + self.assertFalse(any(item["domain"] == "finance" for item in probes)) + + def test_highest_gain_year_is_kept_not_first_hit(self) -> None: + from unittest.mock import patch + + from scripts.rectification import event_probes as probes_mod + + built = { + "static_contexts": [ + _context("05:13", d4_asc=0, sun_house=4, sun_varga_sign=3, moon=100.0), + _context("05:14", d4_asc=1, sun_house=10, sun_varga_sign=9, moon=101.0), + ] + } + + def fake_vim(_birth_date: str, moon: float, _lo: int, _hi: int) -> list[int]: + return [2010, 2020] if moon <= 100.0 else [2009, 2019] + + def fake_narayana(_asc: int, planets: dict, _birth_date: str, _lo: int, _hi: int) -> list[int]: + moon = float(planets.get("Moon") or 0) + return [2010, 2020] if moon <= 100.0 else [2009, 2019] + + def fake_score(context: dict, *, birth_date: str, domain: str, year: int) -> dict: + del birth_date, domain + early = probes_mod._context_time(context) == "05:13" + if year == 2010: + return {"rule_ids": ["vim_ad_domain_lord"] if early else ["no_domain_activation"]} + if year == 2020: + return {"rule_ids": ["vim_md_domain_house"] if early else ["no_domain_activation"]} + return {"rule_ids": ["no_domain_activation"]} + + with ( + patch.object(probes_mod, "_vim_start_years", side_effect=fake_vim), + patch.object(probes_mod, "_narayana_start_years", side_effect=fake_narayana), + patch.object(probes_mod, "_score_year", side_effect=fake_score), + ): + probes = discriminating_event_probes( + _request(), + built, + scan=window_scan(built), + candidate_times=["05:13", "05:14"], + representative_time="05:13", + today=date(2026, 8, 22), + ) + row = next(item for item in probes if item["domain"] == "relocation") + self.assertEqual(row["year"], 2020) + self.assertEqual(row["source"], "dasha_boundary") if __name__ == "__main__": diff --git a/tests/test_rectification_refinement_packet.py b/tests/test_rectification_refinement_packet.py index 946aab6f..b50126a6 100644 --- a/tests/test_rectification_refinement_packet.py +++ b/tests/test_rectification_refinement_packet.py @@ -101,6 +101,8 @@ class RefinementPacketTest(unittest.TestCase): "layer": "d9", "at": "05:14", "user_meaning": "D9 在 05:14 发生变化", + "from_sign": "金牛座", + "to_sign": "天蝎座", }]) encoded = str(scan) self.assertNotIn("Aries", encoded)