fix: make rectification follow-up actionable
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 2–4 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 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, /<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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user