fix: make rectification follow-up actionable

This commit is contained in:
Jesse_Chen
2026-07-21 22:53:56 +08:00
parent bfc6870614
commit f4ac6ce75e
11 changed files with 296 additions and 29 deletions
@@ -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<Value>(
stage: "score_events" | "scan" | "merge_scans" | "candidate_differences" | "time_links" | "technical_packet",
operation: () => Value | Promise<Value>,
): Promise<Value> {
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,
};
}
+13 -1
View File
@@ -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); }
+8 -4
View File
@@ -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
@@ -26,6 +26,42 @@ const domainLabels = {
other: "其他关键经历",
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
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 (
<section className="conversational-candidate" aria-labelledby="conversational-candidate-title">
@@ -69,6 +109,15 @@ function CandidateSummary({ turn }: { readonly turn: ConversationalRectification
</dl>
) : <p></p>}
{!confirmed && <p></p>}
{!confirmed && recordedCount > 0 && turn.status !== "completed" && (
<div className="conversational-candidate-progress" role="status">
<strong> {recordedCount} </strong>
<span> 3 </span>
{nextDomains.length > 0 && (
<span>{nextDomains.join("或")}</span>
)}
</div>
)}
</section>
);
}
@@ -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({
<article className="conversational-narrative" aria-label="校正分析">
<div className="conversational-narrative-body">
<ChatMessageContent text={turn.narrative} />
<ChatMessageContent text={visibleTurnNarrative(turn)} />
</div>
</article>
@@ -135,6 +190,9 @@ export function ConversationalRectificationSurface({
<div>
{requestedDomains.map((domain) => (
<button
aria-label={`${domainLabels[domain]}${domain === priorityDomain
? "下一步建议"
: providedDomains.has(domain) ? "已提供,可继续补充" : "可补充"}`}
aria-pressed={controller.selectedDomain === domain}
data-evidence-domain={domain}
disabled={controller.pending || !canAnswer}
@@ -145,13 +203,35 @@ export function ConversationalRectificationSurface({
focusComposer();
}}
>
{domainLabels[domain]}
<span>{domainLabels[domain]}</span>
<small>{domain === priorityDomain
? "下一步建议"
: providedDomains.has(domain) ? "已提供,可继续补充" : "可补充"}</small>
</button>
))}
</div>
</fieldset>
)}
{controller.pending && canAnswer && (
<div
aria-atomic="true"
aria-label="正在核对经历"
aria-live="polite"
className="conversational-answer-pending"
role="status"
>
<div className="app-loading-symbol" aria-hidden="true">
<span className="app-loading-orbit" />
<span className="app-loading-mark" />
</div>
<div>
<strong></strong>
<span></span>
</div>
</div>
)}
{canAnswer && <form
className="conversational-composer"
onSubmit={(event) => {
@@ -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);
@@ -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*/, "")
@@ -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<LifeEventEvidenceInput>) {
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<Record<RectificationEvidenceDomain, string>>;
function evidenceProgressNarrative(input: Readonly<{
previousCandidate: PrivateCandidateInput;
packet: RectificationTechnicalPacket;
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
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
@@ -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");
@@ -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" });
@@ -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 24 domain choices while free text remains avail
assert.match(markup, /<h2>当前判断<\/h2>/);
assert.match(markup, /<strong>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, /<textarea[^>]+id="conversational-rectification-answer"/);
assert.match(markup, /aria-label="经历发生年份"/);
assert.match(markup, /aria-label="经历发生月份"/);
@@ -103,6 +110,25 @@ test("rich narrative precedes 24 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, /<textarea[^>]+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/);
@@ -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");
});