refactor: rebuild birth time rectification agent

This commit is contained in:
Jesse_Chen
2026-07-28 13:04:30 +08:00
parent ac5aef5f89
commit 8ade6ed5c8
59 changed files with 4422 additions and 1141 deletions
@@ -5,10 +5,14 @@ export type ExtractedLifeEventEvidence = {
readonly id: string;
readonly rawText: string;
readonly domain: RectificationEvidenceDomain;
readonly eventKind: string;
readonly subject: "self" | "family" | "partner" | "other";
readonly relatedPerson: "father" | "mother" | "grandparent" | "sibling" | "partner" | null;
readonly eventSummary: string;
readonly dateValue: string | null;
readonly datePrecision: "day" | "month" | "year" | "unknown";
readonly extractionStatus: "clear" | "needs_clarification" | "corrected";
readonly scoreability: "scoreable" | "context_only" | "pending_review" | "unsupported";
readonly scoreable: boolean;
readonly correctsEvidenceIds: readonly string[];
};
@@ -86,15 +90,59 @@ function eventSummary(fragment: string): string {
: missingEventSummary;
}
function classifyDomain(summary: string): RectificationEvidenceDomain {
if (/确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|去世|离世|死亡|丧亲|健康/.test(summary)) return "health_pressure";
if (/毕业|入学|升学|转学|学校|大学|专业|考试|留学|学业|学习/.test(summary)) return "education";
if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) return "relocation";
if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) return "relationship";
if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) return "family";
if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) return "finance";
if (/工作|入职|离职|辞职|升职|创业|职业|职位|任职|管理职责|公司|项目/.test(summary)) return "career";
return "other";
type EventSemantics = Readonly<{
domain: RectificationEvidenceDomain;
eventKind: string;
subject: "self" | "family" | "partner" | "other";
relatedPerson: "father" | "mother" | "grandparent" | "sibling" | "partner" | null;
scoreability: "scoreable" | "context_only" | "pending_review" | "unsupported";
}>;
function classifyEvent(summary: string): EventSemantics {
const familyPerson = summary.match(/(父亲|爸爸|母亲|妈妈|爷爷|奶奶|外公|外婆|祖父|祖母|外祖父|外祖母|兄弟|姐妹|伴侣|配偶|丈夫|妻子|老公|老婆|男友|女友|儿子|女儿|孩子)/);
if (familyPerson && /确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|重病|去世|离世|死亡|丧亲|葬礼/.test(summary)) {
const relatedPerson = /父亲|爸爸/.test(familyPerson[1])
? "father"
: /母亲|妈妈/.test(familyPerson[1])
? "mother"
: /爷爷|奶奶|外公|外婆|祖父|祖母|外祖父|外祖母/.test(familyPerson[1])
? "grandparent"
: /兄弟|姐妹/.test(familyPerson[1])
? "sibling"
: /伴侣|配偶|丈夫|妻子|老公|老婆|男友|女友/.test(familyPerson[1])
? "partner"
: null;
const bereavement = /去世|离世|死亡|丧亲|葬礼/.test(summary);
return {
domain: "family",
eventKind: bereavement ? "family_bereavement" : "family_health_event",
subject: "family",
relatedPerson,
scoreability: "context_only",
};
}
if (/确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|健康/.test(summary)) {
return { domain: "health_pressure", eventKind: "self_health_event", subject: "self", relatedPerson: null, scoreability: "scoreable" };
}
if (/毕业|入学|升学|转学|学校|大学|专业|考试|考(?:了)?(?:一)?次?研|研究生(?:入学)?考试|留学|学业|学习/.test(summary)) {
return { domain: "education", eventKind: "education_milestone", subject: "self", relatedPerson: null, scoreability: "scoreable" };
}
if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) {
return { domain: "relocation", eventKind: "relocation", subject: "self", relatedPerson: null, scoreability: "scoreable" };
}
if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) {
return { domain: "relationship", eventKind: "relationship_change", subject: /伴侣|配偶/.test(summary) ? "partner" : "self", relatedPerson: /伴侣|配偶/.test(summary) ? "partner" : null, scoreability: "scoreable" };
}
if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) {
return { domain: "family", eventKind: "family_event", subject: "family", relatedPerson: null, scoreability: "context_only" };
}
if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) {
return { domain: "finance", eventKind: "finance_change", subject: "self", relatedPerson: null, scoreability: "scoreable" };
}
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" };
}
function dateIsFuture(date: ParsedDate, asOfDate: string): boolean {
@@ -155,7 +203,11 @@ function coalesceSameEventDetails(
&& previous.dateValue === event.dateValue
&& previous.datePrecision === event.datePrecision
&& previous.domain === event.domain
&& previous.eventKind === event.eventKind
&& previous.subject === event.subject
&& previous.relatedPerson === event.relatedPerson
&& previous.extractionStatus === event.extractionStatus
&& previous.scoreability === event.scoreability
&& previous.scoreable === event.scoreable
&& previous.correctsEvidenceIds.join("\0") === event.correctsEvidenceIds.join("\0");
if (!canMerge) {
@@ -192,19 +244,25 @@ export function extractLifeEventEvidence(
? ownDates[0] ?? null
: ownDates.length === 0 && !unresolvedRelativeTime ? sharedDate : null;
const summary = eventSummary(fragment);
const semantics = classifyEvent(summary);
const complete = summary !== missingEventSummary && date !== null && !unresolvedRelativeTime;
const extractionStatus = !complete
? "needs_clarification"
: correctionTargets.length > 0 ? "corrected" : "clear";
const scoreable = complete && !dateIsFuture(date, input.asOfDate) && semantics.scoreability === "scoreable";
events.push({
id: evidenceId(input, events.length, summary),
rawText: input.rawText,
domain: classifyDomain(summary),
domain: semantics.domain,
eventKind: semantics.eventKind,
subject: semantics.subject,
relatedPerson: semantics.relatedPerson,
eventSummary: summary,
dateValue: date?.value ?? null,
datePrecision: date?.precision ?? "unknown",
extractionStatus,
scoreable: complete && !dateIsFuture(date, input.asOfDate),
scoreability: complete ? semantics.scoreability : "pending_review",
scoreable,
correctsEvidenceIds: correctionTargets,
});
}
@@ -132,6 +132,7 @@ const transitionValidatorVersion = "conversational-rectification-orchestrator-v1
const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/;
const genericUncertaintyPattern = /(?:不知道|不确定)/;
const contextualRelativeMonthPattern = /(?:来年|次年|第二年|翌年|同年|当年|那年)\s*(\d{1,2})\s*月份?/;
const contextualRelativeEventMonthPattern = /(来年|次年|第二年|翌年|同年|当年|那年)([^。!?!?;]{0,80}?)(\d{1,2})\s*月份?([^。!?!?;]*)/;
const contextualBareMonthDayPattern = /^\s*(\d{1,2})\s*月\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/;
const contextualBareDayPattern = /^\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/;
const affirmativeAnswerPattern = /^\s*(?:(?:)?|(?:)?|||||+|)\s*[.!,]?\s*$/u;
@@ -332,7 +333,9 @@ function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
id: item.id,
summary: visibleEvidenceSummary(item.eventSummary),
dateLabel: item.dateValue
? item.scoreable === false && item.extractionStatus !== "needs_clarification"
? item.scoreable === false
&& (item.scoreability === undefined || item.scoreability === "scoreable")
&& item.extractionStatus !== "needs_clarification"
? `${item.dateValue}(未来,仅作背景)`
: item.dateValue
: "日期待补充",
@@ -653,30 +656,45 @@ function nonScoringTurn(input: {
}>;
}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } {
const allEvidence = [...input.current.eventEvidence, ...input.newEvidence];
const latestIncomplete = input.newEvidence
.filter((item) => item.extractionStatus === "needs_clarification")
.at(-1);
const authoredNarrative = input.authoredNarrative;
const latestSummary = input.newEvidence.at(-1)?.eventSummary;
const fallbackSubject = latestSummary && latestSummary !== "事件内容待补充"
? latestSummary
: input.latestUserText.trim().slice(0, 80);
const narrative = authoredNarrative?.narrative
?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,内容已经保留。你可以继续讲这段经历,也可以按自己的节奏说下一件想到的事`;
?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,但这次分析暂时没有完成。内容已经保留,你可以按自己的节奏继续补充它的时间和经过,或直接说下一件已经发生的经历`;
const status = input.correctionReset
? "active" as const
: input.current.status === "confirming" ? "confirming" as const : "active" as const;
const actions = actionsFor(status);
const authoredRequest = authoredNarrative?.output.evidenceRequest;
const priorRequest = input.current.latestTurn.evidenceRequest;
const evidenceRequest = authoredRequest
? {
domains: authoredRequest.domains,
datePrecision: authoredRequest.datePrecision,
freeTextAllowed: true as const,
prompt: authoredRequest.prompt,
followUp: input.followUpOverride ?? authoredRequest.followUp,
}
: input.followUpOverride && priorRequest
? { ...priorRequest, followUp: input.followUpOverride }
const clarificationFollowUp = latestIncomplete?.dateValue === null
&& latestIncomplete.eventSummary !== "事件内容待补充"
? { kind: "event_date" as const, evidenceId: latestIncomplete.id }
: latestIncomplete?.dateValue
&& latestIncomplete.eventSummary === "事件内容待补充"
? { kind: "event_detail" as const, evidenceId: latestIncomplete.id }
: null;
const authoredRequest = authoredNarrative?.output.evidenceRequest;
const priorRequest = input.current.latestTurn.evidenceRequest;
const evidenceRequest = status === "confirming" && priorRequest === null
? null
: authoredRequest
? {
domains: authoredRequest.domains,
datePrecision: authoredRequest.datePrecision,
freeTextAllowed: true as const,
prompt: authoredRequest.prompt,
followUp: input.followUpOverride ?? authoredRequest.followUp,
}
: priorRequest
? {
...priorRequest,
followUp: input.followUpOverride ?? clarificationFollowUp ?? priorRequest.followUp,
}
: null;
const parsed = conversationalRectificationTurnSchema.safeParse({
...input.current.latestTurn,
status,
@@ -901,7 +919,7 @@ export function createConversationalRectificationService(
current: LoadedConversationalRectificationCase,
): string {
const followUp = current.latestTurn.evidenceRequest?.followUp;
if (followUp?.kind !== "event_date" && followUp?.kind !== "event_detail") {
if (!followUp || !["new_event", "event_date", "event_detail"].includes(followUp.kind)) {
return command.answer;
}
const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence);
@@ -914,6 +932,17 @@ export function createConversationalRectificationService(
const anchorYear = Number(anchor?.dateValue?.slice(0, 4));
if (!Number.isInteger(anchorYear)) return command.answer;
const relativeEventMonth = followUp.kind === "new_event"
? command.answer.match(contextualRelativeEventMonthPattern)
: null;
if (relativeEventMonth) {
const month = Number(relativeEventMonth[3]);
if (month >= 1 && month <= 12) {
const sameYear = /(?:同年|当年|那年)/.test(relativeEventMonth[1] ?? "");
return `${sameYear ? anchorYear : anchorYear + 1}${month}${relativeEventMonth[2] ?? ""}${relativeEventMonth[4] ?? ""}`;
}
}
const bareMonthDay = followUp.kind === "event_date"
? command.answer.match(contextualBareMonthDayPattern)
: null;
@@ -1014,6 +1043,10 @@ export function createConversationalRectificationService(
rawText: `${target.rawText}\n确认:${command.answer}`,
eventSummary: target.eventSummary,
domain: target.domain,
eventKind: target.eventKind ?? item.eventKind,
subject: target.subject ?? item.subject,
relatedPerson: target.relatedPerson ?? item.relatedPerson,
scoreability: target.scoreability ?? item.scoreability,
correctsEvidenceIds: [target.id],
})),
};
@@ -1063,7 +1096,19 @@ export function createConversationalRectificationService(
})),
}, { signal: AbortSignal.timeout(8_000) });
if (domain && domain !== "other") {
return [{ ...ambiguous, domain }];
const scoreability = domain === "family" ? "context_only" : "scoreable";
return [{
...ambiguous,
domain,
eventKind: `${domain}_event`,
subject: domain === "family" ? "family" : "self",
relatedPerson: null,
scoreability,
scoreable: scoreability === "scoreable"
&& ambiguous.dateValue !== null
&& ambiguous.extractionStatus !== "needs_clarification"
&& !evidencePostdatesAsOfDate(ambiguous, ports.asOfDate()),
}];
}
} catch {
// Semantic classification is advisory. Keep the deterministic fallback
@@ -1142,6 +1187,10 @@ export function createConversationalRectificationService(
rawText: `${pending.rawText}\n补充:${input.command.answer}`,
eventSummary: summary,
domain: pending.domain === "other" ? item.domain : pending.domain,
eventKind: pending.eventKind ?? item.eventKind,
subject: pending.subject ?? item.subject,
relatedPerson: pending.relatedPerson ?? item.relatedPerson,
scoreability: pending.scoreability ?? item.scoreability,
correctsEvidenceIds: [...item.correctsEvidenceIds],
}));
}
@@ -193,14 +193,26 @@ export type ValidationReceipt = z.infer<typeof validationReceiptSchema>;
const correctionEvidenceIdsSchema = boundedJson(z.array(uuidSchema).max(1), 64);
const eventSubjectSchema = z.enum(["self", "family", "partner", "other"]);
const relatedPersonSchema = z.enum([
"father", "mother", "grandparent", "sibling", "partner",
]);
const eventScoreabilitySchema = z.enum([
"scoreable", "context_only", "pending_review", "unsupported",
]);
export const lifeEventEvidenceSchema = boundedJson(z.object({
id: uuidSchema,
rawText: boundedText(4_000),
domain: evidenceDomainSchema,
eventKind: boundedText(120).optional(),
subject: eventSubjectSchema.optional(),
relatedPerson: relatedPersonSchema.nullable().optional(),
eventSummary: boundedText(1_000),
dateValue: boundedText(80).nullable(),
datePrecision: z.enum(["day", "month", "year", "range", "unknown"]),
extractionStatus: z.enum(["clear", "needs_clarification", "corrected"]),
scoreability: eventScoreabilitySchema.optional(),
scoreable: z.boolean().optional(),
// Optional only for rows written before durable correction lineage existed.
correctsEvidenceIds: correctionEvidenceIdsSchema.optional(),