Files
Jyotisha/frontend/src/lib/rectification-agent/opportunity-builder.ts
T
2026-07-29 18:17:46 +08:00

213 lines
13 KiB
TypeScript

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<Record<Exclude<EvidenceDomain, "family" | "other">, 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<QuestionOpportunity["kind"], number> = {
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<SemanticQuestionOpportunity, "contractVersion" | "opportunityId" | "utility" | "active" | "forbiddenMoves">;
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<EvidenceDomain> {
const result = new Set<EvidenceDomain>();
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<string, number>();
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<EvidenceDomain, "family" | "other">, (typeof domainPolicy)[Exclude<EvidenceDomain, "family" | "other">]][]) {
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);
}