refactor: rebuild birth time rectification agent
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user