T2 of TASK-rectification-grounding-20260927 (product decision P1). The rectification 「重试(重新生成)」 was a blind agent.generate rewrite that persisted unchecked text (invented range / fit rate reproduced). Removed: the regenerate route, regenerate-turn.ts, the regeneration agent and its read-only tool set, the client regenerate action/state and the regenerating/canRegenerate props. ChatMessageActions renders the regenerate button only when onRegenerate is passed; ordinary consultation is unchanged. The DB function regenerate_agentic_rectification_turn is kept (AGENTS §7.6, retire in a later round). DESIGN.md / VOICE.md updated; source-contract tests follow with 原值/新值/原因 notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
219 lines
7.3 KiB
TypeScript
219 lines
7.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
decideRectification,
|
|
publicCanAdopt,
|
|
rangeMatchesOpeningWindow,
|
|
sessionOutcomeAllowsDelivery,
|
|
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
|
import {
|
|
RECTIFICATION_DELIVERED_COPY,
|
|
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
|
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
|
interviewDeliveredGap,
|
|
rectificationQuestionGapState,
|
|
} from "../src/lib/rectification-surface-state.ts";
|
|
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
|
|
import type { CandidateDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
|
|
|
const DATED_PROBE: CandidateDiscriminatorProbe = {
|
|
probeId: "probe-2023-career",
|
|
candidateSetVersion: "set-1",
|
|
question: "2023 年 5 月前后,你有没有入职、换工作,或者当时手上的职责明显变重?",
|
|
expectedOutcomes: [
|
|
{
|
|
outcomeId: "yes",
|
|
supportsCandidateIds: ["05:06"],
|
|
conflictsCandidateIds: ["05:08", "05:14"],
|
|
},
|
|
{
|
|
outcomeId: "no",
|
|
supportsCandidateIds: ["05:08", "05:14"],
|
|
conflictsCandidateIds: ["05:06"],
|
|
},
|
|
],
|
|
candidateSplitHash: "05:06|05:08|05:14",
|
|
informationGain: 0.4,
|
|
sourceFeatures: [{ technique: "test", calculationResultId: null }],
|
|
domain: "career",
|
|
year: 2023,
|
|
semanticKey: "career.2023.entry",
|
|
};
|
|
|
|
const TIED_THREE = [
|
|
{ time: "05:06", score: 13 },
|
|
{ time: "05:08", score: 13 },
|
|
{ time: "05:14", score: 13 },
|
|
{ time: "05:03", score: 12 },
|
|
{ time: "04:59", score: 11 },
|
|
{ time: "04:46", score: 10 },
|
|
{ time: "04:53", score: 10 },
|
|
{ time: "05:15", score: 10 },
|
|
{ time: "04:50", score: 8 },
|
|
] as const;
|
|
|
|
const OPENING = ["04:45", "05:15"] as const;
|
|
|
|
function tiedInput(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
|
|
return {
|
|
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
|
methodCoverageAll: false,
|
|
trainingGateOpen: true,
|
|
candidateScores: TIED_THREE.map((item) => ({ time: item.time, score: item.score })),
|
|
holdoutValidation: "unavailable" as const,
|
|
datedEventCount: 3,
|
|
datedDomainCount: 2,
|
|
inferenceCredibleRange: OPENING,
|
|
openingCandidateRange: OPENING,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("tied first place with a dated probe keeps collecting instead of delivering", () => {
|
|
const decision = decideRectification(tiedInput({
|
|
discriminatorProbe: DATED_PROBE,
|
|
targetedCollectExhausted: true,
|
|
refreshExhausted: false,
|
|
}));
|
|
assert.notEqual(decision.nextAction, "complete_with_range");
|
|
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
|
|
assert.equal(publicCanAdopt(decision), false);
|
|
assert.equal(decision.canAdopt, false);
|
|
assert.equal(decision.nextAction, "ask_candidate_discriminator");
|
|
});
|
|
|
|
test("tied first place with no probe still collects while targeted questions remain", () => {
|
|
const decision = decideRectification(tiedInput({
|
|
discriminatorProbe: null,
|
|
targetedCollectExhausted: false,
|
|
refreshExhausted: true,
|
|
}));
|
|
assert.notEqual(decision.nextAction, "complete_with_range");
|
|
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
|
|
assert.equal(publicCanAdopt(decision), false);
|
|
assert.equal(decision.nextAction, "ask_fact_collection");
|
|
});
|
|
|
|
test("tied first place with no probe and targeted collect exhausted delivers a range", () => {
|
|
const decision = decideRectification(tiedInput({
|
|
discriminatorProbe: null,
|
|
targetedCollectExhausted: true,
|
|
refreshExhausted: false,
|
|
}));
|
|
assert.equal(decision.stopReason, "tied_first");
|
|
assert.equal(decision.nextAction, "complete_with_range");
|
|
assert.equal(decision.canOfferRange, true);
|
|
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
|
|
});
|
|
|
|
test("a live first-place tie with remaining probes does not announce adopt_representative", () => {
|
|
const decision = decideRectification(tiedInput({
|
|
discriminatorProbe: DATED_PROBE,
|
|
datedEventCount: 3,
|
|
targetedCollectExhausted: false,
|
|
refreshExhausted: false,
|
|
}));
|
|
assert.notEqual(decision.sessionOutcome, "adopt_representative");
|
|
assert.equal(publicCanAdopt(decision), false);
|
|
assert.equal(rangeMatchesOpeningWindow(decision.credibleRange, OPENING), true);
|
|
});
|
|
|
|
test("an unnarrowed delivered range withholds adopt", () => {
|
|
const unnarrowed = decideRectification(tiedInput({
|
|
discriminatorProbe: null,
|
|
targetedCollectExhausted: true,
|
|
inferenceCredibleRange: OPENING,
|
|
openingCandidateRange: OPENING,
|
|
}));
|
|
assert.equal(sessionOutcomeAllowsDelivery(unnarrowed.sessionOutcome), true);
|
|
assert.equal(unnarrowed.sessionOutcome === "provisional_range"
|
|
|| unnarrowed.sessionOutcome === "completed_with_range", true);
|
|
assert.equal(unnarrowed.canAdopt, false);
|
|
assert.equal(publicCanAdopt(unnarrowed), false);
|
|
assert.equal(unnarrowed.canOfferRange, true);
|
|
|
|
const narrowed = decideRectification(tiedInput({
|
|
discriminatorProbe: null,
|
|
targetedCollectExhausted: true,
|
|
inferenceCredibleRange: ["04:48", "05:07"],
|
|
openingCandidateRange: OPENING,
|
|
}));
|
|
assert.equal(sessionOutcomeAllowsDelivery(narrowed.sessionOutcome), true);
|
|
assert.equal(narrowed.canAdopt, true);
|
|
assert.equal(narrowed.sessionOutcome, "adopt_representative");
|
|
});
|
|
|
|
test("a delivered dead choice is the delivered gap, not unavailable repair", () => {
|
|
const warnings: string[] = [];
|
|
const original = console.warn;
|
|
console.warn = (value: unknown) => {
|
|
warnings.push(String(value));
|
|
};
|
|
try {
|
|
assert.equal(
|
|
interviewDeliveredGap({
|
|
questionMissing: false,
|
|
deadChoice: true,
|
|
sessionOutcome: "completed_with_range",
|
|
stopReason: "tied_first",
|
|
questionId: "distinguish:career.2023.entry",
|
|
hasChoiceCard: false,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
rectificationQuestionGapState({
|
|
liveQuestionVisible: false,
|
|
questionMissing: false,
|
|
questionLoadFailed: true,
|
|
deadChoice: true,
|
|
questionId: "distinguish:career.2023.entry",
|
|
hasChoiceCard: false,
|
|
busy: false,
|
|
readonly: false,
|
|
snapshotLoaded: true,
|
|
resumableCase: true,
|
|
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
|
sessionOutcome: "completed_with_range",
|
|
stopReason: "tied_first",
|
|
}),
|
|
"delivered",
|
|
);
|
|
assert.ok(warnings.some((line) => line.includes("rectification_delivered_state_dead_choice")));
|
|
} finally {
|
|
console.warn = original;
|
|
}
|
|
assert.equal(RECTIFICATION_DELIVERED_COPY.includes("没有拿到下一个问题"), false);
|
|
});
|
|
|
|
test("a non-delivery dead choice stays on the unavailable repair path", () => {
|
|
assert.equal(
|
|
interviewDeliveredGap({
|
|
questionMissing: false,
|
|
deadChoice: true,
|
|
sessionOutcome: "collect_evidence",
|
|
hasChoiceCard: false,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
rectificationQuestionGapState({
|
|
liveQuestionVisible: false,
|
|
questionMissing: false,
|
|
questionLoadFailed: true,
|
|
deadChoice: true,
|
|
questionPersisted: false,
|
|
collectWaiting: false,
|
|
busy: false,
|
|
readonly: false,
|
|
snapshotLoaded: true,
|
|
resumableCase: true,
|
|
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
|
sessionOutcome: "collect_evidence",
|
|
}),
|
|
"unavailable",
|
|
);
|
|
assert.equal(RECTIFICATION_QUESTION_UNAVAILABLE_COPY, "没有拿到下一个问题。");
|
|
});
|