fix(rectification): bind evidence to current server turn
This commit is contained in:
@@ -55,7 +55,7 @@ const agenticRectificationInstructions = `你是生时校正 Agent,只服务
|
||||
硬性边界(必须服从):
|
||||
1. 先调用 skill 工具加载 jyotish-birth-time-rectification(本 Case 固定版本),再调用 rectification-read-case 读取服务端 Case/Dossier 后才能行动。
|
||||
2. 事件事实只能来自用户原话:不得虚构事件、日期、候选、分盘数据、评分或出生分钟;计算与持久化只能通过工具。
|
||||
3. 工具 input 只传最小引用(caseId、sourceTurnId、evidenceId、resultId、candidateId、quote、proposedKind、日期精度等)。绝不传 userId、出生资料、候选范围、events 数组、分数或权限开关。
|
||||
3. 工具 input 只传最小引用(caseId、evidenceId、resultId、candidateId、quote、proposedKind、日期精度等)。绝不传 userId、出生资料、候选范围、events 数组、分数或权限开关。
|
||||
4. 日期精度如实保留:用户只说年份就按 year 处理,不得诱导编造月份。
|
||||
5. 三层语义严格分开:candidate=当前候选比较结果;accepted=用户明确采用的排盘时间;confirmed=通过服务器确认门且用户明确同意。accepted 不等于 confirmed。
|
||||
6. “是/对”只能确认当前 pending draft;用户更正事实用 revise(生成 revision,不覆盖历史)。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* V9 rectification tools (Case-ref only).
|
||||
*
|
||||
* The model may only pass minimal references: caseId, sourceTurnId, quote,
|
||||
* proposedKind, evidenceId, resultId, candidateId (plus optional date fields
|
||||
* The model may only pass minimal references: caseId, quote, proposedKind,
|
||||
* evidenceId, resultId, candidateId (plus optional date fields
|
||||
* the model grounded in the user's own words). userId, birth data, candidate
|
||||
* ranges, event arrays, scores and permission booleans never appear in tool
|
||||
* inputs. Every tool authenticates through the Case row, validates ownership
|
||||
@@ -214,13 +214,6 @@ function assertEvidenceRef(input: { evidenceId?: unknown }): string {
|
||||
return input.evidenceId;
|
||||
}
|
||||
|
||||
function assertTurnRef(input: { sourceTurnId?: unknown }): string {
|
||||
if (typeof input.sourceTurnId !== "string" || !uuidPattern.test(input.sourceTurnId)) {
|
||||
throw new RectificationToolServiceError("invalid_source_turn_id");
|
||||
}
|
||||
return input.sourceTurnId;
|
||||
}
|
||||
|
||||
function assertCandidateRef(input: { resultId?: unknown; candidateId?: unknown }): { resultId: string; candidateId: string } {
|
||||
if (
|
||||
typeof input.resultId !== "string" || !uuidPattern.test(input.resultId)
|
||||
@@ -304,7 +297,6 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
"提出一条事件证据草稿。quote 必须能规范化匹配当前用户轮次原文;日期只保留用户说过的精度(只说年份则 year);subject 只能 self/family/other;eventKind 使用受控枚举;summary 只复述用户原话,不得补充月份、原因、主动被动或人物关系。返回 evidenceId(服务器生成)。",
|
||||
inputSchema: z.object({
|
||||
caseId: z.string().uuid(),
|
||||
sourceTurnId: z.string().uuid(),
|
||||
quote: z.string().trim().min(2).max(400),
|
||||
proposedKind: z.string().trim().min(1).max(48),
|
||||
subject: z.enum(["self", "family", "other"]).default("self"),
|
||||
@@ -316,13 +308,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
assertCaseRef(input);
|
||||
const sourceTurnId = assertTurnRef(input);
|
||||
if (!isEvidenceKind(input.proposedKind)) throw new RectificationToolServiceError("invalid_event_kind");
|
||||
if (!isEvidenceDomain(input.domain)) throw new RectificationToolServiceError("invalid_domain");
|
||||
if (!isDatePrecision(input.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision");
|
||||
const inputFingerprint = canonicalToolInputFingerprint("rectification-propose-evidence", {
|
||||
caseId: input.caseId,
|
||||
sourceTurnId,
|
||||
sourceTurnId: turnId,
|
||||
quote: input.quote,
|
||||
proposedKind: input.proposedKind,
|
||||
});
|
||||
@@ -331,7 +322,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
const occurredFrom = input.occurredFrom ? normalizeDatePart(input.occurredFrom) : null;
|
||||
const occurredTo = input.occurredTo ? normalizeDatePart(input.occurredTo) : null;
|
||||
const result = await proposeV9Evidence(accounting, userId, input.caseId, {
|
||||
sourceTurnId,
|
||||
sourceTurnId: turnId,
|
||||
quote: input.quote,
|
||||
subject: input.subject,
|
||||
eventKind: input.proposedKind,
|
||||
@@ -651,13 +642,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
caseId: z.string().uuid(),
|
||||
resultId: z.string().uuid(),
|
||||
candidateId: z.string().regex(timePattern),
|
||||
sourceTurnId: z.string().uuid(),
|
||||
consentQuote: z.string().trim().min(2).max(400),
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
assertCaseRef(input);
|
||||
const { resultId } = assertCandidateRef({ resultId: input.resultId, candidateId: input.candidateId });
|
||||
const sourceTurnId = assertTurnRef(input);
|
||||
const inputFingerprint = canonicalToolInputFingerprint("rectification-confirm-birth-time", {
|
||||
caseId: input.caseId,
|
||||
resultId,
|
||||
@@ -669,7 +658,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
resultId,
|
||||
time: input.candidateId,
|
||||
consentQuote: input.consentQuote,
|
||||
sourceTurnId,
|
||||
sourceTurnId: turnId,
|
||||
});
|
||||
const projection = {
|
||||
saved_time: result.savedTime,
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777";
|
||||
|
||||
function toolContext(overrides: {
|
||||
accounting?: ReturnType<typeof fakeAccounting>;
|
||||
@@ -47,14 +46,13 @@ function toolContext(overrides: {
|
||||
};
|
||||
}
|
||||
|
||||
test("propose-evidence schema rejects model-provided ids, birth data and ranges", async () => {
|
||||
test("propose-evidence binds the server-owned current turn and rejects model-provided ids, birth data and ranges", async () => {
|
||||
const { tools } = toolContext();
|
||||
const schema = (tools as Record<string, { inputSchema?: { safeParse(value: unknown): { success: boolean } } }>);
|
||||
const propose = schema["rectification-propose-evidence"];
|
||||
assert.ok(propose?.inputSchema);
|
||||
const valid = propose.inputSchema!.safeParse({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: SOURCE_TURN_ID,
|
||||
quote: "2016年9月离开家去北京工作",
|
||||
proposedKind: "career_entry",
|
||||
subject: "self",
|
||||
@@ -65,9 +63,21 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges"
|
||||
});
|
||||
assert.equal(valid.success, true);
|
||||
|
||||
const withTurnId = propose.inputSchema!.safeParse({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: "77777777-7777-4777-8777-777777777777",
|
||||
quote: "2016年9月离开家去北京工作",
|
||||
proposedKind: "career_entry",
|
||||
subject: "self",
|
||||
domain: "career",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2016-09",
|
||||
summary: "2016年9月离家去北京工作",
|
||||
});
|
||||
assert.equal(withTurnId.success, false);
|
||||
|
||||
const withModelId = propose.inputSchema!.safeParse({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: SOURCE_TURN_ID,
|
||||
quote: "2016年9月离开家去北京工作",
|
||||
proposedKind: "career_entry",
|
||||
datePrecision: "month",
|
||||
@@ -78,7 +88,6 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges"
|
||||
|
||||
const withBirthData = propose.inputSchema!.safeParse({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: SOURCE_TURN_ID,
|
||||
quote: "2016年9月离开家去北京工作",
|
||||
proposedKind: "career_entry",
|
||||
datePrecision: "month",
|
||||
@@ -89,7 +98,6 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges"
|
||||
|
||||
const withRange = propose.inputSchema!.safeParse({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: SOURCE_TURN_ID,
|
||||
quote: "2016年9月离开家去北京工作",
|
||||
proposedKind: "career_entry",
|
||||
datePrecision: "month",
|
||||
@@ -113,7 +121,6 @@ test("quote must be grounded in the source turn's own message", async () => {
|
||||
execute(input: unknown): Promise<unknown>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: SOURCE_TURN_ID,
|
||||
quote: "这段话根本不在用户消息里",
|
||||
proposedKind: "career_entry",
|
||||
subject: "self",
|
||||
@@ -133,7 +140,6 @@ test("year-only evidence keeps year precision and normalizes to a year start", a
|
||||
execute(input: unknown): Promise<unknown>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: SOURCE_TURN_ID,
|
||||
quote: "2016年离开家去北京开始工作",
|
||||
proposedKind: "career_entry",
|
||||
subject: "self",
|
||||
@@ -144,6 +150,7 @@ test("year-only evidence keeps year precision and normalizes to a year start", a
|
||||
});
|
||||
const proposeCall = accounting.calls.find((call) => call.fn === "propose_agentic_rectification_evidence");
|
||||
assert.ok(proposeCall);
|
||||
assert.equal(proposeCall.args.p_source_turn_id, TURN_ID);
|
||||
assert.equal(proposeCall.args.p_date_precision, "year");
|
||||
assert.equal(proposeCall.args.p_occurred_from, "2016-01-01");
|
||||
// The model cannot supply an evidence id; the server generates it.
|
||||
@@ -227,7 +234,6 @@ test("propose is idempotent: replay returns the existing draft without a second
|
||||
execute(input: unknown): Promise<{ idempotent: boolean }>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: SOURCE_TURN_ID,
|
||||
quote: "2016年9月离开家去北京工作",
|
||||
proposedKind: "career_entry",
|
||||
subject: "self",
|
||||
@@ -262,7 +268,6 @@ test("terminal cases reject evidence writes", async () => {
|
||||
execute(input: unknown): Promise<unknown>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: SOURCE_TURN_ID,
|
||||
quote: "2016年9月离开家去北京工作",
|
||||
proposedKind: "career_entry",
|
||||
subject: "self",
|
||||
|
||||
@@ -94,6 +94,43 @@ test("confirmed requires the engine gate plus explicit grounded consent", async
|
||||
);
|
||||
});
|
||||
|
||||
test("confirm-birth-time binds consent to the server-owned current turn", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
confirm_agentic_rectification_birth_time: () => ({
|
||||
success: true,
|
||||
saved_time: "05:02",
|
||||
status: "confirmed",
|
||||
result_id: RESULT_ID,
|
||||
case_status: "confirmed",
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const tools = createRectificationV9Tools({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
turnId: TURN_ID,
|
||||
accounting: accounting.client as never,
|
||||
});
|
||||
const confirm = tools["rectification-confirm-birth-time"] as unknown as {
|
||||
inputSchema: { safeParse(value: unknown): { success: boolean } };
|
||||
execute(input: unknown): Promise<unknown>;
|
||||
};
|
||||
const input = {
|
||||
caseId: CASE_ID,
|
||||
resultId: RESULT_ID,
|
||||
candidateId: "05:02",
|
||||
consentQuote: "就用05:02",
|
||||
};
|
||||
assert.equal(confirm.inputSchema.safeParse(input).success, true);
|
||||
assert.equal(confirm.inputSchema.safeParse({ ...input, sourceTurnId: "77777777-7777-4777-8777-777777777777" }).success, false);
|
||||
|
||||
await confirm.execute(input);
|
||||
const call = accounting.calls.find((item) => item.fn === "confirm_agentic_rectification_birth_time");
|
||||
assert.ok(call);
|
||||
assert.equal(call.args.p_source_turn_id, TURN_ID);
|
||||
});
|
||||
|
||||
test("candidate ownership is case-scoped: the RPC always receives the case id", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
accept_agentic_rectification_candidate_for_case: () => ({
|
||||
@@ -152,7 +189,6 @@ test("terminal cases reject evidence writes and candidate actions", async () =>
|
||||
await assert.rejects(
|
||||
(tools["rectification-propose-evidence"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({
|
||||
caseId: CASE_ID,
|
||||
sourceTurnId: TURN_ID,
|
||||
quote: "2016年9月离开家去北京工作",
|
||||
proposedKind: "career_entry",
|
||||
subject: "self",
|
||||
|
||||
Reference in New Issue
Block a user