fix(rectification): simplify delivery card and guard double turns (BUG-595, BUG-596)
Independent Staging Quality Gate / validate (push) Successful in 25m9s
Independent Staging Quality Gate / publish (push) Successful in 37m19s

Make the range card row-select, drop the composer 先这样 control and adopt status bar, keep delivery copy to three sentences with a folded verification report, and skip a second no-message agent run after a terminal delivery turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-08 19:56:48 +08:00
co-authored by Cursor
parent 6368ebba0f
commit 7e3b6cdc7a
60 changed files with 1561 additions and 1105 deletions
@@ -50,6 +50,7 @@ import {
RECTIFICATION_ACTION_EXECUTION,
type RectificationRouteAction,
} from "@/lib/rectification-agentic/v9/turn-exit";
import { logRectificationDeliveryTurn } from "@/lib/rectification-agentic/v9/delivery-turn-guard";
export const runtime = "nodejs";
export const maxDuration = 240;
@@ -779,6 +780,11 @@ export async function POST(request: Request) {
try {
if (action === "opening") {
logRectificationDeliveryTurn({
trigger: "opening",
caseId,
terminalNote: false,
});
try {
await persistNextInterviewIfIdle({
accounting: accounting as never,
@@ -842,7 +848,9 @@ export async function POST(request: Request) {
},
});
if (!result.ok) {
if (result.errorCode === "already_delivered") {
send({ type: "done", emitted: true });
} else if (!result.ok) {
send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" });
} else {
// Shared gate owns persistNextInterviewIfIdle then ensureNonTerminalTurnExit;
@@ -23,6 +23,7 @@ import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectifica
import { collectionProgressFromReceipt } from "@/lib/rectification-agentic/v9/evidence-model";
import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection";
import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/divergence-panel";
import { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
export const runtime = "nodejs";
@@ -166,6 +167,7 @@ function publicLatestResult(
) {
if (!latest) return null;
const projected = overlayPublicDecision(latest, decision);
const toolProjection = latestResultToolProjection(latest, decision);
const rangeDelivery = rangeDeliveryForSnapshot({
decisionReceipt: projected.decisionReceipt,
candidates: projected.candidates,
@@ -173,6 +175,7 @@ function publicLatestResult(
representative_time: projected.representativeTime ?? decision.representativeTime,
credibleRange: projected.credible_range ?? decision.credibleRange,
credible_range: projected.credible_range ?? decision.credibleRange,
skill_verification_report: toolProjection.skill_verification_report,
});
const withDelivery = {
...projected,
@@ -11,6 +11,7 @@ import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getRectificationV9RegenerationAgent } from "@/mastra/agentic-rectification";
import { logRectificationDeliveryTurn } from "@/lib/rectification-agentic/v9/delivery-turn-guard";
export const runtime = "nodejs";
export const maxDuration = 240;
@@ -128,6 +129,12 @@ export async function POST(request: Request, context: RouteContext) {
turnId,
accounting,
}, skillPackage);
logRectificationDeliveryTurn({
trigger: "regenerate",
caseId,
resultId: turnId,
terminalNote: false,
});
const result = await regenerateV9AssistantTurn({
userId: user.id,
caseId,
+57 -158
View File
@@ -3083,24 +3083,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-choice-why-user p {
margin: var(--space-2) 0 0;
}
.rectification-composer-meta {
display: grid;
gap: var(--space-3);
margin: 0 0 var(--space-3);
}
.rectification-composer-meta .rectification-collect-stop {
margin: 0;
}
.rectification-continue-hint {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-continue-hint strong {
color: var(--color-ink);
font-weight: 600;
}
.rectification-candidates-heading { display: grid; gap: var(--space-1); }
.rectification-candidates-heading strong { font-size: var(--type-title-sm); font-family: var(--font-display); font-weight: 600; letter-spacing: -.2px; }
.rectification-candidates-heading span,
@@ -3152,55 +3134,21 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
display: grid;
gap: var(--space-4);
}
.rectification-range-delivery__range {
font-size: var(--type-title-md);
font-family: var(--font-display);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -.2px;
}
.rectification-range-delivery__facts {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-2) var(--space-3);
margin: 0;
}
.rectification-range-delivery__facts dt {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-range-delivery__facts dd {
margin: 0;
font-size: var(--type-body-sm);
}
.rectification-range-delivery__boundary {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-divergence {
display: grid;
gap: var(--space-3);
}
.rectification-divergence__title {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-divergence-list {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.rectification-divergence-column {
.rectification-range-delivery__rows {
display: grid;
gap: var(--space-2);
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.rectification-range-delivery__row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-3);
align-items: center;
width: 100%;
min-height: 44px;
padding: var(--space-4);
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
color: var(--color-ink);
@@ -3209,59 +3157,60 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
text-align: left;
cursor: pointer;
}
.rectification-divergence-column strong {
font-size: var(--type-body-sm);
font-variant-numeric: tabular-nums;
.rectification-range-delivery__row.is-representative,
.rectification-range-delivery__row[aria-pressed="true"] {
border-color: color-mix(in srgb, var(--color-action) 55%, var(--color-border));
background: color-mix(in srgb, var(--color-action-soft) 60%, var(--color-canvas));
}
.rectification-divergence-column span {
.rectification-range-delivery__row:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.rectification-range-delivery__time {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.rectification-range-delivery__diff {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-divergence-column.is-selected {
border-color: color-mix(in srgb, var(--color-action) 55%, var(--color-border));
background: color-mix(in srgb, var(--color-action-soft) 60%, var(--color-canvas));
}
.rectification-divergence-column:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.rectification-divergence-clear,
.rectification-range-delivery__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.rectification-range-delivery__adopt,
.rectification-range-delivery__continue,
.rectification-range-delivery__quiet {
min-height: 44px;
padding: 0 var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
color: var(--color-ink);
font: inherit;
.rectification-range-delivery__status {
color: var(--color-action);
font-size: var(--type-caption);
font-weight: 600;
cursor: pointer;
}
.rectification-range-delivery__adopt {
color: var(--color-on-dark);
background: var(--color-surface-dark);
border-color: var(--color-surface-dark);
}
.rectification-range-delivery__adopt:hover:not(:disabled),
.rectification-range-delivery__continue:hover:not(:disabled),
.rectification-range-delivery__quiet:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--color-action) 45%, var(--color-border));
}
.rectification-range-delivery__adopt:focus-visible,
.rectification-range-delivery__continue:focus-visible,
.rectification-range-delivery__quiet:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.rectification-range-delivery__adopted {
.rectification-range-delivery__boundary {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-verification-report {
margin: 0;
}
.rectification-verification-report summary {
cursor: pointer;
min-height: 44px;
display: flex;
align-items: center;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-verification-report__body {
margin-top: var(--space-3);
overflow-x: auto;
}
.rectification-verification-report__body table {
width: 100%;
border-collapse: collapse;
}
.rectification-verification-report__body th,
.rectification-verification-report__body td {
padding: 6px 8px;
border: 1px solid var(--color-border);
text-align: left;
font-size: var(--type-caption);
}
.rectification-saved {
width: calc(100% - var(--assistant-content-inset));
@@ -3322,58 +3271,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
line-height: 1.5;
text-align: left;
}
.rectification-collect-stop {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
width: 100%;
margin: 0 0 var(--space-3);
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
color: var(--color-ink-secondary);
font: inherit;
font-size: var(--type-body-sm);
cursor: pointer;
}
.rectification-question-skipped {
margin: var(--space-2) 0 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-adopt-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin: 0 0 var(--space-3);
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas-soft);
color: var(--color-ink);
font-size: var(--type-body-sm);
}
.rectification-adopt-status__link {
padding: 0;
border: 0;
background: transparent;
color: var(--color-action);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.rectification-adopt-status__link:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.rectification-adopt-status__hint {
flex: 1 1 100%;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-house-table {
display: grid;
gap: var(--space-3);
@@ -3407,9 +3309,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
scroll-snap-type: x proximity;
}
.rectification-candidate { min-height: 122px; padding: 12px; scroll-snap-align: start; }
.rectification-divergence-list {
grid-template-columns: minmax(0, 1fr);
}
}
/* The QR stays on literal white in both themes: scanners need the light modules
@@ -39,7 +39,7 @@ import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import { RangeDeliveryContinueHint, RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
@@ -78,7 +78,7 @@ import {
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
import { RECTIFICATION_USER_COPY, postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
@@ -458,7 +458,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [openingRequested, setOpeningRequested] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [dismissedRangeResultId, setDismissedRangeResultId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
@@ -473,9 +472,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const snapshotAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
const offerSectionRef = useRef<HTMLDivElement | null>(null);
const offerFlashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [offerFlash, setOfferFlash] = useState(false);
const runStartedAtRef = useRef(0);
const stepStartedAtRef = useRef(0);
const liveToolRef = useRef<string | null>(null);
@@ -494,17 +490,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
currentQuestionRef.current = currentQuestion;
}, [currentQuestion]);
useEffect(() => () => {
if (offerFlashTimer.current) clearTimeout(offerFlashTimer.current);
}, []);
const scrollToAdoptOffer = useCallback(() => {
offerSectionRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
setOfferFlash(true);
if (offerFlashTimer.current) clearTimeout(offerFlashTimer.current);
offerFlashTimer.current = setTimeout(() => setOfferFlash(false), 600);
}, []);
useLayoutEffect(() => {
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
const update = () => {
@@ -1429,9 +1414,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const adoptedRangeLabel = candidateResult?.credibleRange
? `${candidateResult.credibleRange[0]}${candidateResult.credibleRange[1]}`
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const liveQuestionOnMessages = messages.some((message) => (
message.role === "assistant"
@@ -1521,15 +1503,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void submitStructuredChoice(STOP_ACTION, "stop");
}
const rangeCardDismissed = Boolean(
candidateResult
&& dismissedRangeResultId === candidateResult.resultId
&& !candidateResult.selectedTime
&& savedStatus !== "accepted"
&& savedStatus !== "confirmed"
&& !readonly
);
const spokenCollectOpen = currentQuestion?.kind === "collect_spoken" && !readonly && !busy;
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
@@ -1646,13 +1619,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return (
<div
key={message.renderKey}
className={[
"rectification-message-wrap rectification-message-entry",
offerFlash && message.renderKey === selectionCardMessageKey
? "rectification-message-flash"
: "",
].filter(Boolean).join(" ")}
ref={showSelectionCards && message.renderKey === selectionCardMessageKey ? offerSectionRef : undefined}
className="rectification-message-wrap rectification-message-entry"
>
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
@@ -1676,13 +1643,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onRegenerate={() => void regenerateMessage(message)}
/>
)}
{showSelectionCards && candidateResult && dismissedRangeResultId !== candidateResult.resultId && message.renderKey === selectionCardMessageKey && (
{showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && (
<RectificationRangeDelivery
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
readonly={readonly || busy || regeneratingMessageKey !== null}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
onContinue={() => setDismissedRangeResultId(candidateResult.resultId)}
/>
)}
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
@@ -1708,7 +1674,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)}
{questionGap === "verified_idle" && (
<p className="rectification-pending-note" role="status">
{RECTIFICATION_USER_COPY.postAdoptVerifyDone}
{postAdoptVerifyDoneCopy(
savedTime,
messages.some((message) => message.question?.kind === "reverse_verify"),
)}
</p>
)}
{savedTime && savedStatus === "confirmed" && (
@@ -1730,38 +1699,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{!conversationAnchor.anchored && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
{savedTime && savedStatus === "accepted" && (
<div className="rectification-adopt-status" role="status">
<span>
{savedTime}
{adoptedRangeLabel ? ` · 范围 ${adoptedRangeLabel}` : ""}
</span>
{showSelectionCards && (
<button
type="button"
className="rectification-adopt-status__link"
onClick={scrollToAdoptOffer}
>
</button>
)}
<span className="rectification-adopt-status__hint"></span>
</div>
)}
{(rangeCardDismissed || spokenCollectOpen) && (
<div className="rectification-composer-meta">
<RangeDeliveryContinueHint visible={rangeCardDismissed && !busy} />
{spokenCollectOpen && (
<button
type="button"
className="rectification-collect-stop"
onClick={submitStop}
>
{CHOICE_STOP_LABEL}
</button>
)}
</div>
)}
<ChatComposer
inputRef={composer}
value={draft}
@@ -1,171 +1,80 @@
"use client";
import { useState } from "react";
import type { RectificationCandidateResult } from "@/lib/rectification-candidate-result";
import {
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
rangeDeliveryEventCopy,
rangeDeliverySensitiveCopy,
rangeDeliveryStableCopy,
} from "@/lib/rectification-agentic/user-copy";
import { STEP_STATE_COPY } from "@/lib/rectification-agentic/v9/step-state";
import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel";
function adoptCandidateId(
result: RectificationCandidateResult,
selectedColumnId: string | null,
): string | null {
const delivery = result.rangeDelivery;
if (selectedColumnId) return selectedColumnId;
if (delivery?.representative_candidate_id) return delivery.representative_candidate_id;
const wanted = delivery?.representative_time ?? result.representativeTime;
if (!wanted) return result.candidates[0]?.candidateId ?? null;
return result.candidates.find((item) => item.time === wanted)?.candidateId
?? result.candidates[0]?.candidateId
?? null;
}
export function RangeDeliveryContinueHint({ visible }: Readonly<{ visible: boolean }>) {
if (!visible) return null;
return (
<p className="rectification-continue-hint" role="status">
<strong>{STEP_STATE_COPY.collect.headline}</strong>
{" "}
{RECTIFICATION_USER_COPY.continueCollectFallback}
</p>
);
}
import { RectificationVerificationReport } from "@/components/rectification-verification-report";
export function RectificationRangeDelivery({
result,
acceptingCandidateId,
readonly,
onAccept,
onContinue,
}: Readonly<{
result: RectificationCandidateResult;
acceptingCandidateId: string | null;
readonly: boolean;
onAccept: (candidateId: string) => void;
onContinue: () => void;
}>) {
const delivery: RangeDeliveryProjection | null = result.rangeDelivery;
const [selectedColumnId, setSelectedColumnId] = useState<string | null>(null);
const range = delivery?.range ?? result.credibleRange;
const representative = delivery?.representative_time ?? result.representativeTime;
const selected = Boolean(result.selectedTime);
const eventCount = delivery?.event_count ?? 0;
const selected = result.selectedTime;
const busy = Boolean(acceptingCandidateId) || readonly;
const panel = delivery?.divergence;
const showPanel = panel?.eligible === true && panel.columns.length >= 2 && !selected;
const candidateId = adoptCandidateId(result, selectedColumnId);
const adopting = Boolean(acceptingCandidateId);
const rows = delivery?.rows ?? [];
const title = range
? eventCount > 0
? `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${range[0]}${range[1]} · ${rangeDeliveryEventCopy(eventCount)}`
: `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${range[0]}${range[1]}`
: RECTIFICATION_USER_COPY.rangeDeliveryTitle;
const markdown = delivery?.verification_markdown
?? result.verificationReportMarkdown;
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正区间">
<div className="rectification-candidates-heading">
<strong>{RECTIFICATION_USER_COPY.rangeDeliveryTitle}</strong>
{range ? (
<span className="rectification-range-delivery__range">{range[0]}{range[1]}</span>
) : null}
<strong>{title}</strong>
</div>
<dl className="rectification-range-delivery__facts">
{representative ? (
<>
<dt>{RECTIFICATION_USER_COPY.rangeDeliveryRepresentativeLabel}</dt>
<dd>{representative}</dd>
</>
) : null}
{(delivery?.event_count ?? 0) > 0 ? (
<>
<dt>{RECTIFICATION_USER_COPY.rangeDeliveryEventsLabel}</dt>
<dd>{rangeDeliveryEventCopy(delivery!.event_count)}</dd>
</>
) : null}
{(delivery?.stable_themes.length ?? 0) > 0 ? (
<>
<dt>{RECTIFICATION_USER_COPY.rangeDeliveryStableLabel}</dt>
<dd>{rangeDeliveryStableCopy(delivery!.stable_themes)}</dd>
</>
) : null}
{(delivery?.sensitive_themes.length ?? 0) > 0 ? (
<>
<dt>{RECTIFICATION_USER_COPY.rangeDeliverySensitiveLabel}</dt>
<dd>{rangeDeliverySensitiveCopy(delivery!.sensitive_themes)}</dd>
</>
) : null}
</dl>
<ul className="rectification-range-delivery__rows">
{rows.map((row) => {
const adopted = selected === row.time;
const adopting = acceptingCandidateId === row.candidate_id;
return (
<li key={row.candidate_id}>
<button
type="button"
className={`rectification-range-delivery__row${row.representative ? " is-representative" : ""}${adopted ? " is-adopted" : ""}`}
aria-pressed={adopted}
disabled={busy}
onClick={() => onAccept(row.candidate_id)}
>
<span className="rectification-range-delivery__time">
{row.time}
{row.representative ? ` · ${RECTIFICATION_USER_COPY.rangeDeliveryRepresentativeLabel}` : ""}
</span>
<span className="rectification-range-delivery__diff">
{row.difference_label}
</span>
<span className="rectification-range-delivery__status" role="status">
{adopting
? RECTIFICATION_USER_COPY.rangeDeliveryAdopting
: adopted
? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
: ""}
</span>
</button>
</li>
);
})}
</ul>
<p className="rectification-range-delivery__boundary">
{delivery?.boundary ?? REPRESENTATIVE_MINUTE_DISCLAIMER}
</p>
{showPanel && (
<div className="rectification-divergence">
<p className="rectification-divergence__title">{RECTIFICATION_USER_COPY.divergenceTitle}</p>
<div className="rectification-divergence-list">
{panel.columns.map((column) => {
const id = column.representative_candidate_id;
const active = selectedColumnId === id;
return (
<button
type="button"
key={`${column.cluster_range[0]}-${column.cluster_range[1]}`}
className={`rectification-divergence-column${active ? " is-selected" : ""}`}
aria-pressed={active}
disabled={busy}
onClick={() => setSelectedColumnId(active ? null : id)}
>
<strong>{column.cluster_range[0]}{column.cluster_range[1]} </strong>
{column.rows.map((row) => (
<span key={`${row.layer}-${row.sign}`}>{row.label}</span>
))}
</button>
);
})}
</div>
<div className="rectification-divergence-clear">
<button
type="button"
className="rectification-range-delivery__quiet"
disabled={busy || selectedColumnId === null}
onClick={() => setSelectedColumnId(null)}
>
{RECTIFICATION_USER_COPY.divergenceUnlike}
</button>
<button
type="button"
className="rectification-range-delivery__quiet"
disabled={busy || selectedColumnId === null}
onClick={() => setSelectedColumnId(null)}
>
{RECTIFICATION_USER_COPY.divergenceUnsure}
</button>
</div>
</div>
)}
<div className="rectification-range-delivery__actions">
{selected ? (
<p className="rectification-range-delivery__adopted" role="status"></p>
) : (
<>
<button
type="button"
className="rectification-range-delivery__adopt"
disabled={busy || !candidateId}
onClick={() => candidateId && onAccept(candidateId)}
>
{adopting ? "正在采用…" : RECTIFICATION_USER_COPY.rangeDeliveryAdopt}
</button>
<button
type="button"
className="rectification-range-delivery__continue"
disabled={busy}
onClick={onContinue}
>
{RECTIFICATION_USER_COPY.rangeDeliveryContinue}
</button>
</>
)}
</div>
<RectificationVerificationReport markdown={markdown} />
</section>
);
}
@@ -0,0 +1,19 @@
"use client";
import { renderChatMarkdown } from "@/components/chat-markdown-view";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
export function RectificationVerificationReport({
markdown,
}: Readonly<{ markdown: string | null | undefined }>) {
const body = markdown?.trim() ?? "";
if (!body) return null;
return (
<details className="rectification-verification-report">
<summary>{RECTIFICATION_USER_COPY.verificationReportSummary}</summary>
<div className="rectification-verification-report__body">
{renderChatMarkdown(body)}
</div>
</details>
);
}
@@ -75,7 +75,6 @@ export const RECTIFICATION_USER_COPY = {
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
hostNarrationFallback: "我按现有材料继续往下收。",
collectHandoff: "接下来我们继续。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
@@ -91,26 +90,24 @@ export const RECTIFICATION_USER_COPY = {
deferredCareerWindow: "下一次事业变动的预测窗口留在采用后的核对阶段。",
rangeDeliveryTitle: "这次给出的范围",
rangeDeliveryRepresentativeLabel: "排盘用",
rangeDeliveryEventsLabel: "对照经历",
rangeDeliveryStableLabel: "稳定的主题",
rangeDeliverySensitiveLabel: "随分钟变的主题",
rangeDeliveryAdopt: "按这个范围用",
rangeDeliveryContinue: "再补一件经历",
divergenceTitle: "这几段还没对照过的差别",
divergenceUnlike: "都不像",
divergenceUnsure: "说不好",
rangeDeliveryAdopting: "正在采用…",
rangeDeliveryAdopted: "已采用",
verificationReportSummary: "查看验证报告",
} as const;
export function rangeDeliveryEventCopy(count: number): string {
return `对照了 ${count}带年月的经历`;
return `对照了 ${count} 件经历`;
}
export function rangeDeliveryStableCopy(labels: readonly string[]): string {
return labels.length ? `这段里稳定的是${labels.join("、")}` : "";
}
export function rangeDeliverySensitiveCopy(labels: readonly string[]): string {
return labels.length ? `随分钟变的是${labels.join("、")}` : "";
export function postAdoptVerifyDoneCopy(
time: string | null | undefined,
hadVerifyQuestions: boolean,
): string {
if (hadVerifyQuestions) return RECTIFICATION_USER_COPY.postAdoptVerifyDone;
const clock = time?.trim().slice(0, 5) ?? "";
return clock
? `已采用 ${clock},新建对话即按这个时间排盘。`
: "已采用当前时间,新建对话即按这个时间排盘。";
}
export function withCompareFailedRetryNotice(body: string): string {
@@ -168,6 +165,8 @@ export type RangeNarrationInput = {
stopReason?: string | null;
/** Extra stop sentence after BUG-503 prefixes; used when the probe pool is exhausted. */
stopExplain?: string | null;
eventCount?: number | null;
fitPercent?: number | null;
};
function clockMinutes(value: string): number | null {
@@ -297,10 +296,28 @@ export function nonConvergingRangeNarration(
return prefix ? `${prefix}${body}` : body;
}
export function deliveryTurnNarration(input: RangeNarrationInput = {}): string {
const rangeText = formatClockRange(input.credibleRange ?? null);
const representative = input.representativeTime?.trim() || null;
const sentence1 = rangeText && representative
? `这次给出的范围 ${rangeText},排盘用 ${representative}`
: rangeText
? `这次给出的范围 ${rangeText}`
: representative
? `排盘用 ${representative}`
: "当前几个候选还分不开。";
const count = typeof input.eventCount === "number" && input.eventCount >= 0 ? input.eventCount : 0;
const percent = typeof input.fitPercent === "number" && Number.isFinite(input.fitPercent)
? Math.round(input.fitPercent)
: null;
const sentence2 = percent != null
? `对照了 ${count} 件经历,事件吻合率 ${percent}%。`
: `对照了 ${count} 件经历。`;
return `${sentence1}${sentence2}${REPRESENTATIVE_MINUTE_DISCLAIMER}`;
}
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`
.replace(/\s+/g, " ")
.trim();
return deliveryTurnNarration({ ...input, variant: "delivery" });
}
const INSTRUCTION_TONE_SENTENCE = /不得|请写成/;
@@ -341,7 +358,6 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.collectHandoff,
RECTIFICATION_USER_COPY.continueCollectFallback,
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
@@ -357,18 +373,18 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.deferredCareerWindow,
RECTIFICATION_USER_COPY.rangeDeliveryTitle,
RECTIFICATION_USER_COPY.rangeDeliveryRepresentativeLabel,
RECTIFICATION_USER_COPY.rangeDeliveryEventsLabel,
RECTIFICATION_USER_COPY.rangeDeliveryStableLabel,
RECTIFICATION_USER_COPY.rangeDeliverySensitiveLabel,
RECTIFICATION_USER_COPY.rangeDeliveryAdopt,
RECTIFICATION_USER_COPY.rangeDeliveryContinue,
RECTIFICATION_USER_COPY.divergenceTitle,
RECTIFICATION_USER_COPY.divergenceUnlike,
RECTIFICATION_USER_COPY.divergenceUnsure,
RECTIFICATION_USER_COPY.rangeDeliveryAdopting,
RECTIFICATION_USER_COPY.rangeDeliveryAdopted,
RECTIFICATION_USER_COPY.verificationReportSummary,
postAdoptVerifyDoneCopy("04:53", false),
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
rangeDeliveryEventCopy(3),
rangeDeliveryStableCopy(["事业方向"]),
rangeDeliverySensitiveCopy(["婚恋(D9"]),
deliveryTurnNarration({
credibleRange: ["04:51", "04:59"],
representativeTime: "04:53",
eventCount: 7,
fitPercent: 80,
}),
"关系盘落在天秤座,通常表现为",
"事业盘落在巨蟹座,通常表现为",
"刚才那个日子是查过记录,还是凭记忆?",
@@ -208,6 +208,26 @@ export function adoptDeliveryFacts(
};
}
function topicOrFocusIsReverseVerify(value: unknown): boolean {
return Boolean(
value
&& typeof value === "object"
&& (value as { intent?: unknown }).intent === "reverse_verify",
);
}
/** True when reverse-verify questions were asked, skipped, or are still remaining. */
export function hadPostAdoptVerifyQuestions(
decision: Parameters<typeof adoptDeliveryFacts>[0],
dossier: DecisionDossier,
): boolean {
if (topicOrFocusIsReverseVerify(dossier.conversationSummary.activeFocus)) return true;
if (dossier.conversationSummary.declinedSkippedTopics.some(topicOrFocusIsReverseVerify)) {
return true;
}
return adoptDeliveryFacts(decision, dossier).post_adopt_verification.length > 0;
}
export function shouldWriteAdoptNarration(facts: AdoptDeliveryFacts): boolean {
return facts.precision_stage === "ready_to_adopt" && !facts.already_accepted;
}
@@ -33,6 +33,7 @@ import { toAgentModelFinishReason } from "../../agent-observability.ts";
import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts";
import { decideFromDossier } from "./decision-from-dossier";
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { alreadyDelivered } from "./delivery-turn-guard";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice, withRangeChangedAfterEvidence } from "../user-copy";
import { stripQuestionSentences } from "./collect-prompt";
@@ -293,6 +294,24 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|| dossier.case.skillVersion !== boundIdentity.version) {
throw new RectificationToolServiceError("agentic_rectification_skill_identity_mismatch");
}
const resultId = dossier.latestResult?.resultId ?? "";
if (alreadyDelivered({
caseId,
resultId,
hasUserMessage: Boolean(message?.trim()),
})) {
return {
ok: true,
turnId: "",
turnStatus: "completed",
skillLoaded: true,
answerText: "",
phases: [],
toolsUsed: [],
errorCode: "already_delivered",
previousFocusId,
};
}
const skillPackage = resolveExactSkillPackage(
boundIdentity.name,
boundIdentity.version,
@@ -554,7 +573,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (action === "opening" || action === "evidence") {
try {
const idle = await persistNextInterviewIfIdle({ accounting, userId, caseId, askedTurnId: turnId });
if (idle.terminalNote && idle.hostNarration) {
if (idle.terminalNote && idle.hostNarration && !turnId) {
try {
await persistExhaustionGateTurn({
accounting,
@@ -562,6 +581,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
caseId,
askedTurnId: turnId,
hostNarration: idle.hostNarration,
resultId,
});
} catch (error) {
console.warn(
@@ -9,7 +9,6 @@
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
import { nextProbe } from "../core/build-state";
import {
RECTIFICATION_TERMINATION_COPY,
engineCapabilityCeilingFromReceipt,
isNonConvergingRangeOffer,
nonConvergingRangeNarration,
@@ -21,7 +20,7 @@ import {
acceptanceGateNarration,
deliveryAdoptNarration,
openingRangeFromCandidateRange,
rangeWidthMinutes,
postAdoptVerifyDoneCopy,
RECTIFICATION_USER_COPY,
withLastSuccessfulCompareNotice,
} from "../user-copy.ts";
@@ -30,6 +29,8 @@ import {
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { datedEventCount } from "./divergence-panel.ts";
import { logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
import {
decideAfterInferenceChange,
decideFromDossier,
@@ -67,12 +68,9 @@ import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, type ChoiceKey } from "
import { clusterScoreDeltas } from "./probe-explain.ts";
import {
adoptDeliveryFacts,
templatePostAdoptExplain,
templateRangeReadingExplain,
templateStopExplain,
hadPostAdoptVerifyQuestions,
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
import { runV9RangeReading } from "./engine-client.ts";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model";
import { composeCollectSpokenAssistantText } from "./collect-prompt";
@@ -126,7 +124,14 @@ export async function persistExhaustionGateTurn(input: {
caseId: string;
askedTurnId?: string | null;
hostNarration: string;
resultId?: string | null;
}): Promise<void> {
logRectificationDeliveryTurn({
trigger: "persistExhaustionGateTurn",
caseId: input.caseId,
resultId: input.resultId ?? null,
terminalNote: true,
});
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
userMessage: null,
@@ -134,16 +139,13 @@ export async function persistExhaustionGateTurn(input: {
});
}
function withDeferredCareerWindow(base: string): string {
const extra = RECTIFICATION_USER_COPY.deferredCareerWindow;
if (!base.trim()) return extra;
if (base.includes(extra)) return base;
if (base.includes(RECTIFICATION_USER_COPY.adoptCue)) {
return `${base.replace(RECTIFICATION_USER_COPY.adoptCue, "").trim()} ${extra} ${RECTIFICATION_USER_COPY.adoptCue}`
.replace(/\s+/g, " ")
.trim();
}
return `${base} ${extra}`.replace(/\s+/g, " ").trim();
function rememberDeliveryTurn(
caseId: string,
resultId: string | null | undefined,
terminalNote?: boolean,
): void {
if (!terminalNote || !resultId) return;
markDeliveryTurn({ caseId, resultId });
}
export function appendPostAdoptProspectiveWindows(
@@ -158,43 +160,6 @@ export function appendPostAdoptProspectiveWindows(
return `${narration} ${extra}`.replace(/\s+/g, " ").trim();
}
async function withRangeReadingNarration(
base: string,
input: {
accounting: AccountingClient;
userId: string;
caseId: string;
credibleRange?: readonly [string, string] | null;
representativeTime?: string | null;
coarseLook?: boolean;
},
): Promise<string> {
const range = input.credibleRange;
if (!range?.[0] || !range[1]) return base;
try {
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
const reading = await runV9RangeReading({
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: {
start_time: range[0],
end_time: range[1],
representative_time: input.representativeTime ?? range[0],
},
representativeTime: input.representativeTime,
});
const span = rangeWidthMinutes(range[0], range[1]);
const extra = templateRangeReadingExplain({
widthMinutes: span == null ? null : span + 1,
stableThemes: reading?.stableThemes,
sensitiveThemes: reading?.sensitiveThemes,
coarseLook: input.coarseLook,
});
return extra ? `${base} ${extra}`.replace(/\s+/g, " ").trim() : base;
} catch {
return base;
}
}
function openingRangeFromDossier(dossier: {
case?: {
acceptedTime?: string | null;
@@ -269,25 +234,29 @@ function shouldSkipFollowupPersist(input: {
return true;
}
function postAdoptDoneNarration(
dossier: Parameters<typeof adoptDeliveryFacts>[1],
decision: Parameters<typeof adoptDeliveryFacts>[0],
): string {
return postAdoptVerifyDoneCopy(
dossier.case.acceptedTime,
hadPostAdoptVerifyQuestions(decision, dossier),
);
}
function adoptHostNarration(input: {
dossier: Parameters<typeof decideFromDossier>[0];
decision: ReturnType<typeof decideFromDossier>;
receipt: Readonly<Record<string, unknown>> | null | undefined;
}): string {
const facts = adoptDeliveryFacts(input.decision, input.dossier);
const base = withDeferredCareerWindow(deliveryAdoptNarration({
const inference = previousInferenceFromReceipt(input.receipt);
const fit = refinementFromDecisionReceipt(input.receipt).event_fit_rate;
return deliveryAdoptNarration({
credibleRange: input.decision.credibleRange,
representativeTime: input.decision.representativeTime,
openingRange: openingRangeFromDossier(input.dossier),
stopReason: input.decision.stopReason ?? null,
stopExplain: templateStopExplain(facts),
}));
if (facts.post_adopt_verification.length > 0) return base;
const explain = templatePostAdoptExplain(facts);
if (base.includes("没有还能核对的前事")) return base;
return `${base.replace(RECTIFICATION_USER_COPY.adoptCue, "").trim()} ${explain} ${RECTIFICATION_USER_COPY.adoptCue}`
.replace(/\s+/g, " ")
.trim();
eventCount: datedEventCount(inference),
fitPercent: fit?.percent ?? null,
});
}
function dossierWithCurrentInference(
@@ -391,18 +360,20 @@ function dossierWithClosedFocus<T extends {
const focus = dossier.conversationSummary.activeFocus;
const domain = focus?.targetDomain ?? null;
const intent = focus?.intent ?? null;
const declinedSkippedTopics = (
(status === "declined" || status === "skipped") && domain
? [
...dossier.conversationSummary.declinedSkippedTopics,
{
target_domain: domain,
status,
...(intent ? { intent } : {}),
},
]
: dossier.conversationSummary.declinedSkippedTopics
const rememberClosed = Boolean(
((status === "declined" || status === "skipped") && domain)
|| intent === "reverse_verify",
);
const declinedSkippedTopics = rememberClosed
? [
...dossier.conversationSummary.declinedSkippedTopics,
{
...(domain ? { target_domain: domain } : {}),
status,
...(intent ? { intent } : {}),
},
]
: dossier.conversationSummary.declinedSkippedTopics;
return {
...dossier,
conversationSummary: {
@@ -826,14 +797,9 @@ export async function persistNextInterviewAfterChoice(input: {
const narrated = input.narrateAdopt
? await input.narrateAdopt(facts, fallback)
: fallback;
rememberDeliveryTurn(input.caseId, liveDossier.latestResult?.resultId, true);
return {
hostNarration: await withRangeReadingNarration(narrated, {
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
}),
hostNarration: narrated,
choiceReady: false,
persisted: false,
followup: null,
@@ -842,7 +808,7 @@ export async function persistNextInterviewAfterChoice(input: {
}
if (input.dossier.case.acceptedTime && !followup) {
return {
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
hostNarration: postAdoptDoneNarration(liveDossier, decision),
choiceReady: false,
persisted: false,
followup: null,
@@ -921,7 +887,7 @@ export async function persistNextInterviewAfterChoice(input: {
if (!followup) {
if (input.dossier.case.acceptedTime) {
return {
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
hostNarration: postAdoptDoneNarration(liveDossier, decision),
choiceReady: false,
followup: null,
};
@@ -1160,7 +1126,7 @@ export async function persistNextInterviewIfIdle(input: {
askedTurnId: input.askedTurnId ?? null,
});
let dossier = rescored.dossier;
const finishIdle = <T extends { hostNarration: string | null }>(result: T): T => {
const finishIdle = <T extends { hostNarration: string | null; terminalNote?: boolean }>(result: T): T => {
let hostNarration = result.hostNarration;
if (dossier.case.acceptedTime && hostNarration) {
hostNarration = appendPostAdoptProspectiveWindows(
@@ -1176,8 +1142,13 @@ export async function persistNextInterviewIfIdle(input: {
) {
hostNarration = withLastSuccessfulCompareNotice(hostNarration);
}
if (hostNarration === result.hostNarration) return result;
return { ...result, hostNarration };
if (hostNarration === result.hostNarration) {
rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, result.terminalNote);
return result;
}
const finished = { ...result, hostNarration };
rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, finished.terminalNote);
return finished;
};
const staleFocus = dossier.conversationSummary.activeFocus;
const staleFocusId = staleFocus?.id;
@@ -1271,13 +1242,7 @@ export async function persistNextInterviewIfIdle(input: {
return finishIdle({
persisted: false,
choiceReady: false,
hostNarration: await withRangeReadingNarration(narrated, {
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
}),
hostNarration: narrated,
terminalNote: true,
});
}
@@ -1329,7 +1294,7 @@ export async function persistNextInterviewIfIdle(input: {
return finishIdle({
persisted: false,
choiceReady: false,
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
hostNarration: postAdoptDoneNarration(dossier, decision),
});
}
return finishIdle(await persistExhaustionCollect({
@@ -1460,16 +1425,10 @@ async function persistExhaustionCollect(input: {
return {
persisted: false,
choiceReady: false,
hostNarration: await withRangeReadingNarration(adoptHostNarration({
hostNarration: adoptHostNarration({
dossier,
decision: adopted,
receipt,
}), {
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
credibleRange: adopted.credibleRange,
representativeTime: adopted.representativeTime,
}),
focus: null,
terminalNote: true,
@@ -1606,33 +1565,28 @@ ${nextInterview.hostNarration}`;
}
}
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
const adoptionFacts = !accepted && nextAction.can_adopt
? adoptDeliveryFacts(nextDecision, liveDossier)
: null;
const liveReceipt = liveDossier.latestResult?.decisionReceipt;
const liveInference = previousInferenceFromReceipt(liveReceipt);
const liveFit = refinementFromDecisionReceipt(liveReceipt).event_fit_rate?.percent ?? null;
const adoptionNarration = !accepted && nextAction.can_adopt
? withDeferredCareerWindow(deliveryAdoptNarration({
? deliveryAdoptNarration({
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
stopReason: nextAction.stop_reason,
stopExplain: adoptionFacts ? templateStopExplain(adoptionFacts) : null,
}))
eventCount: datedEventCount(liveInference),
fitPercent: liveFit,
})
: null;
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
const completedRangeNarration = !accepted && nextAction.type === "complete_with_range"
? withDeferredCareerWindow(`${completedRangePrefix}
${nonConvergingRangeNarration({
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
variant: "delivery",
stopReason: nextAction.stop_reason,
}, RECTIFICATION_TERMINATION_COPY)}`)
? deliveryAdoptNarration({
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
eventCount: datedEventCount(liveInference),
fitPercent: liveFit,
})
: null;
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
if (accepted && !keptNextQuestion) {
hostNarration = RECTIFICATION_USER_COPY.postAdoptVerifyDone;
hostNarration = postAdoptDoneNarration(liveDossier, nextDecision);
nextFollowup = null;
} else if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
export const RECTIFICATION_SKILL_VERSION = "10.0.16";
export const RECTIFICATION_SKILL_VERSION = "10.0.17";
@@ -0,0 +1,61 @@
/**
* Same-Case delivery-turn guard (BUG-596).
*
* After persistNextInterviewIfIdle returns terminalNote for a result, a
* second no-user-message agent run inside 60s must not bill or write a turn.
*/
const WINDOW_MS = 60_000;
type DeliveryMark = Readonly<{ resultId: string; at: number }>;
const delivered = new Map<string, DeliveryMark>();
export type DeliveryTurnTrigger =
| "finalizeSuccessfulTurnExit"
| "persistExhaustionGateTurn"
| "opening"
| "regenerate";
export function logRectificationDeliveryTurn(input: {
trigger: DeliveryTurnTrigger;
caseId: string;
resultId?: string | null;
terminalNote?: boolean;
}): void {
console.info(JSON.stringify({
event: "rectification_delivery_turn",
trigger: input.trigger,
case_id: input.caseId,
result_id: input.resultId ?? null,
terminal_note: input.terminalNote === true,
}));
}
export function markDeliveryTurn(input: {
caseId: string;
resultId: string;
now?: number;
}): void {
if (!input.caseId || !input.resultId) return;
delivered.set(input.caseId, {
resultId: input.resultId,
at: input.now ?? Date.now(),
});
}
export function alreadyDelivered(input: {
caseId: string;
resultId: string;
hasUserMessage: boolean;
now?: number;
}): boolean {
if (input.hasUserMessage || !input.caseId || !input.resultId) return false;
const mark = delivered.get(input.caseId);
if (!mark || mark.resultId !== input.resultId) return false;
return (input.now ?? Date.now()) - mark.at <= WINDOW_MS;
}
export function resetDeliveryTurnGuardForTests(): void {
delivered.clear();
}
@@ -1,8 +1,6 @@
/**
* Range-delivery projection: one card for the credible window, plus an
* optional 23 column panel of unasked D9 / D10 / nakshatra splits.
*
* Panel labels come only from `varga-type-tables.ts` and nakshatra traits.
* Range-delivery projection: one clickable row per candidate minute.
* Difference labels come only from D9 / D10 type tables.
*/
import { rankActive } from "../core/convergence-evaluator.ts";
@@ -11,17 +9,9 @@ import type {
ConflictProbe,
InferenceCandidate,
InferenceState,
ProbeAnswer,
} from "../core/types.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import {
RANGE_READING_THEME_LABELS,
} from "./adopt-narration.ts";
import {
refinementFromDecisionReceipt,
type NakshatraBoundary,
type WindowScanTransition,
} from "./refinement-packet.ts";
import { refinementFromDecisionReceipt, type WindowScanTransition } from "./refinement-packet.ts";
import {
parseWindowScan,
type WindowScan,
@@ -36,30 +26,11 @@ import { REPRESENTATIVE_MINUTE_DISCLAIMER } from "../user-copy.ts";
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export type DivergenceLayer = "d9" | "d10" | "nakshatra";
export type DivergenceRow = Readonly<{
layer: DivergenceLayer;
sign: string;
label: string;
}>;
export type DivergenceColumn = Readonly<{
cluster_range: readonly [string, string];
representative_candidate_id: string;
representative_time: string;
rows: readonly DivergenceRow[];
}>;
export type DivergencePanel = Readonly<{
eligible: boolean;
reason: string;
columns: readonly DivergenceColumn[];
}>;
export type RangeReadingThemes = Readonly<{
stable_themes: readonly string[];
sensitive_themes: readonly string[];
export type RangeDeliveryRow = Readonly<{
time: string;
candidate_id: string;
representative: boolean;
difference_label: string;
}>;
export type RangeDeliveryProjection = Readonly<{
@@ -67,10 +38,10 @@ export type RangeDeliveryProjection = Readonly<{
representative_time: string | null;
representative_candidate_id: string | null;
event_count: number;
stable_themes: readonly string[];
sensitive_themes: readonly string[];
fit_percent: number | null;
boundary: string;
divergence: DivergencePanel;
rows: readonly RangeDeliveryRow[];
verification_markdown: string | null;
}>;
export type PublicCandidateClock = Readonly<{
@@ -84,22 +55,6 @@ function clock(value: unknown): string | null {
return CLOCK.test(normalized) ? normalized : null;
}
function clusterKey(range: readonly [string, string]): string {
return `${range[0]}-${range[1]}`;
}
function isAskedLayer(
answered: readonly ProbeAnswer[],
layer: DivergenceLayer,
): boolean {
return answered.some((item) => {
const key = item.semantic_key;
if (layer === "d9") return key.startsWith("varga.d9");
if (layer === "d10") return key.startsWith("varga.d10");
return key.startsWith("nakshatra-boundary");
});
}
function signFromProbe(
probes: readonly ConflictProbe[],
layer: "d9" | "d10",
@@ -159,12 +114,6 @@ export function d10DivergenceLabel(sign: string): string | null {
return `事业盘落在${keyed},通常表现为${row.userChoice}`;
}
function nakshatraLabel(traits: readonly string[]): string | null {
const cleaned = traits.map((item) => item.trim()).filter(Boolean);
if (cleaned.length === 0) return null;
return cleaned.join("、");
}
function publicIdForTime(
publicCandidates: readonly PublicCandidateClock[],
time: string,
@@ -175,181 +124,123 @@ function publicIdForTime(
return fallbackId;
}
function themeLabels(themes: readonly string[] | null | undefined): string[] {
const labels: string[] = [];
const seen = new Set<string>();
for (const theme of themes ?? []) {
const label = RANGE_READING_THEME_LABELS[theme];
if (!label || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels;
}
function parseThemes(value: unknown): RangeReadingThemes | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
const stable = Array.isArray(row.stable_themes)
? row.stable_themes.filter((item): item is string => typeof item === "string")
: Array.isArray(row.stableThemes)
? row.stableThemes.filter((item): item is string => typeof item === "string")
: [];
const sensitive = Array.isArray(row.sensitive_themes)
? row.sensitive_themes.filter((item): item is string => typeof item === "string")
: Array.isArray(row.sensitiveThemes)
? row.sensitiveThemes.filter((item): item is string => typeof item === "string")
: [];
if (stable.length === 0 && sensitive.length === 0) return null;
return { stable_themes: stable, sensitive_themes: sensitive };
}
function datedEventCount(inference: InferenceState | null): number {
export function datedEventCount(inference: InferenceState | null): number {
if (!inference) return 0;
return inference.events.filter((item) => item.year != null && item.usage !== "unused").length;
}
function representativeOf(
members: readonly InferenceCandidate[],
): InferenceCandidate {
return rankActive(members)[0] ?? members[0]!;
function differenceLabel(
candidate: { d9: string | null; d10: string | null },
representative: { d9: string | null; d10: string | null },
): string {
if (candidate.d9 === representative.d9 && candidate.d10 === representative.d10) return "";
const parts: string[] = [];
if (candidate.d9 && representative.d9 && candidate.d9 !== representative.d9) {
const label = d9DivergenceLabel(candidate.d9);
if (label) parts.push(label);
}
if (candidate.d10 && representative.d10 && candidate.d10 !== representative.d10) {
const label = d10DivergenceLabel(candidate.d10);
if (label) parts.push(label);
}
return parts.join("");
}
function nakshatraRowForCluster(
boundary: NakshatraBoundary | null,
members: readonly InferenceCandidate[],
allActive: readonly InferenceCandidate[],
): DivergenceRow | null {
if (!boundary?.near_boundary) return null;
const optionA = boundary.options.find((item) => item.key === "A") ?? null;
const optionB = boundary.options.find((item) => item.key === "B") ?? null;
if (!optionA?.traits.length || !optionB?.traits.length) return null;
if (optionA.time_bias === optionB.time_bias) return null;
const ordered = [...allActive].sort((left, right) => left.time.localeCompare(right.time));
const pivot = Math.ceil(ordered.length / 2);
const earlierIds = new Set(ordered.slice(0, pivot).map((item) => item.id));
const laterIds = new Set(ordered.slice(pivot).map((item) => item.id));
const earlierHits = members.filter((item) => earlierIds.has(item.id)).length;
const laterHits = members.filter((item) => laterIds.has(item.id)).length;
if (earlierHits === laterHits) return null;
const bias = earlierHits > laterHits ? "earlier" : "later";
const option = optionA.time_bias === bias ? optionA : optionB;
const label = nakshatraLabel(option.traits);
if (!label) return null;
return { layer: "nakshatra", sign: bias === "earlier" ? "较早" : "较晚", label };
function inRange(
time: string,
range: readonly [string, string] | null,
): boolean {
if (!range) return true;
return time >= range[0] && time <= range[1];
}
export function buildDivergencePanel(input: {
function buildDeliveryRows(input: {
inference: InferenceState | null;
windowScan?: WindowScan | null;
nakshatraBoundary?: NakshatraBoundary | null;
publicCandidates?: readonly PublicCandidateClock[];
}): DivergencePanel {
windowScan: WindowScan | null;
publicCandidates: readonly PublicCandidateClock[];
range: readonly [string, string] | null;
representativeTime: string | null;
}): RangeDeliveryRow[] {
const inference = input.inference;
if (!inference) {
return { eligible: false, reason: "no_inference", columns: [] };
}
const active = rankActive(inference.candidates);
const groups = new Map<string, InferenceCandidate[]>();
for (const item of active) {
const key = clusterKey(item.cluster_range);
const bucket = groups.get(key);
if (bucket) bucket.push(item);
else groups.set(key, [item]);
}
const clusters = [...groups.values()]
.map((members) => {
const head = representativeOf(members);
return {
range: head.cluster_range,
members,
representative: head,
};
})
.sort((left, right) => left.range[0].localeCompare(right.range[0])
|| left.range[1].localeCompare(right.range[1]));
if (clusters.length < 2 || clusters.length > 3) {
return { eligible: false, reason: "cluster_count", columns: [] };
}
const answered = inference.answered_probes;
const d9Open = !isAskedLayer(answered, "d9");
const d10Open = !isAskedLayer(answered, "d10");
const nakshatraOpen = !isAskedLayer(answered, "nakshatra")
&& input.nakshatraBoundary?.near_boundary === true;
const d9Signs = d9Open
? clusters.map((cluster) => signForCandidate(
inference.probes,
input.windowScan ?? null,
"d9",
cluster.representative,
))
const publicCandidates = input.publicCandidates;
const fromInference = inference
? rankActive(inference.candidates).filter((item) => inRange(item.time, input.range))
: [];
const d10Signs = d10Open
? clusters.map((cluster) => signForCandidate(
inference.probes,
input.windowScan ?? null,
"d10",
cluster.representative,
))
: [];
const d9Differs = d9Open && new Set(d9Signs.filter(Boolean)).size >= 2;
const d10Differs = d10Open && new Set(d10Signs.filter(Boolean)).size >= 2;
const publicCandidates = input.publicCandidates ?? [];
const columns: DivergenceColumn[] = [];
for (const [index, cluster] of clusters.entries()) {
const rows: DivergenceRow[] = [];
if (d9Differs) {
const sign = d9Signs[index];
const label = sign ? d9DivergenceLabel(sign) : null;
if (sign && label) rows.push({ layer: "d9", sign, label });
const clocks = fromInference.length > 0
? fromInference.map((item) => ({
time: item.time,
id: item.id,
candidate: item,
}))
: publicCandidates
.filter((item) => inRange(item.time, input.range))
.map((item) => ({
time: item.time,
id: item.candidateId,
candidate: null as InferenceCandidate | null,
}));
if (clocks.length === 0) return [];
const representativeTime = input.representativeTime
?? clocks[0]?.time
?? null;
const ordered = [...clocks].sort((left, right) => {
if (left.time === representativeTime && right.time !== representativeTime) return -1;
if (right.time === representativeTime && left.time !== representativeTime) return 1;
return left.time.localeCompare(right.time);
});
const representativeClock = ordered.find((item) => item.time === representativeTime) ?? ordered[0]!;
const probes = inference?.probes ?? [];
const representativeSigns = representativeClock.candidate
? {
d9: signForCandidate(probes, input.windowScan, "d9", representativeClock.candidate),
d10: signForCandidate(probes, input.windowScan, "d10", representativeClock.candidate),
}
if (d10Differs) {
const sign = d10Signs[index];
const label = sign ? d10DivergenceLabel(sign) : null;
if (sign && label) rows.push({ layer: "d10", sign, label });
}
if (nakshatraOpen) {
const row = nakshatraRowForCluster(
input.nakshatraBoundary ?? null,
cluster.members,
active,
);
if (row) rows.push(row);
}
columns.push({
cluster_range: cluster.range,
representative_candidate_id: publicIdForTime(
publicCandidates,
cluster.representative.time,
cluster.representative.id,
),
representative_time: cluster.representative.time,
rows,
});
}
const withRows = columns.filter((column) => column.rows.length > 0);
if (withRows.length < 2) {
return {
eligible: false,
reason: d9Open || d10Open || nakshatraOpen ? "no_unasked_divergence" : "layers_asked",
columns: [],
: {
d9: signFromTransitions(input.windowScan?.transitions ?? [], "d9", representativeClock.time),
d10: signFromTransitions(input.windowScan?.transitions ?? [], "d10", representativeClock.time),
};
}
return { eligible: true, reason: "unasked_split", columns: withRows };
return ordered.map((item) => {
const signs = item.candidate
? {
d9: signForCandidate(probes, input.windowScan, "d9", item.candidate),
d10: signForCandidate(probes, input.windowScan, "d10", item.candidate),
}
: {
d9: signFromTransitions(input.windowScan?.transitions ?? [], "d9", item.time),
d10: signFromTransitions(input.windowScan?.transitions ?? [], "d10", item.time),
};
const representative = item.time === representativeTime;
return {
time: item.time,
candidate_id: publicIdForTime(publicCandidates, item.time, item.id),
representative,
difference_label: representative ? "" : differenceLabel(signs, representativeSigns),
};
});
}
export function verificationMarkdownFromUnknown(value: unknown): string | null {
if (typeof value === "string" && value.trim()) return value.trim();
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const markdown = (value as { markdown?: unknown }).markdown;
return typeof markdown === "string" && markdown.trim() ? markdown : null;
}
function fitPercentFromUnknown(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const percent = (value as { percent?: unknown }).percent;
return typeof percent === "number" && Number.isFinite(percent) ? percent : null;
}
export function buildRangeDelivery(input: {
inference: InferenceState | null;
windowScan?: WindowScan | null;
nakshatraBoundary?: NakshatraBoundary | null;
rangeReading?: RangeReadingThemes | null;
publicCandidates?: readonly PublicCandidateClock[];
credibleRange?: readonly [string, string] | null;
representativeTime?: string | null;
fitPercent?: number | null;
verificationMarkdown?: string | null;
}): RangeDeliveryProjection {
const inference = input.inference;
const range = input.credibleRange
@@ -366,21 +257,21 @@ export function buildRangeDelivery(input: {
inference?.candidates.find((item) => item.time === representativeTime)?.id ?? representativeTime,
)
: publicCandidates[0]?.candidateId ?? null;
const divergence = buildDivergencePanel({
inference,
windowScan: input.windowScan ?? null,
nakshatraBoundary: input.nakshatraBoundary ?? null,
publicCandidates,
});
return {
range: range && clock(range[0]) && clock(range[1]) ? [clock(range[0])!, clock(range[1])!] : null,
representative_time: representativeTime,
representative_candidate_id: representativeId,
event_count: datedEventCount(inference),
stable_themes: themeLabels(input.rangeReading?.stable_themes),
sensitive_themes: themeLabels(input.rangeReading?.sensitive_themes),
fit_percent: input.fitPercent ?? null,
boundary: REPRESENTATIVE_MINUTE_DISCLAIMER,
divergence,
rows: buildDeliveryRows({
inference,
windowScan: input.windowScan ?? null,
publicCandidates,
range: range && clock(range[0]) && clock(range[1]) ? [clock(range[0])!, clock(range[1])!] : null,
representativeTime,
}),
verification_markdown: input.verificationMarkdown ?? null,
};
}
@@ -417,8 +308,10 @@ export function rangeDeliveryForSnapshot(snapshot: {
representative_time?: string | null;
credibleRange?: unknown;
credible_range?: unknown;
range_reading?: unknown;
rangeReading?: unknown;
skill_verification_report?: unknown;
skillVerificationReport?: unknown;
event_fit_rate?: unknown;
eventFitRate?: unknown;
} | null | undefined): RangeDeliveryProjection {
const receipt = snapshot?.decisionReceipt ?? snapshot?.decision_receipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
@@ -427,24 +320,53 @@ export function rangeDeliveryForSnapshot(snapshot: {
? (snapshot as { window_scan?: unknown }).window_scan
: null,
);
const nakshatra = refinementFromDecisionReceipt(receipt).nakshatra_boundary;
const rangeReading = parseThemes(snapshot?.range_reading)
?? parseThemes(snapshot?.rangeReading)
?? parseThemes(receipt?.range_reading);
const refinement = refinementFromDecisionReceipt(receipt);
return buildRangeDelivery({
inference,
windowScan,
nakshatraBoundary: nakshatra,
rangeReading,
publicCandidates: publicCandidatesFromUnknown(snapshot?.candidates),
credibleRange: rangeFromUnknown(snapshot?.credibleRange) ?? rangeFromUnknown(snapshot?.credible_range),
representativeTime: clock(snapshot?.representativeTime) ?? clock(snapshot?.representative_time),
fitPercent: refinement.event_fit_rate?.percent
?? fitPercentFromUnknown(snapshot?.event_fit_rate)
?? fitPercentFromUnknown(snapshot?.eventFitRate),
verificationMarkdown: verificationMarkdownFromUnknown(snapshot?.skill_verification_report)
?? verificationMarkdownFromUnknown(snapshot?.skillVerificationReport),
});
}
function parseRows(value: unknown): RangeDeliveryRow[] {
if (!Array.isArray(value)) return [];
const out: RangeDeliveryRow[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
const time = clock(row.time);
const id = typeof row.candidate_id === "string"
? row.candidate_id
: typeof row.candidateId === "string"
? row.candidateId
: "";
if (!time || !id) continue;
out.push({
time,
candidate_id: id,
representative: row.representative === true,
difference_label: typeof row.difference_label === "string"
? row.difference_label
: typeof row.differenceLabel === "string"
? row.differenceLabel
: "",
});
}
return out;
}
export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
const rows = parseRows(row.rows);
if (rows.length === 0) return null;
const range = rangeFromUnknown(row.range);
const representativeTime = clock(row.representative_time) ?? clock(row.representativeTime);
const representativeId = typeof row.representative_candidate_id === "string"
@@ -457,71 +379,18 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu
: typeof row.eventCount === "number" && Number.isInteger(row.eventCount) && row.eventCount >= 0
? row.eventCount
: 0;
const stable = Array.isArray(row.stable_themes)
? row.stable_themes.filter((item): item is string => typeof item === "string")
: Array.isArray(row.stableThemes)
? row.stableThemes.filter((item): item is string => typeof item === "string")
: [];
const sensitive = Array.isArray(row.sensitive_themes)
? row.sensitive_themes.filter((item): item is string => typeof item === "string")
: Array.isArray(row.sensitiveThemes)
? row.sensitiveThemes.filter((item): item is string => typeof item === "string")
: [];
const boundary = typeof row.boundary === "string" && row.boundary.trim()
? row.boundary.trim()
: REPRESENTATIVE_MINUTE_DISCLAIMER;
const rawDivergence = row.divergence;
const divergence = rawDivergence && typeof rawDivergence === "object" && !Array.isArray(rawDivergence)
? parseDivergence(rawDivergence as Record<string, unknown>)
: { eligible: false, reason: "missing", columns: [] as DivergenceColumn[] };
return {
range,
representative_time: representativeTime,
representative_candidate_id: representativeId,
event_count: eventCount,
stable_themes: stable,
sensitive_themes: sensitive,
fit_percent: fitPercentFromUnknown(row.fit_percent) ?? fitPercentFromUnknown(row.fitPercent),
boundary,
divergence,
rows,
verification_markdown: verificationMarkdownFromUnknown(row.verification_markdown)
?? verificationMarkdownFromUnknown(row.verificationMarkdown),
};
}
function parseDivergence(row: Record<string, unknown>): DivergencePanel {
const eligible = row.eligible === true;
const reason = typeof row.reason === "string" && row.reason.trim() ? row.reason.trim() : "missing";
if (!eligible || !Array.isArray(row.columns)) {
return { eligible: false, reason, columns: [] };
}
const columns: DivergenceColumn[] = [];
for (const item of row.columns) {
if (!item || typeof item !== "object") continue;
const column = item as Record<string, unknown>;
const range = rangeFromUnknown(column.cluster_range) ?? rangeFromUnknown(column.clusterRange);
const time = clock(column.representative_time) ?? clock(column.representativeTime);
const id = typeof column.representative_candidate_id === "string"
? column.representative_candidate_id
: typeof column.representativeCandidateId === "string"
? column.representativeCandidateId
: "";
if (!range || !time || !id || !Array.isArray(column.rows)) continue;
const rows: DivergenceRow[] = [];
for (const raw of column.rows) {
if (!raw || typeof raw !== "object") continue;
const line = raw as Record<string, unknown>;
const layer = line.layer;
const sign = typeof line.sign === "string" ? line.sign : "";
const label = typeof line.label === "string" ? line.label.trim() : "";
if ((layer === "d9" || layer === "d10" || layer === "nakshatra") && sign && label) {
rows.push({ layer, sign, label });
}
}
columns.push({
cluster_range: range,
representative_candidate_id: id,
representative_time: time,
rows,
});
}
if (columns.length < 2) return { eligible: false, reason: reason === "unasked_split" ? "no_unasked_divergence" : reason, columns: [] };
return { eligible: true, reason, columns };
}
@@ -3,6 +3,7 @@ import {
persistExhaustionGateTurn,
persistNextInterviewIfIdle,
} from "./answer-choice.ts";
import { logRectificationDeliveryTurn } from "./delivery-turn-guard.ts";
import type { RectificationRpcClient } from "./tool-service.ts";
export type RectificationRouteAction =
@@ -48,6 +49,11 @@ export async function finalizeSuccessfulTurnExit(input: {
askedTurnId: input.askedTurnId ?? null,
});
if (idle.terminalNote) {
logRectificationDeliveryTurn({
trigger: "finalizeSuccessfulTurnExit",
caseId: input.caseId,
terminalNote: true,
});
if (!input.askedTurnId && idle.hostNarration) {
try {
await persistExhaustionGateTurn({
@@ -27,7 +27,7 @@ export function composeRectificationTurnNarration(dto: RectificationNarrationDto
}
if (dto.nextQuestion && /[?]/.test(dto.nextQuestion)) parts.push(dto.nextQuestion);
if (parts.length === 0) {
return RECTIFICATION_USER_COPY.continueCollectFallback;
return RECTIFICATION_USER_COPY.hostNarrationFallback;
}
return parts.join("");
}
@@ -31,6 +31,7 @@ import {
import {
parseRangeDelivery,
rangeDeliveryForSnapshot,
verificationMarkdownFromUnknown,
type RangeDeliveryProjection,
} from "./rectification-agentic/v9/divergence-panel.ts";
@@ -93,6 +94,7 @@ export type RectificationCandidateResult = Readonly<{
sessionOutcome: DecisionSessionOutcome | null;
credibleRange: readonly [string, string] | null;
rangeDelivery: RangeDeliveryProjection | null;
verificationReportMarkdown: string | null;
}>;
function record(value: unknown): Record<string, unknown> | null {
@@ -365,6 +367,11 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
credibleRange: parseCredibleRange(snapshot.credibleRange ?? snapshot.credible_range),
rangeDelivery: parseRangeDelivery(snapshot.rangeDelivery ?? snapshot.range_delivery)
?? rangeDeliveryForSnapshot(snapshot),
verificationReportMarkdown: verificationMarkdownFromUnknown(snapshot.skill_verification_report)
?? verificationMarkdownFromUnknown(snapshot.skillVerificationReport)
?? verificationMarkdownFromUnknown(
(snapshot.rangeDelivery ?? snapshot.range_delivery) as { verification_markdown?: unknown } | undefined,
),
};
}
+1 -1
View File
@@ -64,7 +64,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
2. display_date_label
3. rectification-record-evidence-batch
4. rectification-set-focus spokenPrompt /2-4 set-focus spokenPrompt next_followup=null 2-4 +case.accepted_time choice collect_spoken
5. confirmation_allowed false 5 skill_verification_report80%/60%
5. confirmation_allowed false 5 skill_verification_report80%/60%
6. Skill
2016 9
1 `;
@@ -558,6 +558,7 @@ export function latestResultToolProjection(
representative_time: representativeTime,
credibleRange: decision.credibleRange,
credible_range: decision.credibleRange,
skill_verification_report: base.skill_verification_report,
}),
};
}