From f4ac6ce75e3b8fb14b642312760e7de1fbe79e5d Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 22:53:56 +0800 Subject: [PATCH] fix: make rectification follow-up actionable --- .../app/api/birth-time-conversation/route.ts | 56 ++++++++---- frontend/src/app/globals.css | 14 ++- frontend/src/app/page.tsx | 12 ++- ...onversational-birth-time-rectification.tsx | 86 ++++++++++++++++++- .../conversational-rectification/contracts.ts | 2 + .../evidence-extractor.ts | 1 + .../orchestrator.ts | 84 +++++++++++++++++- .../tests/consultation-entrypoint.test.ts | 12 +++ .../conversational-evidence-extractor.test.ts | 14 +++ ...ersational-rectification-component.test.ts | 36 +++++++- ...ational-rectification-orchestrator.test.ts | 8 +- 11 files changed, 296 insertions(+), 29 deletions(-) diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index f0ee4fac..36eb9ce0 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -458,6 +458,21 @@ function boundaryDistance(range: { readonly startTime: string; readonly endTime: return Math.max(0, Math.min(value - start, end - value)); } +async function rectificationPacketStage( + stage: "score_events" | "scan" | "merge_scans" | "candidate_differences" | "time_links" | "technical_packet", + operation: () => Value | Promise, +): Promise { + try { + return await operation(); + } catch (error) { + const errorKind = error instanceof Error ? error.name : typeof error; + const status = error !== null && typeof error === "object" && "status" in error + && typeof error.status === "number" ? error.status : null; + console.error(`[birth-time-conversation-packet] stage=${stage} error=${errorKind}${status === null ? "" : ` status=${status}`}`); + throw error; + } +} + export async function buildProductionConversationalRectificationPacket( engine: BirthTimeJourneyEngine, input: ConversationalRectificationPacketBuildInput, @@ -466,21 +481,23 @@ export async function buildProductionConversationalRectificationPacket( if (place.latitude === undefined || place.longitude === undefined) { throw new ConversationalRectificationError("profile_incomplete"); } + const latitude = place.latitude; + const longitude = place.longitude; const baseRange = currentRange(input); const events = scoreableLifeEvents( input.evidence as readonly LifeEventEvidence[], input.declaredBirthInput.birthDate, ); const eventScore: CandidateResult | null = events.length >= 3 - ? await engine.scoreEvents({ + ? await rectificationPacketStage("score_events", () => engine.scoreEvents({ birthDate: input.declaredBirthInput.birthDate, startTime: baseRange.startTime, endTime: baseRange.endTime, - lat: place.latitude, - lon: place.longitude, + lat: latitude, + lon: longitude, tz: place.timezoneOffset, events, - }) + })) : null; const selectedRange = !input.preserveCandidateRange && eventScore?.winningSegment ? { startTime: eventScore.winningSegment.startTime, endTime: eventScore.winningSegment.endTime } @@ -488,25 +505,28 @@ export async function buildProductionConversationalRectificationPacket( const questionnaires: RectificationQuestionnaire[] = []; for (const scanRange of boundedScanRanges(selectedRange)) { const scanPoint = scanCoordinates(scanRange); - const { questionnaire } = await engine.scan({ + const { questionnaire } = await rectificationPacketStage("scan", () => engine.scan({ birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`, uncertaintyMinutes: scanPoint.uncertaintyMinutes, - lat: place.latitude, - lon: place.longitude, + lat: latitude, + lon: longitude, tz: place.timezoneOffset, ayanamsa: "lahiri", - }); + })); questionnaires.push(questionnaire); } - const questionnaire = mergeQuestionnaireScans(questionnaires, selectedRange); - const candidateDifferences = await engine.buildDifferencePacket({ + const questionnaire = await rectificationPacketStage( + "merge_scans", + () => mergeQuestionnaireScans(questionnaires, selectedRange), + ); + const candidateDifferences = await rectificationPacketStage("candidate_differences", () => engine.buildDifferencePacket({ caseId: input.caseId, asOfDate: input.asOfDate, birthDate: input.declaredBirthInput.birthDate, startTime: selectedRange.startTime, endTime: selectedRange.endTime, - lat: place.latitude, - lon: place.longitude, + lat: latitude, + lon: longitude, tz: place.timezoneOffset, evidence: [], events, @@ -515,18 +535,22 @@ export async function buildProductionConversationalRectificationPacket( partitionFingerprints: [], recentRanges: [], candidateModel: null, - }); + })); const calculationVersion = eventScore ? `${candidateDifferences.packet.scoringVersion}+${eventScore.algorithmVersion}` : candidateDifferences.packet.scoringVersion; const metadata = layerMetadata(questionnaire, calculationVersion); + const timeLinkedScanSamples = await rectificationPacketStage( + "time_links", + () => sampleTimes(questionnaire), + ); const representative = eventScore?.winningSegment?.representativeTime ?? scanCoordinates(selectedRange).centerTime; const { buildRectificationTechnicalPacket } = await import( "../../../lib/conversational-rectification/technical-packet.ts" ); return { - packet: buildRectificationTechnicalPacket({ + packet: await rectificationPacketStage("technical_packet", () => buildRectificationTechnicalPacket({ scan: questionnaire, candidateDifferences, eventScore: input.preserveCandidateRange && eventScore @@ -537,11 +561,11 @@ export async function buildProductionConversationalRectificationPacket( calculationVersion, availableLayers: metadata.availableLayers, layerReferences: metadata.layerReferences, - timeLinkedScanSamples: sampleTimes(questionnaire), + timeLinkedScanSamples, boundaryDistanceMinutes: boundaryDistance(selectedRange, representative), futureWindows: [], }, - }), + })), resultId: eventScore?.resultId ?? null, }; } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index cb60a1c7..6bc2d95a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -459,8 +459,17 @@ button:disabled { cursor: default; opacity: .45; } .conversational-domain-picker { display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; } .conversational-domain-picker .conversational-domain-question { margin-bottom: var(--space-2); color: var(--color-ink); font-size: var(--type-body-md); font-weight: 600; line-height: 1.5; } .conversational-domain-picker > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-2); } -.conversational-domain-picker button { padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; text-align: left; } +.conversational-domain-picker button { display: grid; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; text-align: left; } +.conversational-domain-picker button > span { font-size: var(--type-body-sm); font-weight: 600; } +.conversational-domain-picker button > small { color: var(--color-ink-secondary); font-size: var(--type-overline); font-weight: 400; } .conversational-domain-picker button[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); } +.conversational-domain-picker button[aria-label$="下一步建议"] { border-color: color-mix(in srgb, var(--color-action) 52%, var(--color-border)); background: var(--color-action-soft); } +.conversational-answer-pending { min-width: 0; display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid color-mix(in srgb, var(--color-action) 24%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-action-soft); } +.conversational-answer-pending .app-loading-symbol { width: 40px; height: 40px; } +.conversational-answer-pending .app-loading-mark { width: 22px; height: 22px; } +.conversational-answer-pending > div:last-child { min-width: 0; display: grid; gap: var(--space-1); } +.conversational-answer-pending strong { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; } +.conversational-answer-pending span { color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; } .conversational-composer { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } .conversational-composer label { display: grid; gap: var(--space-1); font-size: var(--type-body-sm); font-weight: 600; } .conversational-composer label span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 400; line-height: 1.5; } @@ -486,6 +495,9 @@ button:disabled { cursor: default; opacity: .45; } .conversational-candidate dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } .conversational-candidate dd { min-width: 0; margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); overflow-wrap: anywhere; } .conversational-candidate time { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.conversational-candidate-progress { min-width: 0; display: grid; gap: var(--space-1); padding-top: var(--space-3); border-top: 1px solid var(--color-border); } +.conversational-candidate-progress strong { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; } +.conversational-candidate-progress span { color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.55; text-wrap: pretty; } .conversational-confirmation { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); } .conversational-status { min-width: 0; min-height: 1px; } .conversational-original-question { background: var(--color-success-muted); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f0294821..6cecd198 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1953,12 +1953,16 @@ export default function Home() { && session.rectificationCaseId === null) ?? null : null; - const rectificationSession = sourceSession.sessionType === "birth_time_rectification" + const canReuseSourceRectificationSession = action === "resume" + && sourceSession.sessionType === "birth_time_rectification" + && account.rectificationCase !== null + && (sourceSession.rectificationCaseId === account.rectificationCase.caseId + || sourceSession.rectificationCaseId === null); + const rectificationSession = canReuseSourceRectificationSession ? sourceSession : resumableSession ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification"); - const reusingRectificationSession = rectificationSession !== sourceSession - ? resumableSession !== null - : sourceSession.sessionType === "birth_time_rectification"; + const reusingRectificationSession = canReuseSourceRectificationSession + || resumableSession !== null; const requestedQuestion = pendingConsultationQuestion ?? (reusingRectificationSession ? null diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index f9e585b9..f5d3703a 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -26,6 +26,42 @@ const domainLabels = { other: "其他关键经历", } as const satisfies Readonly>; +function orderedEvidenceDomains(turn: ConversationalRectificationTurn): EvidenceDomain[] { + const provided = new Set(turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : [])); + return [...(turn.evidenceRequest?.domains ?? [])].sort((left, right) => + Number(provided.has(left)) - Number(provided.has(right))); +} + +function visibleTurnNarrative(turn: ConversationalRectificationTurn): string { + if ( + turn.status !== "active" + || turn.evidenceRecap.length === 0 + || /(?:已记录|已修订):/.test(turn.narrative) + ) { + return turn.narrative; + } + + const latestEvidence = turn.evidenceRecap.at(-1)!; + const candidate = turn.candidate; + const candidateRange = candidate.rangeStart && candidate.rangeEnd + ? `${candidate.rangeStart}–${candidate.rangeEnd}` + : candidate.representativeTime ?? "当前候选范围"; + const nextDomains = orderedEvidenceDomains(turn) + .slice(0, 2) + .map((domain) => domainLabels[domain]); + const nextStep = candidate.status === "ready_for_confirmation" + ? "当前证据已形成候选总结;请先核对上方候选时间,再决定是否确认或继续补充经历。" + : nextDomains.length > 0 + ? `下一步:请优先补充一件${nextDomains.join("或")}领域已经发生的事件,并选择发生年月。` + : "下一步:请继续补充一件已经发生并带有年月的真实经历。"; + + return [ + `${latestEvidence.isCorrection ? "已修订" : "已记录"}:${latestEvidence.dateLabel} · ${latestEvidence.summary}。`, + `候选范围当前为 ${candidateRange};范围暂未变化不代表提交失败,系统会结合后续经历继续比较相邻分钟。`, + nextStep, + ].join("\n"); +} + type SurfaceProps = Readonly<{ controller: ConversationalRectificationController; pendingConsultationQuestion?: string | null; @@ -47,6 +83,10 @@ function CandidateSummary({ turn }: { readonly turn: ConversationalRectification : candidate.status === "ready_for_confirmation" ? "待确认 · 未验证" : "待验证 · 未确认"; + const recordedCount = turn.evidenceRecap.length; + const nextDomains = orderedEvidenceDomains(turn) + .slice(0, 2) + .map((domain) => domainLabels[domain]); return (
@@ -69,6 +109,15 @@ function CandidateSummary({ turn }: { readonly turn: ConversationalRectification ) :

尚未形成可供确认的具体时间。

} {!confirmed &&

候选仍待真实经历验证,未经你的明确确认不会成为当前排盘时间。

} + {!confirmed && recordedCount > 0 && turn.status !== "completed" && ( +
+ 已记录 {recordedCount} 条经历 + 系统至少需要 3 条时间明确、可评分的经历后才会可靠缩小范围;范围不变不代表提交失败。 + {nextDomains.length > 0 && ( + 下一步优先补充:{nextDomains.join("或")}领域已经发生的事件。 + )} +
+ )}
); } @@ -98,7 +147,13 @@ export function ConversationalRectificationSurface({ } const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed"; - const requestedDomains = turn.evidenceRequest?.domains ?? []; + const requestedDomains = orderedEvidenceDomains(turn); + const providedDomains = new Set( + turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : []), + ); + const priorityDomain = requestedDomains.find((domain) => !providedDomains.has(domain)) + ?? requestedDomains[0] + ?? null; const submit = async () => { if (!canAnswer || !controller.draft.trim() || controller.pending) return; const dateLabel = eventYear @@ -125,7 +180,7 @@ export function ConversationalRectificationSurface({
- +
@@ -135,6 +190,9 @@ export function ConversationalRectificationSurface({
{requestedDomains.map((domain) => ( ))}
)} + {controller.pending && canAnswer && ( +
+ +
+ 正在核对这段经历 + 正在比较相邻候选分钟;完成后会继续提问或更新候选范围。 +
+
+ )} + {canAnswer &&
{ diff --git a/frontend/src/lib/conversational-rectification/contracts.ts b/frontend/src/lib/conversational-rectification/contracts.ts index 0e0cd728..cb9af784 100644 --- a/frontend/src/lib/conversational-rectification/contracts.ts +++ b/frontend/src/lib/conversational-rectification/contracts.ts @@ -83,6 +83,8 @@ const evidenceRecapEntrySchema = boundedJson(z.object({ id: z.string().uuid(), summary: boundedNonblankText(1_000), dateLabel: boundedNonblankText(80), + // Optional so turns written before domain-aware follow-up ordering still resume safely. + domain: evidenceDomainSchema.optional(), // Optional so turns written before correction lineage was introduced still resume safely. isCorrection: z.boolean().optional(), }).strict(), 4_096); diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index 97437b8a..24f5aee4 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -69,6 +69,7 @@ function eventSummary(fragment: string): string { const withoutDates = fragment .replace(chineseDatePattern, "") .replace(isoDatePattern, "") + .replace(/(?:发生时间|事件详情)\s*[::]\s*/g, "") .replace(/^\s*(?:更正|纠正|修正)\s*[::]?\s*/, "") .replace(leadingRelativeTimePattern, "") .replace(/^\s*(?:同时|又)\s*/, "") diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 949c2689..778d4bbd 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -28,6 +28,7 @@ import { } from "./technical-packet.ts"; import { convergenceNotes, + MINIMUM_SCOREABLE_EVENTS, nextPlateauCount, rangeCompletionCopy, rangeCompletionReason, @@ -182,10 +183,27 @@ function commandFingerprint(command: MutableCommand): string { return createHash("sha256").update(JSON.stringify(identity), "utf8").digest("hex"); } +function visibleEvidenceSummary(value: string): string { + const cleaned = value.replace(/(?:发生时间|事件详情)\s*[::]\s*/g, "").trim(); + return cleaned || value; +} + function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn { const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn); if (!parsed.success) throw new ConversationalRectificationError("store_unavailable"); - return parsed.data; + const evidenceDomains = new Map( + effectiveLifeEventEvidence(value.eventEvidence ?? []).map((item) => [item.id, item.domain]), + ); + return { + ...parsed.data, + evidenceRecap: parsed.data.evidenceRecap.map((item) => ({ + ...item, + summary: visibleEvidenceSummary(item.summary), + ...(item.domain ? {} : evidenceDomains.get(item.id) + ? { domain: evidenceDomains.get(item.id) } + : {}), + })), + }; } function transitionReceipt(modelId = "deterministic-rectification-transition"): ValidationReceipt { @@ -222,16 +240,65 @@ export function effectiveLifeEventEvidence< function evidenceRecap(evidence: ReadonlyArray) { return effectiveLifeEventEvidence(evidence).slice(-20).map((item) => ({ id: item.id, - summary: item.eventSummary, + summary: visibleEvidenceSummary(item.eventSummary), dateLabel: item.dateValue ? item.scoreable === false && item.extractionStatus !== "needs_clarification" ? `${item.dateValue}(未来,仅作背景)` : item.dateValue : "日期待补充", + domain: item.domain, ...((item.correctsEvidenceIds?.length ?? 0) > 0 ? { isCorrection: true } : {}), })); } +const progressDomainLabels = { + career: "事业", + education: "学业", + finance: "财务", + relocation: "搬迁", + relationship: "重要关系", + family: "家庭", + other: "其他关键经历", +} as const satisfies Readonly>; + +function evidenceProgressNarrative(input: Readonly<{ + previousCandidate: PrivateCandidateInput; + packet: RectificationTechnicalPacket; + newEvidence: ReadonlyArray; + scoreableEventCount: number; + willContinue: boolean; +}>): string { + const recorded = evidenceRecap(input.newEvidence); + const acknowledgement = recorded.length === 0 + ? "这段经历已经保存。" + : `已记录:${recorded.map((item) => `${item.dateLabel} · ${item.summary}`).join(";")}。`; + const previousStart = input.previousCandidate.rangeStart; + const previousEnd = input.previousCandidate.rangeEnd; + const nextStart = input.packet.candidate.range.startTime; + const nextEnd = input.packet.candidate.range.endTime; + const rangeChanged = previousStart !== nextStart || previousEnd !== nextEnd; + const progress = input.scoreableEventCount < MINIMUM_SCOREABLE_EVENTS + ? `当前累计 ${input.scoreableEventCount} 条可评分经历;系统至少需要 ${MINIMUM_SCOREABLE_EVENTS} 条时间明确的经历才开始事件排序,所以本轮候选范围暂时保持 ${nextStart}–${nextEnd}。` + : rangeChanged + ? `候选范围已从 ${previousStart ?? "原范围"}–${previousEnd ?? "原范围"} 更新为 ${nextStart}–${nextEnd}。` + : `本轮已纳入 ${input.scoreableEventCount} 条可评分经历,但候选范围暂未稳定缩小;这不是提交失败。`; + const suggested = input.packet.suggestedDomains + .slice(0, 2) + .map((item) => progressDomainLabels[item.domain]); + const nextStep = input.willContinue && suggested.length > 0 + ? `下一步:请优先补充一件${suggested.join("或")}领域已经发生的事件,并选择大致年月。` + : ""; + const differenceBasis = input.packet.suggestedDomains.length > 0 + ? `本轮区分重点:${input.packet.suggestedDomains.slice(0, 2) + .map((item) => `${progressDomainLabels[item.domain]}事件用于比较 ${item.layer}`) + .join(";")}。` + : ""; + return [acknowledgement, progress, nextStep, differenceBasis] + .filter(Boolean) + .join("\n") + .slice(0, 12_000); +} + function exactTechnicalReceipt(packet: RectificationTechnicalPacket) { const projected = projectRectificationTechnicalPacket(packet); return { @@ -1060,6 +1127,17 @@ export function createConversationalRectificationService( scoreableEventCount: allScoreable.length, plateauCount, }); + const narrativeWithProgress = { + ...narrative, + narrative: evidenceProgressNarrative({ + previousCandidate: current.privateCandidate, + packet: computed.packet, + newEvidence: evidence, + scoreableEventCount: allScoreable.length, + willContinue: completionReason === null + && computed.packet.candidate.status !== "ready_for_confirmation", + }), + } satisfies RectificationNarrativeResult; const privateCandidate = privateCandidateFromPacket({ packet: computed.packet, resultId: computed.resultId, @@ -1071,7 +1149,7 @@ export function createConversationalRectificationService( turnVersion: command.turnVersion + 1, pendingConsultationQuestion: current.pendingConsultationQuestion, packet: computed.packet, - narrative, + narrative: narrativeWithProgress, evidence: [...current.eventEvidence, ...evidence], }); const turn = completionReason diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 8e3d6b33..849f9bb6 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -151,6 +151,18 @@ test("homepage reuses the session bound to an unfinished rectification case", () assert.match(handler, /resumableSession \?\? createSession/); }); +test("starting again after a completed rectification creates a new dedicated session", () => { + const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const start = source.indexOf("async function openBirthTimeRectification"); + const end = source.indexOf("function handleConversationalRectificationTurn", start); + const handler = source.slice(start, end); + + assert.match(handler, /const canReuseSourceRectificationSession = action === "resume"/); + assert.match(handler, /sourceSession\.rectificationCaseId === account\.rectificationCase\.caseId/); + assert.match(handler, /const rectificationSession = canReuseSourceRectificationSession[\s\S]*?: resumableSession \?\? createSession/); + assert.doesNotMatch(handler, /const rectificationSession = sourceSession\.sessionType === "birth_time_rectification"/); +}); + test("rectify-first suggestions hand the source question to a dedicated rectification session", () => { const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const start = source.indexOf("function chooseConversationSuggestion"); diff --git a/frontend/tests/conversational-evidence-extractor.test.ts b/frontend/tests/conversational-evidence-extractor.test.ts index 0bd1395c..3c14c485 100644 --- a/frontend/tests/conversational-evidence-extractor.test.ts +++ b/frontend/tests/conversational-evidence-extractor.test.ts @@ -24,6 +24,20 @@ test("preserves raw text and splits two clear facts sharing an explicit month", assert.equal(new Set(evidence.map((item) => item.id)).size, 2); }); +test("removes the date-picker transport labels from the visible event summary", () => { + const rawText = "发生时间:2016 年 6 月\n事件详情:大学毕业"; + const [evidence] = extractLifeEventEvidence({ + rawText, + sourceTurnId, + asOfDate: "2026-07-20", + }); + + assert.equal(evidence?.eventSummary, "大学毕业"); + assert.equal(evidence?.dateValue, "2016-06"); + assert.equal(evidence?.domain, "education"); + assert.equal(evidence?.scoreable, true); +}); + test("keeps vague evidence non-scoreable and asks for clarification", () => { const rawText = "那几年工作不太顺"; const [evidence] = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" }); diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index 88b684ef..da6e5191 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -51,6 +51,7 @@ const turn: ConversationalRectificationTurn = { id: "00000000-0000-4000-8000-000000000822", summary: "开始第一份长期工作", dateLabel: "2021 年 7 月", + domain: "career", isCorrection: false, }], actions: ["answer", "pause", "abandon", "confirm"], @@ -93,8 +94,14 @@ test("rich narrative precedes 2–4 domain choices while free text remains avail assert.match(markup, /

当前判断<\/h2>/); assert.match(markup, /05:18<\/strong>/); assert.ok(markup.indexOf("候选时间") < markup.indexOf("当前判断")); - assert.ok(markup.indexOf("当前判断") < markup.indexOf("重要关系")); + assert.ok(markup.indexOf("当前判断") < markup.indexOf('data-evidence-domain="relationship"')); assert.equal((markup.match(/data-evidence-domain=/g) ?? []).length, 3); + assert.match(markup, /aria-label="重要关系,下一步建议"/); + assert.match(markup, /aria-label="事业与身份,已提供,可继续补充"/); + assert.ok( + markup.indexOf('data-evidence-domain="relationship"') + < markup.indexOf('data-evidence-domain="career"'), + ); assert.match(markup, /]+id="conversational-rectification-answer"/); assert.match(markup, /aria-label="经历发生年份"/); assert.match(markup, /aria-label="经历发生月份"/); @@ -103,6 +110,25 @@ test("rich narrative precedes 2–4 domain choices while free text remains avail assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/); }); +test("a resumed legacy turn replaces repeated technical prose with actionable guidance", () => { + const legacyTurn = { + ...turn, + status: "active", + candidate: { ...turn.candidate, status: "pending_validation" }, + narrative: "05:30 是范围内的待验证候选。D1 保持稳定;D9 与 D24 呈现分钟敏感差异。", + actions: ["answer", "pause", "abandon"], + } satisfies ConversationalRectificationTurn; + const markup = renderToStaticMarkup(React.createElement( + ConversationalRectificationSurface, + { controller: controller({ turn: legacyTurn }) }, + )); + + assert.match(markup, /已记录:2021 年 7 月 · 开始第一份长期工作/); + assert.match(markup, /范围暂未变化不代表提交失败/); + assert.match(markup, /下一步:请优先补充一件重要关系或搬迁与居住地领域/); + assert.doesNotMatch(markup, /D1 保持稳定/); +}); + test("an uninitialized surface shows progress without a second start card", () => { const emptyController = controller({ turn: null, @@ -138,6 +164,9 @@ test("evidence is correctable, secondary controls stay hidden, and confirmation assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/); assert.match(markup, /待确认 · 未验证/); assert.match(markup, /确认将 05:18 设为当前排盘时间/); + assert.match(markup, /已记录 1 条经历/); + assert.match(markup, /至少需要 3 条/); + assert.match(markup, /下一步优先补充/); assert.match(markup, /不会自动采用/); assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/); }); @@ -196,6 +225,9 @@ test("pending markup and responsive CSS expose accessibility contracts", () => { ); assert.match(markup, /aria-busy="true"/); + assert.match(markup, /aria-label="正在核对经历"/); + assert.match(markup, /正在核对这段经历/); + assert.match(markup, /app-loading-orbit/); assert.match(markup, /]+disabled=""[^>]*>保留中的文字<\/textarea>/); assert.match(markup, /aria-label="生时校正对话"/); assert.match(markup, /role="alert"|aria-live="polite"/); @@ -204,6 +236,8 @@ test("pending markup and responsive CSS expose accessibility contracts", () => { assert.match(css, /\.conversational-rectification button[^}]*min-height:\s*44px/); assert.match(css, /\.conversational-rectification[^}]*:focus-visible/); assert.match(css, /:where\(button, textarea\):focus-visible/); + assert.match(css, /\.conversational-answer-pending[\s\S]*grid-template-columns:\s*40px minmax\(0, 1fr\)/); + assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/); assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/); assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/); assert.match(component, /onPendingChange/); diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index ff768fa1..9acaf9f8 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -543,6 +543,7 @@ test("evidence corrections are append-only while recap and scoring use only the id: secondId, summary: "其实是离职", dateLabel: "2020-11", + domain: "career", isCorrection: true, }]); @@ -564,6 +565,7 @@ test("evidence corrections are append-only while recap and scoring use only the id: stored[2]?.id, summary: "准确的是入职", dateLabel: "2021-02", + domain: "career", isCorrection: true, }]); assert.deepEqual(value.packetEvidenceCounts, [0, 1, 1, 1]); @@ -591,6 +593,7 @@ test("an unclear correction immediately retires the wrong fact and stays retired id: unclear?.id, summary: "具体年月记不清", dateLabel: "日期待补充", + domain: "other", isCorrection: true, }]); assert.equal(clarification.status, "active"); @@ -825,7 +828,7 @@ test("a rejected professional narrative falls back safely while the first scorea assert.equal(turn.candidate.status, "ready_for_confirmation"); assert.equal(stored?.privateCandidate.resultId, resultId); assert.equal(stored?.validationReceipts.at(-1)?.fallbackUsed, true); - assert.match(turn.narrative, /下一步|当前证据已形成候选总结/); + assert.match(turn.narrative, /已记录:|本轮区分重点/); assert.doesNotMatch(turn.narrative, /候选没有推进|请稍后重试/); }); @@ -850,6 +853,8 @@ test("one and two supported events save and narrate before the third accumulated assert.equal(stored?.eventEvidence.length, index + 1); assert.equal(turn.evidenceRecap.length, index + 1); assert.equal(turn.status, index < 2 ? "active" : "confirming"); + assert.match(turn.narrative, new RegExp(`当前累计 ${index + 1} 条可评分经历|候选范围已从|本轮已纳入 ${index + 1} 条可评分经历`)); + assert.match(turn.narrative, /已记录:/); } assert.equal(value.counts().packetBuilds, 4); @@ -905,6 +910,7 @@ test("family evidence remains stored and public without changing its domain", as id: stored[0]?.id, summary: stored[0]?.eventSummary, dateLabel: "2020-07", + domain: "family", }]); assert.equal(turn.status, "active"); });