fix(rectification): ingest first-turn events without dropping day precision or claiming a unique minute
SQL kinds now match the TypeScript ledger so batch ingest can confirm dated events. Confirm no longer burns the opening focus, recap uses server date labels, and the Agent sees indistinguishable width instead of a fake unique minute. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -78,6 +78,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
assert.match(migration.stdout, /applied 20260814040000_personal_report_jobs_v2\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260816010000_accept_exact_family_birth_times\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260818010000_admin_product_catalog_mutations\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260819010000_rectification_ingest_precision_plateau\.sql/);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
|
||||
@@ -346,15 +346,17 @@ test("rectification Agent output stays natural and keeps tool execution silent",
|
||||
assert.match(strategy, /(?:无需|不要求)结束、暂停或保存进度/);
|
||||
});
|
||||
|
||||
test("clear current-turn events are proposed and confirmed in the same Agent run", () => {
|
||||
test("clear current-turn events go through the batch evidence service", () => {
|
||||
const tools = readFileSync(
|
||||
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(agent, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence,同轮完成记录/);
|
||||
assert.match(agent, /不得要求用户逐条重新发送或再次回答.*确认/);
|
||||
assert.match(agent, /当前轮新事件一律走 rectification-record-evidence-batch/);
|
||||
assert.match(agent, /rectification-confirm-evidence 只用于用户对已有 pending 明确说“对\/是”/);
|
||||
assert.match(agent, /不得要求用户把已说清的事件再发一遍/);
|
||||
assert.doesNotMatch(agent, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
|
||||
assert.doesNotMatch(agent, /“是\/对”只能确认当前 pending draft/);
|
||||
assert.match(tools, /当前轮主动、明确且无歧义的一件或多件可拆分事件/);
|
||||
assert.match(tools, /同一轮有两件及以上可拆分事件时必须改用 rectification-record-evidence-batch/);
|
||||
assert.doesNotMatch(tools, /只有用户明确确认后才进入评分账本/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runnerPath = fileURLToPath(
|
||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
function dockerAvailable(): boolean {
|
||||
const probe = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
});
|
||||
return probe.status === 0;
|
||||
}
|
||||
|
||||
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
|
||||
|
||||
const snapshot = {
|
||||
birth_date: "1997-08-08",
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezone_offset: 8,
|
||||
birth_time_source: "family_exact",
|
||||
reported_birth_time: "05:00",
|
||||
active_birth_time: null,
|
||||
birth_time_period: null,
|
||||
uncertainty_before_minutes: 10,
|
||||
uncertainty_after_minutes: 10,
|
||||
};
|
||||
const fingerprint = "a".repeat(64);
|
||||
const range = { start_time: "04:50", end_time: "05:10" };
|
||||
|
||||
function rpcError(error: unknown): string {
|
||||
if (!error || typeof error !== "object") return "";
|
||||
const value = error as { message?: unknown };
|
||||
return typeof value.message === "string" ? value.message : "";
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === "string") return JSON.parse(value) as Record<string, unknown>;
|
||||
if (value && typeof value === "object") return value as Record<string, unknown>;
|
||||
return {};
|
||||
}
|
||||
|
||||
test("ingest P0: education kinds, batch confirm, opening focus reuse, precision lock", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
assert.match(migration.stdout, /applied 20260819010000_rectification_ingest_precision_plateau\.sql/);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`
|
||||
insert into identity.users (name, email, email_verified, email_verified_at)
|
||||
values ('Ingest', 'ingest-p0@example.com', true, now())
|
||||
`,
|
||||
);
|
||||
const userId = fixture.psql(
|
||||
"select id from identity.users where email = 'ingest-p0@example.com'",
|
||||
);
|
||||
fixture.psql(`
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08',
|
||||
reported_birth_time = '05:00',
|
||||
birth_time_source = 'family_exact',
|
||||
latitude = 36.420487, longitude = 114.209936, timezone_offset = 8,
|
||||
birth_time_status = 'reported'
|
||||
where id = '${userId}';
|
||||
`);
|
||||
|
||||
const service = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
|
||||
const opened = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
p_intent: "homepage",
|
||||
p_session_id: null,
|
||||
p_skill_name: "jyotish-birth-time-rectification",
|
||||
p_skill_version: "10.0.1",
|
||||
p_baseline_profile_fingerprint: fingerprint,
|
||||
p_baseline_birth_snapshot: snapshot,
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.equal(opened.error, null, rpcError(opened.error));
|
||||
const caseId = String(asObject(opened.data).case_id);
|
||||
|
||||
const turn = await service.rpc("append_agentic_rectification_turn", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_user_message: "2016年6月高考结束,2016年9月大学入学",
|
||||
p_assistant_message: "已记下这两件教育经历。",
|
||||
p_model_name: "test-model",
|
||||
p_model_version: "1",
|
||||
p_status: "completed",
|
||||
p_request_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
});
|
||||
assert.equal(turn.error, null, rpcError(turn.error));
|
||||
const turnId = String(asObject(turn.data).turn_id);
|
||||
|
||||
const batch = await service.rpc("record_agentic_rectification_evidence_batch", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_source_turn_id: turnId,
|
||||
p_focus_id: null,
|
||||
p_items: [
|
||||
{
|
||||
idempotency_key: "ingest-gaokao",
|
||||
quote: "2016年6月高考结束",
|
||||
subject: "self",
|
||||
event_kind: "education_milestone",
|
||||
domain: "education",
|
||||
occurred_from: "2016-06-01",
|
||||
occurred_to: null,
|
||||
date_precision: "month",
|
||||
summary: "2016年6月高考结束",
|
||||
},
|
||||
{
|
||||
idempotency_key: "ingest-university",
|
||||
quote: "2016年9月大学入学",
|
||||
subject: "self",
|
||||
event_kind: "education_milestone",
|
||||
domain: "education",
|
||||
occurred_from: "2016-09-01",
|
||||
occurred_to: null,
|
||||
date_precision: "month",
|
||||
summary: "2016年9月大学入学",
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(batch.error, null, rpcError(batch.error));
|
||||
const batchRow = asObject(batch.data);
|
||||
assert.equal(batchRow.accepted_count, 2);
|
||||
assert.equal(batchRow.rejected_count, 0);
|
||||
const batchItems = Array.isArray(batchRow.items) ? batchRow.items as Record<string, unknown>[] : [];
|
||||
assert.equal(batchItems.length, 2);
|
||||
assert.equal(batchItems[0]?.outcome, "accepted");
|
||||
assert.equal(batchItems[0]?.status, "confirmed");
|
||||
assert.equal(batchItems[1]?.outcome, "accepted");
|
||||
assert.equal(batchItems[1]?.status, "confirmed");
|
||||
|
||||
const secondTurn = await service.rpc("append_agentic_rectification_turn", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_user_message: "2016年6月高考结束,2016年9月大学入学,另外2014年8月8日搬家",
|
||||
p_assistant_message: "继续核对这几件经历。",
|
||||
p_model_name: "test-model",
|
||||
p_model_version: "1",
|
||||
p_status: "completed",
|
||||
p_request_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
});
|
||||
assert.equal(secondTurn.error, null, rpcError(secondTurn.error));
|
||||
const secondTurnId = String(asObject(secondTurn.data).turn_id);
|
||||
|
||||
const openingFocus = await service.rpc("set_agentic_rectification_conversation_focus", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_question_id: "opening",
|
||||
p_intent: "collect_first_event",
|
||||
p_target_evidence_id: null,
|
||||
p_target_domain: null,
|
||||
p_target_kind: null,
|
||||
p_expected_answer_schema: {},
|
||||
});
|
||||
assert.equal(openingFocus.error, null, rpcError(openingFocus.error));
|
||||
const focusPayload = asObject(openingFocus.data);
|
||||
const focusId = String(focusPayload.focus_id ?? asObject(focusPayload.focus).id);
|
||||
assert.match(focusId, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
|
||||
const firstDraft = await service.rpc("propose_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_source_turn_id: secondTurnId,
|
||||
p_user_quote: "2016年6月高考结束",
|
||||
p_subject: "self",
|
||||
p_event_kind: "education_milestone",
|
||||
p_domain: "education",
|
||||
p_occurred_from: "2016-06-01",
|
||||
p_occurred_to: null,
|
||||
p_date_precision: "month",
|
||||
p_summary: "2016年6月高考结束",
|
||||
});
|
||||
assert.equal(firstDraft.error, null, rpcError(firstDraft.error));
|
||||
assert.equal(asObject(firstDraft.data).outcome, "accepted");
|
||||
const firstEvidenceId = String(asObject(firstDraft.data).evidence_id);
|
||||
|
||||
const secondDraft = await service.rpc("propose_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_source_turn_id: secondTurnId,
|
||||
p_user_quote: "2016年9月大学入学",
|
||||
p_subject: "self",
|
||||
p_event_kind: "education_milestone",
|
||||
p_domain: "education",
|
||||
p_occurred_from: "2016-09-01",
|
||||
p_occurred_to: null,
|
||||
p_date_precision: "month",
|
||||
p_summary: "2016年9月大学入学",
|
||||
});
|
||||
assert.equal(secondDraft.error, null, rpcError(secondDraft.error));
|
||||
const secondEvidenceId = String(asObject(secondDraft.data).evidence_id);
|
||||
|
||||
const firstConfirm = await service.rpc("confirm_agentic_rectification_evidence_v10", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_focus_id: focusId,
|
||||
p_evidence_id: firstEvidenceId,
|
||||
});
|
||||
assert.equal(firstConfirm.error, null, rpcError(firstConfirm.error));
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_conversation_focuses where id = '${focusId}'`),
|
||||
"active",
|
||||
);
|
||||
|
||||
const secondConfirm = await service.rpc("confirm_agentic_rectification_evidence_v10", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_focus_id: focusId,
|
||||
p_evidence_id: secondEvidenceId,
|
||||
});
|
||||
assert.equal(secondConfirm.error, null, rpcError(secondConfirm.error));
|
||||
|
||||
const dayDraft = await service.rpc("propose_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_source_turn_id: secondTurnId,
|
||||
p_user_quote: "2014年8月8日搬家",
|
||||
p_subject: "self",
|
||||
p_event_kind: "relocation",
|
||||
p_domain: "relocation",
|
||||
p_occurred_from: "2014-08-08",
|
||||
p_occurred_to: null,
|
||||
p_date_precision: "day",
|
||||
p_summary: "2014年8月8日搬家",
|
||||
});
|
||||
assert.equal(dayDraft.error, null, rpcError(dayDraft.error));
|
||||
const dayEvidenceId = String(asObject(dayDraft.data).evidence_id);
|
||||
const dayConfirm = await service.rpc("confirm_agentic_rectification_evidence_v10", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_focus_id: null,
|
||||
p_evidence_id: dayEvidenceId,
|
||||
});
|
||||
assert.equal(dayConfirm.error, null, rpcError(dayConfirm.error));
|
||||
assert.equal(
|
||||
fixture.psql(`select date_precision from public.agentic_rectification_evidence where id = '${dayEvidenceId}'`),
|
||||
"day",
|
||||
);
|
||||
|
||||
const coarsen = await service.rpc("revise_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_evidence_id: dayEvidenceId,
|
||||
p_user_quote: "是",
|
||||
p_occurred_from: "2014-01-01",
|
||||
p_occurred_to: null,
|
||||
p_date_precision: "year",
|
||||
p_summary: "改成年级",
|
||||
});
|
||||
assert.match(rpcError(coarsen.error), /agentic_rectification_precision_downgrade/);
|
||||
assert.equal(
|
||||
fixture.psql(`select date_precision from public.agentic_rectification_evidence where id = '${dayEvidenceId}'`),
|
||||
"day",
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await closeLocalPostgresDataPool(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DATE_PRECISIONS,
|
||||
EVIDENCE_DOMAINS,
|
||||
EVIDENCE_KINDS,
|
||||
displayDateLabel,
|
||||
isEvidenceDomain,
|
||||
isEvidenceKind,
|
||||
quoteIsGroundedInMessage,
|
||||
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
||||
import {
|
||||
confirmationAllowedForWidth,
|
||||
indistinguishableWidthMinutes,
|
||||
} from "../src/lib/rectification-agentic/v9/candidate-plateau.ts";
|
||||
import { RECTIFICATION_POLICY } from "../src/lib/rectification-policy.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import { createRectificationV9Tools, latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
CANDIDATE_ID,
|
||||
EVIDENCE_ID,
|
||||
FOCUS_ID,
|
||||
RESULT_ID,
|
||||
SECOND_CANDIDATE_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
candidateSnapshotFixture,
|
||||
computeFixture,
|
||||
dossierFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
|
||||
const ingestMigration = readFileSync(
|
||||
new URL("../supabase/migrations/20260819010000_rectification_ingest_precision_plateau.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const agentSource = readFileSync(
|
||||
new URL("../src/mastra/agentic-rectification.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const skill = readFileSync(
|
||||
new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const evidenceModel = readFileSync(
|
||||
new URL("../../skills/jyotish-birth-time-rectification/references/evidence-model.md", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const candidateComparison = readFileSync(
|
||||
new URL("../../skills/jyotish-birth-time-rectification/references/candidate-comparison.md", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function quotedSqlValues(source: string, pattern: RegExp): string[] {
|
||||
const match = source.match(pattern);
|
||||
assert.ok(match?.[1], `missing SQL allowlist: ${pattern}`);
|
||||
return [...match[1].matchAll(/'([^']+)'/g)].map((value) => value[1]);
|
||||
}
|
||||
|
||||
test("SQL kind and domain helpers cover the TypeScript evidence allowlists", () => {
|
||||
const sqlKinds = quotedSqlValues(
|
||||
ingestMigration,
|
||||
/create or replace function public\.agentic_rectification_evidence_kinds\(\)[\s\S]*?select array\[([\s\S]*?)\]::text\[\]/,
|
||||
);
|
||||
const sqlDomains = quotedSqlValues(
|
||||
ingestMigration,
|
||||
/create or replace function public\.agentic_rectification_evidence_domains\(\)[\s\S]*?select array\[([\s\S]*?)\]::text\[\]/,
|
||||
);
|
||||
const sqlPrecisions = quotedSqlValues(
|
||||
ingestMigration,
|
||||
/create or replace function public\.agentic_rectification_date_precisions\(\)[\s\S]*?select array\[([\s\S]*?)\]::text\[\]/,
|
||||
);
|
||||
assert.deepEqual(sqlKinds, [...EVIDENCE_KINDS]);
|
||||
assert.deepEqual(sqlDomains, [...EVIDENCE_DOMAINS]);
|
||||
assert.deepEqual(sqlPrecisions, [...DATE_PRECISIONS]);
|
||||
assert.equal(isEvidenceKind("education_milestone"), true);
|
||||
assert.equal(isEvidenceDomain("education"), true);
|
||||
assert.equal(isEvidenceDomain("health_pressure"), true);
|
||||
});
|
||||
|
||||
test("day precision labels stay ISO dates and never collapse to a year sentence", () => {
|
||||
assert.equal(displayDateLabel("day", "2024-08-08", null), "2024-08-08");
|
||||
assert.equal(displayDateLabel("month", "2024-05-01", null), "2024-05");
|
||||
assert.equal(displayDateLabel("year", "2024-01-01", null), "2024年");
|
||||
assert.equal(displayDateLabel("range", "2024-01-01", "2024-03-31"), "2024-01-01–2024-03-31");
|
||||
assert.doesNotMatch(displayDateLabel("day", "2024-08-08", null), /年份|年$/);
|
||||
assert.match(skill, /display_date_label/);
|
||||
assert.match(evidenceModel, /禁止把日级格式化成“年份已确定为 YYYY”/);
|
||||
});
|
||||
|
||||
test("ASCII punctuation is stripped for quote grounding without allowing paraphrase", () => {
|
||||
assert.equal(quoteIsGroundedInMessage("2016年6月高考结束", "2016年6月高考结束,"), true);
|
||||
assert.equal(quoteIsGroundedInMessage("2016年6月高考结束", "那年夏天考完了"), false);
|
||||
});
|
||||
|
||||
test("a 25-minute tied plateau projects width and forbids unique-minute confirmation", () => {
|
||||
const candidates = [
|
||||
{ candidateId: CANDIDATE_ID, time: "04:45", rank: 1, relativeSupport: 40, tiedMinuteCount: 25 },
|
||||
{ candidateId: SECOND_CANDIDATE_ID, time: "04:46", rank: 2, relativeSupport: 35, tiedMinuteCount: 25 },
|
||||
{ candidateId: THIRD_CANDIDATE_ID, time: "04:47", rank: 3, relativeSupport: 25, tiedMinuteCount: 25 },
|
||||
];
|
||||
const width = indistinguishableWidthMinutes(candidates);
|
||||
assert.ok(width >= 25);
|
||||
assert.equal(confirmationAllowedForWidth(true, width), false);
|
||||
assert.equal(RECTIFICATION_POLICY.maxConfirmationWidthMinutes, 5);
|
||||
const projection = latestResultToolProjection({
|
||||
resultId: RESULT_ID,
|
||||
candidates,
|
||||
selectionAllowed: true,
|
||||
confirmationAllowed: true,
|
||||
representativeTime: "04:45",
|
||||
selectedTime: null,
|
||||
selectionKind: null,
|
||||
algorithmVersion: "rectification-v5",
|
||||
});
|
||||
assert.equal(projection.indistinguishable_width_minutes, width);
|
||||
assert.equal(projection.confirmation_allowed, false);
|
||||
assert.match(candidateComparison, /一段不可分区间/);
|
||||
assert.match(candidateComparison, /代表性候选/);
|
||||
assert.match(agentSource, /不得说已定位到唯一分钟/);
|
||||
});
|
||||
|
||||
test("read-case evidence context keeps day labels and confirm does not rewrite dates", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
evidence: [{
|
||||
id: EVIDENCE_ID,
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "relationship_end",
|
||||
domain: "relationship",
|
||||
occurred_from: "2024-08-08",
|
||||
occurred_to: null,
|
||||
date_precision: "day",
|
||||
summary: "2024-08-08 的一件关系变化",
|
||||
status: "pending_confirmation",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:06.000Z",
|
||||
}],
|
||||
latestResult: {
|
||||
...candidateSnapshotFixture({
|
||||
confirmationAllowed: true,
|
||||
representativeTime: "04:45",
|
||||
candidates: [
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "04:45", relative_support: 40, tied_minute_count: 25 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:46", relative_support: 35, tied_minute_count: 25 },
|
||||
{ candidate_id: THIRD_CANDIDATE_ID, rank: 3, time: "04:47", relative_support: 25, tied_minute_count: 25 },
|
||||
],
|
||||
}),
|
||||
selection_allowed: true,
|
||||
confirmation_allowed: true,
|
||||
},
|
||||
}),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
confirm_agentic_rectification_evidence_v10: (_fn, args) => ({
|
||||
focus_id: args.p_focus_id,
|
||||
evidence_id: args.p_evidence_id,
|
||||
status: "confirmed",
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const tools = createRectificationV9Tools({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
turnId: TURN_ID,
|
||||
accounting: accounting.client as never,
|
||||
});
|
||||
const projection = await (tools["rectification-read-case"] as unknown as {
|
||||
execute(input: unknown): Promise<{
|
||||
evidence_context: Array<{ date_precision: string; display_date_label: string }>;
|
||||
latest_result: { confirmation_allowed: boolean; indistinguishable_width_minutes: number };
|
||||
}>;
|
||||
}).execute({ caseId: CASE_ID });
|
||||
assert.equal(projection.evidence_context[0]?.date_precision, "day");
|
||||
assert.equal(projection.evidence_context[0]?.display_date_label, "2024-08-08");
|
||||
assert.doesNotMatch(projection.evidence_context[0]?.display_date_label ?? "", /年份已确定为/);
|
||||
assert.ok(projection.latest_result.indistinguishable_width_minutes >= 25);
|
||||
assert.equal(projection.latest_result.confirmation_allowed, false);
|
||||
|
||||
await (tools["rectification-confirm-evidence"] as unknown as {
|
||||
execute(input: unknown): Promise<unknown>;
|
||||
}).execute({ caseId: CASE_ID, evidenceId: EVIDENCE_ID });
|
||||
const confirmCall = accounting.calls.find((call) => call.fn === "confirm_agentic_rectification_evidence_v10");
|
||||
assert.ok(confirmCall);
|
||||
assert.equal(confirmCall.args.p_focus_id, null);
|
||||
assert.equal("p_date_precision" in confirmCall.args, false);
|
||||
assert.equal("p_occurred_from" in confirmCall.args, false);
|
||||
});
|
||||
|
||||
test("new-case skill identity is 10.0.1 and the prompt prefers batch ingest", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.1");
|
||||
assert.match(skill, /^version: 10\.0\.1$/m);
|
||||
assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/);
|
||||
assert.match(agentSource, /当前轮新事件一律走 rectification-record-evidence-batch/);
|
||||
assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
|
||||
});
|
||||
@@ -155,7 +155,7 @@ test("focus/evidence mutation schemas require durable focus and evidence referen
|
||||
const revise = tools["rectification-revise-evidence"].inputSchema;
|
||||
const resolve = tools["rectification-resolve-focus"].inputSchema;
|
||||
|
||||
assert.equal(confirm.safeParse({ caseId: CASE_ID, evidenceId: EVIDENCE_ID }).success, false);
|
||||
assert.equal(confirm.safeParse({ caseId: CASE_ID, evidenceId: EVIDENCE_ID }).success, true);
|
||||
assert.equal(confirm.safeParse({ caseId: CASE_ID, focusId: FOCUS_ID }).success, false);
|
||||
assert.equal(revise.safeParse({
|
||||
...validInputs["rectification-revise-evidence"],
|
||||
|
||||
@@ -56,6 +56,10 @@ test("system prompt carries only high-priority boundaries, never the method copy
|
||||
assert.doesNotMatch(prompt, /run the required gate/);
|
||||
assert.doesNotMatch(prompt, /candidate_range/);
|
||||
assert.match(prompt, /jyotish-birth-time-rectification/);
|
||||
assert.match(prompt, /display_date_label/);
|
||||
assert.match(prompt, /rectification-record-evidence-batch/);
|
||||
assert.match(prompt, /不可分区间/);
|
||||
assert.doesNotMatch(prompt, /分别 propose\+confirm/);
|
||||
// Keep the prompt short (~30 lines max).
|
||||
assert.ok(prompt.split("\n").length <= 60, "instructions must stay bounded");
|
||||
});
|
||||
@@ -63,11 +67,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.0"));
|
||||
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.1"));
|
||||
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.0");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.1");
|
||||
});
|
||||
|
||||
test("step budgets are bounded per action with a hard ceiling", () => {
|
||||
|
||||
@@ -91,9 +91,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.0");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.1");
|
||||
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
|
||||
assert.match(skill, /^version: 10\.0\.0$/m);
|
||||
assert.match(skill, /^version: 10\.0\.1$/m);
|
||||
for (const reference of references) {
|
||||
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
|
||||
assert.ok(content.length > 0, `${reference} must be non-empty`);
|
||||
@@ -117,7 +117,7 @@ test("evidence model exposes the full kind/domain/precision/status sets", () =>
|
||||
assert.equal(isEvidenceKind("career"), false);
|
||||
assert.equal(isEvidenceDomain("career"), true);
|
||||
assert.equal(isEvidenceDomain("nope"), false);
|
||||
for (const precision of ["year", "month", "day", "range", "unknown"]) {
|
||||
for (const precision of ["year", "month", "quarter", "day", "range", "unknown"]) {
|
||||
assert.equal(isDatePrecision(precision), true);
|
||||
}
|
||||
assert.equal(isDatePrecision("exact_minute"), false);
|
||||
@@ -146,8 +146,9 @@ test("only the server confirmation path may produce confirmed evidence", () => {
|
||||
test("quote grounding normalizes whitespace and punctuation", () => {
|
||||
assert.equal(
|
||||
normalizeQuote("2016 年 9 月,我离开家去北京开始工作。"),
|
||||
"2016年9月,我离开家去北京开始工作。".replace(/[\s\u3000,。!?、;:“”‘’()《》·—…]/g, ""),
|
||||
"2016年9月,我离开家去北京开始工作。".replace(/[\s\u3000,。!?、;:“”‘’()《》·—…,!.;:?]/g, ""),
|
||||
);
|
||||
assert.equal(quoteIsGroundedInMessage("2016年6月高考结束", "2016年6月高考结束,"), true);
|
||||
assert.equal(quoteIsGroundedInMessage("2016年9月离开家去北京工作", "离开家去北京"), true);
|
||||
assert.equal(quoteIsGroundedInMessage("我去了上海", "去了北京"), false);
|
||||
assert.equal(quoteIsGroundedInMessage("", "任意"), false);
|
||||
|
||||
@@ -532,7 +532,7 @@ test("v9 evidence lifecycle: quote grounding, idempotency, confirm and revision
|
||||
"1",
|
||||
);
|
||||
|
||||
// Quotes not present in the source turn are rejected.
|
||||
// Quotes not present in the source turn are rejected without aborting the RPC.
|
||||
const ungrounded = await service.rpc("propose_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
@@ -546,7 +546,10 @@ test("v9 evidence lifecycle: quote grounding, idempotency, confirm and revision
|
||||
p_date_precision: "year",
|
||||
p_summary: "2020年去了上海",
|
||||
});
|
||||
assert.match(rpcError(ungrounded.error), /agentic_rectification_quote_not_grounded/);
|
||||
assert.equal(ungrounded.error, null, rpcError(ungrounded.error));
|
||||
assert.equal((ungrounded.data as Record<string, unknown>).outcome, "rejected");
|
||||
assert.equal((ungrounded.data as Record<string, unknown>).error_code, "quote_not_grounded");
|
||||
assert.equal((ungrounded.data as Record<string, unknown>).evidence_id, null);
|
||||
|
||||
// Confirm transitions draft -> confirmed with a timestamp.
|
||||
const confirmed = await service.rpc("confirm_agentic_rectification_evidence", {
|
||||
|
||||
@@ -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.0",
|
||||
skill_version: "10.0.1",
|
||||
};
|
||||
}
|
||||
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.0");
|
||||
assert.equal(response.skillVersion, "10.0.1");
|
||||
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.0");
|
||||
assert.equal(openCall.args.p_skill_version, "10.0.1");
|
||||
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);
|
||||
|
||||
@@ -117,31 +117,41 @@ test("propose-evidence binds the server-owned current turn and rejects model-pro
|
||||
assert.equal(withRange.success, false);
|
||||
});
|
||||
|
||||
test("quote must be grounded in the source turn's own message", async () => {
|
||||
test("quote mismatch returns a structured reject and completes the receipt", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||||
propose_agentic_rectification_evidence: () => {
|
||||
throw new Error("agentic_rectification_quote_not_grounded");
|
||||
},
|
||||
propose_agentic_rectification_evidence: () => ({
|
||||
evidence_id: null,
|
||||
idempotent: false,
|
||||
outcome: "rejected",
|
||||
error_code: "quote_not_grounded",
|
||||
status: "rejected",
|
||||
}),
|
||||
});
|
||||
const { tools } = toolContext({ accounting });
|
||||
await assert.rejects(
|
||||
(tools["rectification-propose-evidence"] as unknown as {
|
||||
execute(input: unknown): Promise<unknown>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
quote: "这段话根本不在用户消息里",
|
||||
proposedKind: "career_entry",
|
||||
subject: "self",
|
||||
domain: "career",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2016-09",
|
||||
summary: "无法定位原文",
|
||||
}),
|
||||
(error: unknown) => error instanceof RectificationToolServiceError
|
||||
&& error.message.includes("quote_not_grounded"),
|
||||
);
|
||||
const result = await (tools["rectification-propose-evidence"] as unknown as {
|
||||
execute(input: unknown): Promise<{
|
||||
outcome: string;
|
||||
error_code: string | null;
|
||||
evidence_id: string | null;
|
||||
}>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
quote: "这段话根本不在用户消息里",
|
||||
proposedKind: "career_entry",
|
||||
subject: "self",
|
||||
domain: "career",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2016-09",
|
||||
summary: "无法定位原文",
|
||||
});
|
||||
assert.equal(result.outcome, "rejected");
|
||||
assert.equal(result.error_code, "quote_not_grounded");
|
||||
assert.equal(result.evidence_id, null);
|
||||
const receipts = accounting.calls.filter((call) => call.fn === "insert_agentic_rectification_tool_receipt");
|
||||
assert.ok(receipts.some((call) => call.args.p_status === "completed"));
|
||||
assert.equal(receipts.some((call) => call.args.p_status === "failed"), false);
|
||||
});
|
||||
|
||||
test("year-only evidence keeps year precision and normalizes to a year start", async () => {
|
||||
|
||||
@@ -375,6 +375,7 @@ test("read-case exposes bounded safe context for multi-turn follow-ups without r
|
||||
date_precision: "month",
|
||||
occurred_from: "2016-09-01",
|
||||
occurred_to: null,
|
||||
display_date_label: "2016-09",
|
||||
},
|
||||
{
|
||||
evidence_id: revisionId,
|
||||
@@ -385,6 +386,7 @@ test("read-case exposes bounded safe context for multi-turn follow-ups without r
|
||||
date_precision: "year",
|
||||
occurred_from: "2020-01-01",
|
||||
occurred_to: null,
|
||||
display_date_label: "2020年",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(projection.conversation_context, {
|
||||
|
||||
@@ -87,8 +87,8 @@ test("checked-in registry verifies all three active packages and returns absolut
|
||||
},
|
||||
{
|
||||
name: "jyotish-birth-time-rectification",
|
||||
version: "10.0.0",
|
||||
sha256: "b66f243d266e12527b6934cc0c5925df654a9e7bcbaabea3bf244b2e1dfaf2e7",
|
||||
version: "10.0.1",
|
||||
sha256: "1fcdbeef6a2ad96f564f01aa9b9186dfdffe0ea8600748069528fd6a02236b33",
|
||||
},
|
||||
{
|
||||
name: "jyotish-personal-report",
|
||||
@@ -122,6 +122,17 @@ test("checked-in registry verifies all three active packages and returns absolut
|
||||
}),
|
||||
active,
|
||||
);
|
||||
const deprecatedRectification = resolveExactSkillPackage(
|
||||
"jyotish-birth-time-rectification",
|
||||
"10.0.0",
|
||||
"b66f243d266e12527b6934cc0c5925df654a9e7bcbaabea3bf244b2e1dfaf2e7",
|
||||
{ projectRoot },
|
||||
);
|
||||
assert.equal(deprecatedRectification.status, "deprecated");
|
||||
assert.equal(
|
||||
deprecatedRectification.sha256,
|
||||
"b66f243d266e12527b6934cc0c5925df654a9e7bcbaabea3bf244b2e1dfaf2e7",
|
||||
);
|
||||
});
|
||||
|
||||
test("exact resolution of an old deprecated package is independent of active switches", (t) => {
|
||||
|
||||
Reference in New Issue
Block a user