fix: make conversational rectification converge

This commit is contained in:
Jesse_Chen
2026-07-25 11:50:41 +08:00
parent 58579cade4
commit 0e6ed6ba79
23 changed files with 1257 additions and 180 deletions
+10 -8
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { RECTIFICATION_POLICY } from "./rectification-policy.ts";
import type { JourneySnapshot } from "./birth-time-journey.ts";
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
@@ -128,14 +129,14 @@ export const candidateResultSchema = z.object({
techniqueReceipt: rectificationTechniqueReceiptSchema.optional(),
}).strict().readonly().superRefine((value, context) => {
const eligible = value.confidence === "high" && highCandidateMeetsSafetyGates(value);
if (value.confidence === "high" && value.eventCount < 4) {
if (value.confidence === "high" && value.eventCount < RECTIFICATION_POLICY.minConfirmationEvents) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["eventCount"],
message: "high candidates require at least four effective evidence items",
});
}
if (value.confidence === "high" && value.domainCount < 3) {
if (value.confidence === "high" && value.domainCount < RECTIFICATION_POLICY.minConfirmationDomains) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["domainCount"],
@@ -149,14 +150,15 @@ export const candidateResultSchema = z.object({
message: "high candidates require a winning segment",
});
}
if (value.confidence === "high" && value.winningSegment !== null && value.winningSegment.widthMinutes > 5) {
if (value.confidence === "high" && value.winningSegment !== null
&& value.winningSegment.widthMinutes > RECTIFICATION_POLICY.maxConfirmationWidthMinutes) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["winningSegment", "widthMinutes"],
message: "high candidate segments cannot exceed five minutes",
});
}
if (value.confidence === "high" && value.marginPercent < 20) {
if (value.confidence === "high" && value.marginPercent < RECTIFICATION_POLICY.minConfirmationMarginPercent) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["marginPercent"],
@@ -183,10 +185,10 @@ function highCandidateMeetsSafetyGates(value: {
}): boolean {
return value.confidence === "high"
&& value.winningSegment !== null
&& value.eventCount >= 4
&& value.domainCount >= 3
&& value.winningSegment.widthMinutes <= 5
&& value.marginPercent >= 20;
&& value.eventCount >= RECTIFICATION_POLICY.minConfirmationEvents
&& value.domainCount >= RECTIFICATION_POLICY.minConfirmationDomains
&& value.winningSegment.widthMinutes <= RECTIFICATION_POLICY.maxConfirmationWidthMinutes
&& value.marginPercent >= RECTIFICATION_POLICY.minConfirmationMarginPercent;
}
export class CandidateConfirmationError extends Error {
@@ -17,6 +17,41 @@ const evidenceDomainSchema = z.enum([
"other",
]);
const proposedDateSchema = z.object({
value: z.string().regex(/^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$/),
precision: z.enum(["year", "month", "day"]),
}).strict();
export const rectificationFollowUpSchema = z.object({
kind: z.enum(["new_event", "event_date", "event_detail"]),
evidenceId: z.string().uuid().nullable(),
answerMode: z.enum(["free_text", "yes_no"]).optional(),
proposedDate: proposedDateSchema.nullable().optional(),
}).strict().superRefine((value, context) => {
if (value.kind === "new_event" && value.evidenceId !== null) {
context.addIssue({ code: "custom", message: "new_event cannot target existing evidence" });
}
if (value.kind !== "new_event" && value.evidenceId === null) {
context.addIssue({ code: "custom", message: "event follow-up requires evidenceId" });
}
if (value.answerMode === "yes_no" && (value.kind !== "event_date" || !value.proposedDate)) {
context.addIssue({ code: "custom", message: "date confirmation requires proposedDate" });
}
if (value.answerMode !== "yes_no" && value.proposedDate) {
context.addIssue({ code: "custom", message: "proposedDate requires yes_no answer mode" });
}
if (value.proposedDate) {
const expectedParts = value.proposedDate.precision === "year"
? 1
: value.proposedDate.precision === "month" ? 2 : 3;
if (value.proposedDate.value.split("-").length !== expectedParts) {
context.addIssue({ code: "custom", message: "proposedDate precision does not match value" });
}
}
});
export type RectificationFollowUp = z.infer<typeof rectificationFollowUpSchema>;
const boundedNonblankText = (maximum: number) => z.string()
.min(1)
.max(maximum)
@@ -85,11 +120,10 @@ const evidenceRequestSchema = boundedJson(z.object({
domains: z.array(evidenceDomainSchema).min(1).max(4),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
freeTextAllowed: z.literal(true),
// Optional for turns written before the authored question was persisted.
prompt: boundedNonblankText(1_000).optional(),
// Optional for turns written before follow-up state was persisted.
followUp: z.object({
kind: z.enum(["new_event", "event_date", "event_detail"]),
evidenceId: z.string().uuid().nullable(),
}).strict().optional(),
followUp: rectificationFollowUpSchema.optional(),
}).strict(), 2_048);
const evidenceRecapEntrySchema = boundedJson(z.object({
@@ -1,8 +1,20 @@
import type { RectificationTechnicalPacket } from "./technical-packet.ts";
export const MINIMUM_SCOREABLE_EVENTS = 3;
import { RECTIFICATION_POLICY } from "../rectification-policy.ts";
import type {
RectificationEvidenceDomain,
RectificationTechnicalPacket,
} from "./technical-packet.ts";
const plateauNotePrefix = "range_plateau_count:";
const systemBlockers = new Set([
"required_layers_incomplete",
"three_engine_parity_not_passed",
"vedastro_validation_required",
"vedastro_validation_not_passed",
"vedastro_official_response_missing",
"vedastro_minute_snapshot_failed",
"vedastro_minute_sensitive_layers_not_discriminated",
"minute_holdout_not_ready",
]);
type CandidateProgress = Readonly<{
rangeStart?: string | null;
@@ -36,3 +48,21 @@ export function convergenceNotes(candidate: CandidateProgress, plateauCount: num
`${plateauNotePrefix}${plateauCount}`,
];
}
export function shouldCompleteBoundedResult(input: Readonly<{
packet: RectificationTechnicalPacket;
scoreableEventCount: number;
scoreableDomainCount: number;
answeredDomains: ReadonlySet<RectificationEvidenceDomain>;
plateauCount: number;
}>): boolean {
if (input.packet.candidate.status === "ready_for_confirmation"
|| input.scoreableEventCount < RECTIFICATION_POLICY.minConfirmationEvents
|| input.scoreableDomainCount < RECTIFICATION_POLICY.minConfirmationDomains
|| input.packet.suggestedDomains.some((item) => !input.answeredDomains.has(item.domain))) {
return false;
}
if (input.plateauCount >= RECTIFICATION_POLICY.maxPlateauRounds) return true;
const blockers = input.packet.expertWorkflow?.hardBlockers ?? [];
return blockers.length > 0 && blockers.every((blocker) => systemBlockers.has(blocker));
}
@@ -1,4 +1,8 @@
import { z } from "zod";
import {
rectificationFollowUpSchema,
type RectificationFollowUp,
} from "./contracts.ts";
import {
projectRectificationTechnicalPacket,
type RectificationEvidenceDomain,
@@ -15,6 +19,9 @@ export type RectificationConversationMessage = Readonly<{
export type RectificationNarrativeContext = Readonly<{
recentConversation?: ReadonlyArray<RectificationConversationMessage>;
latestUserText?: string;
previousAssistantNarrative?: string;
previousEvidencePrompt?: string;
previousFollowUp?: RectificationFollowUp;
latestEvidence?: ReadonlyArray<{
id?: string;
dateLabel: string;
@@ -55,6 +62,9 @@ const broadYearRangePattern = /(?:19|20)\d{2}\s*年?\s*(?:[-–—~~至到\/]|
const proposedYearAlternativesPattern = /(?:19|20)\d{2}\s*年?\s*(?:还是|或者|或是|或|、|,|)\s*(?:19|20)\d{2}\s*年?/i;
const choiceQuestionPattern = /(?:哪(?:一|个)?(?:年|年份|年代|时间段|区间|时期)|哪个时间段|还是|选择|选项|更符合|更匹配|A\s*[.、:)]|B\s*[.、:)]|which\s+(?:year|period|range)|options?)/i;
const labeledYearChoicesPattern = /A\s*[.、:)]?[\s\S]{0,80}(?:19|20)\d{2}\s*年?[\s\S]{0,120}B\s*[.、:)]?[\s\S]{0,80}(?:19|20)\d{2}\s*年?/i;
const affirmativeAnswerPattern = /^\s*(?:(?:)?|(?:)?|||||+|)\s*[.!,]?\s*$/u;
const negativeAnswerPattern = /^\s*(?:||||)\s*[.!,]?\s*$/u;
const proposedDateQuestionPattern = /(?:19|20)\d{2}\s*(?:\s*(?:1[0-2]|0?[1-9])\s*)?(?:\s*(?:3[01]|[12]\d|0?[1-9])\s*(?:|))?[\s\S]{0,30}(?:|||||)/u;
const domainLabels = {
career: "事业",
education: "学业",
@@ -84,10 +94,7 @@ export const rectificationNarrativeOutputSchema = z.object({
domains: z.array(domainSchema).min(1).max(4),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
prompt: z.string().trim().min(1).max(1_000),
followUp: z.object({
kind: z.enum(["new_event", "event_date", "event_detail"]),
evidenceId: z.string().uuid().nullable(),
}).strict().default({ kind: "new_event", evidenceId: null }),
followUp: rectificationFollowUpSchema.default({ kind: "new_event", evidenceId: null }),
}).strict().nullable(),
}).strict();
@@ -99,10 +106,7 @@ export const rectificationNarrativeAuthoredOutputSchema = z.object({
domains: z.array(domainSchema).min(1).max(4).optional(),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
prompt: z.string().trim().min(1).max(1_000),
followUp: z.object({
kind: z.enum(["new_event", "event_date", "event_detail"]),
evidenceId: z.string().uuid().nullable(),
}).strict().default({ kind: "new_event", evidenceId: null }),
followUp: rectificationFollowUpSchema.default({ kind: "new_event", evidenceId: null }),
}).strict().nullable(),
}).strict();
@@ -255,6 +259,7 @@ export function validateNarrativeAgainstPacket(
output: RectificationNarrativeModelOutput,
packet: RectificationTechnicalPacket,
phase: RectificationNarrativePhase = "first",
context: RectificationNarrativeContext = {},
): NarrativeValidation {
void phase;
const issues: string[] = [];
@@ -292,6 +297,37 @@ export function validateNarrativeAgainstPacket(
for (const domain of output.evidenceRequest.domains) {
if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`);
}
const followUp = output.evidenceRequest.followUp;
if (followUp?.kind === "event_detail"
&& packet.scoredHistoricalEvidence.some((item) => item.evidenceId === followUp.evidenceId)) {
issues.push("event detail follow-up targets already scored evidence");
}
if (proposedDateQuestionPattern.test(output.evidenceRequest.prompt)
&& (followUp?.kind !== "event_date"
|| followUp.answerMode !== "yes_no"
|| !followUp.proposedDate)) {
issues.push("date confirmation prompt lacks structured proposedDate");
}
}
const previousFollowUp = context.previousFollowUp;
const latestUserText = context.latestUserText ?? "";
if (previousFollowUp?.kind === "event_date" && previousFollowUp.answerMode === "yes_no") {
const nextRequest = output.evidenceRequest;
if (affirmativeAnswerPattern.test(latestUserText)) {
if (nextRequest?.followUp?.evidenceId === previousFollowUp.evidenceId) {
issues.push("resolved follow-up still targets completed evidence");
}
if (context.previousEvidencePrompt
&& normalizedQuestion(nextRequest?.prompt ?? "") === normalizedQuestion(context.previousEvidencePrompt)) {
issues.push("repeated resolved follow-up");
}
}
if (negativeAnswerPattern.test(latestUserText)
&& nextRequest?.followUp?.evidenceId === previousFollowUp.evidenceId
&& nextRequest.followUp?.answerMode === "yes_no") {
issues.push("repeated rejected follow-up");
}
}
const allowedTimes = [candidate.representativeTime, candidate.range.startTime, candidate.range.endTime];
@@ -321,6 +357,13 @@ export function validateNarrativeAgainstPacket(
return { valid: uniqueIssues.length === 0, issues: uniqueIssues };
}
function normalizedQuestion(value: string): string {
return value
.normalize("NFKC")
.replace(/[\p{P}\p{S}\s]+/gu, "")
.toLocaleLowerCase("zh-CN");
}
function grounding(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase) {
const projected = projectRectificationTechnicalPacket(packet);
const base = {
@@ -361,6 +404,9 @@ function narrativeConversationContext(context: RectificationNarrativeContext) {
return {
recentConversation: context.recentConversation?.slice(-40),
latestUserText: context.latestUserText,
previousAssistantNarrative: context.previousAssistantNarrative,
previousEvidencePrompt: context.previousEvidencePrompt,
previousFollowUp: context.previousFollowUp,
latestEvidence: context.latestEvidence?.map(({ id, dateLabel, summary }) => ({
id,
dateLabel,
@@ -529,7 +575,8 @@ function promptFor(
useExpertWorkflowAsTechniqueTruth: true,
blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed: true,
technicalTablesMayAppearWhenRelevant: true,
completeTechnicalTableSummaryRequiredBeforeConfirmation: phase === "final",
completeTechnicalTableSummaryRequiredBeforeConfirmation: phase === "final"
&& packet.candidate.status === "ready_for_confirmation",
unchangedTechnicalTablesShouldNotBeRepeated: true,
privateScoresAndCandidateWeightsMustNeverBeShown: true,
internalEventDomainsAndRoutingMustNeverBeShown: true,
@@ -544,11 +591,15 @@ function promptFor(
continueCurrentEventWhenItRemainsInformative: phase === "intermediate",
resolveDateContradictionsBeforeScoring: phase === "intermediate",
mergeSameEventDetailsWithoutDoubleCounting: phase === "intermediate",
treatCauseResultAgencyAndNextTransitionAsPartsOfTheCurrentEvent: phase === "intermediate",
onlyAskForDateEventIdentityOrInformationThatCanChangeTheScoringDomain: phase === "intermediate",
doNotAskWhyWhetherVoluntaryOrWhatImpactForAlreadyScoreableEvidence: phase === "intermediate",
useEventLedgerToAvoidRepeatingAnsweredQuestions: phase === "intermediate",
askForDatesOnlyWhenNeededToIdentifyOrScoreTheEvent: phase !== "final",
persistFollowUpState: phase !== "final"
? "Treat followUp as advisory metadata: use event_detail or event_date with an existing evidenceId when clear; otherwise omit assumptions and use new_event with null evidenceId."
? "Persist the exact question intent. Use event_detail or event_date with an existing evidenceId. For a yes/no date proposal, set answerMode=yes_no and proposedDate={value,precision}; otherwise use answerMode=free_text and no proposedDate. Use new_event with null evidenceId only for a genuinely new event."
: false,
boundedResultBoundary: phase === "final" && packet.candidate.status === "pending_validation"
? "当前只支持候选范围,系统验证尚未闭环。本次不会替换当前排盘时间,也不再要求用户继续提供人生事件;evidenceRequest 必须为 null。"
: false,
domainReasonsMayBeNaturallyParaphrased: true,
},
@@ -560,8 +611,10 @@ function fallbackNarrative(packet: RectificationTechnicalPacket, phase: Rectific
const candidate = packet.candidate;
const nextDomain = packet.suggestedDomains[0]?.domain;
const nextLabel = nextDomain ? domainLabels[nextDomain] : "重要经历";
const phaseLine = phase === "final"
const phaseLine = phase === "final" && candidate.status === "ready_for_confirmation"
? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。"
: phase === "final"
? "当前证据只能支持候选范围,系统验证尚未闭环;本次不会替换当前排盘时间,也不再强制追问更多人生事件。"
: `先说一件已经发生的${nextLabel}事件好吗?尽量写明哪一年、哪一月以及发生了什么。`;
return [
`当前仍在核对 ${candidate.range.startTime}${candidate.range.endTime} 的候选范围,还不能把其中某一分钟当作确定出生时间。`,
@@ -739,7 +792,12 @@ export async function generateRectificationNarrative(input: {
parseModelOutput(generated.text, input.packet),
input.phase,
);
const validation = validateNarrativeAgainstPacket(output, input.packet, input.phase);
const validation = validateNarrativeAgainstPacket(
output,
input.packet,
input.phase,
input.context ?? {},
);
if (validation.valid) {
const narrative = input.phase === "final"
? appendFinalAnalysisTables(output.narrative, input.packet, input.context ?? {})
@@ -3,6 +3,7 @@ import {
conversationalRectificationCommandSchema,
conversationalRectificationTurnSchema,
type ConversationalRectificationCommand,
type RectificationFollowUp,
type ConversationalRectificationTurn,
} from "./contracts.ts";
import { ConversationalRectificationError } from "./errors.ts";
@@ -24,13 +25,15 @@ import {
} from "./narrative-agent.ts";
import {
projectRectificationTechnicalPacket,
type RectificationEvidenceDomain,
type RectificationTechnicalPacket,
} from "./technical-packet.ts";
import {
convergenceNotes,
MINIMUM_SCOREABLE_EVENTS,
nextPlateauCount,
shouldCompleteBoundedResult,
} from "./convergence.ts";
import { RECTIFICATION_POLICY } from "../rectification-policy.ts";
import type { ConversationalRectificationBilling } from "./billing.ts";
import {
projectLegacyCaseForConversationalImport,
@@ -131,8 +134,8 @@ const genericUncertaintyPattern = /(?:不知道|不确定)/;
const contextualRelativeMonthPattern = /(?:来年|次年|第二年|翌年|同年|当年|那年)\s*(\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*$/;
const proposedDatePattern = /((?:19|20)\d{2})\s*年\s*(1[0-2]|0?[1-9])\s*月(?:\s*(3[01]|[12]\d|0?[1-9])\s*(?:日|号))?/g;
const affirmativeAnswerPattern = /^\s*(?:(?:)?|(?:)?|||||+|)\s*[.!,]?\s*$/u;
const negativeAnswerPattern = /^\s*(?:||||)\s*[.!,]?\s*$/u;
export function evidencePredatesBirthDate(
evidence: Pick<LifeEventEvidence, "dateValue" | "datePrecision">,
@@ -289,6 +292,19 @@ function normalizedEventSemantics(value: string): string {
.replace(/[\p{P}\p{S}\s]+/gu, "");
}
const scoringEvidenceDomains = new Set<RectificationEvidenceDomain>([
"career",
"education",
"finance",
"health_pressure",
"relocation",
"relationship",
]);
function hasScoringEvidenceDomain(item: Pick<LifeEventEvidenceInput, "domain">): boolean {
return scoringEvidenceDomains.has(item.domain);
}
function uniqueScoreableLifeEventEvidence(
evidence: ReadonlyArray<LifeEventEvidenceInput>,
birthDate: string,
@@ -296,6 +312,7 @@ function uniqueScoreableLifeEventEvidence(
const seen = new Set<string>();
return effectiveLifeEventEvidence(evidence)
.filter((item) => item.scoreable === true
&& hasScoringEvidenceDomain(item)
&& item.extractionStatus !== "needs_clarification"
&& !evidencePredatesBirthDate(item, birthDate))
.filter((item) => {
@@ -327,6 +344,9 @@ function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
function narrativeConversationContext(input: Readonly<{
recentConversation?: ReadonlyArray<RectificationConversationMessage>;
latestUserText: string;
previousAssistantNarrative?: string;
previousEvidencePrompt?: string;
previousFollowUp?: RectificationFollowUp;
allEvidence: ReadonlyArray<LifeEventEvidenceInput>;
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
}>) {
@@ -335,6 +355,9 @@ function narrativeConversationContext(input: Readonly<{
return {
recentConversation: input.recentConversation,
latestUserText: input.latestUserText.trim().slice(0, 4_000),
previousAssistantNarrative: input.previousAssistantNarrative,
previousEvidencePrompt: input.previousEvidencePrompt,
previousFollowUp: input.previousFollowUp,
latestEvidence: evidenceRecap(input.newEvidence).map((item) => ({
id: item.id,
dateLabel: item.dateLabel,
@@ -384,16 +407,24 @@ function actionsFor(status: "active" | "confirming") {
function confirmationGatedPacket(
packet: RectificationTechnicalPacket,
scoreableEventCount: number,
scoreableDomainCount: number,
): RectificationTechnicalPacket {
if (packet.candidate.status !== "ready_for_confirmation"
|| scoreableEventCount >= MINIMUM_SCOREABLE_EVENTS) return packet;
|| (scoreableEventCount >= RECTIFICATION_POLICY.minConfirmationEvents
&& scoreableDomainCount >= RECTIFICATION_POLICY.minConfirmationDomains)) return packet;
return {
...packet,
candidate: { ...packet.candidate, status: "pending_validation" },
useBoundary: `当前候选仍需至少 ${MINIMUM_SCOREABLE_EVENTS} 条时间明确、可评分的真实经历验证,不能作为已经校正完成的出生分钟。`,
useBoundary: `当前候选仍需至少 ${RECTIFICATION_POLICY.minConfirmationEvents} 条时间明确、覆盖 ${RECTIFICATION_POLICY.minConfirmationDomains} 个领域的真实经历验证,不能作为已经校正完成的出生分钟。`,
};
}
function scoreableDomains(
evidence: ReadonlyArray<LifeEventEvidenceInput>,
): Set<RectificationEvidenceDomain> {
return new Set(evidence.map((item) => item.domain));
}
function turnFromNarrative(input: {
readonly caseId: string;
readonly turnVersion: number;
@@ -409,6 +440,7 @@ function turnFromNarrative(input: {
domains: input.narrative.output.evidenceRequest.domains,
datePrecision: input.narrative.output.evidenceRequest.datePrecision,
freeTextAllowed: true as const,
prompt: input.narrative.output.evidenceRequest.prompt,
followUp: input.narrative.output.evidenceRequest.followUp,
}
: null;
@@ -435,6 +467,19 @@ function turnFromNarrative(input: {
return parsed.data;
}
function boundedResultTurn(
input: Parameters<typeof turnFromNarrative>[0],
): ConversationalRectificationTurn {
const turn = turnFromNarrative(input);
return conversationalRectificationTurnSchema.parse({
...turn,
status: "completed",
candidate: { ...turn.candidate, status: "pending_validation" },
evidenceRequest: null,
actions: turn.pendingConsultationQuestion ? ["continue_original_question"] : [],
});
}
function privateCandidateFromPacket(input: {
readonly packet: RectificationTechnicalPacket;
readonly resultId: string | null;
@@ -524,6 +569,7 @@ function nonScoringTurn(input: {
readonly newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
readonly latestUserText: string;
readonly authoredNarrative?: RectificationNarrativeResult | null;
readonly followUpOverride?: RectificationFollowUp;
readonly correctionReset?: Readonly<{
packet: RectificationTechnicalPacket;
reason: CorrectionResetReason;
@@ -560,12 +606,13 @@ function nonScoringTurn(input: {
domains: authoredRequest.domains,
datePrecision: authoredRequest.datePrecision,
freeTextAllowed: true as const,
followUp: authoredRequest.followUp,
prompt: authoredRequest.prompt,
followUp: input.followUpOverride ?? authoredRequest.followUp,
}
: priorRequest
? {
...priorRequest,
followUp: clarificationFollowUp ?? priorRequest.followUp,
followUp: input.followUpOverride ?? clarificationFollowUp ?? priorRequest.followUp,
}
: null;
const parsed = conversationalRectificationTurnSchema.safeParse({
@@ -732,7 +779,7 @@ export function createConversationalRectificationService(
evidence: projected.evidence,
preserveCandidateRange: true,
});
const gatedPacket = confirmationGatedPacket(computed.packet, 0);
const gatedPacket = confirmationGatedPacket(computed.packet, 0, 0);
const narrative = await generateRectificationNarrative({
phase: "first",
packet: gatedPacket,
@@ -795,14 +842,6 @@ export function createConversationalRectificationService(
if (followUp?.kind !== "event_date" && followUp?.kind !== "event_detail") {
return command.answer;
}
if (followUp.kind === "event_date" && affirmativeAnswerPattern.test(command.answer)) {
const dates = [...current.latestTurn.narrative.matchAll(proposedDatePattern)];
const proposed = dates.at(-1);
if (proposed) {
const [, year, month, day] = proposed;
return `${year}${Number(month)}${day ? `${Number(day)}` : ""}`;
}
}
const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence);
const target = followUp.evidenceId
? activeEvidence.find((item) => item.id === followUp.evidenceId)
@@ -857,11 +896,67 @@ export function createConversationalRectificationService(
return narrativeConversationContext({
recentConversation: [...recentConversation, { role: "user", text: input.latestUserText }],
latestUserText: input.latestUserText,
previousAssistantNarrative: input.current.latestTurn.narrative,
previousEvidencePrompt: input.current.latestTurn.evidenceRequest?.prompt,
previousFollowUp: input.current.latestTurn.evidenceRequest?.followUp,
allEvidence: input.allEvidence,
newEvidence: input.newEvidence,
});
}
type StructuredFollowUpResolution =
| Readonly<{ kind: "confirmed"; evidence: readonly LifeEventEvidence[] }>
| Readonly<{ kind: "rejected"; evidence: readonly []; followUp: RectificationFollowUp }>
| null;
function resolveStructuredFollowUp(
command: CommandOf<"answer">,
current: LoadedConversationalRectificationCase,
): StructuredFollowUpResolution {
const followUp = current.latestTurn.evidenceRequest?.followUp;
if (followUp?.kind !== "event_date"
|| followUp.answerMode !== "yes_no"
|| !followUp.evidenceId
|| !followUp.proposedDate) {
return null;
}
const target = effectiveLifeEventEvidence(current.eventEvidence)
.find((item) => item.id === followUp.evidenceId);
if (!target) return null;
if (negativeAnswerPattern.test(command.answer)) {
return {
kind: "rejected",
evidence: [],
followUp: {
kind: "event_date",
evidenceId: target.id,
answerMode: "free_text",
proposedDate: null,
},
};
}
if (!affirmativeAnswerPattern.test(command.answer)) return null;
const merged = extractLifeEventEvidence({
rawText: `${followUp.proposedDate.value} ${target.eventSummary}`,
sourceTurnId: command.actionId,
asOfDate: ports.asOfDate(),
correctsEvidenceId: target.id,
});
if (merged.length !== 1) return null;
return {
kind: "confirmed",
evidence: merged.map((item) => ({
...item,
rawText: `${target.rawText}\n确认:${command.answer}`,
eventSummary: target.eventSummary,
domain: target.domain,
correctsEvidenceIds: [target.id],
})),
};
}
async function extractedEvidence(
command: CommandOf<"answer">,
current: LoadedConversationalRectificationCase,
@@ -869,7 +964,8 @@ export function createConversationalRectificationService(
let extracted: readonly LifeEventEvidence[];
try {
const answerForExtraction = contextualizedAnswer(command, current);
if (affirmativeAnswerPattern.test(command.answer) && answerForExtraction === command.answer) {
if ((affirmativeAnswerPattern.test(command.answer) || negativeAnswerPattern.test(command.answer))
&& answerForExtraction === command.answer) {
return [];
}
extracted = extractLifeEventEvidence({
@@ -1105,7 +1201,7 @@ export function createConversationalRectificationService(
privateCandidate: null,
evidence: [],
});
const gatedPacket = confirmationGatedPacket(computed.packet, 0);
const gatedPacket = confirmationGatedPacket(computed.packet, 0, 0);
const narrative = await generateRectificationNarrative({
phase: "first",
packet: gatedPacket,
@@ -1198,12 +1294,16 @@ export function createConversationalRectificationService(
if (receipt) return receipt;
const current = await load(userId, command.caseId);
requireMutable(current);
const structuredFollowUp = resolveStructuredFollowUp(command, current);
const extracted = structuredFollowUp?.kind === "confirmed"
? structuredFollowUp.evidence
: structuredFollowUp?.kind === "rejected"
? structuredFollowUp.evidence
: await extractedEvidence(command, current);
const evidence = evidenceForDeclaredBirthDate(
completeLatestClarification({
command,
current,
extracted: await extractedEvidence(command, current),
}),
structuredFollowUp?.kind === "confirmed"
? extracted
: completeLatestClarification({ command, current, extracted }),
current.declaredBirthInput.birthDate,
);
@@ -1235,6 +1335,7 @@ export function createConversationalRectificationService(
}
const scoreableEvidence = evidence.filter((item) => item.scoreable === true
&& hasScoringEvidenceDomain(item)
&& item.extractionStatus !== "needs_clarification");
const explicitDirectionChange = explicitDirectionChangePattern.test(command.answer);
const directionChange = explicitDirectionChange
@@ -1260,6 +1361,7 @@ export function createConversationalRectificationService(
const gatedPacket = confirmationGatedPacket(
computed.packet,
allScoreable.length,
scoreableDomains(allScoreable).size,
);
const replacement = evidence[0];
if (!replacement) throw new ConversationalRectificationError("invalid_command");
@@ -1309,7 +1411,16 @@ export function createConversationalRectificationService(
return publicTurn(saved);
}
const phase = gatedPacket.candidate.status === "ready_for_confirmation"
const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket);
const answeredDomains = scoreableDomains(allScoreable);
const boundedResult = shouldCompleteBoundedResult({
packet: gatedPacket,
scoreableEventCount: allScoreable.length,
scoreableDomainCount: answeredDomains.size,
answeredDomains,
plateauCount,
});
const phase = boundedResult || gatedPacket.candidate.status === "ready_for_confirmation"
? "final" as const
: "intermediate" as const;
const narrative = await generateRectificationNarrative({
@@ -1330,16 +1441,18 @@ export function createConversationalRectificationService(
? computed.resultId
: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
forceCollecting: gatedPacket.candidate.status !== "ready_for_confirmation",
notes: convergenceNotes(current.privateCandidate, plateauCount),
forceCollecting: boundedResult || gatedPacket.candidate.status !== "ready_for_confirmation",
});
const turn = turnFromNarrative({
const turnInput = {
caseId: command.caseId,
turnVersion: command.turnVersion + 1,
pendingConsultationQuestion: current.pendingConsultationQuestion,
packet: gatedPacket,
narrative,
evidence: [...current.eventEvidence, ...evidence],
});
};
const turn = boundedResult ? boundedResultTurn(turnInput) : turnFromNarrative(turnInput);
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
@@ -1371,6 +1484,7 @@ export function createConversationalRectificationService(
const gatedPacket = confirmationGatedPacket(
computed.packet,
allScoreable.length,
scoreableDomains(allScoreable).size,
);
authoredNarrative = await generateRectificationNarrative({
phase: "intermediate",
@@ -1394,6 +1508,9 @@ export function createConversationalRectificationService(
newEvidence: evidence,
latestUserText: command.answer,
authoredNarrative,
followUpOverride: structuredFollowUp?.kind === "rejected"
? structuredFollowUp.followUp
: undefined,
});
try {
const saved = await ports.store.saveTurn({
@@ -1426,8 +1543,18 @@ export function createConversationalRectificationService(
const gatedPacket = confirmationGatedPacket(
computed.packet,
allScoreable.length,
scoreableDomains(allScoreable).size,
);
const phase = gatedPacket.candidate.status === "ready_for_confirmation"
const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket);
const answeredDomains = scoreableDomains(allScoreable);
const boundedResult = shouldCompleteBoundedResult({
packet: gatedPacket,
scoreableEventCount: allScoreable.length,
scoreableDomainCount: answeredDomains.size,
answeredDomains,
plateauCount,
});
const phase = boundedResult || gatedPacket.candidate.status === "ready_for_confirmation"
? "final" as const
: "intermediate" as const;
const narrative = await generateRectificationNarrative({
@@ -1442,7 +1569,6 @@ export function createConversationalRectificationService(
newEvidence: evidence,
}),
});
const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket);
const privateCandidate = privateCandidateFromPacket({
packet: gatedPacket,
resultId: gatedPacket.candidate.status === "ready_for_confirmation"
@@ -1450,16 +1576,17 @@ export function createConversationalRectificationService(
: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
notes: convergenceNotes(current.privateCandidate, plateauCount),
forceCollecting: gatedPacket.candidate.status !== "ready_for_confirmation",
forceCollecting: boundedResult || gatedPacket.candidate.status !== "ready_for_confirmation",
});
const narratedTurn = turnFromNarrative({
const turnInput = {
caseId: command.caseId,
turnVersion: command.turnVersion + 1,
pendingConsultationQuestion: current.pendingConsultationQuestion,
packet: gatedPacket,
narrative,
evidence: [...current.eventEvidence, ...evidence],
});
};
const narratedTurn = boundedResult ? boundedResultTurn(turnInput) : turnFromNarrative(turnInput);
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
@@ -1507,7 +1634,11 @@ export function createConversationalRectificationService(
evidence: scoreableEvidence,
preserveCandidateRange: true,
});
const gatedPacket = confirmationGatedPacket(computed.packet, scoreableEvidence.length);
const gatedPacket = confirmationGatedPacket(
computed.packet,
scoreableEvidence.length,
scoreableDomains(scoreableEvidence).size,
);
const phase = gatedPacket.candidate.status === "ready_for_confirmation"
? "final" as const
: activeEvidence.length === 0 ? "first" as const : "intermediate" as const;
@@ -404,7 +404,7 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
(timeToMinute(left.time) - timeToMinute(range.startTime) + 1_440) % 1_440
- (timeToMinute(right.time) - timeToMinute(range.startTime) + 1_440) % 1_440
));
if (selectedSamples.length < 2) {
if (selectedSamples.length === 0 || (selectedSamples.length === 1 && !eventSegment)) {
throw new RectificationTechnicalPacketRangeError("insufficient_samples");
}
const layers = layerEvidence(selectedSamples.map((item) => item.sample), input.consultation);
@@ -415,9 +415,6 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
&& item.values.length > 1
&& available.has(item.layer));
const domains = suggestedDomains(sensitiveLayers, selectedSamples);
if (domains.length < 2) {
throw new RectificationTechnicalPacketRangeError("insufficient_domains");
}
const scoredHistoricalEvidence = (input.eventScore?.evidence ?? []).map((item) => ({
evidenceId: item.eventId,
domain: eventDomain(item.domain),
@@ -497,11 +494,12 @@ export function projectRectificationTechnicalPacket(packet: RectificationTechnic
.filter((reference) => reference.trim().length > 0 && reference.length <= 120)
.slice(0, 40),
},
evidenceRequest: {
domains: packet.suggestedDomains.map((item) => item.domain),
datePrecision: "month_preferred" as const,
freeTextAllowed: true as const,
},
evidenceRequest: packet.candidate.status === "ready_for_confirmation"
|| packet.suggestedDomains.length === 0 ? null : {
domains: packet.suggestedDomains.map((item) => item.domain),
datePrecision: "month_preferred" as const,
freeTextAllowed: true as const,
},
futureWindows: packet.futureWindows.map((window) => ({ ...window })),
};
}
+3
View File
@@ -0,0 +1,3 @@
import policy from "../../../references/rectification_policy.v1.json";
export const RECTIFICATION_POLICY = policy;