From ee9d4327620af7bbff7f16210031605ddd2827de Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 29 Jul 2026 17:49:04 +0800 Subject: [PATCH 01/15] 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. From 163d3fd1be4b57ed1f9397f86b79698f877bb78f Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 29 Jul 2026 18:17:46 +0800 Subject: [PATCH 02/15] fix: stabilize rectification staging release --- docs/BUG_HISTORY.md | 28 ++++ .../evidence-extractor.ts | 21 ++- .../opportunity-builder.ts | 3 +- .../lib/rectification-agent/reasoner-agent.ts | 3 +- .../lib/rectification-agent/renderer-agent.ts | 19 ++- .../lib/rectification-v4/evidence-ledger.ts | 6 + frontend/tests/rectification-agent-v6.test.ts | 25 ++++ ...tion-assisted-extraction-safety-v6.test.ts | 66 +++++++++ .../rectification-renderer-safety-v6.test.ts | 136 ++++++++++++++++++ pyproject.toml | 2 +- requirements.txt | 2 +- tests/test_mcp_dependency_contract.py | 17 +++ 12 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 frontend/tests/rectification-assisted-extraction-safety-v6.test.ts create mode 100644 frontend/tests/rectification-renderer-safety-v6.test.ts create mode 100644 tests/test_mcp_dependency_contract.py diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d374a9a1..53925c57 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1606,3 +1606,31 @@ - 修复:删除客户端不应拥有的 `high_rigor` 输入;在异步请求结束后从 `globalThis` 重新读取身份缓存;按生产边界把 `Date` 归一化为日期字符串后再构建 onboarding cache identity。 - 验证:`npx tsc --noEmit`、相关前端测试和生产构建通过。 - 防复发:测试输入只使用公开类型拥有的字段;异步初始化的全局缓存不要依赖删除前的局部控制流;数据库日期联合类型在进入纯字符串合同前必须归一化。 + +## BUG-089 | Staging CI 自动升级 MCP 2.0 导致旧 FastMCP 导入失败 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:`Staging Backend Quality Gate` 的 Python quick quality gate 与 staging 发布 +- 用户现象:本地完整验证通过,但 staging push 的质量门在导入 `mcp_server.py` 时失败,报 `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`。 +- 根因:`requirements.txt` 与 `pyproject.toml` 仅声明 `mcp>=1.0`;CI 在 2026-07-29 安装了不兼容的 `mcp 2.0.0`,而仓库当前服务端仍使用 MCP 1.x 的 `mcp.server.fastmcp.FastMCP` 导入合同。本地环境保留 `mcp 1.25.0`,因此未复现依赖漂移。 +- 修复:两个发布依赖入口统一限制为 `mcp>=1.0,<2`,继续使用已验证的 MCP 1.x API,不在本次发布中混入 MCP 2.0 迁移。 +- 验证:新增依赖合同测试同时读取 `requirements.txt` 和 `pyproject.toml`,防止任一入口再次放宽到 MCP 2.x;Python quick quality gate 与构建重新执行。 +- 防复发:运行时依赖的主版本兼容边界必须在全部安装入口保持一致;升级 MCP 2.x 必须作为独立迁移处理并先替换导入/API 合同。 +- 相关记录:BUG-087、BUG-088 +- 修复版本:待本次 staging 修复提交与部署验收 + +## BUG-090 | V6 审查发现用户可见分钟注入、家庭事件越权和最新事件排序错误 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:生时纠正 Renderer、模型辅助事件提取、Opportunity 与 Reasoner 最近事件上下文 +- 用户现象:模型可能在 acknowledgement 或 limitation 中声称唯一出生分钟;家庭健康事件可能被矛盾模型字段伪装成本人可评分事件;UUID 排序与创建时间相反时,下一问可能承接较早经历。 +- 根因:分钟安全验证只覆盖 question;辅助提取校验未拒绝 `subject=self` 与非空家庭 `relatedPerson` 的矛盾组合;账本的稳定 UUID 排序被误当作会话时间顺序。 +- 修复:全部用户可见 Renderer 字段统一执行出生分钟和内部信息安全校验并回落到服务器确定性文案;辅助提取在服务器拒绝主体/亲属矛盾并保持家庭事件 `context_only` 边界;Builder 与 Reasoner 显式按 `createdAt`、`eventId`、revision 稳定排序最近事件,不改变证据哈希使用的账本排序。 +- 验证:新增 acknowledgement/limitation 分钟注入、家庭 ICU 事件越权、UUID 与创建时间逆序的回归测试,并重新运行前端完整测试、lint、TypeScript 与构建。 +- 防复发:所有模型可写用户文案共享同一安全边界;模型提取不能决定评分主体;用于哈希的稳定顺序不得被复用为会话时序。 +- 相关记录:BUG-075、BUG-086、BUG-087 +- 修复版本:待本次 staging 修复提交与部署验收 diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index 6973a6e4..b9df6abf 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -292,6 +292,18 @@ const allowedKindsByDomain: Readonly([ + "father", + "mother", + "grandparent", + "sibling", +]); +const explicitFamilySubjectMarkers = [ + "父亲", "爸爸", "老爸", "母亲", "妈妈", "老妈", + "爷爷", "奶奶", "外公", "外婆", "祖父", "祖母", "外祖父", "外祖母", + "兄弟", "姐妹", "家里老人", "家中老人", +] as const; + export function validatedModelAssistedEvidence(input: Readonly<{ rawText: string; sourceTurnId: string; @@ -305,7 +317,14 @@ export function validatedModelAssistedEvidence(input: Readonly<{ 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 { subject, relatedPerson, domain } = input.extraction; + if (subject === "self" && relatedPerson !== null) return null; + if ((subject === "family") !== (domain === "family")) return null; + if (familyRelatedPeople.has(relatedPerson) && subject !== "family") return null; + if (relatedPerson === "partner" && (subject !== "partner" || domain !== "relationship")) return null; + if (subject === "partner" && (domain !== "relationship" || relatedPerson !== "partner")) return null; + if (explicitFamilySubjectMarkers.some((marker) => sourceSpan.includes(marker)) + && (subject !== "family" || domain !== "family")) return null; const summary = eventSummary(sourceSpan); if (summary === missingEventSummary) return null; const familyContext = input.extraction.subject === "family" || input.extraction.domain === "family"; diff --git a/frontend/src/lib/rectification-agent/opportunity-builder.ts b/frontend/src/lib/rectification-agent/opportunity-builder.ts index 365b9a24..0a88ea5c 100644 --- a/frontend/src/lib/rectification-agent/opportunity-builder.ts +++ b/frontend/src/lib/rectification-agent/opportunity-builder.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts"; +import { chronologicalEvents } from "../rectification-v4/evidence-ledger.ts"; import type { TargetDisposition } from "../rectification-v4/extraction.ts"; import type { DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts"; @@ -181,12 +182,12 @@ export function buildQuestionOpportunities(input: Readonly<{ } const scoreableCount = input.events.filter((event) => event.scoreability === "scoreable").length; + const latestEvent = chronologicalEvents(input.events).at(-1); 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}` diff --git a/frontend/src/lib/rectification-agent/reasoner-agent.ts b/frontend/src/lib/rectification-agent/reasoner-agent.ts index 9f044b6f..d36e1864 100644 --- a/frontend/src/lib/rectification-agent/reasoner-agent.ts +++ b/frontend/src/lib/rectification-agent/reasoner-agent.ts @@ -4,6 +4,7 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts"; +import { chronologicalEvents } from "../rectification-v4/evidence-ledger.ts"; import type { TargetDisposition } from "../rectification-v4/extraction.ts"; import { deterministicDecision } from "./fallback-policy.ts"; import { recordRectificationAgentTelemetry } from "./telemetry.ts"; @@ -54,7 +55,7 @@ export function buildReasonerState(input: Readonly<{ 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 })), + recentEvents: chronologicalEvents(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: { diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts index 2c216bc3..e2d0d2c7 100644 --- a/frontend/src/lib/rectification-agent/renderer-agent.ts +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -11,7 +11,8 @@ 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}(?:出生|生时)/; +const exactClockMinute = /(?:[01]?\d|2[0-3])[::][0-5]\d|(?:[零〇一二两三四五六七八九十]{1,3}|(?:[01]?\d|2[0-3]))点(?:[零〇一二两三四五六七八九十]{1,3}|[0-5]?\d)分/; +const exactMinuteClaim = /(?:唯一|准确|精确|确切|确认|确定|代表).{0,12}(?:出生|生时)?(?:时间|时刻|分钟)|(?:出生|生时)(?:时间|时刻|分钟)?.{0,12}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/; function agentFor(modelId: string | null): { id: string; agent: Agent } | null { const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel(); @@ -33,6 +34,13 @@ function normalized(value: string): string { return value.normalize("NFKC").replace(/[“”"'\s,,。.!!??::;;]/g, ""); } +function visibleTextSafetyIssues(value: string): string[] { + const issues: string[] = []; + if (internalTerms.test(value)) issues.push("internal_information_exposed"); + if (exactClockMinute.test(value) || exactMinuteClaim.test(value)) issues.push("birth_minute_injected"); + return issues; +} + 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(); @@ -41,9 +49,8 @@ export function validateQuestionRealization(question: unknown, opportunity: Ques 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"); + issues.push(...visibleTextSafetyIssues(value)); 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"); @@ -111,9 +118,9 @@ export function realizePublicMessage(value: unknown, input: Parameters 0 + || 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 @@ -124,7 +131,7 @@ export function realizePublicMessage(value: unknown, input: Parameters left.eventId.localeCompare(right.eventId)); } +export function chronologicalEvents(events: readonly LifeEventRevision[]): readonly LifeEventRevision[] { + return [...events].sort((left, right) => left.createdAt.localeCompare(right.createdAt) + || left.eventId.localeCompare(right.eventId) + || left.revision - right.revision); +} + export function appendEventRevision( revisions: readonly LifeEventRevision[], input: NewEventRevision, diff --git a/frontend/tests/rectification-agent-v6.test.ts b/frontend/tests/rectification-agent-v6.test.ts index 5c23b277..d95ab428 100644 --- a/frontend/tests/rectification-agent-v6.test.ts +++ b/frontend/tests/rectification-agent-v6.test.ts @@ -191,6 +191,31 @@ test("Builder 的领域排序不受事件输入数组顺序影响", () => { assert.deepEqual(domains([education, career]), domains([career, education])); }); +test("Builder 和 Reasoner 按事件创建时间承接最近经历而不是 UUID 顺序", () => { + const older = event({ + eventId: "ffffffff-ffff-4fff-8fff-ffffffffffff", + summary: "较早的研究院实习", + createdAt: "2026-07-29T01:00:00.000Z", + }); + const newer = event({ + eventId: "00000000-0000-4000-8000-000000000000", + domain: "relocation", + eventKind: "relocation", + summary: "刚提到的搬到北京", + rawText: "2018年8月搬到北京", + dateRange: { start: "2018-08-01", end: "2018-08-31", precision: "month", label: "2018年8月" }, + createdAt: "2026-07-29T02:00:00.000Z", + }); + const opportunities = buildQuestionOpportunities({ caseId, events: [newer, older], turns: [], snapshot: null, diagnostics: null }); + const askNewEvent = opportunities.find((item) => item.kind === "ask_new_event"); + assert.ok(askNewEvent); + assert.match(askNewEvent.fallbackPrompt, /刚提到的搬到北京/); + assert.doesNotMatch(askNewEvent.fallbackPrompt, /较早的研究院实习/); + + const state = buildReasonerState({ snapshot: null, diagnostics: diagnostics(), opportunities, recentEvents: [newer, older] }); + assert.equal(state.recentEvents.at(-1)?.summary, "刚提到的搬到北京"); +}); + test("Reasoner 状态包含最近语义上下文但不包含贡献矩阵", () => { const latestEvent = event(); const opportunity = targetOpportunity(latestEvent); diff --git a/frontend/tests/rectification-assisted-extraction-safety-v6.test.ts b/frontend/tests/rectification-assisted-extraction-safety-v6.test.ts new file mode 100644 index 00000000..78003a2b --- /dev/null +++ b/frontend/tests/rectification-assisted-extraction-safety-v6.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { validatedModelAssistedEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts"; + +test("模型辅助提取拒绝 self 与家庭 relatedPerson 的矛盾组合", () => { + const rawText = "2020年4月老爸进了ICU。"; + const result = validatedModelAssistedEvidence({ + rawText, + sourceTurnId: randomUUID(), + asOfDate: "2026-07-29", + extraction: { + sourceSpan: "2020年4月老爸进了ICU", + summary: "老爸进了ICU", + domain: "health_pressure", + eventKind: "self_health_event", + subject: "self", + relatedPerson: "father", + dateText: "2020年4月", + }, + }); + + assert.equal(result, null); +}); + +test("明确家庭主体不能被模型伪装为 self scoreable", () => { + const rawText = "2021年6月家里老人病危。"; + const result = validatedModelAssistedEvidence({ + rawText, + sourceTurnId: randomUUID(), + asOfDate: "2026-07-29", + extraction: { + sourceSpan: "2021年6月家里老人病危", + summary: "家里老人病危", + domain: "health_pressure", + eventKind: "self_health_event", + subject: "self", + relatedPerson: null, + dateText: "2021年6月", + }, + }); + + assert.equal(result, null); +}); + +test("合法家庭健康事件只作为 context_only", () => { + const rawText = "2020年4月老爸进了ICU。"; + const result = validatedModelAssistedEvidence({ + rawText, + sourceTurnId: randomUUID(), + asOfDate: "2026-07-29", + extraction: { + sourceSpan: "2020年4月老爸进了ICU", + summary: "老爸进了ICU", + domain: "family", + eventKind: "family_health_event", + subject: "family", + relatedPerson: "father", + dateText: "2020年4月", + }, + }); + + assert.ok(result); + assert.equal(result.scoreability, "context_only"); + assert.equal(result.scoreable, false); +}); diff --git a/frontend/tests/rectification-renderer-safety-v6.test.ts b/frontend/tests/rectification-renderer-safety-v6.test.ts new file mode 100644 index 00000000..c31fae51 --- /dev/null +++ b/frontend/tests/rectification-renderer-safety-v6.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import type { QuestionOpportunity, ValidatedDecision } from "../src/lib/rectification-agent/contracts.ts"; +import { realizePublicMessage, validateQuestionRealization } from "../src/lib/rectification-agent/renderer-agent.ts"; +import type { CandidateSnapshot, LifeEventRevision, PendingEvidence } from "../src/lib/rectification-v4/contracts.ts"; + +const caseId = "00000000-0000-4000-8000-000000000701"; +const now = "2026-07-29T00:00:00.000Z"; + +function event(): LifeEventRevision { + return { + id: randomUUID(), + eventId: randomUUID(), + revision: 1, + domain: "career", + eventKind: "career_change", + subject: "self", + relatedPerson: null, + summary: "2020年4月研究院实习", + rawText: "2020年4月去石油化工研究院实习做研究员。", + dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" }, + scoreability: "scoreable", + supersedesRevisionId: null, + createdAt: now, + }; +} + +function opportunity(target: LifeEventRevision): QuestionOpportunity { + return { + contractVersion: "semantic-question-v2", + opportunityId: randomUUID(), + kind: "refine_event_date", + domain: "career", + targetEventId: target.eventId, + goal: "确认研究院实习发生的大概阶段。", + requestedFields: ["event_stage"], + 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: 0.7, + dateSensitivity: 0.7, + candidateSplitRelevance: 0.4, + domainCoverageGain: 0, + recallEase: 0.6, + novelty: 0.8, + repetitionPenalty: 0, + privacyCost: 0.05, + utility: 0.65, + active: true, + }; +} + +function validated(selectedOpportunity: QuestionOpportunity): ValidatedDecision { + return { + decision: { action: "ask_question", opportunityId: selectedOpportunity.opportunityId, narrativeFocus: ["latest_event"] }, + mode: "agent", + validationIssues: [], + selectedOpportunity, + }; +} + +function snapshot(range: readonly [string, string]): 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: 0.8, dateSensitivityRetentionRate: 0.8, calculationSpecHashMatched: true }, + canConfirmExactMinute: false, + canAcceptRange: true, + gateReasons: [], + createdAt: now, + }; +} + +test("Renderer 对全部模型可见文本执行 exact-minute 和内部信息安全回落", () => { + const target = event(); + const selectedOpportunity = opportunity(target); + const input = { + latestAnswer: target.rawText, + acceptedEvents: [target], + pendingEvidence: [] as PendingEvidence[], + snapshot: null, + previousSnapshot: null, + validated: validated(selectedOpportunity), + }; + + const message = realizePublicMessage({ + acknowledgement: `你提到的是“${target.summary}”,所以准确出生分钟是05:13。`, + candidateUpdate: null, + limitation: "准确出生分钟是五点十三分,snapshotId 已确认。", + question: `关于${target.summary},唯一出生分钟是什么?`, + }, input); + + assert.equal(message.acknowledgement, `你提到的是 ${target.dateRange.label} 的“${target.summary}”。`); + assert.equal(message.limitation, null); + assert.equal(message.question, selectedOpportunity.fallbackPrompt); + assert.doesNotMatch(JSON.stringify(message), /05:13|五点十三分|准确出生分钟|唯一出生分钟|snapshotId/); + + for (const question of [ + `关于${target.summary},你是不是五点十三分出生?`, + `关于${target.summary},eventId 是什么?`, + ]) { + assert.equal(validateQuestionRealization(question, selectedOpportunity).valid, false, question); + } +}); + +test("Renderer 保留服务器生成的合法候选范围表达", () => { + const target = event(); + const selectedOpportunity = opportunity(target); + const message = realizePublicMessage({ + acknowledgement: `你提到的是“${target.summary}”。`, + candidateUpdate: "模型声称出生时间就是05:13。", + limitation: null, + question: `关于${target.summary},你更记得是开始、高峰还是结束阶段吗?`, + }, { + latestAnswer: target.rawText, + acceptedEvents: [target], + pendingEvidence: [], + snapshot: snapshot(["05:12", "05:18"]), + previousSnapshot: null, + validated: validated(selectedOpportunity), + }); + + assert.match(message.candidateUpdate ?? "", /候选范围.*05:12.*05:18/); + assert.match(message.candidateUpdate ?? "", /不代表其中某一分钟已被确认/); + assert.doesNotMatch(message.candidateUpdate ?? "", /就是05:13/); +}); diff --git a/pyproject.toml b/pyproject.toml index 94341479..2c41f44d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dev = [ "wheel>=0.40", ] api = [ - "mcp>=1.0", + "mcp>=1.0,<2", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index ddbb3fcb..374dafc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ pandas>=1.3,<3 numpy>=1.20,<3 # 严格证据收集器通过 mcp_server 复用(API 运行时必需) -mcp>=1.0 +mcp>=1.0,<2 # 以下为标准库,无需安装(仅供参考): # argparse, json, sys, os, csv, math, sqlite3 diff --git a/tests/test_mcp_dependency_contract.py b/tests/test_mcp_dependency_contract.py new file mode 100644 index 00000000..0475e0b6 --- /dev/null +++ b/tests/test_mcp_dependency_contract.py @@ -0,0 +1,17 @@ +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MCP_REQUIREMENT = "mcp>=1.0,<2" + + +def test_mcp_dependency_stays_on_compatible_major_version() -> None: + requirements = { + line.strip() + for line in (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + + assert MCP_REQUIREMENT in requirements + assert MCP_REQUIREMENT in project["project"]["optional-dependencies"]["api"] From 6ed9f2dd0a5f2a573991c3d49a817f13a202b56c Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 29 Jul 2026 19:18:09 +0800 Subject: [PATCH 03/15] fix: enable semantic rectification rollout --- deploy/README.md | 11 +++++++---- deploy/configure-staging-rectification-rollout.sh | 6 +++++- docs/BUG_HISTORY.md | 14 ++++++++++++++ frontend/tests/staging-backend-workflows.test.ts | 11 ++++++++++- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index fc9142b9..32e69025 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -406,11 +406,14 @@ previous-revision smoke SHA must remain pending. If the create flag, migration flag, deployment SHA, or strict UUID allowlist is invalid, creation audience must be `paused`, including for the smoke account. -After the smoke sequence below passes, set +After the smoke sequence below passes, use the guarded rollout workflow to set `RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` to the exact deployed 40-character -lowercase Git SHA, remove `RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, and -restart the web container. Then fetch health again and -verify all of the following against the revision that passed validation: +lowercase Git SHA, remove `RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, enable +`RECTIFICATION_AGENT_V5_ENABLED=true`, disable shadow mode, set the canary to +100 percent, and restart both the web and rectification worker containers. The +workflow writes these selectors together so public Case creation cannot silently +fall back to the fixed `v4_legacy` projector. Then fetch health again and verify +all of the following against the revision that passed validation: - `deployment.gitCommit` exactly equals the tested 40-character Git SHA; - `rollout.conversationalRectificationV3.protocol` is diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh index 4edfbf9b..1725d6c6 100755 --- a/deploy/configure-staging-rectification-rollout.sh +++ b/deploy/configure-staging-rectification-rollout.sh @@ -102,12 +102,16 @@ awk \ -v create="$creation_enabled" \ -v migrations="true" \ -v smoke_sha="$smoke_sha" \ - -v smoke_users="$smoke_user_ids" ' + -v smoke_users="$smoke_user_ids" \ + -v agent_enabled="$creation_enabled" ' BEGIN { values["RECTIFICATION_V3_CREATE_ENABLED"] = create values["RECTIFICATION_V3_MIGRATIONS_READY"] = migrations values["RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA"] = smoke_sha values["RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS"] = smoke_users + values["RECTIFICATION_AGENT_V5_ENABLED"] = agent_enabled + values["RECTIFICATION_AGENT_V5_SHADOW"] = "false" + values["RECTIFICATION_AGENT_V5_CANARY_PERCENT"] = "100" } { split($0, parts, "=") diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 53925c57..3daf8723 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1634,3 +1634,17 @@ - 防复发:所有模型可写用户文案共享同一安全边界;模型提取不能决定评分主体;用于哈希的稳定顺序不得被复用为会话时序。 - 相关记录:BUG-075、BUG-086、BUG-087 - 修复版本:待本次 staging 修复提交与部署验收 + +## BUG-091 | Staging Case rollout 未启用 V5 Agent 导致 V6 继续输出固定模板 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:staging 生时纠正新 Case 与 V6 未完成 Case 的用户可见回复 +- 用户现象:用户提交“2016 年 9 月离家去外地上大学”后,回复仍固定为“我记下了这段经历。接下来请继续讲另一件……”,没有进入 Semantic Question Renderer。 +- 根因:Case rollout 只写入 V3 创建门和 smoke 状态,没有写入 `RECTIFICATION_AGENT_V5_ENABLED`、`RECTIFICATION_AGENT_V5_SHADOW`、`RECTIFICATION_AGENT_V5_CANARY_PERCENT`;因此 `selectRectificationDeploymentMode()` 把新 Case 持久化为 `v4_legacy`,Orchestrator 必然调用 Legacy Projector。 +- 修复:受控 staging rollout 现在原子写入 V5 Agent 开关,public 与 smoke rollout 使用 `v5_agent`、100% canary,并重建 web/worker;staging 中唯一满足 V6 版本、未完成、无 open Job 条件的错误 Case 已原位升级为 `rectification-evidence-v5` / `v5_agent`,历史 Turn、Event、Job 与 Agent Run 保持不变。 +- 验证:rollout 脚本测试断言三项 V5 选择器只写一次;staging 运行容器已读取 `enabled=true`、`shadow=false`、`canary=100`;活跃 Case 聚合只剩 `v5_agent`;健康检查保持 exact SHA、public、ready。 +- 防复发:公开 Case rollout 必须同时控制创建门与 Agent deployment mode;仅有 `readyForNewCases=true` 不再视为新对话 Renderer 已启用的充分证据。 +- 相关记录:BUG-085、BUG-086、BUG-087 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 9986d41c..2a94a8b7 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -405,7 +405,7 @@ test("production remains manual-only and separate from staging database automati }); -test("public rectification rollout rewrites only rollout gates and recreates web runtimes", () => { +test("public rectification rollout enables the semantic agent and recreates web runtimes", () => { const root = mkdtempSync(join(tmpdir(), "jyotisha-rollout-")); const deploymentPath = join(root, "app"); const statePath = join(deploymentPath, ".state"); @@ -441,6 +441,9 @@ test("public rectification rollout rewrites only rollout gates and recreates web "RECTIFICATION_V3_MIGRATIONS_READY=false", "RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA=old", "RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS=00000000-0000-4000-8000-000000009001", + "RECTIFICATION_AGENT_V5_ENABLED=false", + "RECTIFICATION_AGENT_V5_SHADOW=true", + "RECTIFICATION_AGENT_V5_CANARY_PERCENT=0", "", ].join("\n"), { mode: 0o600 }, @@ -487,7 +490,13 @@ test("public rectification rollout rewrites only rollout gates and recreates web assert.match(env, /^RECTIFICATION_V3_MIGRATIONS_READY=true$/m); assert.match(env, new RegExp(`^RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA=${sha}$`, "m")); assert.match(env, /^RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS=$/m); + assert.match(env, /^RECTIFICATION_AGENT_V5_ENABLED=true$/m); + assert.match(env, /^RECTIFICATION_AGENT_V5_SHADOW=false$/m); + assert.match(env, /^RECTIFICATION_AGENT_V5_CANARY_PERCENT=100$/m); assert.equal((env.match(/^RECTIFICATION_V3_CREATE_ENABLED=/gm) ?? []).length, 1); + assert.equal((env.match(/^RECTIFICATION_AGENT_V5_ENABLED=/gm) ?? []).length, 1); + assert.equal((env.match(/^RECTIFICATION_AGENT_V5_SHADOW=/gm) ?? []).length, 1); + assert.equal((env.match(/^RECTIFICATION_AGENT_V5_CANARY_PERCENT=/gm) ?? []).length, 1); assert.match(readFileSync(join(root, "docker.log"), "utf8"), /force-recreate --no-deps web rectification-v4-worker/); } finally { rmSync(root, { recursive: true, force: true }); From f0f7c27382df297ea3e1c275e695922567e048b3 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 29 Jul 2026 19:26:51 +0800 Subject: [PATCH 04/15] fix: verify semantic rollout runtime --- deploy/README.md | 6 +++--- deploy/configure-staging-rectification-rollout.sh | 12 ++++++++++++ docs/BUG_HISTORY.md | 4 ++-- frontend/tests/staging-backend-workflows.test.ts | 3 ++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 32e69025..80bf1127 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -437,9 +437,9 @@ sequence. A plain HTTP `200` is not substitute evidence: event, then a clear event. Verify the ambiguous/future facts do not score. 4. Pause, reload, and resume from a second authenticated browser session. Verify no second rectification charge. -5. Reach a candidate, verify the prior active time is still in force, reject a - mismatched candidate confirmation, then explicitly confirm the exact - candidate. Verify the time changes atomically. +5. Reach a stable candidate range and verify the prior active time remains in + force. Confirm that no exact minute can be accepted and that rectification + does not write `profiles.active_birth_time`. 6. Explicitly continue the saved ordinary question. Verify one normal consultation reservation. Delete its chat and verify the account case still resumes/loads. diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh index 1725d6c6..75cd5bd1 100755 --- a/deploy/configure-staging-rectification-rollout.sh +++ b/deploy/configure-staging-rectification-rollout.sh @@ -151,6 +151,18 @@ compose=(docker compose -p jyotisha-staging --env-file .env.staging "${compose_f "${compose[@]}" config --quiet "${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker +for service in web rectification-v4-worker; do + container="$(docker ps -q --filter 'label=com.docker.compose.project=jyotisha-staging' --filter "label=com.docker.compose.service=$service" | head -n 1)" + [ -n "$container" ] || { + echo "staging $service container is missing after rollout" >&2 + false + } + runtime_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container")" + grep -Fxq "RECTIFICATION_AGENT_V5_ENABLED=$creation_enabled" <<<"$runtime_env" + grep -Fxq "RECTIFICATION_AGENT_V5_SHADOW=false" <<<"$runtime_env" + grep -Fxq "RECTIFICATION_AGENT_V5_CANARY_PERCENT=100" <<<"$runtime_env" +done + health="" for _ in $(seq 1 30); do health="$(curl --fail --silent --show-error "$STAGING_URL/api/health" 2>/dev/null || true)" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3daf8723..9ae1bb04 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1644,7 +1644,7 @@ - 用户现象:用户提交“2016 年 9 月离家去外地上大学”后,回复仍固定为“我记下了这段经历。接下来请继续讲另一件……”,没有进入 Semantic Question Renderer。 - 根因:Case rollout 只写入 V3 创建门和 smoke 状态,没有写入 `RECTIFICATION_AGENT_V5_ENABLED`、`RECTIFICATION_AGENT_V5_SHADOW`、`RECTIFICATION_AGENT_V5_CANARY_PERCENT`;因此 `selectRectificationDeploymentMode()` 把新 Case 持久化为 `v4_legacy`,Orchestrator 必然调用 Legacy Projector。 - 修复:受控 staging rollout 现在原子写入 V5 Agent 开关,public 与 smoke rollout 使用 `v5_agent`、100% canary,并重建 web/worker;staging 中唯一满足 V6 版本、未完成、无 open Job 条件的错误 Case 已原位升级为 `rectification-evidence-v5` / `v5_agent`,历史 Turn、Event、Job 与 Agent Run 保持不变。 -- 验证:rollout 脚本测试断言三项 V5 选择器只写一次;staging 运行容器已读取 `enabled=true`、`shadow=false`、`canary=100`;活跃 Case 聚合只剩 `v5_agent`;健康检查保持 exact SHA、public、ready。 -- 防复发:公开 Case rollout 必须同时控制创建门与 Agent deployment mode;仅有 `readyForNewCases=true` 不再视为新对话 Renderer 已启用的充分证据。 +- 验证:rollout 脚本测试断言三项 V5 选择器只写一次,并在成功前核对 web/worker 容器实际读取的 `enabled`、`shadow`、`canary`;staging 活跃 Case 聚合只剩 `v5_agent`;健康检查保持 exact SHA、public、ready。 +- 防复发:公开 Case rollout 必须同时控制创建门与 Agent deployment mode,并验证运行容器的实际环境;仅有 `readyForNewCases=true` 不再视为新对话 Renderer 已启用的充分证据。 - 相关记录:BUG-085、BUG-086、BUG-087 - 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 2a94a8b7..a9bde16d 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -458,7 +458,8 @@ test("public rectification rollout enables the semantic agent and recreates web [ "#!/usr/bin/env bash", 'if [ "$1" = ps ]; then echo web-container; exit 0; fi', - `if [ "$1" = inspect ]; then echo ghcr.io/jesse-ux/jyotisha-web@sha256:${"b".repeat(64)}; exit 0; fi`, + `if [ "$1" = inspect ] && [[ "$*" == *Config.Image* ]]; then echo ghcr.io/jesse-ux/jyotisha-web@sha256:${"b".repeat(64)}; exit 0; fi`, + 'if [ "$1" = inspect ] && [[ "$*" == *Config.Env* ]]; then printf "%s\n" RECTIFICATION_AGENT_V5_ENABLED=true RECTIFICATION_AGENT_V5_SHADOW=false RECTIFICATION_AGENT_V5_CANARY_PERCENT=100; exit 0; fi', `printf '%s\n' "$*" >>${join(root, "docker.log")}`, ].join("\n"), ); From 555e7d9befe9c40410eb614c6f138fc102a75a32 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 29 Jul 2026 20:30:23 +0800 Subject: [PATCH 05/15] fix: stop rectification question template fallback --- docs/BUG_HISTORY.md | 14 +++++ .../opportunity-builder.ts | 33 ++++------- .../lib/rectification-agent/renderer-agent.ts | 28 +++++++-- frontend/tests/rectification-agent-v6.test.ts | 59 +++++++++++++++++++ .../references/question-policy.md | 4 ++ 5 files changed, 113 insertions(+), 25 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 9ae1bb04..734e0022 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1648,3 +1648,17 @@ - 防复发:公开 Case rollout 必须同时控制创建门与 Agent deployment mode,并验证运行容器的实际环境;仅有 `readyForNewCases=true` 不再视为新对话 Renderer 已启用的充分证据。 - 相关记录:BUG-085、BUG-086、BUG-087 - 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` + +## BUG-092 | Semantic Renderer 成功后仍被静默替换成固定领域模板 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:V6 `ask_new_event` Opportunity 排序、问题验证、Renderer telemetry 与 staging 用户可见下一问 +- 用户现象:用户提交“2020 年 4 月去石油化工研究院实习做研究员”后,系统仍显示“承接……请再说一件……哪次搬家、离乡或长期迁居……”,像固定问卷。 +- 根因:Builder 将最近六轮答案拼接为当前主题,使较早教育事件中的“离家/外地”再次提升 relocation,同时未覆盖领域奖励在已经满足最小领域数后仍占主导;Renderer 对自然 `new_dated_event` 问法使用过窄词面校验,校验失败后静默替换为 Builder 固定 fallback,telemetry 仍记为 `renderer succeeded`。 +- 修复:当前主题只读取最新回答或最新事件;达到两个可评分领域后显著降低纯领域覆盖收益并提高最新主题连续性;移除“承接……请再说一件……”拼接,fallback 改为锚定当前经历的单句问题;放宽自然新事件词面但继续执行单问题、锚点、内部信息和出生分钟安全校验;validator 回退单独记录 `renderer rejected` 与脱敏错误码。 +- 验证:真实两事件重放断言 career 机会优先于旧 relocation 关键词、月份不被细化、旧固定模板被拒绝、锚定研究院实习的自然问题被保留且不等于 fallback;完整前端、lint、TypeScript、Python V5 与 staging smoke 随发布记录执行。 +- 防复发:模型调用成功、Schema 成功和问题被接受必须分开观测;Opportunity utility 不得把历史关键词与“未覆盖领域”组合成伪装的固定轮询。 +- 相关记录:BUG-086、BUG-090、BUG-091 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` diff --git a/frontend/src/lib/rectification-agent/opportunity-builder.ts b/frontend/src/lib/rectification-agent/opportunity-builder.ts index 0a88ea5c..b505ebce 100644 --- a/frontend/src/lib/rectification-agent/opportunity-builder.ts +++ b/frontend/src/lib/rectification-agent/opportunity-builder.ts @@ -11,17 +11,17 @@ const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [ const domainPolicy: Readonly, Readonly<{ goal: string; - fallbackPrompt: string; + fallbackPrompt: (anchor: string | null) => 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 }, + education: { goal: "收集一件有大致日期的教育转折。", fallbackPrompt: (anchor) => anchor ? `在“${anchor}”之外,你还记得哪次入学、毕业或专业变化大概发生在哪年哪月?` : "你还记得哪次入学、毕业或专业变化大概发生在哪年哪月?", keywords: /大学|学校|入学|毕业|考试|专业|读书/, recallEase: .82, privacyCost: .03 }, + relocation: { goal: "收集一件有大致日期的迁居经历。", fallbackPrompt: (anchor) => anchor ? `以“${anchor}”为时间参照,你哪次搬到新城市或长期离乡的年月最确定?` : "你哪次搬到新城市或长期离乡的年月最确定?", keywords: /搬家|迁居|离家|外地|城市|北京|上海|出国/, recallEase: .78, privacyCost: .04 }, + relationship: { goal: "在用户愿意的前提下收集一件有大致日期的关系转折。", fallbackPrompt: (anchor) => anchor ? `说到“${anchor}”这段时期,如果你愿意,哪次关系变化的大概年月还记得?` : "如果你愿意,哪次关系变化的大概年月还记得?", keywords: /恋爱|关系|结婚|离婚|分手|伴侣|对象/, recallEase: .62, privacyCost: .22 }, + career: { goal: "收集一件有大致日期的职业转折。", fallbackPrompt: (anchor) => anchor ? `在“${anchor}”之后,哪次工作或职责明显变化的年月你还记得?` : "哪次工作或职责明显变化的年月你还记得?", keywords: /工作|实习|公司|研究院|职业|入职|离职|创业|负责/, recallEase: .85, privacyCost: .03 }, + finance: { goal: "在用户愿意的前提下收集一件有大致日期的财务转折。", fallbackPrompt: (anchor) => anchor ? `以“${anchor}”为时间参照,如果方便,哪次财务状况明显变化的年月你还记得?` : "如果方便,哪次财务状况明显变化的年月你还记得?", keywords: /收入|负债|投资|资产|财务|买房|卖房/, recallEase: .6, privacyCost: .18 }, + health_pressure: { goal: "在用户愿意的前提下收集一件本人有大致日期的健康转折。", fallbackPrompt: (anchor) => anchor ? `说到“${anchor}”前后,如果方便,你本人哪次健康变化的大概年月还记得?` : "如果方便,你本人哪次健康变化的大概年月还记得?", keywords: /住院|手术|事故|健康|生病|确诊|康复/, recallEase: .58, privacyCost: .28 }, }; function stableUuid(value: string): string { @@ -70,10 +70,6 @@ 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) { @@ -98,7 +94,8 @@ export function buildQuestionOpportunities(input: Readonly<{ 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 latestEvent = chronologicalEvents(input.events).at(-1); + const latestContext = input.turns.at(-1)?.answer ?? latestEvent?.rawText ?? ""; const opportunities: QuestionOpportunity[] = []; if (input.targetDisposition === "answered_other_event") { @@ -182,25 +179,21 @@ export function buildQuestionOpportunities(input: Readonly<{ } const scoreableCount = input.events.filter((event) => event.scoreability === "scoreable").length; - const latestEvent = chronologicalEvents(input.events).at(-1); 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 themeBonus = policy.keywords.test(latestContext) ? .22 : 0; const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId); const latestAnchor = latestEvent ? anchorFor(latestEvent) : null; - const prompt = latestAnchor - ? `承接“${latestAnchor}”,请再说一件时间相对明确的经历:${policy.fallbackPrompt}` - : policy.fallbackPrompt; opportunities.push(opportunity(input.caseId, { 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, + fallbackPrompt: policy.fallbackPrompt(latestAnchor), reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。", + expectedInformationGain: covered ? .54 + themeBonus : .65 + themeBonus / 2, dateSensitivity: input.snapshot ? .5 : .35, candidateSplitRelevance: input.diagnostics?.candidateSplits.length ? .58 : .42, - domainCoverageGain: covered ? 0 : 1, + domainCoverageGain: covered ? 0 : scoreableDomains.size < 2 ? 1 : .15, recallEase: policy.recallEase, novelty: alreadyAsked ? .35 : .9, repetitionPenalty: alreadyAsked ? .3 : 0, privacyCost: policy.privacyCost, })); diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts index e2d0d2c7..5aa2d6f0 100644 --- a/frontend/src/lib/rectification-agent/renderer-agent.ts +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -11,6 +11,7 @@ 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 cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']?,?请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对)/; const exactClockMinute = /(?:[01]?\d|2[0-3])[::][0-5]\d|(?:[零〇一二两三四五六七八九十]{1,3}|(?:[01]?\d|2[0-3]))点(?:[零〇一二两三四五六七八九十]{1,3}|[0-5]?\d)分/; const exactMinuteClaim = /(?:唯一|准确|精确|确切|确认|确定|代表).{0,12}(?:出生|生时)?(?:时间|时刻|分钟)|(?:出生|生时)(?:时间|时刻|分钟)?.{0,12}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/; @@ -34,6 +35,16 @@ function normalized(value: string): string { return value.normalize("NFKC").replace(/[“”"'\s,,。.!!??::;;]/g, ""); } +function includesAnchor(question: string, anchor: string): boolean { + const normalizedQuestion = normalized(question); + const normalizedAnchor = normalized(anchor); + if (normalizedQuestion.includes(normalizedAnchor)) return true; + for (let start = 0; start <= normalizedAnchor.length - 4; start += 1) { + if (normalizedQuestion.includes(normalizedAnchor.slice(start, start + 4))) return true; + } + return false; +} + function visibleTextSafetyIssues(value: string): string[] { const issues: string[] = []; if (internalTerms.test(value)) issues.push("internal_information_exposed"); @@ -51,9 +62,9 @@ export function validateQuestionRealization(question: unknown, opportunity: Ques if (/\n\s*(?:[-*•]|\d+[.)、])/.test(value)) issues.push("question_list_forbidden"); issues.push(...visibleTextSafetyIssues(value)); if (multiQuestionMoves.test(value)) issues.push("multiple_question_instruction"); - if (opportunity.targetEventId) { - const questionText = normalized(value); - if (!opportunity.anchors.some((anchor) => questionText.includes(normalized(anchor)))) issues.push("target_anchor_missing"); + if (cannedQuestion.test(value)) issues.push("canned_question_forbidden"); + if (opportunity.targetEventId || (opportunity.kind === "ask_new_event" && opportunity.anchors.length > 0)) { + if (!opportunity.anchors.some((anchor) => includesAnchor(value, anchor))) issues.push("target_anchor_missing"); } for (const field of opportunity.requestedFields) { if (field === "event_subject" && !/(?:本人|你自己|家人|伴侣|配偶)/.test(value)) issues.push("event_subject_not_requested"); @@ -61,7 +72,7 @@ export function validateQuestionRealization(question: unknown, opportunity: Ques 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_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"); } @@ -172,7 +183,14 @@ export async function renderPublicTurn(input: Readonly<{ forbiddenMoves: opportunity.forbiddenMoves, } : null, }), { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), structuredOutput: { schema: publicMessageSchema, jsonPromptInjection: "inline" } }); - const message = realizePublicMessage(result.object, input); + const generated = publicMessageSchema.parse(result.object); + const questionValidation = opportunity ? validateQuestionRealization(generated.question, opportunity) : null; + const message = realizePublicMessage(generated, input); + if (questionValidation && !questionValidation.valid) { + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "rejected", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: questionValidation.issues[0] ?? "renderer_question_rejected", deploymentSha }); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_question_rejected", deploymentSha }); + return message; + } 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/tests/rectification-agent-v6.test.ts b/frontend/tests/rectification-agent-v6.test.ts index d95ab428..98c4192b 100644 --- a/frontend/tests/rectification-agent-v6.test.ts +++ b/frontend/tests/rectification-agent-v6.test.ts @@ -191,6 +191,65 @@ test("Builder 的领域排序不受事件输入数组顺序影响", () => { assert.deepEqual(domains([education, career]), domains([career, education])); }); +test("研究院实习后优先延续最新主题,不被旧教育事件的离家关键词拉回迁居问卷", () => { + const education = event({ + summary: "离家去外地上大学", + rawText: "2016年9月离家去外地上大学", + createdAt: "2026-07-29T01:00:00.000Z", + }); + const career = event({ + domain: "career", + eventKind: "career_change", + summary: "去石油化工研究院实习做研究员", + rawText: "2020年4月去石油化工研究院实习做研究员", + dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" }, + createdAt: "2026-07-29T02:00:00.000Z", + }); + const opportunities = buildQuestionOpportunities({ + caseId, + events: [education, career], + turns: [ + turn({ answer: education.rawText, createdAt: "2026-07-29T01:00:00.000Z" }), + turn({ answer: career.rawText, createdAt: "2026-07-29T02:00:00.000Z" }), + ], + snapshot: null, + diagnostics: null, + }); + + assert.equal(opportunities.some((item) => item.kind === "refine_event_date"), false); + assert.equal(opportunities[0]?.kind, "ask_new_event"); + assert.equal(opportunities[0]?.domain, "career"); + assert.match(opportunities[0]?.fallbackPrompt ?? "", /研究院实习/); + assert.doesNotMatch(opportunities[0]?.fallbackPrompt ?? "", /承接.*请再说一件|哪次搬家、离乡或长期迁居/); +}); + +test("Renderer 接受锚定最新事件的自然新事件问题并拒绝旧固定模板", () => { + const latest = 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 opportunity = buildQuestionOpportunities({ caseId, events: [latest], turns: [turn({ answer: latest.rawText })], snapshot: null, diagnostics: null }) + .find((item) => item.kind === "ask_new_event" && item.domain === "career"); + assert.ok(opportunity); + const naturalQuestion = "研究院实习之后,下一次工作发生明显变化大概是什么时候?"; + const canned = `承接“${latest.summary}”,请再说一件时间相对明确的经历:哪次工作变化的时间你比较确定?`; + assert.equal(validateQuestionRealization(naturalQuestion, opportunity).valid, true); + assert.equal(validateQuestionRealization(canned, opportunity).valid, false); + const message = realizePublicMessage({ acknowledgement: `你提到的是“${latest.summary}”。`, candidateUpdate: null, limitation: null, question: naturalQuestion }, { + latestAnswer: latest.rawText, + acceptedEvents: [latest], + pendingEvidence: [], + snapshot: null, + previousSnapshot: null, + validated: validated(opportunity), + }); + assert.equal(message.question, naturalQuestion); + assert.notEqual(message.question, opportunity.fallbackPrompt); +}); + test("Builder 和 Reasoner 按事件创建时间承接最近经历而不是 UUID 顺序", () => { const older = event({ eventId: "ffffffff-ffff-4fff-8fff-ffffffffffff", diff --git a/skills/birth-time-rectification/references/question-policy.md b/skills/birth-time-rectification/references/question-policy.md index b09f4fdc..c88af13d 100644 --- a/skills/birth-time-rectification/references/question-policy.md +++ b/skills/birth-time-rectification/references/question-policy.md @@ -6,6 +6,8 @@ Question opportunities describe meaning, not final prose. New opportunities use 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. +Use the latest answer and latest accepted event as the current topic. Do not let keywords from older turns pull the conversation back to a stale domain, and do not give an uncovered domain both a coverage reward and a second topic reward from the same older event. Once the minimum domain coverage is already present, continuity and information gain should outweigh collecting another domain merely because it is missing. + ## One-turn rule - Ask one question only. @@ -14,6 +16,8 @@ The builder produces several candidates and publishes at most five active opport - 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. +- Reject canned realizations such as `承接……请再说一件……`; a deterministic fallback must still read as one short contextual question. +- Validate the question semantically. Natural wording such as “下一次明显变化大概发生在什么时候” must not be rejected only because it omits a fixed phrase such as `哪次` or `哪件`. ## Target disposition From a5f409a500b42cf568023af5acd537580f9c2434 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 29 Jul 2026 22:49:17 +0800 Subject: [PATCH 06/15] fix: align rectification spec hashes across runtimes --- docs/BUG_HISTORY.md | 14 ++++++++++++++ scripts/rectification/scoring_service.py | 7 ++++++- tests/test_rectification_v5_services.py | 15 ++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 734e0022..b6d279c8 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1662,3 +1662,17 @@ - 防复发:模型调用成功、Schema 成功和问题被接受必须分开观测;Opportunity utility 不得把历史关键词与“未覆盖领域”组合成伪装的固定轮询。 - 相关记录:BUG-086、BUG-090、BUG-091 - 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` + +## BUG-093 | 首次候选评分因跨语言计算规格哈希不一致而失败 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:V5 Candidate Engine 首次达到三事件评分门后的 Feature Snapshot 原子持久化 +- 用户现象:第三件可评分事件已经保存在 Turn,但 Worker 最终显示“这次比较没有完成,回答已经保留,请再试一次”,Case 恢复上一问题且没有写入新事件。 +- 根因:服务器创建 Case 时由 TypeScript 对整数时区 `8` 计算规格哈希;Python 请求归一化把它变成浮点数 `8.0`,而 Python JSON 序列化保留 `.0`。两个运行时对语义相同的规格得到不同哈希,完成事务因此拒绝 Feature Snapshot 并抛出 `rectification_v5_feature_snapshot_mismatch`。 +- 修复:Python 生成跨服务 Calculation Spec 时将整数值的经纬度和时区规范化为整数,使其 JSON 数字表示与 TypeScript `JSON.stringify` 一致;评分算法和候选矩阵不变。 +- 验证:新增已知 TypeScript 哈希向量测试,修复前稳定失败、修复后通过;staging Case `e2d3e1d2-efb0-461d-9914-f890bc2b8569` 的 PostgreSQL 日志确认原始异常,使用同一规格重放确认 Python Feature Snapshot 哈希恢复为 Case 哈希。 +- 防复发:跨语言持久化指纹必须使用已知向量验证 JSON 数字规范化,不能只在各自语言内断言自洽。 +- 相关记录:BUG-087、BUG-092 +- 修复版本:`rectification-v5-matrix-scoring-1`(仅修复输入规范化,算法版本不变) diff --git a/scripts/rectification/scoring_service.py b/scripts/rectification/scoring_service.py index 6f119573..84a2816f 100644 --- a/scripts/rectification/scoring_service.py +++ b/scripts/rectification/scoring_service.py @@ -150,11 +150,16 @@ def score_from_matrix(request: RectificationRequest, built: dict[str, Any]) -> l def calculation_spec(request: RectificationRequest) -> dict[str, Any]: + def json_number(value: float) -> int | float: + return int(value) if value.is_integer() else value + return { "version": INPUT_CONTRACT_VERSION, "birthDate": request["birth_date"], "candidateRange": {"start": request["start_time"], "end": request["end_time"]}, - "latitude": request["lat"], "longitude": request["lon"], "timezoneOffsetHours": request["tz"], + "latitude": json_number(request["lat"]), + "longitude": json_number(request["lon"]), + "timezoneOffsetHours": json_number(request["tz"]), "ayanamsa": "lahiri", "nodeMode": "mean", "minuteStep": 1, } diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index 76bd1224..60364f0e 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -6,7 +6,13 @@ from unittest.mock import patch from scripts.rectification.api_service import diagnostics, score_candidates from scripts.rectification.contracts import normalize_rectification_request -from scripts.rectification.scoring_service import build_event_contribution_matrix, sample_event_dates, score_from_matrix +from scripts.rectification.scoring_service import ( + build_event_contribution_matrix, + calculation_spec, + sample_event_dates, + score_from_matrix, + sha256, +) from scripts.jyotish_api_server import ( API_COMMAND_MAP, TECHNIQUE_EXAMPLE_ENDPOINTS, @@ -38,6 +44,13 @@ def request(*, precision: str = "month", event_kind: str = "education_milestone" class RectificationV5ServicesTest(unittest.TestCase): + def test_calculation_spec_hash_matches_typescript_for_integral_timezone(self): + normalized = normalize_rectification_request(request(), today=date(2026, 7, 28)) + self.assertEqual( + sha256(calculation_spec(normalized)), + "f05fe0f56ef9ba2b18ec3c6c54f1649f06f1ae5a926491a5c5f676d718d92865", + ) + def test_shared_validator_rejects_family_and_non_self_health_scoring(self): with self.assertRaisesRegex(ValueError, "domain is not scoreable"): normalize_rectification_request(request(domain="family", event_kind="family_bereavement"), today=date(2026, 7, 28)) From 0a5b8cd23930e8ffe93d8779519977523cacfa12 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 30 Jul 2026 00:41:48 +0800 Subject: [PATCH 07/15] fix: restore rectification agent message actions --- docs/BUG_HISTORY.md | 16 +++ .../v4/cases/[caseId]/regenerate/route.ts | 22 ++++ .../src/components/rectification-v4-panel.tsx | 118 +++++++++++++++++- frontend/src/hooks/use-rectification-v4.ts | 4 + .../lib/rectification-agent/renderer-agent.ts | 41 ++++++ .../src/lib/rectification-v4/case-service.ts | 35 ++++++ frontend/src/lib/rectification-v4/client.ts | 6 + .../src/lib/rectification-v4/memory-store.ts | 31 +++++ frontend/src/lib/rectification-v4/store.ts | 9 ++ .../lib/rectification-v4/supabase-store.ts | 22 ++++ ...ation_v4_current_question_regeneration.sql | 80 ++++++++++++ ...ersational-rectification-component.test.ts | 47 ++++++- .../conversational-visible-narrative.test.ts | 3 +- .../tests/rectification-v4-migration.test.ts | 14 +++ .../tests/rectification-v4-service.test.ts | 69 ++++++++++ 15 files changed, 513 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts create mode 100644 frontend/supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b6d279c8..5e4ae6c7 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1676,3 +1676,19 @@ - 防复发:跨语言持久化指纹必须使用已知向量验证 JSON 数字规范化,不能只在各自语言内断言自洽。 - 相关记录:BUG-087、BUG-092 - 修复版本:`rectification-v5-matrix-scoring-1`(仅修复输入规范化,算法版本不变) + +## BUG-094 | V5 Agent 消息操作栏在 V4 面板切换后消失 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:生时校正 V5 Agent 对话、助手消息反馈与当前问题重新生成 +- 用户现象:助手消息气泡下不再显示点赞、点踩、复制和重新生成操作。 +- 触发条件:生时校正页面使用 `RectificationV4Panel` 渲染 V5 Agent 会话。 +- 根因:旧对话组件中的消息操作栏没有迁入 V4/V5 共用面板,同时新 V4 API 没有与当前语义问题绑定的重新生成命令。 +- 修复:复用现有消息操作栏样式,仅为 `v5_agent` 的稳定助手消息恢复操作;重新生成只重写当前已验证 Semantic Question Opportunity 的自然语言实现,并通过用户、Case 版本、当前目标和 action ID 原子校验,不重跑事件提取、候选评分、诊断或 Job。 +- 验证:组件资格与反馈互斥测试、V4 service 幂等重放与数据不变量测试、Renderer 安全回落测试、迁移契约测试,以及 staging 精确 SHA 部署和浏览器验收。 +- 防复发:测试锁定 V5 Agent 操作栏、仅当前问题可重跑、legacy/shadow 不启用新 Renderer、重跑不改变 turns/events/snapshots/profile 且 completed Job 不被改写。 +- 相关记录:BUG-090、BUG-091、BUG-092、BUG-093 +- 复发自:无 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts new file mode 100644 index 00000000..d98398c6 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts"; +import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) { + try { + const body = await requestBody(request, caseActionRequestSchema); + const context = await rectificationV4Context(); + const result = await context.service.regenerateQuestion({ + ...body, + userId: context.userId, + caseId: routeId((await params).caseId), + }); + return result + ? NextResponse.json(result) + : NextResponse.json({ error: "当前问题不能重新生成,请刷新后重试。" }, { status: 409 }); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/components/rectification-v4-panel.tsx b/frontend/src/components/rectification-v4-panel.tsx index 53b64b3b..8a42dcb5 100644 --- a/frontend/src/components/rectification-v4-panel.tsx +++ b/frontend/src/components/rectification-v4-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useRectificationV4 } from "@/hooks/use-rectification-v4"; import type { ChatMessageView } from "@/lib/chat-message-view"; @@ -29,6 +29,28 @@ type RectificationV4PanelProps = Readonly<{ onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void; }>; +export function toggleRectificationFeedback( + current: "up" | "down" | undefined, + requested: "up" | "down", +): "up" | "down" | undefined { + return current === requested ? undefined : requested; +} + +export function canRegenerateRectificationMessage(input: Readonly<{ + message: ChatMessageView; + currentMessageKey: string | null; + deploymentMode: RectificationV4ApiResponse["case"]["deploymentMode"] | null; + busy: boolean; + canAnswer: boolean; +}>): boolean { + return input.deploymentMode === "v5_agent" + && input.message.role === "assistant" + && input.message.state === "settled" + && input.message.renderKey === input.currentMessageKey + && !input.busy + && input.canAnswer; +} + export function rectificationV4ChatMessages( data: RectificationV4ApiResponse | null, processing: boolean, @@ -129,6 +151,9 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { onPendingChange: props.onPendingChange, }); const [draft, setDraft] = useState(""); + const [feedback, setFeedback] = useState>({}); + const [copiedMessageKey, setCopiedMessageKey] = useState(null); + const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); const composer = useRef(null); const conversationEnd = useRef(null); const caseValue = controller.data?.case; @@ -145,6 +170,10 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { && !processing && !controller.pending && ["awaiting_answer", "range_ready"].includes(caseValue?.status ?? ""); + const currentMessageKey = caseValue?.currentQuestion + ? `rectification-current-${caseValue.currentQuestion.id}` + : null; + const busy = processing || controller.pending || regeneratingMessageKey !== null; const canAcceptRange = caseValue?.status === "range_ready" && Boolean(caseValue.latestSnapshot?.canAcceptRange) && !caseValue.acceptedRange; @@ -174,6 +203,27 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { if (result) setDraft(""); } + async function copyMessage(message: ChatMessageView) { + try { + await navigator.clipboard.writeText(message.text); + setCopiedMessageKey(message.renderKey); + window.setTimeout(() => setCopiedMessageKey((current) => ( + current === message.renderKey ? null : current + )), 1_500); + } catch { + // Clipboard permission failures must not interrupt the conversation. + } + } + + async function regenerateMessage(messageKey: string) { + setRegeneratingMessageKey(messageKey); + try { + await controller.regenerate(); + } finally { + setRegeneratingMessageKey((current) => current === messageKey ? null : current); + } + } + function continueOriginalQuestion() { if (!caseValue?.acceptedRange || !handoff) return; props.onContinueOriginalQuestion?.({ @@ -189,7 +239,71 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { <>
- {messages.map((message) => )} + {messages.map((message) => { + const showActions = caseValue?.deploymentMode === "v5_agent" + && message.role === "assistant" + && message.state === "settled" + && Boolean(message.text); + const regenerating = regeneratingMessageKey === message.renderKey; + const canRegenerate = canRegenerateRectificationMessage({ + message, + currentMessageKey, + deploymentMode: caseValue?.deploymentMode ?? null, + busy, + canAnswer, + }); + return ( +
+ + {showActions && !regenerating && ( +
+ + + + +
+ )} +
+ ); + })} {controller.error &&

{controller.error}

}
diff --git a/frontend/src/hooks/use-rectification-v4.ts b/frontend/src/hooks/use-rectification-v4.ts index 981a1f9c..abc027d2 100644 --- a/frontend/src/hooks/use-rectification-v4.ts +++ b/frontend/src/hooks/use-rectification-v4.ts @@ -16,6 +16,7 @@ import { loadRectificationV4, loadRectificationV4Handoff, loadRectificationV4Job, + regenerateRectificationV4Question, transitionRectificationV4, } from "@/lib/rectification-v4/client"; @@ -136,6 +137,9 @@ export function useRectificationV4(input: { answer: (answer: string, modelId?: string | null) => data ? mutate(() => answerRectificationV4(data.case.id, data.case.version, answer, modelId)) : Promise.resolve(null), + regenerate: () => data + ? mutate(() => regenerateRectificationV4Question(data.case.id, data.case.version)) + : Promise.resolve(null), pause: () => data ? mutate(() => transitionRectificationV4(data.case.id, data.case.version, "pause")) : Promise.resolve(null), diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts index 5aa2d6f0..b7d3ca02 100644 --- a/frontend/src/lib/rectification-agent/renderer-agent.ts +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { Agent } from "@mastra/core/agent"; +import { z } from "zod"; import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts"; import { publicMessageSchema, type PublicMessage, type QuestionOpportunity, type ValidatedDecision } from "./contracts.ts"; @@ -14,6 +15,7 @@ const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便 const cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']?,?请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对)/; const exactClockMinute = /(?:[01]?\d|2[0-3])[::][0-5]\d|(?:[零〇一二两三四五六七八九十]{1,3}|(?:[01]?\d|2[0-3]))点(?:[零〇一二两三四五六七八九十]{1,3}|[0-5]?\d)分/; const exactMinuteClaim = /(?:唯一|准确|精确|确切|确认|确定|代表).{0,12}(?:出生|生时)?(?:时间|时刻|分钟)|(?:出生|生时)(?:时间|时刻|分钟)?.{0,12}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/; +const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict(); function agentFor(modelId: string | null): { id: string; agent: Agent } | null { const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel(); @@ -199,3 +201,42 @@ export async function renderPublicTurn(input: Readonly<{ return fallback; } } + +export async function regenerateQuestionRealization(input: Readonly<{ + caseValue: RectificationV4Case; + currentPrompt: string; + latestAnswer: string; + acceptedEvents: readonly LifeEventRevision[]; + opportunity: QuestionOpportunity; + timeoutMs?: number; +}>): Promise { + const selected = agentFor(input.caseValue.narrationModelId); + if (!selected) return input.currentPrompt; + try { + const result = await selected.agent.generate(JSON.stringify({ + task: "Rewrite the current question naturally without changing its semantic target. Return one question only.", + currentPrompt: input.currentPrompt, + latestAnswer: input.latestAnswer, + recentEvents: input.acceptedEvents.slice(-5).map((event) => ({ + summary: event.summary, + date: event.dateRange.label, + subject: event.subject, + })), + selectedOpportunity: { + kind: input.opportunity.kind, + goal: input.opportunity.goal, + requestedFields: input.opportunity.requestedFields, + anchors: input.opportunity.anchors, + contextFacts: input.opportunity.contextFacts, + forbiddenMoves: input.opportunity.forbiddenMoves, + }, + }), { + abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), + structuredOutput: { schema: questionRealizationSchema, jsonPromptInjection: "inline" }, + }); + const question = questionRealizationSchema.parse(result.object).question; + return validateQuestionRealization(question, input.opportunity).valid ? question : input.currentPrompt; + } catch { + return input.currentPrompt; + } +} diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index 8766a152..db4fc5fc 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -8,6 +8,7 @@ import type { 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 { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts"; import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; import { openingQuestion } from "./opening-question.ts"; import type { RectificationV4Store } from "./store.ts"; @@ -88,6 +89,40 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op return response(input.userId, saved.case, saved.job.id); }, + async regenerateQuestion(input: { + readonly userId: string; + readonly caseId: string; + readonly actionId: string; + readonly expectedCaseVersion: number; + }) { + const current = await store.loadCase(input.userId, input.caseId); + if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null; + const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId); + const opportunity = validated?.selectedOpportunity; + if (!opportunity) return null; + const [events, turns] = await Promise.all([ + store.loadEvents(input.userId, input.caseId), + store.loadTurns(input.userId, input.caseId), + ]); + const prompt = await regenerateQuestionRealization({ + caseValue: current, + currentPrompt: current.currentQuestion.prompt, + latestAnswer: turns.at(-1)?.answer ?? "", + acceptedEvents: events, + opportunity, + }); + const nextQuestion = { + ...current.currentQuestion, + id: randomUUID(), + prompt, + }; + return response(input.userId, await store.replaceCurrentQuestion({ + ...input, + question: nextQuestion, + now: now().toISOString(), + })); + }, + async reviseEvent(input: { readonly userId: string; readonly caseId: string; diff --git a/frontend/src/lib/rectification-v4/client.ts b/frontend/src/lib/rectification-v4/client.ts index 5c8af417..b0fe65ff 100644 --- a/frontend/src/lib/rectification-v4/client.ts +++ b/frontend/src/lib/rectification-v4/client.ts @@ -63,6 +63,12 @@ export function answerRectificationV4(caseId: string, expectedCaseVersion: numbe }); } +export function regenerateRectificationV4Question(caseId: string, expectedCaseVersion: number) { + return post(`/api/rectification/v4/cases/${caseId}/regenerate`, { + actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, + }); +} + export function transitionRectificationV4( caseId: string, expectedCaseVersion: number, diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts index aef2c457..37f6522c 100644 --- a/frontend/src/lib/rectification-v4/memory-store.ts +++ b/frontend/src/lib/rectification-v4/memory-store.ts @@ -69,6 +69,14 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { .filter((turn) => turn.caseId === caseId) .sort((left, right) => left.caseVersion - right.caseVersion || left.createdAt.localeCompare(right.createdAt)); }, + async loadLatestValidatedDecision(userId, caseId) { + const caseValue = cases.get(caseId); + if (!caseValue || caseValue.userId !== userId) return null; + const latest = [...agentRuns.values()] + .filter((run) => run.caseId === caseId) + .sort((left, right) => right.caseVersion - left.caseVersion || right.createdAt.localeCompare(left.createdAt))[0]; + return latest ? validatedDecisions.get(latest.jobId) ?? latest.validatedDecision : null; + }, async createCase(input) { const replay = actionResults.get(`${input.case.userId}:${input.actionId}`); if (replay) return owned(input.case.userId, replay.caseId); @@ -91,6 +99,29 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: input.case.id, jobId: null }); return input.case; }, + async replaceCurrentQuestion(input) { + const key = `${input.userId}:${input.actionId}`; + const replay = actionResults.get(key); + if (replay) return owned(input.userId, replay.caseId); + const current = owned(input.userId, input.caseId); + if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version"); + if (current.deploymentMode !== "v5_agent" + || !["awaiting_answer", "range_ready"].includes(current.status) + || !current.currentQuestion) throw new RectificationV4StoreError("invalid_state"); + const updated: RectificationV4Case = { + ...current, + version: current.version + 1, + currentQuestion: { + ...input.question, + domain: current.currentQuestion.domain, + targetEventId: current.currentQuestion.targetEventId, + }, + updatedAt: input.now, + }; + cases.set(current.id, updated); + actionResults.set(key, { caseId: current.id, jobId: null }); + return updated; + }, async submitAnswer(input) { const key = `${input.userId}:${input.actionId}`; const replay = actionResults.get(key); diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts index 812d3dcc..1601d21c 100644 --- a/frontend/src/lib/rectification-v4/store.ts +++ b/frontend/src/lib/rectification-v4/store.ts @@ -45,7 +45,16 @@ export interface RectificationV4Store { loadCase(userId: string, caseId: string): Promise; loadEvents(userId: string, caseId: string): Promise; loadTurns(userId: string, caseId: string): Promise; + loadLatestValidatedDecision(userId: string, caseId: string): Promise; createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise; + replaceCurrentQuestion(input: { + readonly userId: string; + readonly caseId: string; + readonly actionId: string; + readonly expectedCaseVersion: number; + readonly question: RectificationV4Question; + readonly now: string; + }): Promise; submitAnswer(input: { readonly userId: string; readonly caseId: string; diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index 045bd25e..3ae3e1d9 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from "@supabase/supabase-js"; +import { validatedDecisionSchema, type ValidatedDecision } from "../rectification-agent/contracts.ts"; import { candidateSnapshotSchema, lifeEventRevisionSchema, @@ -199,6 +200,14 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re loadCase: loadCaseById, loadEvents: loadEventsByCase, loadTurns: loadTurnsByCase, + async loadLatestValidatedDecision(userId, caseId): Promise { + const { data, error } = await supabase.from("birth_time_rectification_agent_runs") + .select("validated_decision_json").eq("case_id", caseId).eq("user_id", userId) + .order("case_version", { ascending: false }).order("created_at", { ascending: false }) + .limit(1).maybeSingle(); + if (error) throw storeError(error); + return data ? validatedDecisionSchema.parse((data as Row).validated_decision_json) : null; + }, async createCase(input) { const id = String(await rpc("create_birth_time_rectification_v5_case", { p_user_id: input.case.userId, @@ -222,6 +231,19 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re if (!value) throw new RectificationV4StoreError("not_found"); return value; }, + async replaceCurrentQuestion(input) { + const id = String(await rpc("replace_birth_time_rectification_v4_current_question", { + p_user_id: input.userId, + p_case_id: input.caseId, + p_action_id: input.actionId, + p_expected_version: input.expectedCaseVersion, + p_question: input.question, + p_now: input.now, + })); + const value = await loadCaseById(input.userId, id); + if (!value) throw new RectificationV4StoreError("not_found"); + return value; + }, async submitAnswer(input) { const jobId = String(await rpc("submit_birth_time_rectification_v4_answer", { p_user_id: input.userId, diff --git a/frontend/supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql b/frontend/supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql new file mode 100644 index 00000000..210d837d --- /dev/null +++ b/frontend/supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql @@ -0,0 +1,80 @@ +begin; + +create or replace function public.replace_birth_time_rectification_v4_current_question( + p_user_id uuid, + p_case_id uuid, + p_action_id uuid, + p_expected_version bigint, + p_question jsonb, + p_now timestamptz +) returns uuid +language plpgsql security definer set search_path = '' as $$ +declare + v_case public.birth_time_rectification_v4_cases%rowtype; + v_case_id uuid; + v_question jsonb; +begin + select action.case_id into v_case_id + from public.birth_time_rectification_v4_actions action + where action.user_id = p_user_id and action.action_id = p_action_id; + if v_case_id is not null then return v_case_id; end if; + + select value.* into v_case + from public.birth_time_rectification_v4_cases value + where value.id = p_case_id and value.user_id = p_user_id + for update; + if not found then raise exception 'rectification_v4_case_not_found'; end if; + + select action.case_id into v_case_id + from public.birth_time_rectification_v4_actions action + where action.user_id = p_user_id and action.action_id = p_action_id; + if v_case_id is not null then return v_case_id; end if; + + if v_case.version <> p_expected_version then raise exception 'stale_rectification_v4_case'; end if; + if v_case.deployment_mode <> 'v5_agent' + or v_case.status not in ('awaiting_answer', 'range_ready') + or v_case.current_question is null then + raise exception 'rectification_v4_question_not_regenerable'; + end if; + if p_question is null or pg_catalog.jsonb_typeof(p_question) <> 'object' then + raise exception 'invalid_rectification_v4_question'; + end if; + if nullif(pg_catalog.btrim(p_question->>'id'), '') is null + or (p_question->>'id') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + or nullif(pg_catalog.btrim(p_question->>'prompt'), '') is null + or pg_catalog.length(pg_catalog.btrim(p_question->>'prompt')) > 1000 + or p_question->>'recallCost' not in ('low', 'medium', 'high') + or nullif(pg_catalog.btrim(p_question->>'reason'), '') is null + or pg_catalog.length(pg_catalog.btrim(p_question->>'reason')) > 240 then + raise exception 'invalid_rectification_v4_question'; + end if; + + v_question := p_question || pg_catalog.jsonb_build_object( + 'domain', v_case.current_question->'domain', + 'targetEventId', v_case.current_question->'targetEventId' + ); + + update public.birth_time_rectification_v4_cases + set version = p_expected_version + 1, + current_question = v_question, + updated_at = p_now + where id = p_case_id; + + insert into public.birth_time_rectification_v4_actions( + user_id, action_id, case_id, created_at + ) values ( + p_user_id, p_action_id, p_case_id, p_now + ); + + return p_case_id; +end; +$$; + +revoke all on function public.replace_birth_time_rectification_v4_current_question( + uuid, uuid, uuid, bigint, jsonb, timestamptz +) from public, anon, authenticated, service_role; +grant execute on function public.replace_birth_time_rectification_v4_current_question( + uuid, uuid, uuid, bigint, jsonb, timestamptz +) to service_role; + +commit; diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index 667c1e43..7030d996 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { rectificationV4ChatMessages } from "../src/components/rectification-v4-panel.tsx"; +import { + canRegenerateRectificationMessage, + rectificationV4ChatMessages, + toggleRectificationFeedback, +} from "../src/components/rectification-v4-panel.tsx"; import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts"; const id = "00000000-0000-4000-8000-000000000901"; @@ -87,6 +91,11 @@ test("v4 rectification reuses the ordinary session message list, composer, and m assert.match(component, /className="composer"/); assert.match(component, /