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"; 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(""); hex[12] = "4"; hex[16] = ((Number.parseInt(hex[16]!, 16) & 3) | 8).toString(16); return `${hex.slice(0, 8).join("")}-${hex.slice(8, 12).join("")}-${hex.slice(12, 16).join("")}-${hex.slice(16, 20).join("")}-${hex.slice(20).join("")}`; } const routingValue: Record = { clarify_intake: .18, resolve_event_conflict: .16, clarify_event_subject: .14, refine_event_date: .08, pair_related_event: .05, disambiguate_candidate_split: .04, ask_new_event: 0, }; 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 + routingValue[value.kind] - value.repetitionPenalty - value.privacyCost ).toFixed(6)); } 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; } export function buildQuestionOpportunities(input: Readonly<{ caseId: string; events: readonly LifeEventRevision[]; turns: readonly RectificationV4Turn[]; snapshot: CandidateSnapshot | null; diagnostics: DiagnosticsSummary | null; targetDisposition?: TargetDisposition; retryTargetEventIds?: readonly string[]; }>): readonly QuestionOpportunity[] { 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[] = []; 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 (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, 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) && (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, 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 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 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, 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)) .slice(0, 5); }