fix: make conversational rectification converge
This commit is contained in:
@@ -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 })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import policy from "../../../references/rectification_policy.v1.json";
|
||||
|
||||
export const RECTIFICATION_POLICY = policy;
|
||||
Reference in New Issue
Block a user