fix(rectification): harden scoring and replay boundaries

This commit is contained in:
Jesse_Chen
2026-07-21 03:16:46 +08:00
parent 1112d8460b
commit 1684278304
6 changed files with 425 additions and 29 deletions
@@ -282,22 +282,19 @@ function currentRange(input: ConversationalRectificationPacketBuildInput) {
}
function scoreableLifeEvents(evidence: readonly LifeEventEvidence[]): LifeEvent[] {
return evidence.slice(-10).flatMap((item) => {
return evidence.flatMap((item) => {
if (item.scoreable !== true || !item.dateValue
|| !(["day", "month", "year"] as const).includes(item.datePrecision as "day" | "month" | "year")) {
return [];
}
const domain = item.domain === "family" ? "relationship"
: item.domain === "other" ? null
: item.domain;
if (!domain) return [];
if (item.domain === "family" || item.domain === "other") return [];
return [{
id: item.id,
domain,
domain: item.domain,
precision: item.datePrecision as "day" | "month" | "year",
date: item.dateValue,
} as LifeEvent];
});
}).slice(-6);
}
function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] {
@@ -336,7 +333,6 @@ function mergeQuestionnaireScans(
): RectificationQuestionnaire {
const first = scans[0];
if (!first) throw new ConversationalRectificationError("service_unavailable");
if (scans.length === 1) return first;
const byTime = new Map<string, {
sample: RectificationQuestionnaire["samples"][number];
@@ -413,7 +409,7 @@ export async function buildProductionConversationalRectificationPacket(
}
const baseRange = currentRange(input);
const events = scoreableLifeEvents(input.evidence as readonly LifeEventEvidence[]);
const eventScore: CandidateResult | null = events.length > 0
const eventScore: CandidateResult | null = events.length >= 3
? await engine.scoreEvents({
birthDate: input.declaredBirthInput.birthDate,
startTime: baseRange.startTime,
@@ -590,10 +590,10 @@ begin
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'save_turn'
or v_receipt.expected_turn_version is distinct from p_expected_version
or (v_receipt.request ? 'commandFingerprint'
and v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint)
or v_receipt.request_fingerprint is distinct from v_fingerprint then
or (case when v_receipt.request ? 'commandFingerprint' then
v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint
else v_receipt.request_fingerprint is distinct from v_fingerprint end) then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return v_receipt.response;
@@ -756,10 +756,10 @@ begin
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'pause'
or v_receipt.expected_turn_version is distinct from p_expected_version
or (v_receipt.request ? 'commandFingerprint'
and v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint)
or v_receipt.request_fingerprint is distinct from v_fingerprint then
or (case when v_receipt.request ? 'commandFingerprint' then
v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint
else v_receipt.request_fingerprint is distinct from v_fingerprint end) then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return v_receipt.response;
@@ -890,10 +890,10 @@ begin
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'abandon'
or v_receipt.expected_turn_version is distinct from p_expected_version
or (v_receipt.request ? 'commandFingerprint'
and v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint)
or v_receipt.request_fingerprint is distinct from v_fingerprint then
or (case when v_receipt.request ? 'commandFingerprint' then
v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint
else v_receipt.request_fingerprint is distinct from v_fingerprint end) then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return v_receipt.response;
@@ -1037,10 +1037,10 @@ begin
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'confirm'
or v_receipt.expected_turn_version is distinct from p_expected_version
or (v_receipt.request ? 'commandFingerprint'
and v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint)
or v_receipt.request_fingerprint is distinct from v_fingerprint then
or (case when v_receipt.request ? 'commandFingerprint' then
v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint
else v_receipt.request_fingerprint is distinct from v_fingerprint end) then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return v_receipt.response;
@@ -22,6 +22,8 @@ const confirmActionId = "00000000-0000-4000-8000-000000000706";
const priorCaseId = "00000000-0000-4000-8000-000000000707";
const resultId = "00000000-0000-4000-8000-000000000708";
const laterActionId = "00000000-0000-4000-8000-000000000710";
const secondAnswerActionId = "00000000-0000-4000-8000-000000000711";
const thirdAnswerActionId = "00000000-0000-4000-8000-000000000712";
const declaredBirthInput = {
source: "approximate" as const,
@@ -92,10 +94,12 @@ function packet(ready = false): RectificationTechnicalPacket {
};
}
function validGenerator(events: string[]) {
function validGenerator(events: string[], varyNarrative = false) {
let generation = 0;
return {
modelId: "synthetic-rectification-model",
async generate(prompt: string) {
generation += 1;
events.push("narrative");
const request = JSON.parse(prompt) as {
phase: "first" | "intermediate" | "final";
@@ -113,6 +117,7 @@ function validGenerator(events: string[]) {
"D1Cancer)保持稳定。",
"D9Aries / Leo)呈现分钟敏感差异,关系事件可区分 D9。",
"D10Taurus / Libra)呈现分钟敏感差异,事业事件可区分 D10。",
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
request.phase === "final" ? "当前证据已形成候选总结。" : "请提供已经发生的真实事件,写明哪一年、哪一月以及发生了什么。",
"这仅是候选,必须由你确认后才会替换当前排盘时间。",
].join("");
@@ -145,6 +150,8 @@ function harness(options: {
readonly packetFailure?: Error;
readonly completeFailures?: number;
readonly releaseFailure?: boolean;
readonly readyAfterEvidenceCount?: number;
readonly varyNarrative?: boolean;
} = {}) {
const events: string[] = [];
const mutations: string[] = [];
@@ -204,7 +211,13 @@ function harness(options: {
) {
const prior = receipts.get(actionId);
if (prior) {
assert.deepEqual(input, prior.input);
if (prior.actionKind && prior.commandFingerprint) {
assert.equal(actionKind, prior.actionKind);
assert.equal(input.expectedVersion, prior.expectedVersion);
assert.equal(input.commandFingerprint, prior.commandFingerprint);
} else {
assert.deepEqual(input, prior.input);
}
return prior.response;
}
const response = make();
@@ -344,11 +357,11 @@ function harness(options: {
packetBuilds += 1;
events.push(input.evidence.length > 0 ? "score-packet" : "packet");
if (options.packetFailure) throw options.packetFailure;
return input.evidence.length > 0
return input.evidence.length >= (options.readyAfterEvidenceCount ?? 1)
? { packet: packet(true), resultId }
: { packet: packet(false), resultId: null };
},
narrativeGenerator: validGenerator(events),
narrativeGenerator: validGenerator(events, options.varyNarrative),
asOfDate: () => "2026-07-21",
};
@@ -489,6 +502,57 @@ test("generic date uncertainty does not suppress clear historical evidence", asy
.some((item) => item.eventSummary.includes("毕业") && item.scoreable === true));
});
test("one and two supported events save and narrate before the third accumulated event ranks", async () => {
const value = harness({ readyAfterEvidenceCount: 3 });
await start(value, null);
const answers = [
[answerActionId, "2019年7月毕业"],
[secondAnswerActionId, "2020年8月搬家"],
[thirdAnswerActionId, "2021年9月换工作"],
] as const;
for (const [index, [receivedActionId, answer]] of answers.entries()) {
const turn = await value.service.answer(userId, {
type: "answer",
caseId: startActionId,
actionId: receivedActionId,
turnVersion: index,
answer,
});
const stored = value.cases.get(startActionId)?.row;
assert.equal(stored?.eventEvidence.length, index + 1);
assert.equal(turn.evidenceRecap.length, index + 1);
assert.equal(turn.status, index < 2 ? "active" : "confirming");
}
assert.equal(value.counts().packetBuilds, 4);
assert.equal(value.events.filter((event) => event === "narrative").length, 4);
});
test("family evidence remains stored and public without changing its domain", async () => {
const value = harness({ readyAfterEvidenceCount: 3 });
await start(value, null);
const turn = await value.service.answer(userId, {
type: "answer",
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2020年7月父亲生病",
});
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
assert.equal(stored.length, 1);
assert.equal(stored[0]?.domain, "family");
assert.match(stored[0]?.eventSummary ?? "", /父亲/);
assert.deepEqual(turn.evidenceRecap, [{
id: stored[0]?.id,
summary: stored[0]?.eventSummary,
dateLabel: "2020-07",
}]);
assert.equal(turn.status, "active");
});
test("vague, future, and unmatched answers stay conversational and never score", async () => {
for (const [answer, domain] of [
["后来换了工作", undefined],
@@ -556,6 +620,28 @@ test("a lost-response retry replays the saved answer without rescoring or regene
assert.deepEqual(value.events, before);
});
test("overlapping identical answers converge on the first receipt despite different derived narratives", async () => {
const value = harness({ varyNarrative: true });
await start(value, null);
const command = {
type: "answer" as const,
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2021年7月毕业,并在2022年3月去外地工作",
};
const [first, second] = await Promise.all([
value.service.answer(userId, command),
value.service.answer(userId, command),
]);
assert.deepEqual(second, first);
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, 2);
assert.equal(value.events.filter((event) => event === "narrative").length, 3);
assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 2);
});
test("receipt-first delayed retries replay the original answer, pause, abandon, and confirm after later turns", async () => {
const scenarios = [
{
@@ -8,6 +8,8 @@ import {
} from "../src/app/api/birth-time-conversation/route.ts";
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
import type { BirthTimeJourneyEngine } from "../src/lib/birth-time-journey-service.ts";
import type { LifeEventEvidence } from "../src/lib/conversational-rectification/persistence-contracts.ts";
import type { LifeEvent } from "../src/lib/birth-time-evidence.ts";
const userId = "00000000-0000-4000-8000-000000000711";
const actionId = "00000000-0000-4000-8000-000000000712";
@@ -65,6 +67,105 @@ function service(overrides: Partial<BirthTimeConversationRouteService> = {}): Bi
};
}
function syntheticEvidence(
index: number,
domain: LifeEventEvidence["domain"],
): LifeEventEvidence {
return {
id: `00000000-0000-4000-8000-${String(800 + index).padStart(12, "0")}`,
rawText: `synthetic event ${index}`,
domain,
eventSummary: `synthetic summary ${index}`,
dateValue: `${2010 + index}-07`,
datePrecision: "month",
extractionStatus: "clear",
scoreable: true,
};
}
function packetEngine(options: {
readonly scoreCalls?: LifeEvent[][];
readonly scanTimes?: readonly string[];
} = {}): BirthTimeJourneyEngine {
const minute = (value: string) => {
const [hour = 0, part = 0] = value.slice(-5).split(":").map(Number);
return hour * 60 + part;
};
const clock = (value: number) => {
const normalized = ((value % 1_440) + 1_440) % 1_440;
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
};
return {
async scan(input) {
const center = minute(input.birthTime);
const times = options.scanTimes ?? [
clock(center - input.uncertaintyMinutes),
clock(center),
clock(center + input.uncertaintyMinutes),
];
return {
questionnaire: {
questions: [],
samples: times.map((time, index) => ({
ascendantSign: "Cancer",
d4Sign: index % 2 === 0 ? "Aries" : "Taurus",
d9Sign: index % 2 === 0 ? "Gemini" : "Virgo",
d10Sign: index % 2 === 0 ? "Leo" : "Libra",
d24Sign: "Sagittarius",
d30Sign: "Pisces",
})),
raw: {
candidate_scan: {
samples: times.map((time) => ({ time: `1990-01-01 ${time}` })),
},
},
},
};
},
async score() { throw new Error("unexpected questionnaire score"); },
async scoreEvents(input) {
options.scoreCalls?.push([...input.events]);
return {
resultId: "00000000-0000-4000-8000-000000000899",
confidence: "low",
canApply: false,
winningSegment: null,
eventCount: input.events.length,
domainCount: new Set(input.events.map((event) => event.domain)).size,
topScore: 1,
secondScore: 1,
marginPercent: 0,
reasons: ["synthetic low result"],
evidence: [],
algorithmVersion: "synthetic-event-score-v1",
};
},
async buildDifferencePacket(input) {
return {
packet: {
caseId: input.caseId,
scoringVersion: "birth-time-choice-scoring-v2",
currentRange: { startTime: input.startTime, endTime: input.endTime },
opportunities: [],
askedQuestionFingerprints: [],
candidatePartitionFingerprints: [],
recentRangeHistory: [],
},
candidateModel: { version: "birth-time-choice-scoring-v2" },
scoringPartitions: {},
};
},
async scoreChoices() { throw new Error("unexpected choice score"); },
};
}
const packetBirthplace = {
cityCode: "TPE-CITY",
latitude: 25.0268,
longitude: 121.5434,
timezoneOffset: 8,
};
test("authentication happens before body parsing and unauthenticated requests create no privileged service", async () => {
const events: string[] = [];
let creates = 0;
@@ -326,3 +427,129 @@ test("production unknown-time adapter covers the declared full day with bounded
assert.equal(new Set(sampleTimes).size, sampleTimes.length);
assert.deepEqual(sampleTimes, [...sampleTimes].sort((left, right) => minute(left) - minute(right)));
});
test("production packet waits for three supported events and then scores the accumulated evidence", async () => {
const scoreCalls: LifeEvent[][] = [];
const engine = packetEngine({ scoreCalls });
const evidence = [
syntheticEvidence(1, "education"),
syntheticEvidence(2, "relocation"),
syntheticEvidence(3, "career"),
];
for (let count = 1; count <= 3; count += 1) {
const built = await buildProductionConversationalRectificationPacket(engine, {
userId,
caseId,
asOfDate: "2026-07-21",
declaredBirthInput: {
source: "approximate",
birthDate: "1990-01-01",
reportedTime: "05:20",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthTimeClue: null,
birthplace: packetBirthplace,
},
privateCandidate: null,
evidence: evidence.slice(0, count),
});
assert.equal(built.packet.candidate.status, "pending_validation");
assert.equal(
built.resultId,
count < 3 ? null : "00000000-0000-4000-8000-000000000899",
);
}
assert.equal(scoreCalls.length, 1);
assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id));
});
test("production packet deterministically sends only the latest six supported events", async () => {
const scoreCalls: LifeEvent[][] = [];
const engine = packetEngine({ scoreCalls });
const domains = ["education", "relocation", "career", "relationship"] as const;
const evidence = Array.from({ length: 8 }, (_, index) =>
syntheticEvidence(index + 1, domains[index % domains.length] ?? "career"));
await buildProductionConversationalRectificationPacket(engine, {
userId,
caseId,
asOfDate: "2026-07-21",
declaredBirthInput: {
source: "approximate",
birthDate: "1990-01-01",
reportedTime: "05:20",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthTimeClue: null,
birthplace: packetBirthplace,
},
privateCandidate: null,
evidence,
});
assert.equal(scoreCalls.length, 1);
assert.deepEqual(
scoreCalls[0]?.map((event) => event.id),
evidence.slice(-6).map((item) => item.id),
);
});
test("family evidence stays out of relationship scoring when three real scorer domains exist", async () => {
const scoreCalls: LifeEvent[][] = [];
const engine = packetEngine({ scoreCalls });
const family = syntheticEvidence(1, "family");
const supported = [
syntheticEvidence(2, "education"),
syntheticEvidence(3, "relocation"),
syntheticEvidence(4, "career"),
];
await buildProductionConversationalRectificationPacket(engine, {
userId,
caseId,
asOfDate: "2026-07-21",
declaredBirthInput: {
source: "approximate",
birthDate: "1990-01-01",
reportedTime: "05:20",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthTimeClue: null,
birthplace: packetBirthplace,
},
privateCandidate: null,
evidence: [family, ...supported],
});
assert.equal(scoreCalls.length, 1);
assert.deepEqual(scoreCalls[0]?.map((event) => event.domain), [
"education",
"relocation",
"career",
]);
assert.equal(scoreCalls[0]?.some((event) => event.id === family.id), false);
assert.equal(scoreCalls[0]?.some((event) => event.domain === "relationship"), false);
});
test("a single period-only scan filters duplicate and out-of-range samples from the exact :59 range", async () => {
const engine = packetEngine({ scanTimes: ["08:00", "10:00", "10:00", "12:00"] });
const built = await buildProductionConversationalRectificationPacket(engine, {
userId,
caseId,
asOfDate: "2026-07-21",
declaredBirthInput: {
source: "period_only",
birthDate: "1990-01-01",
reportedPeriod: "morning",
birthTimeClue: null,
birthplace: packetBirthplace,
},
privateCandidate: null,
evidence: [],
});
assert.deepEqual(built.packet.candidate.range, { startTime: "08:00", endTime: "11:59" });
assert.deepEqual(built.packet.sensitivityScope.sampleTimes, ["08:00", "10:00"]);
});
@@ -296,6 +296,12 @@ def test_historical_action_replay_is_owner_scoped_exact_bounded_and_read_only()
assert "p_command_fingerprint text" in body
assert "p_command_fingerprint !~ '^[0-9a-f]{64}$'" in body
assert "'commandfingerprint', p_command_fingerprint" in body
assert (
"case when v_receipt.request ? 'commandfingerprint' then "
"v_receipt.request ->> 'commandfingerprint' is distinct from "
"p_command_fingerprint else v_receipt.request_fingerprint is "
"distinct from v_fingerprint end"
) in body
def test_start_identity_and_account_concurrency_are_server_guarded() -> None:
@@ -563,6 +563,87 @@ def test_historical_receipt_replays_exact_public_response_after_later_turns(
assert privileges == {"anon": False, "authenticated": False, "serviceRole": True}
def test_overlapping_mutation_uses_command_identity_while_legacy_uses_full_request(
pg14_database: PgDatabase,
) -> None:
user_id = "00000000-0000-4000-8000-000000001041"
case_id = "00000000-0000-4000-8000-000000001042"
action_id = "00000000-0000-4000-8000-000000001043"
fingerprint = "c" * 64
_create_user(pg14_database, user_id)
_reserve(pg14_database, user_id, case_id)
_create_case(pg14_database, user_id, case_id, _valid_declared_birth_input())
_complete(pg14_database, user_id, case_id)
original = json.loads(pg14_database.sql(_save_statement(
user_id,
case_id,
0,
action_id,
[],
command_fingerprint=fingerprint,
)))
alternate_turn = {
**_valid_turn(case_id),
"turnVersion": 1,
"narrative": "A different valid narrative derived by an overlapping request.",
}
replayed = json.loads(pg14_database.sql(_save_statement(
user_id,
case_id,
0,
action_id,
[],
turn=alternate_turn,
validation_receipt={"modelId": "alternate-model", "schemaValidated": True},
command_fingerprint=fingerprint,
)))
assert replayed == original
assert pg14_database.rejects(_save_statement(
user_id,
case_id,
0,
action_id,
[],
turn=alternate_turn,
command_fingerprint="d" * 64,
))
legacy_user_id = "00000000-0000-4000-8000-000000001051"
legacy_case_id = "00000000-0000-4000-8000-000000001052"
legacy_action_id = "00000000-0000-4000-8000-000000001053"
_create_user(pg14_database, legacy_user_id)
_reserve(pg14_database, legacy_user_id, legacy_case_id)
_create_case(
pg14_database,
legacy_user_id,
legacy_case_id,
_valid_declared_birth_input(),
)
_complete(pg14_database, legacy_user_id, legacy_case_id)
legacy_statement = _save_statement(
legacy_user_id,
legacy_case_id,
0,
legacy_action_id,
[],
)
legacy_original = json.loads(pg14_database.sql(legacy_statement))
assert json.loads(pg14_database.sql(legacy_statement)) == legacy_original
assert pg14_database.rejects(_save_statement(
legacy_user_id,
legacy_case_id,
0,
legacy_action_id,
[],
turn={
**_valid_turn(legacy_case_id),
"turnVersion": 1,
"narrative": "A different legacy-derived narrative must remain a conflict.",
},
))
def test_database_rejects_oversize_or_unknown_durable_json(pg14_database: PgDatabase) -> None:
user_id = "00000000-0000-4000-8000-000000000951"
action_id = "00000000-0000-4000-8000-000000000952"