fix(rectification): keep hour-window tail clusters and lock the search window (BUG-623, BUG-624, BUG-625)
Hour windows no longer drop later signature clusters. Credible range uses cluster coverage, and a mid-session spoken birth window gets a fixed reply without calling the model. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,7 @@ import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocus
|
||||
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
|
||||
import { isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision";
|
||||
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
|
||||
import { parseDeclaredBirthWindow } from "@/lib/rectification-agentic/v9/declared-window-utterance";
|
||||
import {
|
||||
awaitTurnExitBeforeResponse,
|
||||
finalizeSuccessfulTurnExit,
|
||||
@@ -268,6 +269,30 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "message" && parseDeclaredBirthWindow(parsed.data.message ?? "")) {
|
||||
try {
|
||||
const narration = RECTIFICATION_USER_COPY.declaredWindowLockedReply;
|
||||
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
requestId,
|
||||
userMessage: parsed.data.message ?? null,
|
||||
assistantMessage: narration,
|
||||
});
|
||||
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationToolServiceError) {
|
||||
const mapped = mapRectificationRpcError(error);
|
||||
return NextResponse.json(
|
||||
{ error: mapped.message, message: mapped.message, code: mapped.code },
|
||||
{ status: mapped.status },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "校正回复保存失败", message: "请稍后重试。" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedModel = isStructuredChoice
|
||||
? null
|
||||
: await resolveSessionLanguageModel(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { applyProbeOutcome, outcomeByMinuteForAnswer } from "./apply-probe-outco
|
||||
import type { TransitionSignLookup } from "./sign-from-transitions.ts";
|
||||
import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts";
|
||||
import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts";
|
||||
import { unionStillValidRange } from "./credible-range.ts";
|
||||
import { rangeFromTimes, unionStillValidRange } from "./credible-range.ts";
|
||||
import { entropyFromScores, normalizeScores } from "./entropy.ts";
|
||||
import { selectHighestGainProbe } from "./select-probe.ts";
|
||||
import { holdoutDomainYears, holdoutEventIds, stickyHoldoutEvents } from "./split-holdout.ts";
|
||||
@@ -22,8 +22,16 @@ export type EngineCandidateInput = Readonly<{
|
||||
id: string;
|
||||
time: string;
|
||||
relative_support: number;
|
||||
cluster_times?: readonly string[];
|
||||
cluster_start?: string;
|
||||
cluster_end?: string;
|
||||
}>;
|
||||
|
||||
function engineClusterRange(item: EngineCandidateInput): readonly [string, string] | null {
|
||||
if (item.cluster_start && item.cluster_end) return [item.cluster_start, item.cluster_end];
|
||||
return item.cluster_times?.length ? rangeFromTimes(item.cluster_times) : null;
|
||||
}
|
||||
|
||||
export type EngineEventInput = Readonly<{
|
||||
id: string;
|
||||
domain: string;
|
||||
@@ -126,7 +134,7 @@ export function buildInferenceState(input: {
|
||||
return {
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
cluster_range: clusterRangeFor(clusters, item.id, item.time),
|
||||
cluster_range: engineClusterRange(item) ?? clusterRangeFor(clusters, item.id, item.time),
|
||||
prior_score: trainingPrior[item.id] ?? 0,
|
||||
posterior_score: scores[item.id] ?? 0,
|
||||
probability: eliminated.has(item.id) ? 0 : probabilities[item.id] ?? 0,
|
||||
@@ -254,6 +262,8 @@ export function replayInferenceState(
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
relative_support: item.prior_score,
|
||||
cluster_start: item.cluster_range[0],
|
||||
cluster_end: item.cluster_range[1],
|
||||
})),
|
||||
events: state.events,
|
||||
probes: state.probes,
|
||||
@@ -348,6 +358,8 @@ function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswe
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
relative_support: item.prior_score,
|
||||
cluster_start: item.cluster_range[0],
|
||||
cluster_end: item.cluster_range[1],
|
||||
})),
|
||||
events: state.events,
|
||||
probes: state.probes,
|
||||
|
||||
@@ -111,6 +111,7 @@ export const RECTIFICATION_USER_COPY = {
|
||||
collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY,
|
||||
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
|
||||
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
|
||||
declaredWindowLockedReply: "搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。",
|
||||
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
|
||||
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
|
||||
hostNarrationFallback: "我按现有材料继续往下收。",
|
||||
@@ -461,6 +462,7 @@ export function listUserVisibleCopy(): string[] {
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
RECTIFICATION_USER_COPY.choicePrompt,
|
||||
RECTIFICATION_USER_COPY.unclearFocusReply,
|
||||
RECTIFICATION_USER_COPY.declaredWindowLockedReply,
|
||||
RECTIFICATION_USER_COPY.questionUpdated,
|
||||
RECTIFICATION_USER_COPY.adoptCue,
|
||||
RECTIFICATION_USER_COPY.hostNarrationFallback,
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
withCompareFailedRetryNotice,
|
||||
withRangeChangedAfterEvidence,
|
||||
} from "../user-copy";
|
||||
import { stripQuestionSentences, trimSpokenTurnForInterview } from "./collect-prompt";
|
||||
import { stripQuestionSentences, stripVerbalWindowChange, trimSpokenTurnForInterview } from "./collect-prompt";
|
||||
import { focusSpokenPrompt } from "./turn-question";
|
||||
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
|
||||
import {
|
||||
@@ -1031,6 +1031,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
rangeBeforeCompare,
|
||||
rangeAfterEvidence,
|
||||
);
|
||||
answerText = stripVerbalWindowChange(answerText)
|
||||
|| RECTIFICATION_USER_COPY.declaredWindowLockedReply;
|
||||
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
|
||||
return completeAttempt();
|
||||
} finally {
|
||||
|
||||
@@ -24,4 +24,5 @@ export const MACHINE_VOICE_LEXICON = [
|
||||
"强相关",
|
||||
"有关联",
|
||||
"弱关联",
|
||||
"以你说的为准",
|
||||
] as const;
|
||||
|
||||
@@ -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.19";
|
||||
export const RECTIFICATION_SKILL_VERSION = "10.0.20";
|
||||
|
||||
@@ -51,6 +51,17 @@ function isQuestionSentence(text: string, stem: string): boolean {
|
||||
return /[??]$/.test(text);
|
||||
}
|
||||
|
||||
export function stripVerbalWindowChange(body: string): string {
|
||||
const stripped = body
|
||||
.replace(/以你说的.{0,40}为准/g, "")
|
||||
.replace(/[,、]{2,}/g, ",")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/[,、]+\s*(?=[。..!?!?;;]|$)/g, "")
|
||||
.trim();
|
||||
if (!stripped || /^[,、。..!?!?;;::\s]+$/.test(stripped)) return "";
|
||||
return stripped;
|
||||
}
|
||||
|
||||
export function stripQuestionSentences(body: string, stem: string): string {
|
||||
const prompt = stem.trim();
|
||||
const spoken = body.trim();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Mid-session spoken birth-time windows are not a search-window change.
|
||||
* Detect them so the route can answer with a fixed reply and skip the model.
|
||||
*/
|
||||
|
||||
const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*[0-5]?\d(?:\s*分)?/;
|
||||
const RANGE_SEP = /\s*(?:到|至|[-–—~~])\s*/;
|
||||
const AROUND = /\s*(?:左右|前后)/;
|
||||
const RANGE_PATTERN = new RegExp(`(${CLOCK.source})${RANGE_SEP.source}(${CLOCK.source})`);
|
||||
const AROUND_PATTERN = new RegExp(`(${CLOCK.source})${AROUND.source}`);
|
||||
|
||||
export type DeclaredBirthWindow =
|
||||
| { kind: "range"; start: string; end: string }
|
||||
| { kind: "around"; time: string };
|
||||
|
||||
export function parseDeclaredBirthWindow(message: string): DeclaredBirthWindow | null {
|
||||
const text = message.trim();
|
||||
if (!text) return null;
|
||||
const range = RANGE_PATTERN.exec(text);
|
||||
if (range) {
|
||||
const start = normalizeClock(range[1] ?? "");
|
||||
const end = normalizeClock(range[2] ?? "");
|
||||
if (start && end && start !== end) return { kind: "range", start, end };
|
||||
}
|
||||
const around = AROUND_PATTERN.exec(text);
|
||||
if (around) {
|
||||
const time = normalizeClock(around[1] ?? "");
|
||||
if (time) return { kind: "around", time };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeClock(raw: string): string | null {
|
||||
const match = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (!Number.isInteger(hour) || hour > 23 || !Number.isInteger(minute) || minute > 59) return null;
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -58,6 +58,9 @@ export type V9EngineCandidate = Readonly<{
|
||||
rank: number;
|
||||
relativeSupport: number;
|
||||
tiedMinuteCount: number;
|
||||
clusterTimes?: readonly string[];
|
||||
clusterStart?: string;
|
||||
clusterEnd?: string;
|
||||
}>;
|
||||
|
||||
export type V9DecisionReceipt = Readonly<Record<string, unknown>>;
|
||||
@@ -282,6 +285,31 @@ export async function readV9EngineScoringIdentity(): Promise<LiveEngineScoringId
|
||||
}
|
||||
}
|
||||
|
||||
function readClusterCoverage(row: Record<string, unknown> | null): {
|
||||
clusterTimes?: readonly string[];
|
||||
clusterStart?: string;
|
||||
clusterEnd?: string;
|
||||
} {
|
||||
if (!row) return {};
|
||||
const times = Array.isArray(row.cluster_times)
|
||||
? row.cluster_times.flatMap((item) => (
|
||||
typeof item === "string" && timePattern.test(item) ? [item] : []
|
||||
))
|
||||
: [];
|
||||
const start = typeof row.cluster_start === "string" && timePattern.test(row.cluster_start)
|
||||
? row.cluster_start
|
||||
: times[0];
|
||||
const end = typeof row.cluster_end === "string" && timePattern.test(row.cluster_end)
|
||||
? row.cluster_end
|
||||
: times[times.length - 1];
|
||||
if (!start || !end) return {};
|
||||
return {
|
||||
...(times.length > 0 ? { clusterTimes: times } : {}),
|
||||
clusterStart: start,
|
||||
clusterEnd: end,
|
||||
};
|
||||
}
|
||||
|
||||
function readCandidates(
|
||||
value: unknown,
|
||||
range: { start_time: string; end_time: string },
|
||||
@@ -311,7 +339,15 @@ function readCandidates(
|
||||
}
|
||||
seenIds.add(candidateId);
|
||||
seenTimes.add(time);
|
||||
candidates.push({ candidateId, time, rank, relativeSupport, tiedMinuteCount });
|
||||
const cluster = readClusterCoverage(row);
|
||||
candidates.push({
|
||||
candidateId,
|
||||
time,
|
||||
rank,
|
||||
relativeSupport,
|
||||
tiedMinuteCount,
|
||||
...cluster,
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -387,7 +387,14 @@ export function compactInferenceProjection(state: InferenceState | null | undefi
|
||||
|
||||
export function buildCaseInferenceState(input: {
|
||||
range: { start_time: string; end_time: string };
|
||||
candidates: readonly Readonly<{ candidateId: string; time: string; relativeSupport: number }>[];
|
||||
candidates: readonly Readonly<{
|
||||
candidateId: string;
|
||||
time: string;
|
||||
relativeSupport: number;
|
||||
clusterTimes?: readonly string[];
|
||||
clusterStart?: string;
|
||||
clusterEnd?: string;
|
||||
}>[];
|
||||
evidence: readonly Readonly<{
|
||||
id: string;
|
||||
domain: string;
|
||||
@@ -421,6 +428,9 @@ export function buildCaseInferenceState(input: {
|
||||
id: item.time,
|
||||
time: item.time,
|
||||
relative_support: item.relativeSupport,
|
||||
...(item.clusterTimes ? { cluster_times: item.clusterTimes } : {}),
|
||||
...(item.clusterStart ? { cluster_start: item.clusterStart } : {}),
|
||||
...(item.clusterEnd ? { cluster_end: item.clusterEnd } : {}),
|
||||
})),
|
||||
events,
|
||||
probes,
|
||||
|
||||
@@ -280,6 +280,9 @@ export type V9Candidate = Readonly<{
|
||||
rank: number;
|
||||
relativeSupport: number;
|
||||
tiedMinuteCount: number;
|
||||
clusterTimes?: readonly string[];
|
||||
clusterStart?: string;
|
||||
clusterEnd?: string;
|
||||
}>;
|
||||
|
||||
export type V9CandidateSnapshot = Readonly<{
|
||||
@@ -303,6 +306,28 @@ export type V9CandidateSnapshot = Readonly<{
|
||||
invalidatedAt: string | null;
|
||||
}>;
|
||||
|
||||
function clusterCoverageFromRow(row: Record<string, unknown> | null): {
|
||||
clusterTimes?: readonly string[];
|
||||
clusterStart?: string;
|
||||
clusterEnd?: string;
|
||||
} {
|
||||
if (!row) return {};
|
||||
const times = Array.isArray(row.cluster_times)
|
||||
? row.cluster_times.flatMap((item) => {
|
||||
const time = timeValue(item);
|
||||
return time ? [time] : [];
|
||||
})
|
||||
: [];
|
||||
const start = timeValue(row.cluster_start) ?? times[0];
|
||||
const end = timeValue(row.cluster_end) ?? times[times.length - 1];
|
||||
if (!start || !end) return {};
|
||||
return {
|
||||
...(times.length > 0 ? { clusterTimes: times } : {}),
|
||||
clusterStart: start,
|
||||
clusterEnd: end,
|
||||
};
|
||||
}
|
||||
|
||||
function timeValue(value: unknown): string | null {
|
||||
const time = typeof value === "string" ? value.slice(0, 5) : "";
|
||||
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
|
||||
@@ -489,7 +514,14 @@ export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot |
|
||||
|| tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1
|
||||
) return null;
|
||||
seenIds.add(candidateId);
|
||||
candidates.push({ candidateId, time: candidateTime, rank, relativeSupport, tiedMinuteCount });
|
||||
candidates.push({
|
||||
candidateId,
|
||||
time: candidateTime,
|
||||
rank,
|
||||
relativeSupport,
|
||||
tiedMinuteCount,
|
||||
...clusterCoverageFromRow(candidate),
|
||||
});
|
||||
}
|
||||
const decisionReceipt = rowObject(row.decision_receipt);
|
||||
const executionLedger = Array.isArray(row.execution_ledger)
|
||||
@@ -1469,6 +1501,9 @@ export async function persistV9Candidate(
|
||||
rank: candidate.rank,
|
||||
relative_support: candidate.relativeSupport,
|
||||
tied_minute_count: candidate.tiedMinuteCount,
|
||||
...(candidate.clusterTimes ? { cluster_times: candidate.clusterTimes } : {}),
|
||||
...(candidate.clusterStart ? { cluster_start: candidate.clusterStart } : {}),
|
||||
...(candidate.clusterEnd ? { cluster_end: candidate.clusterEnd } : {}),
|
||||
})),
|
||||
p_decision_receipt: input.decisionReceipt,
|
||||
p_execution_ledger: input.executionLedger,
|
||||
|
||||
@@ -463,8 +463,8 @@ function rpcDossier(decision: DecisionDossier, activeFocus?: Record<string, unkn
|
||||
});
|
||||
}
|
||||
|
||||
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
});
|
||||
|
||||
test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => {
|
||||
|
||||
@@ -359,7 +359,7 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
|
||||
assert.match(agentSource, /不得宣称唯一出生分钟/);
|
||||
assert.doesNotMatch(agentSource, /±2 分钟/);
|
||||
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
|
||||
@@ -199,12 +199,12 @@ test("delivery report gives 04:53 D10 as Cancer instead of letting the model inf
|
||||
assert.match(report.markdown, /04:53 \| .*巨蟹座/);
|
||||
});
|
||||
|
||||
test("skill 10.0.19 forbids computing varga signs from transition times", () => {
|
||||
test("skill 10.0.20 forbids computing varga signs from transition times", () => {
|
||||
const skillDir = fileURLToPath(new URL("../../skills/jyotish-birth-time-rectification", import.meta.url));
|
||||
const skill = readFileSync(`${skillDir}/SKILL.md`, "utf8");
|
||||
const comparison = readFileSync(`${skillDir}/references/candidate-comparison.md`, "utf8");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
assert.match(skill, /^version: 10\.0\.19$/m);
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
assert.match(skill, /^version: 10\.0\.20$/m);
|
||||
assert.match(skill, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(comparison, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
});
|
||||
|
||||
@@ -1454,9 +1454,9 @@ test("rescore failure does not fail the evidence write", async () => {
|
||||
assert.ok(result.rescore.error_code);
|
||||
});
|
||||
|
||||
test("public tool surface stays at 14 and new cases bind 10.0.19", () => {
|
||||
test("public tool surface stays at 14 and new cases bind 10.0.20", () => {
|
||||
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
const deprecated = resolveExactSkillPackage(
|
||||
"jyotish-birth-time-rectification",
|
||||
"10.0.2",
|
||||
|
||||
@@ -576,8 +576,8 @@ function warnLines(run: () => Promise<unknown> | unknown) {
|
||||
}).then((result) => ({ result, lines }));
|
||||
}
|
||||
|
||||
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
});
|
||||
|
||||
test("USER_COLLECT_QUESTION no longer has an other fallback", () => {
|
||||
|
||||
@@ -213,9 +213,9 @@ test("read-case evidence context keeps day labels and confirm does not rewrite d
|
||||
assert.equal("p_occurred_from" in confirmCall.args, false);
|
||||
});
|
||||
|
||||
test("new-case skill identity is 10.0.19 and the prompt prefers batch ingest", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
assert.match(skill, /^version: 10\.0\.19$/m);
|
||||
test("new-case skill identity is 10.0.20 and the prompt prefers batch ingest", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
assert.match(skill, /^version: 10\.0\.20$/m);
|
||||
assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/);
|
||||
assert.match(agentSource, /新事件走 rectification-record-evidence-batch/);
|
||||
assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
|
||||
|
||||
@@ -184,8 +184,8 @@ const CANDIDATE_IDS = [
|
||||
"88888888-8888-4888-8888-888888888882",
|
||||
] as const;
|
||||
|
||||
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
});
|
||||
|
||||
test("nineteen-row ledger opens the training gate with four scoreable domains", () => {
|
||||
|
||||
@@ -426,8 +426,8 @@ function rpcDossier(decision: DecisionDossier) {
|
||||
});
|
||||
}
|
||||
|
||||
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
});
|
||||
|
||||
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
|
||||
|
||||
@@ -94,8 +94,8 @@ function collectPersistResult(overrides: {
|
||||
};
|
||||
}
|
||||
|
||||
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
});
|
||||
|
||||
test("cases current_question remains the submit contract, not a visual slot", () => {
|
||||
|
||||
@@ -96,11 +96,11 @@ test("system prompt carries only high-priority boundaries, never the method copy
|
||||
test("agent pins the dedicated rectification skill and its fixed version", () => {
|
||||
assert.equal(RECTIFICATION_V9_SKILL_NAME, "jyotish-birth-time-rectification");
|
||||
assert.equal(basename(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_SKILL_NAME);
|
||||
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.19"));
|
||||
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.20"));
|
||||
assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH);
|
||||
assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH);
|
||||
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
});
|
||||
|
||||
test("step budgets are bounded per action with a hard ceiling", () => {
|
||||
|
||||
@@ -95,9 +95,9 @@ test("terminal transitions are one-way and evidence writes stop at terminal", ()
|
||||
|
||||
test("the active rectification skill pins the v10 identity and lives in the right directory", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
|
||||
assert.match(skill, /^version: 10\.0\.19$/m);
|
||||
assert.match(skill, /^version: 10\.0\.20$/m);
|
||||
assert.match(skill, /至多一个主问题且唯一来源:[\s\S]*不得自行提出、复述、改写或预告问题/);
|
||||
for (const reference of references) {
|
||||
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
|
||||
|
||||
@@ -209,7 +209,7 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
|
||||
session_id: SESSION_ID,
|
||||
status: "draft",
|
||||
should_start_opening: true,
|
||||
skill_version: "10.0.19",
|
||||
skill_version: "10.0.20",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -247,11 +247,11 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
|
||||
});
|
||||
assert.equal(response.disposition, "created");
|
||||
assert.equal(response.shouldStartOpening, true);
|
||||
assert.equal(response.skillVersion, "10.0.19");
|
||||
assert.equal(response.skillVersion, "10.0.20");
|
||||
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2");
|
||||
assert.ok(openCall);
|
||||
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
|
||||
assert.equal(openCall.args.p_skill_version, "10.0.19");
|
||||
assert.equal(openCall.args.p_skill_version, "10.0.20");
|
||||
assert.equal(openCall.args.p_user_id, "user-1");
|
||||
// The server derives the baseline; the request never carries it from the browser.
|
||||
assert.equal("birth_date" in openCall.args, false);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { unionStillValidRange } from "../src/lib/rectification-agentic/core/credible-range.ts";
|
||||
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
|
||||
import { stripVerbalWindowChange } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { parseDeclaredBirthWindow } from "../src/lib/rectification-agentic/v9/declared-window-utterance.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
|
||||
const ROUTE = readFileSync(
|
||||
new URL("../src/app/api/rectification/agent/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const SKILL = readFileSync(
|
||||
new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("declared birth-window utterances parse ranges and around-times", () => {
|
||||
assert.deepEqual(
|
||||
parseDeclaredBirthWindow("我的出生时间是 14 点 45 到 14 点 50"),
|
||||
{ kind: "range", start: "14:45", end: "14:50" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseDeclaredBirthWindow("14:45–14:50"),
|
||||
{ kind: "range", start: "14:45", end: "14:50" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseDeclaredBirthWindow("14:47 左右"),
|
||||
{ kind: "around", time: "14:47" },
|
||||
);
|
||||
assert.equal(parseDeclaredBirthWindow("没有"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("2014年入学,大概秋天"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("继续吧"), null);
|
||||
});
|
||||
|
||||
test("choice-focus declared window is answered from the route before the model", () => {
|
||||
const intercept = ROUTE.indexOf('if (action === "message" && parseDeclaredBirthWindow');
|
||||
const selectedModel = ROUTE.indexOf("const selectedModel = isStructuredChoice");
|
||||
const classifyCall = ROUTE.indexOf("await classifyRectificationTurnIntent");
|
||||
assert.ok(intercept > 0);
|
||||
assert.ok(selectedModel > intercept);
|
||||
assert.ok(classifyCall > intercept);
|
||||
assert.match(ROUTE, /declaredWindowLockedReply/);
|
||||
assert.match(ROUTE, /persistV9DeterministicTurn/);
|
||||
assert.equal(
|
||||
RECTIFICATION_USER_COPY.declaredWindowLockedReply,
|
||||
"搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。",
|
||||
);
|
||||
});
|
||||
|
||||
test("engine cluster coverage becomes the credible-range right edge", () => {
|
||||
const state = buildInferenceState({
|
||||
range_start: "14:00",
|
||||
range_end: "15:00",
|
||||
candidates: [{
|
||||
id: "14:40",
|
||||
time: "14:40",
|
||||
relative_support: 40,
|
||||
cluster_times: ["14:40", "14:41", "14:42", "14:43", "14:44", "14:45"],
|
||||
cluster_start: "14:40",
|
||||
cluster_end: "14:45",
|
||||
}],
|
||||
events: [],
|
||||
probes: [],
|
||||
});
|
||||
assert.deepEqual(state.candidates[0]?.cluster_range, ["14:40", "14:45"]);
|
||||
assert.deepEqual(unionStillValidRange(state.candidates), ["14:40", "14:45"]);
|
||||
});
|
||||
|
||||
test("agent body cannot verbally accept a spoken birth window", () => {
|
||||
assert.ok(MACHINE_VOICE_LEXICON.includes("以你说的为准"));
|
||||
assert.equal(
|
||||
stripVerbalWindowChange("明白了,出生时间以你说的 14:45–14:50 为准。"),
|
||||
"明白了,出生时间。",
|
||||
);
|
||||
assert.equal(stripVerbalWindowChange("以你说的为准。"), "");
|
||||
assert.equal(stripVerbalWindowChange("明白了,以你说的为准。"), "明白了。");
|
||||
assert.match(SKILL, /不得回答『以你说的为准』或改写搜索窗口/);
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
|
||||
});
|
||||
@@ -85,8 +85,8 @@ test("checked-in registry verifies hashed product packages and leaves consult on
|
||||
[
|
||||
{
|
||||
name: "jyotish-birth-time-rectification",
|
||||
version: "10.0.19",
|
||||
sha256: "a68885d3cf2110ee456bff65210c60e6bef4da4ea45e2a790a5462fd6d7307c8",
|
||||
version: "10.0.20",
|
||||
sha256: "9c09867591cc9e8726f3577346230339b5a6dd4b4f3d634a9cda3e66170d6ac6",
|
||||
},
|
||||
{
|
||||
name: "jyotish-personal-report",
|
||||
|
||||
Reference in New Issue
Block a user