From ee9d4327620af7bbff7f16210031605ddd2827de Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 29 Jul 2026 17:49:04 +0800 Subject: [PATCH] feat: make rectification agent conversational --- docs/BUG_HISTORY.md | 28 ++ .../evidence-extractor.ts | 67 ++++- .../src/lib/rectification-agent/contracts.ts | 150 +++++++++-- .../event-extractor-agent.ts | 68 +++++ .../opportunity-builder.ts | 206 +++++++++++---- .../lib/rectification-agent/orchestrator.ts | 43 ++- .../lib/rectification-agent/reasoner-agent.ts | 56 +++- .../lib/rectification-agent/renderer-agent.ts | 155 +++++++++-- .../src/lib/rectification-v4/case-service.ts | 5 +- .../src/lib/rectification-v4/extraction.ts | 47 +++- ...010000_rectification_agent_v6_versions.sql | 14 + .../tests/birth-time-journey-engine.test.ts | 1 - ...ational-rectification-orchestrator.test.ts | 4 +- .../tests/identity-auth-integration.test.ts | 2 +- frontend/tests/onboarding-route.test.ts | 6 +- .../rectification-agent-contracts.test.ts | 8 +- frontend/tests/rectification-agent-v5.test.ts | 134 ++++++++-- frontend/tests/rectification-agent-v6.test.ts | 246 ++++++++++++++++++ .../tests/rectification-v4-domain.test.ts | 4 +- .../tests/rectification-v4-replay.test.ts | 33 +-- .../tests/rectification-v4-service.test.ts | 9 +- skills/birth-time-rectification/SKILL.md | 65 +++-- .../references/event-schema.md | 28 +- .../references/failure-policy.md | 20 +- .../references/output-contract.md | 43 ++- .../references/product-contract.md | 28 +- .../references/question-policy.md | 43 ++- .../references/technique-policy.md | 21 +- 28 files changed, 1329 insertions(+), 205 deletions(-) create mode 100644 frontend/src/lib/rectification-agent/event-extractor-agent.ts create mode 100644 frontend/supabase/migrations/20260729010000_rectification_agent_v6_versions.sql create mode 100644 frontend/tests/rectification-agent-v6.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 329fcca0..d374a9a1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1578,3 +1578,31 @@ - 防复发:当前事件延续必须是服务端 opportunity 所有权规则,而不是 prompt 建议;模型输出即使结构合法,也必须经过 bounded tool budget、active-opportunity lookup、decision validation 和 completion payload hash 四层门控。 - 相关记录:BUG-075、BUG-085 - 修复版本:本地 V5 重构,待提交与 staging 验收 + +## BUG-087 | 语义机会仍退化为固定文案并在拒绝后重复追问 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:生时校正 Agent 的证据协调、问题机会、Reasoner 上下文、Renderer、候选范围提示与 Skill 合同 +- 用户现象:对话会把月份已经明确的经历继续机械追问具体日期,按固定领域顺序轮询;用户表示不知道、跳过或换方向后仍可能回到同一事件,Renderer 还会重复套话和未变化的候选范围。 +- 触发条件:Question Opportunity 直接持久化最终 `prompt`,Renderer 再用服务端问题覆盖自然生成结果;缺失领域按固定数组选择,日期策略把所有非日精度事件视为未完成,且证据协调未完整区分拒绝、未知、换向和回答了另一事件。 +- 根因:机会合同混合了“为什么问、要补什么字段”和“最终怎么说”,导致 Reasoner 看不到最近对话语义、Renderer 无法安全自然表达;同时 target disposition 和重复追问预算不完整,固定领域与日期控制流绕过了信息增益、隐私成本和稳定性诊断。 +- 修复:升级为兼容旧 `prompt` 的 `semantic-question-v2`,由 Builder 同时生成并按 utility 排序最多五个活动机会;补齐 `resolved / unknown / declined / direction_change / answered_other_event / unresolved / not_applicable`,限制同一目标连续追问和回答其他事件后的温和补问次数;月份默认足够,仅在日期敏感诊断不稳定时细化。Reasoner 获得脱敏的最近 Turn、事件、目标和机会语义;Renderer 改为验证自然问题并在失败时使用锚定 fallback,同时只在公开门首次通过或范围实际变化时播报范围,相同范围即使再次计算或收到 offer 决策也不重复播报。受限模型辅助提取仅补充确定性解析缺口,服务端继续校验原文子串和日期。 +- 验证:V6 语义合同、日期敏感性、拒绝/换向、回答新事件不覆盖旧事件、单次补问、Renderer 锚点/单问题/分钟注入/内部信息拒绝、候选范围去重、Reasoner 上下文、模型辅助提取和旧 Opportunity 兼容测试通过;四轮端到端测试覆盖月份职业事件、外地入学、无日期搬家换向和后续职业事件,并保留 exact-minute、Profile 写入、legacy/shadow、V5 候选引擎与 completed-job replay 边界。完整前端测试、lint、TypeScript 与 Python 结果见本任务交付记录。 +- 防复发:问题机会只表达服务器拥有的语义目标和约束,最终文案必须通过 realization validator;日期追问必须有诊断依据,拒绝/换向必须关闭目标,领域选择必须由 utility 和上下文驱动。Skill 明确禁止固定问卷、重复范围、唯一分钟、D60 驱动和公开内部评分/技术轨迹。 +- 相关记录:BUG-068、BUG-075、BUG-080、BUG-081、BUG-082、BUG-085、BUG-086 +- 复发自:BUG-085、BUG-086 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`,本地验证完成,待提交与 staging 发布 + +## BUG-088 | TypeScript 全量检查被过期测试夹具阻塞 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:`npx tsc --noEmit` 本地发布前质量门 +- 用户现象:生产构建通过,但全量 TypeScript 检查在三个测试文件报错:事件评分输入仍传入服务端固定的 `high_rigor`、身份全局缓存清理被控制流误收窄为 `never`、onboarding 测试未归一化 PostgreSQL `Date` 联合类型。 +- 根因:测试夹具落后于现有生产合同;这些报错不来自 V6 Agent 运行时,但会让显式 TypeScript 验证失败。 +- 修复:删除客户端不应拥有的 `high_rigor` 输入;在异步请求结束后从 `globalThis` 重新读取身份缓存;按生产边界把 `Date` 归一化为日期字符串后再构建 onboarding cache identity。 +- 验证:`npx tsc --noEmit`、相关前端测试和生产构建通过。 +- 防复发:测试输入只使用公开类型拥有的字段;异步初始化的全局缓存不要依赖删除前的局部控制流;数据库日期联合类型在进入纯字符串合同前必须归一化。 diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index d7ecd277..6973a6e4 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -35,7 +35,7 @@ const unresolvedRelativeTimePattern = /(?:来年|次年|第二年|翌年|后来| const leadingRelativeTimePattern = /^\s*(?:(?:来年|次年|第二年|翌年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/; const missingEventSummary = "事件内容待补充"; -function normalizedDate(value: string, asOfDate: string): ParsedDate | null { +export function parseDeclaredDateText(value: string, asOfDate: string): ParsedDate | null { const chinese = value.match(/^((?:1\d{3}|20\d{2}|\d{2}))\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/); const iso = value.match(/^((?:1\d{3}|20\d{2}))-(\d{2})(?:-(\d{2}))?$/); const match = chinese ?? iso; @@ -70,7 +70,7 @@ function datesIn(value: string, asOfDate: string): ParsedDate[] { const matches = [...value.matchAll(chineseDatePattern), ...value.matchAll(isoDatePattern)] .sort((left, right) => (left.index ?? 0) - (right.index ?? 0)); return matches.flatMap((match) => { - const parsed = normalizedDate(match[0], asOfDate); + const parsed = parseDeclaredDateText(match[0], asOfDate); return parsed ? [parsed] : []; }); } @@ -139,7 +139,7 @@ function classifyEvent(summary: string): EventSemantics { if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) { return { domain: "finance", eventKind: "finance_change", subject: "self", relatedPerson: null, scoreability: "scoreable" }; } - if (/工作|入职|离职|辞职|升职|创业|职业|职位|任职|管理职责|公司|项目/.test(summary)) { + if (/工作|实习|研究员|入职|离职|辞职|升职|创业|职业|职位|任职|负责|管理职责|公司|项目/.test(summary)) { return { domain: "career", eventKind: "career_change", subject: "self", relatedPerson: null, scoreability: "scoreable" }; } return { domain: "other", eventKind: "other", subject: "other", relatedPerson: null, scoreability: "unsupported" }; @@ -269,3 +269,64 @@ export function extractLifeEventEvidence( } return coalesceSameEventDetails(input, events); } + + +export type ModelAssistedEventExtraction = Readonly<{ + sourceSpan: string; + summary: string; + domain: RectificationEvidenceDomain; + eventKind: string; + subject: "self" | "family" | "partner" | "other"; + relatedPerson: "father" | "mother" | "grandparent" | "sibling" | "partner" | null; + dateText: string | null; +}>; + +const allowedKindsByDomain: Readonly> = { + education: ["education_milestone"], + relocation: ["relocation"], + relationship: ["relationship_start", "relationship_end", "relationship_change"], + career: ["career_change"], + finance: ["finance_change"], + health_pressure: ["self_health_event"], + family: ["family_health_event", "family_bereavement", "family_event"], + other: ["other"], +}; + +export function validatedModelAssistedEvidence(input: Readonly<{ + rawText: string; + sourceTurnId: string; + asOfDate: string; + extraction: ModelAssistedEventExtraction; +}>): ExtractedLifeEventEvidence | null { + const sourceSpan = input.extraction.sourceSpan.trim(); + const dateText = input.extraction.dateText?.trim() || null; + if (!sourceSpan || !input.rawText.includes(sourceSpan)) return null; + if (!dateText || !input.rawText.includes(dateText)) return null; + const date = parseDeclaredDateText(dateText.normalize("NFKC"), input.asOfDate); + if (!date || dateIsFuture(date, input.asOfDate)) return null; + if (!allowedKindsByDomain[input.extraction.domain]?.includes(input.extraction.eventKind)) return null; + if (input.extraction.subject === "partner" && input.extraction.domain !== "relationship") return null; + const summary = eventSummary(sourceSpan); + if (summary === missingEventSummary) return null; + const familyContext = input.extraction.subject === "family" || input.extraction.domain === "family"; + const scoreability = familyContext + ? "context_only" as const + : input.extraction.subject === "self" || (input.extraction.subject === "partner" && input.extraction.domain === "relationship") + ? "scoreable" as const + : "unsupported" as const; + return { + id: evidenceId({ rawText: input.rawText, sourceTurnId: input.sourceTurnId, asOfDate: input.asOfDate }, 0, summary), + rawText: input.rawText, + domain: input.extraction.domain, + eventKind: input.extraction.eventKind, + subject: input.extraction.subject, + relatedPerson: input.extraction.relatedPerson, + eventSummary: summary, + dateValue: date.value, + datePrecision: date.precision, + extractionStatus: "clear", + scoreability, + scoreable: scoreability === "scoreable", + correctsEvidenceIds: [], + }; +} diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts index 840fe28d..a75629cd 100644 --- a/frontend/src/lib/rectification-agent/contracts.ts +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -5,6 +5,9 @@ const uuid = z.string().uuid(); const hash = z.string().regex(/^[a-f0-9]{64}$/); const nonblank = (max: number) => z.string().trim().min(1).max(max); +export const CURRENT_RECTIFICATION_SKILL_VERSION = "birth-time-rectification-v6" as const; +export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-agent-v6-1" as const; + export const rectificationDiagnosticSchema = z.enum([ "leave_one_event_out", "leave_one_domain_out", @@ -26,21 +29,41 @@ export const rectificationDecisionSchema = z.discriminatedUnion("action", [ ]); export type RectificationDecision = z.infer; -export const questionOpportunitySchema = z.object({ - opportunityId: uuid, - kind: z.enum([ - "clarify_intake", - "clarify_event_subject", - "refine_event_date", - "pair_related_event", - "ask_new_event", - "resolve_event_conflict", - "disambiguate_candidate_split", - ]), - domain: evidenceDomainSchema, - targetEventId: uuid.nullable(), - prompt: nonblank(1_000), - reason: nonblank(240), +export const semanticQuestionKindSchema = z.enum([ + "clarify_intake", + "clarify_event_subject", + "refine_event_date", + "pair_related_event", + "ask_new_event", + "resolve_event_conflict", + "disambiguate_candidate_split", +]); +export type SemanticQuestionKind = z.infer; + +export const requestedQuestionFieldSchema = z.enum([ + "event_year", + "event_month", + "event_day", + "event_range", + "event_subject", + "event_stage", + "new_dated_event", +]); +export type RequestedQuestionField = z.infer; + +export const forbiddenQuestionMoveSchema = z.enum([ + "switch_target_event", + "ask_multiple_questions", + "claim_exact_birth_minute", + "invent_event", + "invent_date", + "expose_private_score", + "expose_internal_id", + "expose_technique_trace", +]); +export type ForbiddenQuestionMove = z.infer; + +const opportunityMetrics = { expectedInformationGain: z.number().finite().min(0).max(1), dateSensitivity: z.number().finite().min(0).max(1), candidateSplitRelevance: z.number().finite().min(0).max(1), @@ -51,8 +74,103 @@ export const questionOpportunitySchema = z.object({ privacyCost: z.number().finite().min(0).max(1), utility: z.number().finite(), active: z.boolean(), +} as const; + +export const semanticQuestionOpportunitySchema = z.object({ + contractVersion: z.literal("semantic-question-v2"), + opportunityId: uuid, + kind: semanticQuestionKindSchema, + domain: evidenceDomainSchema, + targetEventId: uuid.nullable(), + goal: nonblank(500), + requestedFields: z.array(requestedQuestionFieldSchema).min(1).max(4), + anchors: z.array(nonblank(240)).max(8), + contextFacts: z.array(nonblank(500)).max(16), + forbiddenMoves: z.array(forbiddenQuestionMoveSchema).min(1).max(8), + fallbackPrompt: nonblank(1_000), + reason: nonblank(500), + ...opportunityMetrics, }).strict(); -export type QuestionOpportunity = z.infer; +export type SemanticQuestionOpportunity = z.infer; + +const legacyQuestionOpportunitySchema = z.object({ prompt: nonblank(1_000) }).passthrough(); + +function legacyUuid(value: string): string { + let hashValue = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hashValue ^= value.charCodeAt(index); + hashValue = Math.imul(hashValue, 16777619); + } + const block = (hashValue >>> 0).toString(16).padStart(8, "0"); + return `${block}-${block.slice(0, 4)}-4${block.slice(1, 4)}-8${block.slice(1, 4)}-${block}${block.slice(0, 4)}`; +} + +const defaultForbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [ + "switch_target_event", + "ask_multiple_questions", + "claim_exact_birth_minute", + "invent_event", + "invent_date", + "expose_private_score", + "expose_internal_id", + "expose_technique_trace", +]; + +function numberFrom(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function requestedFieldsFor(kind: SemanticQuestionKind): SemanticQuestionOpportunity["requestedFields"] { + if (kind === "clarify_event_subject") return ["event_subject"]; + if (kind === "refine_event_date") return ["event_month"]; + if (kind === "disambiguate_candidate_split") return ["event_stage"]; + if (kind === "ask_new_event" || kind === "pair_related_event") return ["new_dated_event"]; + return ["event_range"]; +} + +export function normalizeQuestionOpportunity(value: unknown): SemanticQuestionOpportunity { + const semantic = semanticQuestionOpportunitySchema.safeParse(value); + if (semantic.success) return semantic.data; + const legacy = legacyQuestionOpportunitySchema.parse(value) as Record & { prompt: string }; + const kind = semanticQuestionKindSchema.safeParse(legacy.kind).success + ? semanticQuestionKindSchema.parse(legacy.kind) + : "clarify_intake"; + const domain = evidenceDomainSchema.safeParse(legacy.domain).success + ? evidenceDomainSchema.parse(legacy.domain) + : "other"; + const targetEventId = uuid.safeParse(legacy.targetEventId).success ? uuid.parse(legacy.targetEventId) : null; + const reason = typeof legacy.reason === "string" && legacy.reason.trim() ? legacy.reason.trim().slice(0, 500) : "历史问题机会兼容读取。"; + return semanticQuestionOpportunitySchema.parse({ + contractVersion: "semantic-question-v2", + opportunityId: uuid.safeParse(legacy.opportunityId).success ? legacy.opportunityId : legacyUuid(legacy.prompt), + kind, + domain, + targetEventId, + goal: reason, + requestedFields: requestedFieldsFor(kind), + anchors: [], + contextFacts: [], + forbiddenMoves: defaultForbiddenMoves, + fallbackPrompt: legacy.prompt, + reason, + expectedInformationGain: numberFrom(legacy.expectedInformationGain, .5), + dateSensitivity: numberFrom(legacy.dateSensitivity, .5), + candidateSplitRelevance: numberFrom(legacy.candidateSplitRelevance, .5), + domainCoverageGain: numberFrom(legacy.domainCoverageGain, 0), + recallEase: numberFrom(legacy.recallEase, .5), + novelty: numberFrom(legacy.novelty, .5), + repetitionPenalty: numberFrom(legacy.repetitionPenalty, 0), + privacyCost: numberFrom(legacy.privacyCost, 0), + utility: numberFrom(legacy.utility, .5), + active: typeof legacy.active === "boolean" ? legacy.active : true, + }); +} + +export const questionOpportunitySchema = z.union([ + semanticQuestionOpportunitySchema, + legacyQuestionOpportunitySchema, +]).transform(normalizeQuestionOpportunity); +export type QuestionOpportunity = z.output; export const eventDateSensitivitySchema = z.object({ eventId: uuid, diff --git a/frontend/src/lib/rectification-agent/event-extractor-agent.ts b/frontend/src/lib/rectification-agent/event-extractor-agent.ts new file mode 100644 index 00000000..bfe02192 --- /dev/null +++ b/frontend/src/lib/rectification-agent/event-extractor-agent.ts @@ -0,0 +1,68 @@ +import { Agent } from "@mastra/core/agent"; +import { z } from "zod"; +import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; +import { + eventKindSchema, + eventSubjectSchema, + evidenceDomainSchema, + relatedPersonSchema, +} from "../rectification-v4/contracts.ts"; +import { + validatedModelAssistedEvidence, + type ExtractedLifeEventEvidence, + type ModelAssistedEventExtraction, +} from "../conversational-rectification/evidence-extractor.ts"; + +export const modelAssistedEventExtractionSchema = z.object({ + sourceSpan: z.string().trim().min(1).max(4_000), + summary: z.string().trim().min(1).max(1_000), + domain: evidenceDomainSchema, + eventKind: eventKindSchema, + subject: eventSubjectSchema, + relatedPerson: relatedPersonSchema.nullable(), + dateText: z.string().trim().min(1).max(80).nullable(), +}).strict(); + +export type EventExtractorGenerator = (prompt: string) => Promise>; + +export async function extractEventWithModel(input: Readonly<{ + rawText: string; + sourceTurnId: string; + asOfDate: string; + modelId?: string | null; + timeoutMs?: number; + generateExtraction?: EventExtractorGenerator; +}>): Promise { + const model = (input.modelId ? resolveLanguageModel(input.modelId) : null) ?? defaultLanguageModel(); + if (!model && !input.generateExtraction) return null; + const agent = model ? new Agent({ + id: `rectification-event-extractor-${model.id}`, + name: "Restricted Rectification Event Extractor", + model: model.model, + instructions: "Extract at most one explicitly stated dated life event. sourceSpan and dateText must be exact continuous substrings of the user text. Never infer or invent a date, normalized range, candidate time, score, id, or profile value. Return strict JSON only.", + }) : null; + const generate = input.generateExtraction ?? (async (prompt: string) => { + if (!agent) throw new Error("event_extractor_model_unavailable"); + return agent.generate(prompt, { + abortSignal: AbortSignal.timeout(input.timeoutMs ?? 10_000), + structuredOutput: { schema: modelAssistedEventExtractionSchema, jsonPromptInjection: "inline" }, + }); + }); + try { + const result = await generate(JSON.stringify({ + task: "Extract one event that deterministic parsing could not classify. Use only literal text from userText.", + userText: input.rawText, + asOfDate: input.asOfDate, + allowedOutput: ["sourceSpan", "summary", "domain", "eventKind", "subject", "relatedPerson", "dateText"], + })); + const extraction = modelAssistedEventExtractionSchema.parse(result.object) as ModelAssistedEventExtraction; + return validatedModelAssistedEvidence({ + rawText: input.rawText, + sourceTurnId: input.sourceTurnId, + asOfDate: input.asOfDate, + extraction, + }); + } catch { + return null; + } +} diff --git a/frontend/src/lib/rectification-agent/opportunity-builder.ts b/frontend/src/lib/rectification-agent/opportunity-builder.ts index 8dd73b4f..365b9a24 100644 --- a/frontend/src/lib/rectification-agent/opportunity-builder.ts +++ b/frontend/src/lib/rectification-agent/opportunity-builder.ts @@ -1,8 +1,27 @@ import { createHash } from "node:crypto"; import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts"; -import type { DiagnosticsSummary, QuestionOpportunity } from "./contracts.ts"; +import type { TargetDisposition } from "../rectification-v4/extraction.ts"; +import type { DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts"; -const domains: readonly EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure"]; +const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [ + "switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event", + "invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace", +]; + +const domainPolicy: Readonly, Readonly<{ + goal: string; + fallbackPrompt: string; + keywords: RegExp; + recallEase: number; + privacyCost: number; +}>>> = { + education: { goal: "收集一件有大致日期的教育转折。", fallbackPrompt: "哪次入学、毕业或专业变化的时间你比较确定?", keywords: /大学|学校|入学|毕业|考试|专业|读书/, recallEase: .82, privacyCost: .03 }, + relocation: { goal: "收集一件有大致日期的迁居经历。", fallbackPrompt: "哪次搬家、离乡或长期迁居的时间你比较确定?", keywords: /搬家|迁居|离家|外地|城市|北京|上海|出国/, recallEase: .78, privacyCost: .04 }, + relationship: { goal: "在用户愿意的前提下收集一件有大致日期的关系转折。", fallbackPrompt: "如果方便,哪段关系开始、结束或进入婚姻的时间比较确定?", keywords: /恋爱|关系|结婚|离婚|分手|伴侣|对象/, recallEase: .62, privacyCost: .22 }, + career: { goal: "收集一件有大致日期的职业转折。", fallbackPrompt: "哪次入职、离职、转行、创业或职责变化的时间你比较确定?", keywords: /工作|实习|公司|研究院|职业|入职|离职|创业|负责/, recallEase: .85, privacyCost: .03 }, + finance: { goal: "在用户愿意的前提下收集一件有大致日期的财务转折。", fallbackPrompt: "如果方便,哪次收入、负债或资产明显变化的时间比较确定?", keywords: /收入|负债|投资|资产|财务|买房|卖房/, recallEase: .6, privacyCost: .18 }, + health_pressure: { goal: "在用户愿意的前提下收集一件本人有大致日期的健康转折。", fallbackPrompt: "如果方便,你本人哪次住院、手术、事故或健康转折的时间比较确定?", keywords: /住院|手术|事故|健康|生病|确诊|康复/, recallEase: .58, privacyCost: .28 }, +}; function stableUuid(value: string): string { const hex = createHash("sha256").update(value).digest("hex").slice(0, 32).split(""); @@ -21,7 +40,9 @@ const routingValue: Record = { ask_new_event: 0, }; -function utility(value: Omit): number { +type OpportunityInput = Omit; + +function utility(value: OpportunityInput): number { return Number(( .35 * value.expectedInformationGain + .20 * value.dateSensitivity + .15 * value.candidateSplitRelevance + .10 * value.domainCoverageGain + .10 * value.recallEase + .10 * value.novelty @@ -29,8 +50,34 @@ function utility(value: Omit): QuestionOpportunity { - const result = { ...input, opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.prompt}`), utility: utility(input), active: true }; +function opportunity(caseId: string, input: OpportunityInput): QuestionOpportunity { + return { + contractVersion: "semantic-question-v2", + ...input, + forbiddenMoves, + opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.goal}:${input.fallbackPrompt}`), + utility: utility(input), + active: true, + }; +} + +function daysWide(event: LifeEventRevision): number { + return Math.floor((Date.parse(`${event.dateRange.end}T00:00:00Z`) - Date.parse(`${event.dateRange.start}T00:00:00Z`)) / 86_400_000) + 1; +} + +function anchorFor(event: LifeEventRevision): string { + return event.summary.replace(/[“”"']/g, "").trim().slice(0, 80); +} + +function recentText(turns: readonly RectificationV4Turn[]): string { + return turns.slice(-6).map((turn) => turn.answer).join(" "); +} + +function declinedSensitiveDomains(turns: readonly RectificationV4Turn[]): ReadonlySet { + const result = new Set(); + for (const turn of turns) { + if (turn.questionDomain && /不想说|不方便说|不想回答|跳过|这个不说|换个方向|不聊这个/.test(turn.answer)) result.add(turn.questionDomain); + } return result; } @@ -40,74 +87,125 @@ export function buildQuestionOpportunities(input: Readonly<{ turns: readonly RectificationV4Turn[]; snapshot: CandidateSnapshot | null; diagnostics: DiagnosticsSummary | null; + targetDisposition?: TargetDisposition; retryTargetEventIds?: readonly string[]; }>): readonly QuestionOpportunity[] { - const attempted = new Set(input.turns.flatMap((turn) => turn.questionTargetEventId ? [turn.questionTargetEventId] : [])); + const targetAttempts = new Map(); + for (const turn of input.turns) { + if (turn.questionTargetEventId) targetAttempts.set(turn.questionTargetEventId, (targetAttempts.get(turn.questionTargetEventId) ?? 0) + 1); + } const retryTargets = new Set(input.retryTargetEventIds ?? []); const scoreableDomains = new Set(input.events.filter((event) => event.scoreability === "scoreable").map((event) => event.domain)); + const refusedDomains = declinedSensitiveDomains(input.turns); + const latestContext = recentText(input.turns); const opportunities: QuestionOpportunity[] = []; - for (const eventId of retryTargets) { - const event = input.events.find((value) => value.eventId === eventId); - if (!event) continue; - opportunities.push(opportunity(input.caseId, { - kind: "resolve_event_conflict", domain: event.domain, targetEventId: event.eventId, - prompt: `你刚才补充的新经历已经另行保存。关于“${event.summary}”的时间仍没有确定;如果记不清,可以直接说不知道。`, - reason: "用户补充了另一件事,原事件的日期或主体仍待确认。", - expectedInformationGain: .85, dateSensitivity: .75, candidateSplitRelevance: .6, domainCoverageGain: 0, recallEase: .8, novelty: .7, repetitionPenalty: .15, privacyCost: .05, - })); - } - if (opportunities.length > 0) { - return opportunities.sort((left, right) => - right.utility - left.utility - || left.opportunityId.localeCompare(right.opportunityId)); + + if (input.targetDisposition === "answered_other_event") { + for (const eventId of retryTargets) { + const event = input.events.find((value) => value.eventId === eventId); + if (!event || (targetAttempts.get(eventId) ?? 0) > 1) continue; + const anchor = anchorFor(event); + opportunities.push(opportunity(input.caseId, { + kind: "resolve_event_conflict", domain: event.domain, targetEventId: event.eventId, + goal: `温和确认“${anchor}”尚缺的日期或主体;允许用户直接跳过。`, + requestedFields: ["event_range"], anchors: [anchor], contextFacts: [`用户刚补充了另一件完整事件。`, `同一目标最多补问一次。`], + fallbackPrompt: `关于“${anchor}”,如果还记得大概时间范围,可以补充一下吗?`, + reason: "用户回答了另一件新事件,原目标只允许一次温和补问。", + expectedInformationGain: .78, dateSensitivity: .7, candidateSplitRelevance: .55, domainCoverageGain: 0, + recallEase: .72, novelty: .55, repetitionPenalty: .25, privacyCost: .05, + })); + } } + + const targetClosed = input.targetDisposition === "unknown" + || input.targetDisposition === "declined" + || input.targetDisposition === "direction_change"; for (const event of input.events) { - if (retryTargets.has(event.eventId)) continue; - if ((event.scoreability === "pending_review" || event.subject === "other") && !attempted.has(event.eventId)) { + if (targetClosed && retryTargets.has(event.eventId)) continue; + const attemptCount = targetAttempts.get(event.eventId) ?? 0; + const anchor = anchorFor(event); + if ((event.scoreability === "pending_review" || event.subject === "other") && attemptCount === 0) { opportunities.push(opportunity(input.caseId, { kind: "clarify_event_subject", domain: event.domain, targetEventId: event.eventId, - prompt: `你刚才提到“${event.summary}”,这件事主要发生在你本人,还是家人或伴侣身上?`, reason: "事件主体决定是否允许进入个人分盘评分。", - expectedInformationGain: .9, dateSensitivity: .2, candidateSplitRelevance: .3, domainCoverageGain: .2, recallEase: .95, novelty: .9, repetitionPenalty: 0, privacyCost: .05, - })); - } - if (event.scoreability === "scoreable" && event.dateRange.precision !== "day" && !attempted.has(event.eventId)) { - const sensitivity = input.diagnostics?.eventDateSensitivity.find((item) => item.eventId === event.eventId); - opportunities.push(opportunity(input.caseId, { - kind: "refine_event_date", domain: event.domain, targetEventId: event.eventId, - prompt: `关于“${event.summary}”,你还记得更具体的月份或日期吗?不确定也可以只说大概范围。`, reason: "日期采样显示这件事的时间精度可能影响候选排序。", - expectedInformationGain: sensitivity ? 1 - sensitivity.winnerRetentionRate : .72, - dateSensitivity: sensitivity ? 1 - sensitivity.candidateClusterRetentionRate : .7, - candidateSplitRelevance: .55, domainCoverageGain: 0, recallEase: .72, novelty: .8, repetitionPenalty: 0, privacyCost: .05, + goal: `确认“${anchor}”发生在本人、家人还是伴侣。`, requestedFields: ["event_subject"], + anchors: [anchor], contextFacts: [`当前主体为 ${event.subject}。`], + fallbackPrompt: `“${anchor}”主要发生在你本人、家人还是伴侣身上?`, + reason: "事件主体决定是否允许进入个人评分。", + expectedInformationGain: .9, dateSensitivity: .2, candidateSplitRelevance: .3, domainCoverageGain: .2, + recallEase: .95, novelty: .9, repetitionPenalty: 0, privacyCost: event.domain === "health_pressure" || event.domain === "family" ? .24 : .05, })); } + if (event.scoreability !== "scoreable" || event.dateRange.precision === "day" || attemptCount > 0) continue; + const sensitivity = input.diagnostics?.eventDateSensitivity.find((item) => item.eventId === event.eventId); + const dateSensitive = Boolean(sensitivity && (sensitivity.winnerRetentionRate < .65 || sensitivity.candidateClusterRetentionRate < .65)); + const precision = event.dateRange.precision; + const shouldRefine = precision === "quarter" || precision === "year" || (precision === "month" && dateSensitive) + || (precision === "range" && daysWide(event) > 120 && dateSensitive); + if (!shouldRefine) continue; + const requestedFields: SemanticQuestionOpportunity["requestedFields"] = precision === "year" || precision === "quarter" + ? ["event_month"] + : precision === "range" ? ["event_range"] : ["event_day"]; + const fallbackPrompt = precision === "year" || precision === "quarter" + ? `“${anchor}”大概发生在哪个月,或一年中的哪个时间段?` + : precision === "range" + ? `“${anchor}”的时间范围还能再缩小一些吗?` + : `关于“${anchor}”,你还记得大概哪一天吗?`; + opportunities.push(opportunity(input.caseId, { + kind: "refine_event_date", domain: event.domain, targetEventId: event.eventId, + goal: `仅在必要精度上细化“${anchor}”的日期。`, requestedFields, anchors: [anchor], + contextFacts: [`现有精度为 ${precision}。`, ...(sensitivity ? [`候选保持率 ${sensitivity.candidateClusterRetentionRate}。`] : [])], + fallbackPrompt, reason: dateSensitive ? "日期敏感性诊断显示该事件可能改变候选排序。" : "当前日期范围较宽。", + expectedInformationGain: sensitivity ? 1 - sensitivity.winnerRetentionRate : .66, + dateSensitivity: sensitivity ? 1 - sensitivity.candidateClusterRetentionRate : .55, + candidateSplitRelevance: .55, domainCoverageGain: 0, recallEase: precision === "year" ? .8 : .62, + novelty: .78, repetitionPenalty: 0, privacyCost: .05, + })); } + const split = input.diagnostics?.candidateSplits[0]; if (split) { - const target = input.events.find((event) => split.eventIds.includes(event.eventId)); + const target = input.events.find((event) => split.eventIds.includes(event.eventId) + && (targetAttempts.get(event.eventId) ?? 0) === 0 + && !(targetClosed && retryTargets.has(event.eventId))); + const anchor = target ? anchorFor(target) : null; opportunities.push(opportunity(input.caseId, { kind: "disambiguate_candidate_split", domain: target?.domain ?? "other", targetEventId: target?.eventId ?? null, - prompt: target ? `围绕“${target.summary}”,当时最明显的转折是事情开始、达到高峰,还是正式结束?` : "剩余候选在同一事件的阶段上有差异:你记得当时更接近开始、达到高峰,还是正式结束吗?", - reason: `候选簇在 ${split.techniqueLayers.slice(0, 3).join("、") || "技术层"} 上出现可检验分歧。`, - expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0, recallEase: .65, novelty: .9, repetitionPenalty: target && attempted.has(target.eventId) ? .35 : 0, privacyCost: .1, + goal: target ? `确认“${anchor}”更接近开始、高峰还是正式结束。` : "确认一件现有事件的发生阶段。", + requestedFields: ["event_stage"], anchors: anchor ? [anchor] : [], + contextFacts: [`候选分歧涉及 ${split.techniqueLayers.length} 个已计算技术层。`], + fallbackPrompt: target ? `“${anchor}”当时更接近事情开始、达到高峰,还是正式结束?` : "那件经历更接近开始、达到高峰,还是正式结束?", + reason: "候选簇在现有诊断中出现可检验分歧。", + expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0, + recallEase: .65, novelty: .9, repetitionPenalty: 0, privacyCost: .1, })); } - const missingDomain = domains.find((domain) => !scoreableDomains.has(domain)); - if (missingDomain) { - const prompts: Record = { - education: "你人生中有没有一次入学、毕业、考试或专业变化,时间大致在什么时候?", - relocation: "你有没有一次印象深刻的搬家、离乡或长期迁居?大致在什么时候?", - relationship: "你有没有一段关系正式开始、结束或进入婚姻的明确时间点?", - career: "你有没有一次入职、离职、升职、转行或创业的明确时间点?", - finance: "你有没有一次收入、投资、负债或资产状况明显改变的时间点?", - health_pressure: "你本人有没有一次住院、手术、事故或明显健康转折?大致在什么时候?", - family: "请补充一个家庭事件。", other: "请补充一个有明确时间的重要人生事件。", - }; + + const scoreableCount = input.events.filter((event) => event.scoreability === "scoreable").length; + for (const [domain, policy] of Object.entries(domainPolicy) as [Exclude, (typeof domainPolicy)[Exclude]][]) { + if (refusedDomains.has(domain)) continue; + const covered = scoreableDomains.has(domain); + const themeBonus = policy.keywords.test(latestContext) ? .12 : 0; + const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId); + const latestEvent = input.events.at(-1); + const latestAnchor = latestEvent ? anchorFor(latestEvent) : null; + const prompt = latestAnchor + ? `承接“${latestAnchor}”,请再说一件时间相对明确的经历:${policy.fallbackPrompt}` + : policy.fallbackPrompt; opportunities.push(opportunity(input.caseId, { - kind: "ask_new_event", domain: missingDomain, targetEventId: null, prompt: prompts[missingDomain], reason: "当前证据领域覆盖不足。", - expectedInformationGain: .7, dateSensitivity: .45, candidateSplitRelevance: .5, domainCoverageGain: 1, recallEase: .7, novelty: 1, repetitionPenalty: 0, privacyCost: missingDomain === "health_pressure" ? .2 : .08, + kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal, + requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [], + contextFacts: [`已有 ${scoreableCount} 件可评分事件。`, `该领域${covered ? "已有覆盖" : "尚未覆盖"}。`], + fallbackPrompt: prompt, reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。", + expectedInformationGain: covered ? .54 + themeBonus : .7 + themeBonus, + dateSensitivity: input.snapshot ? .5 : .35, + candidateSplitRelevance: input.diagnostics?.candidateSplits.length ? .58 : .42, + domainCoverageGain: covered ? 0 : 1, + recallEase: policy.recallEase, novelty: alreadyAsked ? .35 : .9, + repetitionPenalty: alreadyAsked ? .3 : 0, privacyCost: policy.privacyCost, })); } - return opportunities.sort((left, right) => - right.utility - left.utility - || left.opportunityId.localeCompare(right.opportunityId)); + + return opportunities + .sort((left, right) => right.utility - left.utility || left.opportunityId.localeCompare(right.opportunityId)) + .slice(0, 5); } diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index 29e2085c..b5eb9525 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -4,6 +4,7 @@ import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.t import type { CandidateSnapshot, RectificationV4Question } from "../rectification-v4/contracts.ts"; import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts"; import { reconcileV4Evidence } from "../rectification-v4/extraction.ts"; +import { extractEventWithModel } from "./event-extractor-agent.ts"; import { evidenceSetHash } from "../rectification-v4/fingerprints.ts"; import { latestEventRevisions, scoreableEvents } from "../rectification-v4/evidence-ledger.ts"; import { projectLegacyV4Turn } from "../rectification-v4/legacy-projector.ts"; @@ -48,15 +49,38 @@ export async function processRectificationAgentTurn(input: Readonly<{ }>> { const { claimed, now } = input; await input.onPhase?.("extracting_evidence"); - const reconciliation = claimed.turn.answer ? reconcileV4Evidence({ + const asOfDate = now.toISOString().slice(0, 10); + let reconciliation = claimed.turn.answer ? reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, - asOfDate: now.toISOString().slice(0, 10), + asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now, - }) : { revisions: [], pending: [], unansweredTargetEventId: null }; + }) : { revisions: [], pending: [], unansweredTargetEventId: null, targetDisposition: "not_applicable" as const }; + const needsAssistance = claimed.case.deploymentMode !== "v4_legacy" && ( + reconciliation.pending.some((event) => event.reasonCode === "event_unparsed") + || reconciliation.revisions.some((event) => event.scoreability === "pending_review" || event.scoreability === "unsupported") + ); + if (needsAssistance) { + const assisted = await extractEventWithModel({ + rawText: claimed.turn.answer, + sourceTurnId: claimed.turn.id, + asOfDate, + modelId: claimed.case.orchestrationModelId, + }); + if (assisted) reconciliation = reconcileV4Evidence({ + caseId: claimed.case.id, + answer: claimed.turn.answer, + sourceTurnId: claimed.turn.id, + asOfDate, + existing: claimed.events, + targetEventId: claimed.turn.questionTargetEventId, + assistedEvidence: [assisted], + now, + }); + } const extracted = reconciliation.revisions; const events = latestEventRevisions([...claimed.events, ...extracted]); const scoreable = scoreableEvents(events); @@ -174,6 +198,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ turns: claimed.turns, snapshot, diagnostics, + targetDisposition: reconciliation.targetDisposition, retryTargetEventIds: reconciliation.unansweredTargetEventId ? [reconciliation.unansweredTargetEventId] : [], }); await input.onPhase?.("reasoning"); @@ -182,6 +207,15 @@ export async function processRectificationAgentTurn(input: Readonly<{ snapshot, diagnostics: safeDiagnostics, opportunities, + recentTurns: claimed.turns, + recentEvents: events, + currentTarget: claimed.turn.questionTargetEventId + ? events.find((event) => event.eventId === claimed.turn.questionTargetEventId) ?? null + : null, + targetDisposition: reconciliation.targetDisposition, + pendingEvidence: reconciliation.pending, + candidateRangeChanged: claimed.case.latestSnapshot?.clusters[0]?.startTime !== snapshot?.clusters[0]?.startTime + || claimed.case.latestSnapshot?.clusters[0]?.endTime !== snapshot?.clusters[0]?.endTime, enabled: claimed.case.deploymentMode !== "v4_legacy", }); const rawDecision = reasoned.decision; @@ -241,6 +275,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ acceptedEvents: extracted, pendingEvidence: reconciliation.pending, snapshot, + previousSnapshot: claimed.case.latestSnapshot, validated: validatedDecision, }) : legacyProjection.publicMessage; @@ -248,7 +283,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ id: randomUUID(), domain: selectedOpportunity.domain, targetEventId: selectedOpportunity.targetEventId, - prompt: selectedOpportunity.prompt, + prompt: publicMessage.question ?? selectedOpportunity.fallbackPrompt, recallCost: selectedOpportunity.privacyCost >= .2 ? "high" as const : selectedOpportunity.recallEase < .6 diff --git a/frontend/src/lib/rectification-agent/reasoner-agent.ts b/frontend/src/lib/rectification-agent/reasoner-agent.ts index 18eec113..9f044b6f 100644 --- a/frontend/src/lib/rectification-agent/reasoner-agent.ts +++ b/frontend/src/lib/rectification-agent/reasoner-agent.ts @@ -3,7 +3,8 @@ import { Agent } from "@mastra/core/agent"; import { createTool } from "@mastra/core/tools"; import { z } from "zod"; import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; -import type { CandidateSnapshot, RectificationV4Case } from "../rectification-v4/contracts.ts"; +import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts"; +import type { TargetDisposition } from "../rectification-v4/extraction.ts"; import { deterministicDecision } from "./fallback-policy.ts"; import { recordRectificationAgentTelemetry } from "./telemetry.ts"; import { @@ -34,11 +35,53 @@ function diagnosticPayload(diagnostic: RectificationDiagnostic, summary: Diagnos } } +export function buildReasonerState(input: Readonly<{ + snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary; + opportunities: readonly QuestionOpportunity[]; + recentTurns?: readonly RectificationV4Turn[]; + recentEvents?: readonly LifeEventRevision[]; + currentTarget?: LifeEventRevision | null; + targetDisposition?: TargetDisposition; + pendingEvidence?: readonly PendingEvidence[]; + candidateRangeChanged?: boolean; +}>) { + return { + task: "Choose the next bounded rectification action.", + currentSnapshotId: input.snapshot?.id ?? null, + canOfferCandidateRange: input.snapshot?.canAcceptRange ?? false, + hasCandidateRange: Boolean(input.snapshot?.clusters[0]), + candidateRangeChanged: input.candidateRangeChanged ?? false, + latestAnswer: input.recentTurns?.at(-1)?.answer ?? "", + recentTurns: (input.recentTurns ?? []).slice(-6).map((turn) => ({ question: turn.question, answer: turn.answer })), + recentEvents: (input.recentEvents ?? []).slice(-5).map((event) => ({ summary: event.summary, date: event.dateRange.label, domain: event.domain, subject: event.subject })), + currentTarget: input.currentTarget ? { summary: input.currentTarget.summary, date: input.currentTarget.dateRange.label, domain: input.currentTarget.domain } : null, + targetDisposition: input.targetDisposition ?? "not_applicable", + pendingEvidence: { + count: input.pendingEvidence?.length ?? 0, + reasons: [...new Set((input.pendingEvidence ?? []).map((item) => item.reasonCode))], + }, + compactDiagnostics: { + primaryClusterRetentionRate: input.diagnostics.primaryClusterRetentionRate, + mostDiscriminatingLayers: input.diagnostics.mostDiscriminatingLayers, + }, + opportunities: input.opportunities.map(({ opportunityId, kind, targetEventId, goal, requestedFields, anchors, utility, reason }) => ({ + opportunityId, kind, targetEventId, goal, requestedFields, anchors, utility, reason, + })), + }; +} + export async function runBoundedReasoner(input: Readonly<{ caseValue: RectificationV4Case; snapshot: CandidateSnapshot | null; diagnostics: DiagnosticsSummary; opportunities: readonly QuestionOpportunity[]; + recentTurns?: readonly RectificationV4Turn[]; + recentEvents?: readonly LifeEventRevision[]; + currentTarget?: LifeEventRevision | null; + targetDisposition?: TargetDisposition; + pendingEvidence?: readonly PendingEvidence[]; + candidateRangeChanged?: boolean; maxToolCalls?: number; timeoutMs?: number; enabled?: boolean; @@ -131,16 +174,7 @@ export async function runBoundedReasoner(input: Readonly<{ outputTokenCount += Math.max(0, Math.trunc(usage.outputTokens ?? 0)); usageObserved = true; }; - const baseState = { - task: "Choose the next bounded rectification action.", - currentSnapshotId: input.snapshot?.id ?? null, - canOfferCandidateRange: input.snapshot?.canAcceptRange ?? false, - compactDiagnostics: { - primaryClusterRetentionRate: input.diagnostics.primaryClusterRetentionRate, - mostDiscriminatingLayers: input.diagnostics.mostDiscriminatingLayers, - }, - opportunities: input.opportunities.map(({ opportunityId, kind, targetEventId, utility, reason }) => ({ opportunityId, kind, targetEventId, utility, reason })), - }; + const baseState = buildReasonerState(input); recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "started", modelId, toolName: null, decisionAction: null, durationMs: null, errorCode: null, deploymentSha }); try { diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts index 553c779c..2c216bc3 100644 --- a/frontend/src/lib/rectification-agent/renderer-agent.ts +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -2,46 +2,142 @@ import path from "node:path"; import { Agent } from "@mastra/core/agent"; import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts"; -import { publicMessageSchema, type PublicMessage, type ValidatedDecision } from "./contracts.ts"; +import { publicMessageSchema, type PublicMessage, type QuestionOpportunity, type ValidatedDecision } from "./contracts.ts"; import { recordRectificationAgentTelemetry } from "./telemetry.ts"; const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification"); const agents = new Map(); +const bannedAcknowledgement = /(?:这个信息很有用|它不是单纯的|而是把|接下来最有价值的是|这样可以避免|已记录[::]?|我记下了)/; +const overinterpretedAcknowledgement = /(?:职业方向正式落地|人生意义|意味着你|说明你(?:已经|开始|正式)|标志着你)/; +const internalTerms = /(?:opportunityId|snapshotId|eventId|targetEventId|requestedFields|fallbackPrompt|tool\s*call|tool_call|score|评分|模型名|opportunity|snapshot|D\d{1,2}|KP\b|Vimshottari)/i; +const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便|再告诉我)/; +const birthMinute = /(?:出生|生时|几点).{0,12}(?:[01]\d|2[0-3]):[0-5]\d|(?:[01]\d|2[0-3]):[0-5]\d.{0,12}(?:出生|生时)/; + function agentFor(modelId: string | null): { id: string; agent: Agent } | null { const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel(); if (!selected) return null; const cached = agents.get(selected.id); if (cached) return { id: selected.id, agent: cached }; const agent = new Agent({ - id: `rectification-v5-renderer-${selected.id}`, name: "Birth Time Rectification Response Renderer", model: selected.model, skills: [skillPath], - instructions: "Write concise natural Simplified Chinese. Acknowledge the latest experience, state uncertainty honestly, and never expose ids, scores, internal domains, representative minutes, model/tool details, or claim an exact birth minute. Return strict JSON only.", + id: `rectification-v6-renderer-${selected.id}`, + name: "Birth Time Rectification Response Renderer", + model: selected.model, + skills: [skillPath], + instructions: "Write concise natural Simplified Chinese. Realize exactly one question from the supplied semantic opportunity. Do not invent events or dates, switch targets, interpret the life meaning of an experience, expose ids/scores/techniques, mention a representative minute, or claim an exact birth minute. Avoid canned acknowledgement. Return strict JSON only.", }); agents.set(selected.id, agent); return { id: selected.id, agent }; } -function deterministic(input: { latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision }): PublicMessage { - const latest = input.acceptedEvents.at(-1); - const acknowledgement = latest - ? `我记下了你提到的“${latest.summary}”,并保留了你给出的时间精度。` - : input.pendingEvidence.length - ? "我保留了你刚才的原始描述;其中的日期或事件关系还不能安全进入评分。" - : input.latestAnswer - ? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。" - : "我会继续根据已确认的人生事件比较候选范围。"; - const primary = input.snapshot?.clusters[0]; - const candidateUpdate = primary ? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。` : null; - const limitation = input.validated.decision.action === "stop_low_confidence" ? "现有证据不足以安全缩小范围,我不会把不稳定结果包装成确定时间。" : null; - return { acknowledgement, candidateUpdate, limitation, question: input.validated.selectedOpportunity?.prompt ?? null }; +function normalized(value: string): string { + return value.normalize("NFKC").replace(/[“”"'\s,,。.!!??::;;]/g, ""); } -export function enforceServerQuestion(value: unknown, question: string | null): PublicMessage { - return { ...publicMessageSchema.parse(value), question }; +export function validateQuestionRealization(question: unknown, opportunity: QuestionOpportunity): Readonly<{ valid: boolean; issues: readonly string[] }> { + if (typeof question !== "string") return { valid: false, issues: ["question_missing"] }; + const value = question.trim(); + const issues: string[] = []; + if (value.length < 8 || value.length > 180) issues.push("question_length_invalid"); + if ((value.match(/[??]/g) ?? []).length > 1) issues.push("multiple_question_marks"); + if ((value.match(/[。.!!??]/g) ?? []).length > 2) issues.push("too_many_sentences"); + if (/\n\s*(?:[-*•]|\d+[.)、])/.test(value)) issues.push("question_list_forbidden"); + if (internalTerms.test(value)) issues.push("internal_information_exposed"); + if (multiQuestionMoves.test(value)) issues.push("multiple_question_instruction"); + if (birthMinute.test(value)) issues.push("birth_minute_injected"); + if (opportunity.targetEventId) { + const questionText = normalized(value); + if (!opportunity.anchors.some((anchor) => questionText.includes(normalized(anchor)))) issues.push("target_anchor_missing"); + } + for (const field of opportunity.requestedFields) { + if (field === "event_subject" && !/(?:本人|你自己|家人|伴侣|配偶)/.test(value)) issues.push("event_subject_not_requested"); + if (field === "event_month" && !/(?:月份|哪个月|几月|大概月份|时间段)/.test(value)) issues.push("event_month_not_requested"); + if (field === "event_day" && !/(?:哪一天|几号|具体日期|大概日期)/.test(value)) issues.push("event_day_not_requested"); + if (field === "event_range" && !/(?:大概时间|时间范围|什么时候|哪个时间|哪一段时间)/.test(value)) issues.push("event_range_not_requested"); + if (field === "event_stage" && !/(?:开始|高峰|结束|正式发生)/.test(value)) issues.push("event_stage_not_requested"); + if (field === "new_dated_event" && !/(?:哪次|哪件|一件|经历)/.test(value)) issues.push("new_event_not_requested"); + if (field === "new_dated_event" && !/(?:时间|日期|什么时候|哪年|哪月|几月)/.test(value)) issues.push("new_event_date_not_requested"); + if (field === "event_year" && !/(?:哪年|年份|哪一年)/.test(value)) issues.push("event_year_not_requested"); + } + return { valid: issues.length === 0, issues }; +} + +function primaryRange(snapshot: CandidateSnapshot | null): string | null { + const primary = snapshot?.clusters[0]; + return primary ? `${primary.startTime}–${primary.endTime}` : null; +} + +export function candidateUpdateFor(input: Readonly<{ + snapshot: CandidateSnapshot | null; + previousSnapshot: CandidateSnapshot | null; + decisionAction: ValidatedDecision["decision"]["action"]; +}>): string | null { + if (!input.snapshot?.canAcceptRange) return null; + const current = primaryRange(input.snapshot); + if (!current) return null; + const previous = primaryRange(input.previousSnapshot); + const firstStable = !input.previousSnapshot?.canAcceptRange; + const changed = previous !== current; + if (!firstStable && !changed) return null; + return `目前通过稳定性门的候选范围是 ${current};它仍是待验证范围,不代表其中某一分钟已被确认。`; +} + +function naturalAcknowledgement(input: { latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; pendingEvidence: readonly PendingEvidence[] }): string { + const latest = input.acceptedEvents.at(-1); + if (latest) return `你提到的是 ${latest.dateRange.label} 的“${latest.summary}”。`; + if (input.pendingEvidence.length) return "这段经历的事件或日期目前还不足以安全进入评分。"; + if (input.latestAnswer) return "我会保留你刚才的原始说法,不补写你没有确认的信息。"; + return "我们继续用时间相对明确的经历比较候选范围。"; +} + +function deterministic(input: { + latestAnswer: string; + acceptedEvents: readonly LifeEventRevision[]; + pendingEvidence: readonly PendingEvidence[]; + snapshot: CandidateSnapshot | null; + previousSnapshot: CandidateSnapshot | null; + validated: ValidatedDecision; +}): PublicMessage { + return { + acknowledgement: naturalAcknowledgement(input), + candidateUpdate: candidateUpdateFor({ snapshot: input.snapshot, previousSnapshot: input.previousSnapshot, decisionAction: input.validated.decision.action }), + limitation: input.validated.decision.action === "stop_low_confidence" + ? "现有证据不足以安全缩小范围,我会在这里停下,不把不稳定结果包装成确定时间。" + : null, + question: input.validated.selectedOpportunity?.fallbackPrompt ?? null, + }; +} + +export function realizePublicMessage(value: unknown, input: Parameters[0]): PublicMessage { + const parsed = publicMessageSchema.parse(value); + const opportunity = input.validated.selectedOpportunity; + const fallback = deterministic(input); + const acknowledgement = bannedAcknowledgement.test(parsed.acknowledgement) + || overinterpretedAcknowledgement.test(parsed.acknowledgement) + || internalTerms.test(parsed.acknowledgement) + || (parsed.acknowledgement.match(/[。.!!??]/g) ?? []).length > 2 + || (input.acceptedEvents.at(-1) && !normalized(parsed.acknowledgement).includes(normalized(input.acceptedEvents.at(-1)!.summary))) + ? fallback.acknowledgement + : parsed.acknowledgement; + const question = opportunity + ? validateQuestionRealization(parsed.question, opportunity).valid ? parsed.question : opportunity.fallbackPrompt + : null; + return { + acknowledgement, + candidateUpdate: fallback.candidateUpdate, + limitation: fallback.limitation ?? (parsed.limitation && !internalTerms.test(parsed.limitation) ? parsed.limitation : null), + question, + }; } export async function renderPublicTurn(input: Readonly<{ - caseValue: RectificationV4Case; latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; - pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision; timeoutMs?: number; + caseValue: RectificationV4Case; + latestAnswer: string; + acceptedEvents: readonly LifeEventRevision[]; + pendingEvidence: readonly PendingEvidence[]; + snapshot: CandidateSnapshot | null; + previousSnapshot: CandidateSnapshot | null; + validated: ValidatedDecision; + timeoutMs?: number; }>): Promise { const started = Date.now(); const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null; @@ -53,14 +149,23 @@ export async function renderPublicTurn(input: Readonly<{ } recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "started", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: null, errorCode: null, deploymentSha }); try { + const opportunity = input.validated.selectedOpportunity; const result = await selected.agent.generate(JSON.stringify({ - task: "Render the public turn. The server-owned question must not be changed.", latestAnswer: input.latestAnswer, + task: "Render one public turn and naturally realize the semantic question contract.", + latestAnswer: input.latestAnswer, acceptedEvents: input.acceptedEvents.slice(-3).map((event) => ({ summary: event.summary, date: event.dateRange.label, subject: event.subject })), - pendingEvidence: input.pendingEvidence.slice(-3).map((event) => ({ rawText: event.rawText, reasonCode: event.reasonCode })), - candidateRange: input.snapshot?.clusters[0] ? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime } : null, - action: input.validated.decision.action, exactQuestion: input.validated.selectedOpportunity?.prompt ?? null, + pendingEvidence: input.pendingEvidence.slice(-3).map((event) => ({ reasonCode: event.reasonCode })), + action: input.validated.decision.action, + selectedOpportunity: opportunity ? { + kind: opportunity.kind, + goal: opportunity.goal, + requestedFields: opportunity.requestedFields, + anchors: opportunity.anchors, + contextFacts: opportunity.contextFacts, + forbiddenMoves: opportunity.forbiddenMoves, + } : null, }), { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), structuredOutput: { schema: publicMessageSchema, jsonPromptInjection: "inline" } }); - const message = enforceServerQuestion(result.object, input.validated.selectedOpportunity?.prompt ?? null); + const message = realizePublicMessage(result.object, input); recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: null, deploymentSha }); return message; } catch { diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index 886c63c9..8766a152 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -7,6 +7,7 @@ import type { } from "./contracts.ts"; import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts"; import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts"; +import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts"; import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; import { openingQuestion } from "./opening-question.ts"; import type { RectificationV4Store } from "./store.ts"; @@ -45,8 +46,8 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op latestSnapshot: null, orchestrationModelId: process.env.RECTIFICATION_ORCHESTRATION_MODEL_ID?.trim() || null, narrationModelId: process.env.RECTIFICATION_NARRATION_MODEL_ID?.trim() || null, - skillVersion: "birth-time-rectification-v5", - promptVersion: "rectification-agent-v5-1", + skillVersion: CURRENT_RECTIFICATION_SKILL_VERSION, + promptVersion: CURRENT_RECTIFICATION_PROMPT_VERSION, algorithmVersion: rectificationV4AlgorithmVersion, deploymentMode, agentMode: "deterministic_fallback", diff --git a/frontend/src/lib/rectification-v4/extraction.ts b/frontend/src/lib/rectification-v4/extraction.ts index e6ecb0c2..c1231b72 100644 --- a/frontend/src/lib/rectification-v4/extraction.ts +++ b/frontend/src/lib/rectification-v4/extraction.ts @@ -17,6 +17,25 @@ const allowedKinds = new Set([ "career_change", "finance_change", "self_health_event", "family_health_event", "family_bereavement", "family_event", "other", ]); const missingEventSummary = "事件内容待补充"; +const directionChangePattern = /(?:换一个|换个问题|问别的|换个方向|都不符合|不是这个|不聊这个)/; +const declinedPattern = /(?:不想说|不方便说|不想回答|跳过|这个不说)/; +const unknownPattern = /(?:不知道|不清楚|记不清|不确定|没印象|忘了|想不起来)/; + +export type TargetDisposition = + | "resolved" + | "unknown" + | "declined" + | "direction_change" + | "answered_other_event" + | "unresolved" + | "not_applicable"; + +function explicitDisposition(answer: string): TargetDisposition | null { + if (directionChangePattern.test(answer)) return "direction_change"; + if (declinedPattern.test(answer)) return "declined"; + if (unknownPattern.test(answer)) return "unknown"; + return null; +} function normalizeKind(domain: EvidenceDomain, value: string, summary: string): EventKind { if (allowedKinds.has(value as EventKind)) return value as EventKind; @@ -100,6 +119,7 @@ export type ReconciledV4Evidence = Readonly<{ revisions: readonly LifeEventRevision[]; pending: readonly PendingEvidence[]; unansweredTargetEventId: string | null; + targetDisposition: TargetDisposition; }>; export function reconcileV4Evidence(input: { @@ -109,13 +129,16 @@ export function reconcileV4Evidence(input: { readonly asOfDate: string; readonly existing: readonly LifeEventRevision[]; readonly targetEventId?: string | null; + readonly assistedEvidence?: readonly ExtractedLifeEventEvidence[]; readonly now?: Date; }): ReconciledV4Evidence { - const extracted = extractLifeEventEvidence({ rawText: input.answer, sourceTurnId: input.sourceTurnId, asOfDate: input.asOfDate }); + const deterministic = extractLifeEventEvidence({ rawText: input.answer, sourceTurnId: input.sourceTurnId, asOfDate: input.asOfDate }); + const extracted = [...(input.assistedEvidence ?? []), ...deterministic]; const target = input.targetEventId ? latestEventRevisions(input.existing).find((event) => event.eventId === input.targetEventId) ?? null : null; if (input.targetEventId && !target) throw new Error("rectification_v4_target_event_not_found"); const revisions: LifeEventRevision[] = []; + const consumed = new Set(); let unresolvedReason: PendingEvidence["reasonCode"] | null = null; let targetResolved = !target; @@ -140,6 +163,7 @@ export function reconcileV4Evidence(input: { dateRange, scoreability: target.scoreability, }, { id: targetAnswer.id, now: input.now })); + consumed.add(targetAnswer.id); targetResolved = true; } } @@ -147,17 +171,27 @@ export function reconcileV4Evidence(input: { } for (const event of extracted) { - if (revisions.some((revision) => revision.id === event.id)) continue; + if (consumed.has(event.id) || revisions.some((revision) => revision.id === event.id)) continue; const revision = newRevision(event, [...input.existing, ...revisions], input.now); if (revision && revision.dateRange.start <= input.asOfDate) { - revisions.push(revision); + if (!revisions.some((value) => value.eventId === revision.eventId)) revisions.push(revision); continue; } unresolvedReason = event.datePrecision === "unknown" ? "date_unresolved" : "event_unparsed"; } - if (extracted.length === 0) unresolvedReason = "event_unparsed"; - const pending = unresolvedReason ? [ + const explicit = explicitDisposition(input.answer); + const addedOtherEvent = target + ? revisions.some((revision) => revision.eventId !== target.eventId) + : false; + const targetDisposition: TargetDisposition = explicit ?? (!target + ? "not_applicable" + : targetResolved ? "resolved" : addedOtherEvent ? "answered_other_event" : "unresolved"); + const suppressPending = targetDisposition === "unknown" + || targetDisposition === "declined" + || targetDisposition === "direction_change"; + if (extracted.length === 0 && !suppressPending) unresolvedReason = "event_unparsed"; + const pending = unresolvedReason && !suppressPending ? [ pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, @@ -171,7 +205,8 @@ export function reconcileV4Evidence(input: { return { revisions, pending, - unansweredTargetEventId: target && !targetResolved ? target.eventId : null, + unansweredTargetEventId: target && (targetDisposition === "unresolved" || targetDisposition === "answered_other_event") ? target.eventId : null, + targetDisposition, }; } diff --git a/frontend/supabase/migrations/20260729010000_rectification_agent_v6_versions.sql b/frontend/supabase/migrations/20260729010000_rectification_agent_v6_versions.sql new file mode 100644 index 00000000..beb9fc6c --- /dev/null +++ b/frontend/supabase/migrations/20260729010000_rectification_agent_v6_versions.sql @@ -0,0 +1,14 @@ +-- New cases use the semantic-question V6 prompt contract by default. +alter table public.birth_time_rectification_v4_cases + alter column skill_version set default 'birth-time-rectification-v6', + alter column prompt_version set default 'rectification-agent-v6-1'; + +-- Advance only unfinished rectification cases to the semantic-question V6 prompt contract. +-- Historical completed, abandoned, range-ready, and audit artifacts remain immutable. +update public.birth_time_rectification_v4_cases +set skill_version = 'birth-time-rectification-v6', + prompt_version = 'rectification-agent-v6-1', + updated_at = greatest(updated_at, now()) +where status in ('awaiting_answer', 'processing', 'paused') + and (skill_version is distinct from 'birth-time-rectification-v6' + or prompt_version is distinct from 'rectification-agent-v6-1'); diff --git a/frontend/tests/birth-time-journey-engine.test.ts b/frontend/tests/birth-time-journey-engine.test.ts index e46e819d..97dd966e 100644 --- a/frontend/tests/birth-time-journey-engine.test.ts +++ b/frontend/tests/birth-time-journey-engine.test.ts @@ -19,7 +19,6 @@ test("journey engine serializes only stored event-scoring inputs", () => { lat: 31.2304, lon: 121.4737, tz: 8, - high_rigor: true, events: [ { id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" }, { id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "education", date: "2011", precision: "year" }, diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index 1cc3814d..047d5f2f 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -774,10 +774,10 @@ test("uses Agent semantic classification when a single event falls through the d caseId: startActionId, actionId: answerActionId, turnVersion: 0, - answer: "2020年4月去石油化工研究院实习做研究员", + answer: "2020年4月开始带团队做商业巡演", }); - assert.deepEqual(classifiedTexts, ["2020年4月去石油化工研究院实习做研究员"]); + assert.deepEqual(classifiedTexts, ["2020年4月开始带团队做商业巡演"]); const saved = value.cases.get(startActionId)?.row.eventEvidence.at(-1); assert.equal(saved?.dateValue, "2020-04"); assert.equal(saved?.domain, "career"); diff --git a/frontend/tests/identity-auth-integration.test.ts b/frontend/tests/identity-auth-integration.test.ts index 50af8fbd..407719be 100644 --- a/frontend/tests/identity-auth-integration.test.ts +++ b/frontend/tests/identity-auth-integration.test.ts @@ -346,7 +346,7 @@ test("Better Auth supports user OTP/password flows and password-only admin login ); assert.equal(adminPasswordRoute.status, 401); } finally { - const globalServices = identityGlobal.jyotishaIdentityAuth; + const globalServices = (globalThis as typeof identityGlobal).jyotishaIdentityAuth; if (globalServices) { await globalServices.pool.end(); delete identityGlobal.jyotishaIdentityAuth; diff --git a/frontend/tests/onboarding-route.test.ts b/frontend/tests/onboarding-route.test.ts index 6e316d9d..aa49a79d 100644 --- a/frontend/tests/onboarding-route.test.ts +++ b/frontend/tests/onboarding-route.test.ts @@ -228,7 +228,11 @@ test("profile B replaces profile A active pending claim instead of waiting on A" // Given: A has a fresh pending claim, then B changes the active birth time. const profile = completeProfileRow(); const identityA = createOnboardingCacheIdentity({ - name: profile.name, birthDate: profile.birth_date, birthTime: profile.birth_time, + name: profile.name, + birthDate: profile.birth_date instanceof Date + ? profile.birth_date.toISOString().slice(0, 10) + : profile.birth_date, + birthTime: profile.birth_time, activeBirthTime: profile.active_birth_time, birthTimeStatus: profile.birth_time_status, countryCode: profile.country_code, provinceCode: profile.province_code, cityCode: profile.city_code, }); diff --git a/frontend/tests/rectification-agent-contracts.test.ts b/frontend/tests/rectification-agent-contracts.test.ts index c8379eb7..33d9bc2c 100644 --- a/frontend/tests/rectification-agent-contracts.test.ts +++ b/frontend/tests/rectification-agent-contracts.test.ts @@ -30,11 +30,17 @@ const diagnostics: DiagnosticsSummary = { createdAt: "2026-07-28T00:00:00.000Z", }; const opportunity: QuestionOpportunity = { + contractVersion: "semantic-question-v2", opportunityId, kind: "ask_new_event", domain: "career", targetEventId: null, - prompt: "请补充一个有明确年月的重要事件。", + goal: "收集一件有大致日期的职业转折。", + requestedFields: ["new_dated_event"], + anchors: [], + contextFacts: ["职业领域尚未覆盖。"], + forbiddenMoves: ["switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event", "invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace"], + fallbackPrompt: "请说一件时间比较明确的职业变化经历。", reason: "当前证据领域覆盖不足。", expectedInformationGain: 0.8, dateSensitivity: 0.5, diff --git a/frontend/tests/rectification-agent-v5.test.ts b/frontend/tests/rectification-agent-v5.test.ts index 96b72771..fe91dd29 100644 --- a/frontend/tests/rectification-agent-v5.test.ts +++ b/frontend/tests/rectification-agent-v5.test.ts @@ -11,7 +11,7 @@ import { import { rectificationCanaryBucket, selectRectificationDeploymentMode } from "../src/lib/rectification-agent/feature-policy.ts"; import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts"; import { runBoundedReasoner } from "../src/lib/rectification-agent/reasoner-agent.ts"; -import { enforceServerQuestion } from "../src/lib/rectification-agent/renderer-agent.ts"; +import { realizePublicMessage, validateQuestionRealization } from "../src/lib/rectification-agent/renderer-agent.ts"; import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts"; import type { CalculationSpec, @@ -82,11 +82,17 @@ const diagnostics: DiagnosticsSummary = diagnosticsSummarySchema.parse({ createdAt: now, }); const opportunity: QuestionOpportunity = { + contractVersion: "semantic-question-v2", opportunityId, kind: "ask_new_event", domain: "career", targetEventId: null, - prompt: "请补充一次职业变化。", + goal: "收集一件有大致日期的职业变化。", + requestedFields: ["new_dated_event"], + anchors: [], + contextFacts: ["职业领域尚未覆盖。"], + forbiddenMoves: ["switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event", "invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace"], + fallbackPrompt: "请说一件时间比较明确的职业变化经历。", reason: "领域覆盖不足。", expectedInformationGain: .8, dateSensitivity: .5, @@ -171,7 +177,7 @@ test("opportunities are ordered only by their published utility", () => { assert.deepEqual(values.map((value) => value.utility), [...values].map((value) => value.utility).sort((a, b) => b - a)); }); -test("an unresolved current target exclusively owns the next-question route", () => { +test("a previously asked unresolved target does not monopolize the next-question route", () => { const target = event(); const values = buildQuestionOpportunities({ caseId, @@ -179,9 +185,11 @@ test("an unresolved current target exclusively owns the next-question route", () turns: [], snapshot: null, diagnostics: null, + targetDisposition: "unresolved", retryTargetEventIds: [target.eventId], }); - assert.deepEqual(values.map((value) => [value.kind, value.targetEventId]), [["resolve_event_conflict", target.eventId]]); + assert.ok(values.length > 0); + assert.ok(values.every((value) => value.targetEventId !== target.eventId)); }); test("reasoner falls back when unavailable", async () => { @@ -238,18 +246,39 @@ test("reasoner rejects a second diagnostic and enforces the tool budget", async assert.equal(exhausted.toolCalls[0]?.outcome, "rejected"); }); -test("renderer cannot replace the server-owned question", () => { - assert.deepEqual(enforceServerQuestion({ - acknowledgement: "已记录。", +test("renderer rejects an unrelated question and falls back to the semantic contract", () => { + const target = event({ summary: "2020年4月研究院实习" }); + const targeted: QuestionOpportunity = { + ...opportunity, + kind: "refine_event_date", + domain: "career", + targetEventId: target.eventId, + goal: "细化研究院实习日期。", + requestedFields: ["event_day"], + anchors: [target.summary], + contextFacts: ["日期敏感。"], + fallbackPrompt: "关于“2020年4月研究院实习”,你还记得大概哪一天吗?", + }; + assert.equal(validateQuestionRealization("你后来有没有搬家?", targeted).valid, false); + const message = realizePublicMessage({ + acknowledgement: "你提到的是2020年4月研究院实习。", candidateUpdate: null, limitation: null, - question: "模型注入的问题", - }, "服务器选定的问题"), { - acknowledgement: "已记录。", - candidateUpdate: null, - limitation: null, - question: "服务器选定的问题", + question: "你后来有没有搬家?", + }, { + latestAnswer: "2020年4月研究院实习", + acceptedEvents: [target], + pendingEvidence: [], + snapshot: null, + previousSnapshot: null, + validated: { + decision: { action: "ask_question", opportunityId: targeted.opportunityId, narrativeFocus: [] }, + mode: "agent", + validationIssues: [], + selectedOpportunity: targeted, + }, }); + assert.equal(message.question, targeted.fallbackPrompt); }); test("agent-run persistence contract carries deployment, tool, token, and latency facts", () => { @@ -289,13 +318,15 @@ test("an answer about another event never overwrites the current target and crea turns: [], snapshot: null, diagnostics: null, + targetDisposition: reconciled.targetDisposition, retryTargetEventIds: [target.eventId], }); - assert.equal(opportunities[0]?.kind, "resolve_event_conflict"); - assert.equal(opportunities[0]?.targetEventId, target.eventId); + const conflict = opportunities.filter((item) => item.kind === "resolve_event_conflict"); + assert.equal(conflict.length, 1); + assert.equal(conflict[0]?.targetEventId, target.eventId); }); -test("unparsed answers are retained as pending evidence", () => { +test("unknown target answers are kept in the turn without pending evidence", () => { const target = event(); const turnId = randomUUID(); const reconciled = reconcileV4Evidence({ @@ -308,9 +339,8 @@ test("unparsed answers are retained as pending evidence", () => { now: new Date(now), }); assert.equal(reconciled.revisions.length, 0); - assert.equal(reconciled.pending.length, 1); - assert.equal(reconciled.pending[0]?.turnId, turnId); - assert.equal(reconciled.pending[0]?.targetEventId, target.eventId); + assert.equal(reconciled.targetDisposition, "unknown"); + assert.equal(reconciled.pending.length, 0); }); test("shadow mode persists V5 artifacts while preserving the legacy visible reply", async () => { @@ -344,3 +374,69 @@ test("shadow mode persists V5 artifacts while preserving the legacy visible repl assert.equal(shadow.question?.prompt, legacy.question?.prompt); assert.equal(shadow.agentRuns, 1); }); + +test("V6 agent conversation follows dated events, respects direction change, and runs the existing V5 engine", async () => { + await withV5Mode("v5_agent", async () => { + const store = createRectificationV4MemoryStore(); + const service = createRectificationV4CaseService(store, { now: () => new Date("2026-07-29T00:00:00.000Z") }); + let scoreCalls = 0; + const worker = createRectificationV4Worker({ + store, + now: () => new Date("2026-07-29T00:00:00.000Z"), + engine: { score: async ({ calculationSpec, events }) => { + scoreCalls += 1; + return v5EngineResult(calculationSpec, events); + } }, + }); + const userId = randomUUID(); + const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + + async function answer(value: string) { + const current = await service.loadCase(userId, created.case.id); + assert.ok(current?.case.currentQuestion); + const queued = await service.answer({ + userId, + caseId: created.case.id, + actionId: randomUUID(), + expectedCaseVersion: current.case.version, + answer: value, + }); + assert.ok(queued?.job); + assert.equal(await worker.runOnce(), true); + const loaded = await service.loadCase(userId, created.case.id); + assert.ok(loaded); + return loaded; + } + + const first = await answer("2020 年 4 月去石油化工研究院实习做研究员。"); + const firstEvent = first.events.find((event) => event.summary.includes("石油化工研究院实习做研究员")); + assert.deepEqual( + firstEvent && [firstEvent.domain, firstEvent.subject, firstEvent.dateRange.precision, firstEvent.scoreability], + ["career", "self", "month", "scoreable"], + ); + assert.doesNotMatch(first.case.currentQuestion?.prompt ?? "", /哪一天|几号|具体日期/); + const firstMessage = [...store.publicMessages.values()].at(-1); + assert.doesNotMatch(firstMessage?.acknowledgement ?? "", /已记录|我记下了|职业方向正式落地/); + assert.match(firstMessage?.acknowledgement ?? "", /研究院实习/); + + const second = await answer("2016 年 9 月离家去外地上大学。"); + assert.ok(second.events.some((event) => event.domain === "education" && event.dateRange.precision === "month")); + assert.doesNotMatch(second.case.currentQuestion?.prompt ?? "", /^请说一次搬家/); + assert.ok(!second.case.currentQuestion?.targetEventId || /离家去外地上大学/.test(second.case.currentQuestion.prompt)); + + const pendingBefore = store.pendingEvidence.size; + const third = await answer("后来有一次搬家,但我记不清时间了,换一个吧。"); + assert.equal(store.pendingEvidence.size, pendingBefore); + assert.equal(third.events.filter((event) => event.domain === "relocation").length, 0); + assert.doesNotMatch(third.case.currentQuestion?.prompt ?? "", /搬家.*(?:时间|日期|月份)|(?:时间|日期|月份).*搬家/); + + const fourth = await answer("2023 年 9 月开始负责一家商业巡演经纪公司。"); + assert.ok(fourth.events.some((event) => event.domain === "career" && event.summary.includes("商业巡演经纪公司"))); + assert.equal(scoreCalls, 1); + assert.ok(store.diagnostics.size > 0); + assert.equal(fourth.case.latestSnapshot?.canConfirmExactMinute, false); + assert.equal(fourth.case.algorithmVersion, "rectification-v5-matrix-scoring-1"); + const finalMessage = [...store.publicMessages.values()].at(-1); + assert.doesNotMatch(`${finalMessage?.candidateUpdate ?? ""}${finalMessage?.limitation ?? ""}`, /唯一分钟|准确分钟|代表分钟|05:13/); + }); +}); diff --git a/frontend/tests/rectification-agent-v6.test.ts b/frontend/tests/rectification-agent-v6.test.ts new file mode 100644 index 00000000..5c23b277 --- /dev/null +++ b/frontend/tests/rectification-agent-v6.test.ts @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + diagnosticsSummarySchema, + normalizeQuestionOpportunity, + type DiagnosticsSummary, + type QuestionOpportunity, + type ValidatedDecision, +} from "../src/lib/rectification-agent/contracts.ts"; +import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts"; +import { buildReasonerState } from "../src/lib/rectification-agent/reasoner-agent.ts"; +import { candidateUpdateFor, realizePublicMessage, validateQuestionRealization } from "../src/lib/rectification-agent/renderer-agent.ts"; +import { extractLifeEventEvidence, validatedModelAssistedEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts"; +import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Turn } from "../src/lib/rectification-v4/contracts.ts"; +import { reconcileV4Evidence } from "../src/lib/rectification-v4/extraction.ts"; + +const now = "2026-07-29T00:00:00.000Z"; +const caseId = "00000000-0000-4000-8000-000000000601"; +const snapshotId = "00000000-0000-4000-8000-000000000602"; + +function event(overrides: Partial = {}): LifeEventRevision { + return { + id: randomUUID(), eventId: randomUUID(), revision: 1, domain: "education", eventKind: "education_milestone", + subject: "self", relatedPerson: null, summary: "2016年大学入学", rawText: "2016年9月离家去外地上大学", + dateRange: { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "2016年9月" }, + scoreability: "scoreable", supersedesRevisionId: null, createdAt: now, ...overrides, + }; +} + +function turn(overrides: Partial = {}): RectificationV4Turn { + return { + id: randomUUID(), caseId, caseVersion: 1, questionId: randomUUID(), questionDomain: "education", + questionTargetEventId: null, question: "请说一件时间比较确定的经历。", answer: "2016年9月离家去外地上大学。", + modelId: null, actionId: randomUUID(), createdAt: now, ...overrides, + }; +} + +function diagnostics(overrides: Partial = {}): DiagnosticsSummary { + return diagnosticsSummarySchema.parse({ + id: randomUUID(), caseId, snapshotId, primaryClusterRetentionRate: .8, leaveOneEventOutRetentionRate: .8, + leaveOneDomainOutRetentionRate: .8, dateSensitivityRetentionRate: .8, neighborSupportMinutes: 8, + primarySecondaryMarginPercent: 14, clusterMassRatio: .7, unstableEventIds: [], mostDiscriminatingLayers: ["D9"], + eventDateSensitivity: [], candidateSplits: [], calculationHash: "d".repeat(64), createdAt: now, ...overrides, + }); +} + +function snapshot(range: readonly [string, string], overrides: Partial = {}): CandidateSnapshot { + const [startTime, endTime] = range; + return { + id: randomUUID(), caseId, caseVersion: 3, evidenceSetHash: "e".repeat(64), calculationSpecHash: "c".repeat(64), + algorithmVersion: "rectification-v5-matrix-scoring-1", + candidates: [{ time: startTime, score: 10, supportingEventIds: [], conflictingEventIds: [] }], + clusters: [{ rank: 1, startTime, endTime, representativeTime: startTime, widthMinutes: 7, peakScore: 10, scoreMass: 1 }], + robustness: { neighborSupportMinutes: 8, leaveOneOutRetentionRate: .8, dateSensitivityRetentionRate: .8, calculationSpecHashMatched: true }, + canConfirmExactMinute: false, canAcceptRange: true, gateReasons: [], createdAt: now, ...overrides, + }; +} + +function targetOpportunity(target: LifeEventRevision): QuestionOpportunity { + return { + contractVersion: "semantic-question-v2", opportunityId: randomUUID(), kind: "refine_event_date", domain: target.domain, + targetEventId: target.eventId, goal: `细化“${target.summary}”的日期。`, requestedFields: ["event_day"], anchors: [target.summary], + contextFacts: ["日期敏感性较高。"], + forbiddenMoves: ["switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event", "invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace"], + fallbackPrompt: `关于“${target.summary}”,你还记得大概哪一天吗?`, reason: "日期敏感性诊断显示该事件可能改变候选排序。", + expectedInformationGain: .8, dateSensitivity: .8, candidateSplitRelevance: .5, domainCoverageGain: 0, + recallEase: .6, novelty: .8, repetitionPenalty: 0, privacyCost: .05, utility: .7, active: true, + }; +} + +function validated(opportunity: QuestionOpportunity): ValidatedDecision { + return { + decision: { action: "ask_question", opportunityId: opportunity.opportunityId, narrativeFocus: ["latest_event"] }, + mode: "agent", validationIssues: [], selectedOpportunity: opportunity, + }; +} + +test("研究院实习被确定性提取为 career/self/month/scoreable", () => { + const [result] = extractLifeEventEvidence({ rawText: "2020 年 4 月去石油化工研究院实习做研究员。", sourceTurnId: randomUUID(), asOfDate: "2026-07-29" }); + assert.ok(result); + assert.equal(result.domain, "career"); + assert.equal(result.eventKind, "career_change"); + assert.equal(result.subject, "self"); + assert.equal(result.datePrecision, "month"); + assert.equal(result.dateValue, "2020-04"); + assert.equal(result.scoreability, "scoreable"); + assert.equal(result.scoreable, true); +}); + +test("month 默认不细化,只有 retention 低于 .65 时才允许细化", () => { + const internship = event({ domain: "career", eventKind: "career_change", summary: "去石油化工研究院实习做研究员", rawText: "2020年4月去石油化工研究院实习做研究员", dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" } }); + const coveredEvents = [ + internship, + event({ domain: "education", eventKind: "education_milestone" }), + event({ domain: "relocation", eventKind: "relocation" }), + event({ domain: "relationship", eventKind: "relationship_change", relatedPerson: "partner" }), + event({ domain: "finance", eventKind: "finance_change" }), + event({ domain: "health_pressure", eventKind: "self_health_event" }), + ]; + const build = (summary: DiagnosticsSummary | null) => buildQuestionOpportunities({ caseId, events: coveredEvents, turns: [], snapshot: null, diagnostics: summary }); + assert.equal(build(null).some((item) => item.kind === "refine_event_date"), false); + const sensitivity = (winnerRetentionRate: number, candidateClusterRetentionRate: number) => diagnostics({ + eventDateSensitivity: [{ eventId: internship.eventId, declaredDateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month" }, sampleDates: ["2020-04-01", "2020-04-30"], winnerRetentionRate, scoreVariance: 1, candidateClusterRetentionRate }], + }); + const sensitiveOpportunities = build(sensitivity(.64, .1)); + assert.equal(sensitiveOpportunities.some((item) => item.kind === "refine_event_date" && item.targetEventId === internship.eventId), true); + assert.doesNotMatch(sensitiveOpportunities.find((item) => item.kind === "refine_event_date")!.fallbackPrompt, /敏感性|排序|保持率|score/i); + assert.equal(build(sensitivity(.65, .65)).some((item) => item.kind === "refine_event_date"), false); +}); + +test("不知道或换方向不产生 pending,也不再生成同 target 机会", () => { + const target = event({ dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" } }); + const answer = "记不清了,换一个吧。"; + const result = reconcileV4Evidence({ caseId, answer, sourceTurnId: randomUUID(), asOfDate: "2026-07-29", existing: [target], targetEventId: target.eventId }); + assert.equal(result.targetDisposition, "direction_change"); + assert.deepEqual(result.pending, []); + const opportunities = buildQuestionOpportunities({ caseId, events: [target], turns: [turn({ questionTargetEventId: target.eventId, answer })], snapshot: null, diagnostics: null, targetDisposition: result.targetDisposition, retryTargetEventIds: [target.eventId] }); + assert.equal(opportunities.some((item) => item.targetEventId === target.eventId), false); + assert.ok(opportunities.some((item) => item.kind === "ask_new_event")); +}); + +test("无 target 的换方向表达也不会被记为 event_unparsed", () => { + const result = reconcileV4Evidence({ caseId, answer: "后来有一次搬家,但我记不清时间了,换一个吧。", sourceTurnId: randomUUID(), asOfDate: "2026-07-29", existing: [] }); + assert.equal(result.targetDisposition, "direction_change"); + assert.deepEqual(result.pending, []); + assert.equal(result.revisions.some((item) => item.scoreability === "scoreable"), false); +}); + +test("回答新 relocation 不覆盖 education target,原目标最多补问一次", () => { + const target = event({ dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" } }); + const answer = "2018 年 8 月搬到北京。"; + const assisted = validatedModelAssistedEvidence({ + rawText: answer, + sourceTurnId: randomUUID(), + asOfDate: "2026-07-29", + extraction: { sourceSpan: "2018 年 8 月搬到北京", summary: "搬到北京", domain: "relocation", eventKind: "relocation", subject: "self", relatedPerson: null, dateText: "2018 年 8 月" }, + }); + assert.ok(assisted); + const result = reconcileV4Evidence({ caseId, answer, sourceTurnId: randomUUID(), asOfDate: "2026-07-29", existing: [target], targetEventId: target.eventId, assistedEvidence: [assisted] }); + assert.equal(result.targetDisposition, "answered_other_event"); + const relocation = result.revisions.find((item) => item.domain === "relocation"); + assert.ok(relocation); + assert.notEqual(relocation.eventId, target.eventId); + assert.equal(result.revisions.some((item) => item.eventId === target.eventId), false); + const firstTurns = [turn({ questionTargetEventId: target.eventId, answer: "2018 年 8 月搬到北京。" })]; + const first = buildQuestionOpportunities({ caseId, events: [target, relocation], turns: firstTurns, snapshot: null, diagnostics: null, targetDisposition: "answered_other_event", retryTargetEventIds: [target.eventId] }); + assert.equal(first.filter((item) => item.kind === "resolve_event_conflict" && item.targetEventId === target.eventId).length, 1); + const second = buildQuestionOpportunities({ caseId, events: [target, relocation], turns: [...firstTurns, turn({ questionTargetEventId: target.eventId, answer: "2020年又搬到上海。" })], snapshot: null, diagnostics: null, targetDisposition: "answered_other_event", retryTargetEventIds: [target.eventId] }); + assert.equal(second.some((item) => item.kind === "resolve_event_conflict" && item.targetEventId === target.eventId), false); +}); + +test("Renderer 对切换目标、多问题、出生分钟和内部信息统一回落锚定 fallback", () => { + const target = event({ summary: "2020年4月研究院实习" }); + const opportunity = targetOpportunity(target); + const input = { latestAnswer: target.rawText, acceptedEvents: [target], pendingEvidence: [] as PendingEvidence[], snapshot: null, previousSnapshot: null, validated: validated(opportunity) }; + const invalidQuestions = [ + "你后来有没有搬家?", + "关于2020年4月研究院实习,你还记得具体月份吗?另外后来有没有换工作?", + "关于2020年4月研究院实习,你是不是05:13出生?", + "关于2020年4月研究院实习,opportunityId 是什么?", + "关于2020年4月研究院实习,snapshotId 是什么?", + "关于2020年4月研究院实习,请告诉我 score。", + "关于2020年4月研究院实习,D9 显示什么?", + "关于2020年4月研究院实习,tool call 返回什么?", + ]; + for (const question of invalidQuestions) { + assert.equal(validateQuestionRealization(question, opportunity).valid, false, question); + const message = realizePublicMessage({ acknowledgement: `你提到的是“${target.summary}”。`, candidateUpdate: null, limitation: null, question }, input); + assert.equal(message.question, opportunity.fallbackPrompt, question); + } +}); + +test("稳定候选范围相同不重复提示,实际变化才提示且不确认唯一分钟", () => { + const previous = snapshot(["05:00", "05:30"]); + const same = snapshot(["05:00", "05:30"]); + const changed = snapshot(["05:12", "05:18"]); + assert.equal(candidateUpdateFor({ snapshot: same, previousSnapshot: previous, decisionAction: "ask_question" }), null); + const update = candidateUpdateFor({ snapshot: changed, previousSnapshot: previous, decisionAction: "ask_question" }); + assert.ok(update); + assert.match(update, /05:12.*05:18/); + assert.doesNotMatch(update, /唯一|确认.*分钟|代表分钟/); + assert.equal(changed.canConfirmExactMinute, false); +}); + +test("Builder 的领域排序不受事件输入数组顺序影响", () => { + const education = event(); + const career = event({ domain: "career", eventKind: "career_change", summary: "2023年开始负责商业巡演经纪公司", rawText: "2023年9月开始负责一家商业巡演经纪公司", dateRange: { start: "2023-09-01", end: "2023-09-30", precision: "month", label: "2023年9月" } }); + const domains = (events: readonly LifeEventRevision[]) => buildQuestionOpportunities({ caseId, events, turns: [], snapshot: null, diagnostics: null }).map((item) => [item.kind, item.domain, item.utility]); + assert.deepEqual(domains([education, career]), domains([career, education])); +}); + +test("Reasoner 状态包含最近语义上下文但不包含贡献矩阵", () => { + const latestEvent = event(); + const opportunity = targetOpportunity(latestEvent); + const recentTurns = Array.from({ length: 7 }, (_, index) => turn({ answer: `回答${index}` })); + const pending: PendingEvidence = { id: randomUUID(), caseId, turnId: recentTurns.at(-1)!.id, rawText: "后来去了北京", reasonCode: "date_unresolved", targetEventId: null, resolvedEventId: null, createdAt: now, resolvedAt: null }; + const state = buildReasonerState({ snapshot: snapshot(["05:12", "05:18"]), diagnostics: diagnostics(), opportunities: [opportunity], recentTurns, recentEvents: [latestEvent], currentTarget: latestEvent, targetDisposition: "unresolved", pendingEvidence: [pending], candidateRangeChanged: true }); + assert.equal(state.latestAnswer, "回答6"); + assert.equal(state.recentTurns.length, 6); + assert.equal(state.recentEvents[0]?.summary, latestEvent.summary); + assert.equal(state.targetDisposition, "unresolved"); + assert.equal(state.opportunities[0]?.goal, opportunity.goal); + assert.equal(state.pendingEvidence.count, 1); + assert.doesNotMatch(JSON.stringify(state), /contribution(?:Matrix| matrix|_matrix)?/i); +}); + +test("模型辅助提取拒绝发明日期,接受原文连续日期并可进入 Event Ledger", () => { + const invented = validatedModelAssistedEvidence({ + rawText: "大学毕业后去了北京。", sourceTurnId: randomUUID(), asOfDate: "2026-07-29", + extraction: { sourceSpan: "大学毕业后去了北京", summary: "大学毕业后去了北京", domain: "relocation", eventKind: "relocation", subject: "self", relatedPerson: null, dateText: "2020年7月" }, + }); + assert.equal(invented, null); + const normalizedButNotLiteral = validatedModelAssistedEvidence({ + rawText: "2022年11月把生活重心挪到了成都。", sourceTurnId: randomUUID(), asOfDate: "2026-07-29", + extraction: { sourceSpan: "2022年11月把生活重心挪到了成都", summary: "把生活重心挪到了成都", domain: "relocation", eventKind: "relocation", subject: "self", relatedPerson: null, dateText: "2022年11月" }, + }); + assert.equal(normalizedButNotLiteral, null); + const rawText = "2022年11月把生活重心挪到了成都。"; + const assisted = validatedModelAssistedEvidence({ + rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-29", + extraction: { sourceSpan: "2022年11月把生活重心挪到了成都", summary: "把生活重心挪到了成都", domain: "relocation", eventKind: "relocation", subject: "self", relatedPerson: null, dateText: "2022年11月" }, + }); + assert.ok(assisted); + assert.equal(assisted.dateValue, "2022-11"); + const reconciled = reconcileV4Evidence({ caseId, answer: rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-29", existing: [], assistedEvidence: [assisted] }); + assert.ok(reconciled.revisions.some((item) => item.domain === "relocation" && item.scoreability === "scoreable")); +}); + +test("旧 prompt Opportunity 归一化为 semantic-question-v2", () => { + const legacyPrompt = "请说一件时间比较明确的经历。"; + const normalized = normalizeQuestionOpportunity({ prompt: legacyPrompt, domain: "career", reason: "历史记录" }); + assert.equal(normalized.contractVersion, "semantic-question-v2"); + assert.equal(normalized.fallbackPrompt, legacyPrompt); + assert.equal(normalized.domain, "career"); + assert.ok(normalized.opportunityId); +}); + +test("V6 迁移只更新未完成 Case 版本且不写 active_birth_time", () => { + const migration = readFileSync(new URL("../supabase/migrations/20260729010000_rectification_agent_v6_versions.sql", import.meta.url), "utf8"); + assert.match(migration, /alter column skill_version set default 'birth-time-rectification-v6'/); + assert.match(migration, /alter column prompt_version set default 'rectification-agent-v6-1'/); + assert.match(migration, /where status in \('awaiting_answer', 'processing', 'paused'\)/); + assert.doesNotMatch(migration, /profiles\s*\.\s*active_birth_time|active_birth_time/i); +}); diff --git a/frontend/tests/rectification-v4-domain.test.ts b/frontend/tests/rectification-v4-domain.test.ts index ee33c936..9e3b6770 100644 --- a/frontend/tests/rectification-v4-domain.test.ts +++ b/frontend/tests/rectification-v4-domain.test.ts @@ -97,8 +97,8 @@ test("Opportunity Builder prioritizes event-local date refinement and never asks const local = opportunities.find((item) => item.kind === "refine_event_date"); assert.equal(local?.targetEventId, event.eventId); assert.equal(local?.domain, "education"); - assert.match(local?.prompt ?? "", /离家去外地上大学/); - assert.match(local?.prompt ?? "", /月份或日期/); + assert.match(local?.fallbackPrompt ?? "", /离家去外地上大学/); + assert.match(local?.fallbackPrompt ?? "", /哪个月|时间段/); assert.ok(opportunities.every((item, index) => index === 0 || opportunities[index - 1]!.utility >= item.utility)); assert.ok(opportunities.every((item) => item.domain !== "family")); }); diff --git a/frontend/tests/rectification-v4-replay.test.ts b/frontend/tests/rectification-v4-replay.test.ts index 5081ffbb..f7fdcebe 100644 --- a/frontend/tests/rectification-v4-replay.test.ts +++ b/frontend/tests/rectification-v4-replay.test.ts @@ -73,32 +73,7 @@ test("V5 golden replay persists the full artifact chain, returns ranges only, an ["2015-07-01", "2015-07-31"], ["2016-06-01", "2016-06-30"], ]); - const firstTarget = loaded.case.currentQuestion?.targetEventId; - assert.ok(firstTarget); - const firstTargetEvent = loaded.events.find((event) => event.eventId === firstTarget); - assert.ok(firstTargetEvent); - loaded = await answerAndRun( - service, - worker, - userId, - created.case.id, - loaded.case.version, - firstTargetEvent.dateRange.start.startsWith("2015-") ? "2015年7月18日" : "2016年6月22日", - ); - const secondTarget = loaded.case.currentQuestion?.targetEventId; - assert.ok(secondTarget); - assert.notEqual(secondTarget, firstTarget); - const secondTargetEvent = loaded.events.find((event) => event.eventId === secondTarget); - assert.ok(secondTargetEvent); - loaded = await answerAndRun( - service, - worker, - userId, - created.case.id, - loaded.case.version, - secondTargetEvent.dateRange.start.startsWith("2015-") ? "2015年7月18日" : "2016年6月22日", - ); - assert.equal(loaded.case.currentQuestion?.domain, "relocation"); + assert.equal(loaded.case.currentQuestion?.targetEventId, null, "month precision should move to a new dated event"); loaded = await answerAndRun( service, worker, @@ -119,9 +94,9 @@ test("V5 golden replay persists the full artifact chain, returns ranges only, an assert.ok(loaded.case.latestDiagnosticsId); assert.equal(store.featureSnapshots.size, 1); assert.equal(store.diagnostics.size, 1); - assert.equal(store.agentRuns.size, 4); - assert.equal(store.publicMessages.size, 4); - assert.equal(store.validatedDecisions.size, 4); + assert.equal(store.agentRuns.size, 2); + assert.equal(store.publicMessages.size, 2); + assert.equal(store.validatedDecisions.size, 2); const finalRun = [...store.agentRuns.values()].at(-1); assert.equal(finalRun?.validatedDecision.decision.action, "offer_candidate_range"); assert.equal(finalRun?.inputTokenCount, null); diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index 4d0c202b..c8adb6fb 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -93,9 +93,11 @@ test("V5 agent fallback persists Agent Run, Public Message and a server-owned op assert.ok(event && run && message); assert.equal(run.deploymentMode, "v5_agent"); assert.equal(run.validatedDecision.mode, "deterministic_fallback"); - assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, event.eventId); - assert.equal(done?.case.currentQuestion?.targetEventId, event.eventId); + assert.equal(run.validatedDecision.selectedOpportunity?.kind, "ask_new_event"); + assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, null); + assert.equal(done?.case.currentQuestion?.targetEventId, null); assert.match(done?.case.currentQuestion?.prompt ?? "", /离家去外地上大学/); + assert.doesNotMatch(done?.case.currentQuestion?.prompt ?? "", /具体哪一天|几号/); assert.equal(message.question, done?.case.currentQuestion?.prompt); assert.equal(done?.case.latestSnapshot, null); })); @@ -119,7 +121,8 @@ test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible proj const run = [...store.agentRuns.values()][0]; assert.ok(queued?.job && event && run); assert.equal(run.deploymentMode, "v5_shadow"); - assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, event.eventId); + assert.equal(run.validatedDecision.selectedOpportunity?.kind, "ask_new_event"); + assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, null); assert.equal(done.case.currentQuestion?.targetEventId, event.eventId); assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/); assert.match(store.publicMessages.get(queued.job.id)?.acknowledgement ?? "", /我记下了/); diff --git a/skills/birth-time-rectification/SKILL.md b/skills/birth-time-rectification/SKILL.md index 68b08205..ff626c1b 100644 --- a/skills/birth-time-rectification/SKILL.md +++ b/skills/birth-time-rectification/SKILL.md @@ -1,36 +1,63 @@ --- name: birth-time-rectification -description: Evidence-led birth-time rectification for the Web agent. Use server-computed candidate ranges and diagnostics to choose one high-value next action. Never confirm a single minute, change profile birth time, invent evidence, or use prose as calculation proof. +description: Natural, evidence-led birth-time rectification for the Web agent. Select one server-owned semantic question opportunity at a time; never create candidate results, confirm a unique minute, change profile birth time, invent evidence, or expose private scoring and technique traces. --- # Birth-time rectification -This is a constrained evidence workflow, not a generic astrology reading. +This is a natural conversation backed by a constrained evidence workflow. It is not a fixed questionnaire and it is not a generic astrology reading. -Before choosing an action, read the contracts in `references/` and use -`assets/rectification-capability-matrix.json` only as a capability boundary. +Before choosing an action, read the contracts in `references/`. Treat `assets/rectification-capability-matrix.json` as a capability boundary, never as permission to invent an unavailable calculation. -## Hard boundaries +## Product boundary -- The server owns candidate scanning, scores, diagnostics, event IDs, and policy gates. -- The agent may select one server-provided opportunity or request one server-provided diagnostic. -- Never invent candidate times, scores, event IDs, dates, techniques, or tool inputs. -- Never confirm a single minute or write `profiles.active_birth_time`. -- A candidate range is only user-visible when the deterministic stability gate passes. -- Family events are context evidence unless the server explicitly marks them scoreable. +- Current skill version: `birth-time-rectification-v6`. +- Current prompt version: `rectification-agent-v6-1`. +- The scoring algorithm remains `rectification-v5-matrix-scoring-1`; the V6 label describes the conversation contract, not a replacement scoring engine. +- The server owns event reconciliation, candidate-minute scanning, event contributions, snapshots, diagnostics, stability gates, jobs, replay, persistence, and final decision validation. +- The agent may select one active server opportunity, call at most one allowed read-only diagnostic, offer an already-gated candidate range, or stop for low confidence. +- The agent never creates events, dates, scores, candidate minutes, or profile updates. +- `canConfirmExactMinute` is always `false`. Never write `profiles.active_birth_time` automatically. + +## Seventeen conversation boundaries + +1. Conduct a natural conversation, never a fixed questionnaire. +2. Ask at most one question in an ordinary turn. +3. Do not rotate through domains in a fixed order. +4. Month precision is sufficient by default. +5. Ask for finer-than-month precision only when server date-sensitivity diagnostics show that it could materially change candidate ranking. +6. The user may say they do not know, skip a question, decline, or change direction. +7. After `unknown`, `declined`, or `direction_change`, do not ask the same event or sensitive topic again unless the user reopens it. +8. Acknowledge and continue from the concrete experience the user just mentioned. +9. Do not use empty stock phrases such as “这个信息很有用” or repetitive “已记录” openings. +10. Do not assign life meaning to an ordinary experience or claim an unconfirmed turning point. +11. The agent selects a server-generated semantic opportunity; it does not create candidate results or an unrestricted question route. +12. Show a candidate range only after the deterministic stability gate passes. +13. Never confirm, imply, or display a unique or representative birth minute as the answer. +14. Stop with an honest low-confidence result when evidence is sparse, conflicting, or unstable; do not prolong the interview indefinitely. +15. Family events are background/context evidence by default, not the user's own scoreable event. +16. D60 is reference-only and must not drive a conclusion. +17. Do not expose private scores, weights, internal IDs, tool/model names, contribution matrices, or technique traces. ## Turn strategy -1. Acknowledge the concrete experience the user just supplied. -2. Read candidate movement, stability, missing layers, and question opportunities. -3. Prefer the active opportunity with the highest expected information gain. -4. Ask one natural question only. -5. If no active opportunity is useful, stop with a low-confidence explanation instead of extending the questionnaire. +1. Reconcile the latest answer and preserve its original wording and stated date precision. +2. Respect the current target disposition before generating a follow-up. +3. Review up to five active semantic opportunities built from evidence coverage, candidate split, date sensitivity, recent topics, novelty, recall ease, repetition, and privacy cost. +4. Select one useful opportunity and realize one short, anchored question. If none is useful, stop at low confidence. +5. Mention a candidate range only when it is newly displayable or materially changed; never repeat an unchanged range. -## Layer priority +A user who answers with a different complete event may have that event saved without overwriting the old target. The old target may receive at most one gentle clarification; repeated diversion closes it and moves the conversation on. -Use the server's available layers only. Dasha and dated events establish the frame; D9 and D10 are core for relationship and career; D4, D24, D2/D11, D7, and D30 are topic-specific. D60 is reference-only and must never drive a conclusion. +## Date and privacy policy + +- `day`: never request finer precision. +- `month`: normally complete; do not ask for a day merely because a day is absent. +- `quarter`: a month may be requested. +- `year`: a month or approximate range may be requested. +- `range`: refine only when the range is broad and diagnostics show ranking impact. +- Health, bereavement, family illness, and relationship questions have higher privacy cost. Once declined in a Case, do not proactively ask that sensitive category again unless the user raises it. ## Public language -Explain whether the latest evidence moved or supported the current candidate range. Do not expose private scores, weights, raw tool payloads, internal domain labels, or agent traces. +Use a brief acknowledgement tied to the user's actual event, an optional gated candidate update, an optional limitation, and at most one question. Do not repeat an unchanged range, over-interpret the event, or turn sparse/conflicting evidence into certainty. diff --git a/skills/birth-time-rectification/references/event-schema.md b/skills/birth-time-rectification/references/event-schema.md index bdb6038a..7cf1f139 100644 --- a/skills/birth-time-rectification/references/event-schema.md +++ b/skills/birth-time-rectification/references/event-schema.md @@ -1,3 +1,29 @@ # Event schema -Keep event subject, related person, event kind, date precision, extraction status, correction lineage, and scoreability. A family bereavement is a family context event, not the user's health event. +Preserve the event's subject, related person, domain, event kind, original user wording, declared date text, normalized date range, precision, extraction status, correction lineage, source Turn, and scoreability. + +## Subject and scoreability + +- A user's own supported event may be `scoreable`. +- A partner relationship event is scoreable only when the server policy explicitly permits it. +- Family events, bereavement, illness of relatives, and other third-party events are `context_only` by default. +- Do not classify a family health or death event as the user's own health event. +- A newly supplied event must not overwrite the event currently being clarified. + +## Date precision + +Keep `day`, `month`, `quarter`, `year`, `range`, and unresolved precision honestly. Never invent a day to complete a month, or a month/year from common sense. Month precision is sufficient by default; finer detail requires a server date-sensitivity reason. + +Relative phrases such as “后来”, “第二年”, or “那时候” may be resolved only by the existing server context-date parser. If that parser cannot resolve them reliably, keep the evidence pending or contextual rather than guessing. + +## Extraction boundary + +Run deterministic extraction first. Model assistance is allowed only for deterministic `event_unparsed`, `pending_review`, or unsupported results. Its output is limited to a source span, summary, domain, event kind, subject, related person, and date text. + +- `sourceSpan` and `dateText` must be continuous substrings of the user's answer. +- The model cannot provide normalized start/end dates. +- Server date parsing and schema validation remain authoritative. +- Invalid, timed-out, or invented model output is rejected and the deterministic pending result remains. +- The extraction agent receives no candidate ranges, scores, database write access, or profile mutation authority. + +The raw answer remains in the Turn even when the user skips, declines, changes direction, or the event cannot be scored. diff --git a/skills/birth-time-rectification/references/failure-policy.md b/skills/birth-time-rectification/references/failure-policy.md index e14b46af..f376fd7a 100644 --- a/skills/birth-time-rectification/references/failure-policy.md +++ b/skills/birth-time-rectification/references/failure-policy.md @@ -1,3 +1,21 @@ # Failure policy -On invalid model output, unavailable tools, or a failed policy gate, use the deterministic fallback and record the failure. Do not fabricate a next question or candidate result. +## Conversation failures + +- Invalid Reasoner output, an unavailable model, or exhausted diagnostic budget uses the deterministic server policy. +- Invalid Renderer output uses the selected opportunity's validated `fallbackPrompt`. +- A failed or unavailable model-assisted event extraction leaves deterministic extraction and pending evidence intact; it must not fabricate an event or date. +- `unknown`, `declined`, and `direction_change` are valid conversation outcomes, not parsing failures and not life events. +- After a refusal or direction change, close the target and do not repeat it. + +## Evidence failures + +Stop with low confidence when evidence is too sparse, conflicting, tied, unstable, privacy-costly, or unlikely to add information. Do not turn an internal Snapshot into a public range before its gate passes. Do not keep asking merely to fill a domain checklist. + +A month-dated event is not a failure. Refine it only when date-sensitivity diagnostics show that finer precision could change candidate ranking. + +## System failures + +Preserve the existing Job and persistence guarantees: claim/lease, idempotency, completed-job replay, and atomic completion. A renderer or extraction failure must not cause partial artifact writes, duplicate completion, profile mutation, or a different replay result. + +Never log raw sensitive answers to ordinary telemetry. Persist user text only in the approved Turn/evidence stores required by the product contract. diff --git a/skills/birth-time-rectification/references/output-contract.md b/skills/birth-time-rectification/references/output-contract.md index 95e57758..6d682629 100644 --- a/skills/birth-time-rectification/references/output-contract.md +++ b/skills/birth-time-rectification/references/output-contract.md @@ -1,3 +1,44 @@ # Output contract -Public output contains an acknowledgement, a concise calculation update grounded in the packet, and at most one question. It never contains a single-minute conclusion or private scores. +Public output keeps the existing shape: + +```ts +{ + acknowledgement: string; + candidateUpdate: string | null; + limitation: string | null; + question: string | null; +} +``` + +## Acknowledgement + +Use at most one or two short sentences and refer to the user's concrete experience. Do not repeatedly begin with “已记录” or “我记下了”. Do not use “这个信息很有用”, “它不是单纯的……”, “而是把……”, “接下来最有价值的是……”, or “这样可以避免……”. Do not interpret an ordinary event as a confirmed life turning point. + +## Question + +When a validated opportunity is selected, `question` is required; otherwise it is `null`. The question must: + +- be 8-180 characters, at most two sentences, and contain at most one question mark; +- ask one thing only and match the opportunity's requested fields; +- include a valid anchor when `targetEventId` is present; +- ask self/family/partner only for `event_subject`; +- ask month, approximate month, or range for `event_month`; +- ask start, peak, end, or formal stage for `event_stage`; +- ask for one new roughly dated event for `new_dated_event`; +- contain no internal ID/field, score, snapshot, opportunity, tool call, model name, technique trace such as `D9`/`D60`, or unapproved `HH:MM` birth time. + +Reject multi-question transitions such as “另外”, “还有”, “同时再说”, or “并且告诉我” when they introduce another request. On validation failure, use the selected opportunity's short, anchored `fallbackPrompt`. + +## Candidate update + +`candidateUpdate` is allowed only when the current range has passed every public gate and one of these is true: + +1. it differs materially from the previous Snapshot's primary range; or +2. it is the first Snapshot to pass the public stability gate. + +The no-repeat rule takes precedence: it must be `null` for an unchanged range, insufficient event/domain coverage, an internal unstable Snapshot, or a repeated equivalent calculation. Never state or imply a unique or representative birth minute. + +## Deterministic fallback + +Fallback follows the same public rules as model output: acknowledge the actual event naturally, ask one anchored question, avoid repetition and over-interpretation, and never claim exact-minute certainty. diff --git a/skills/birth-time-rectification/references/product-contract.md b/skills/birth-time-rectification/references/product-contract.md index 32041ad4..b9991d3f 100644 --- a/skills/birth-time-rectification/references/product-contract.md +++ b/skills/birth-time-rectification/references/product-contract.md @@ -1,3 +1,29 @@ # Product contract -The product returns a candidate range, not a verified birth minute. Existing profile birth time remains unchanged until the user explicitly saves an allowed candidate range through the product flow. +## Version and ownership + +- Skill: `birth-time-rectification-v6`. +- Prompt: `rectification-agent-v6-1`. +- Algorithm: `rectification-v5-matrix-scoring-1` remains unchanged. +- V6 changes the conversation and semantic-question contracts; it does not replace the V5 candidate engine. +- The server owns candidate-minute scanning, the event contribution matrix, Candidate Snapshots, diagnostics, stability gates, Decision Validator, deterministic fallback, Jobs, claim/lease, completed-job replay, atomic completion, idempotency, and persistence. +- Preserve `v4_legacy`, `v5_shadow`, and `v5_agent` deployment behavior. Shadow artifacts must not change the legacy visible reply. + +## Result boundary + +The product can return a candidate time range only after deterministic minimum-event, minimum-domain, and stability gates pass. An internal or unstable Snapshot is not a public result. A repeated calculation of the same primary range is not a new update. + +`canConfirmExactMinute` is always `false`. The product must not present a unique minute or representative minute as the user's true birth time, and rectification completion must not automatically write `profiles.active_birth_time`. + +When evidence is sparse, conflicting, tied, date-sensitive, or unstable, stop or continue with one genuinely useful question. Never package uncertainty as certainty or extend the interview without a useful active opportunity. + +## Agent authority + +The agent may only: + +1. select one active server-generated semantic question opportunity; +2. call at most one permitted read-only diagnostic; +3. offer a server-generated candidate range that has passed the public gate; or +4. stop with low confidence. + +The agent must not create or alter events, normalized dates, candidate minutes, scores, diagnostic results, or profile birth data. diff --git a/skills/birth-time-rectification/references/question-policy.md b/skills/birth-time-rectification/references/question-policy.md index fa92f57a..b09f4fdc 100644 --- a/skills/birth-time-rectification/references/question-policy.md +++ b/skills/birth-time-rectification/references/question-policy.md @@ -1,3 +1,44 @@ # Question policy -Choose one active server opportunity. Prefer date sensitivity, candidate-split relevance, and new domain coverage over recency or fixed domain order. Do not repeat a resolved follow-up. +## Semantic opportunities + +Question opportunities describe meaning, not final prose. New opportunities use `semantic-question-v2` and carry a goal, requested fields, anchors, context facts, forbidden moves, a natural fallback prompt, utility inputs, target event, and active state. Historical opportunities with only `prompt` remain readable by normalizing that text to `fallbackPrompt`. + +The builder produces several candidates and publishes at most five active opportunities. Rank them by evidence and context: expected information gain, candidate-split relevance, date sensitivity, domain coverage, recent user topics, recall ease, novelty, repetition penalty, and privacy cost. Never select the first missing domain from a fixed education/relocation/relationship/career/finance/health sequence. + +## One-turn rule + +- Ask one question only. +- Prefer the concrete event the user just mentioned. +- A targeted question must include a valid text anchor for that event and must not switch targets. +- Do not ask a list of questions or combine a clarification with a new-domain request. +- Do not invent an event or date. +- Do not expose IDs, fields, scores, tools, models, or technique traces. + +## Target disposition + +Respect the reconciled target state: + +- `resolved`: close the target. +- `unknown`: close it; do not create an unparsed-event pending item for the refusal phrase. +- `declined`: close it and do not proactively return to that event or sensitive category. +- `direction_change`: close it and choose another useful opportunity. +- `answered_other_event`: save the new event without overwriting the old target; allow at most one gentle follow-up to the old target. +- `unresolved`: one follow-up is allowed only when the user has not refused or changed direction. +- `not_applicable`: no old target is being resolved. + +The same `targetEventId` may be followed up consecutively at most once. A second answer about another event closes the old target instead of creating a loop. + +## Date precision + +- `day`: complete; never ask for finer detail. +- `month`: complete by default. Ask for a day or narrower stage only when diagnostics exist and either `winnerRetentionRate < 0.65` or `candidateClusterRetentionRate < 0.65`. +- `quarter`: a month may be requested. +- `year`: request a month or approximate range only when useful. +- `range`: refine only when it is broad and diagnostics show candidate-ranking impact. + +Before enough events exist to score candidates, a month-dated event should lead to another important dated event, not a request for the exact day. + +## Privacy and stopping + +Health, death, illness, family, and relationship questions carry higher privacy cost. Once the user declines a category in the current Case, do not ask it again unless the user raises it. If no opportunity has enough value, stop with low confidence rather than running a longer questionnaire. diff --git a/skills/birth-time-rectification/references/technique-policy.md b/skills/birth-time-rectification/references/technique-policy.md index 08d891f1..4f6a2262 100644 --- a/skills/birth-time-rectification/references/technique-policy.md +++ b/skills/birth-time-rectification/references/technique-policy.md @@ -1,3 +1,22 @@ # Technique policy -Only server-reported available layers may be described as used. Missing, blocked, reference-only, and research-only layers are not evidence of a result. +The conversation refactor does not change the scoring algorithm. Keep `rectification-v5-matrix-scoring-1`, Python candidate-minute scanning, the event contribution matrix, Candidate Snapshots, leave-one-event-out, leave-one-domain-out, date sensitivity, neighbor stability, candidate split, Decision Validator, and deterministic fallback. + +Only server-reported available layers may be described as used. Missing, blocked, reference-only, and research-only layers are not evidence of a result. Do not import or reproduce the portable ZIP's candidate segmentation, manual `supports/conflicts` scoring, fixed unknown-mode blocks, dynamic repository loading, or `main_repository_enhanced` mode. + +## Diagnostic use + +- The Reasoner may request at most one allowed read-only diagnostic in a turn. +- Send only compact conclusions needed for opportunity selection, not the full contribution matrix. +- Date sensitivity determines whether finer date precision is worth asking for. +- Leave-one-event/domain-out, neighbor stability, and candidate split diagnose fragility; they do not independently authorize public certainty. +- Sparse, conflicting, or unstable diagnostics require a lower-confidence stop or another genuinely discriminating question. + +## Technique boundaries + +- Dasha and dated evidence can frame comparison only when present in server results. +- D9 and D10 may support relationship and career analysis when available. +- Topic-specific layers such as D4, D24, D2/D11, D7, and D30 remain bounded by server capability. +- D60 is reference-only and must never drive candidate selection or the public conclusion. +- Never expose private scores, weights, contribution values, internal technique traces, or tool/model names in the user-facing message. +- No technique result can override `canConfirmExactMinute === false` or authorize an automatic profile birth-time write.