fix(web): count only training events for discrimination and split user-stop from validated range

Three collected events with a reserved holdout were stalling because the discriminator door counted holdout. Public selection_allowed still had snapshot fallbacks, and health only proved the image SHA.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-26 19:36:04 +08:00
parent a0ce55f066
commit b1173f7245
37 changed files with 1362 additions and 234 deletions
+16
View File
@@ -5771,6 +5771,22 @@
- 复发自:BUG-367(点选身份与滚动);BUG-366(覆盖完成被当成收敛);BUG-393(出卡即完成)
- 修复版本:待发布
## BUG-396 | Holdout 门槛、用户停止收口与决策入口未钉死
- 状态:resolved
- 首次发现:2026-08-26
- 最近更新:2026-08-26
- 影响面:training 门槛、`decideRectification`、session_outcome、`/api/health` 数据库版本、Skill 10.0.11 示例
- 用户现象:3 条事件预留 1 条 holdout 后可能既不能出 Probe 又以为事件够了;用户主动停止被写成已验证完成;健康检查只证明镜像 SHA,不证明迁移已执行。
- 触发条件:满 2 条即预留 holdout,同时区分门按全部已收事件计 3 条;用户停止与 holdout 通过共用 `completed_with_range``/api/health` 只有 `deployment.gitCommit`
- 根因:区分门槛把 holdout 算进可评分事件。用户停止与验证通过共用收口状态。公开决策字段仍可能读 snapshot 而不是 reducer。
- 修复:区分和冲突探针只计 **training** 事件:3 training / 2 training 领域才开门。2 条继续收集;3 条(2 training + 1 holdout)继续收集;4 条(3 training + 1 holdout)才进入区分。用户停止为 `provisional_range_user_stopped`holdout 通过为 `validated_range`,不得把停止写成已完成验证。GET/点选/刷新/工具投影的 `selection_allowed` 等从 `decideRectification` 派生。`/api/health` 增加 `database.latestMigration``rectificationContractVersion=v3`。Skill 10.0.11 仍不改写;后续新建 10.0.12 时用 `{timeWindow}/{semanticTarget}/{domain}` 抽象示例。
- 验证:`tests/test_candidate_discriminator_contract.py``frontend/tests/rectification-decide-next-action.test.ts``frontend/tests/rectification-decision-authority.test.ts``frontend/tests/rectification-hidden-e2e.test.ts``frontend/tests/health-deployment.test.ts`。GET `/api/rectification/cases/[caseId]``latest_result.selection_allowed``interview``decideFromDossier` 覆盖,不得回退 snapshot。
- 防复发:不得用含 holdout 的总事件数开区分门。不得把用户停止标成 `validated_range` 或“最终校正结果”。不得在 interview / answer_choice / turn_decision / Case GET 里独立判断 `selection_allowed`。不得只凭 `gitCommit` 声称迁移已执行。不得原地改 Skill `10.0.11`。后续 Skill `10.0.12` 只用 `{timeWindow}/{semanticTarget}/{domain}`
- 相关记录:BUG-394、BUG-395、BUG-393、BUG-366
- 复发自:BUG-394holdout 预留后未把门槛改成 training);BUG-395staging publish 被 holdout 类型和重复 re-export 挡住)
- 修复版本:待发布
## BUG-379 | 生时纠正已记入学后仍编造高考年并再问入学
- 状态:resolved
@@ -215,3 +215,15 @@ The inventory above is the pre-change baseline. Runtime now:
- Emits `CandidateContrastOpportunity`; distinguish probes fail closed unless `candidateIds>=2`, `expectedOutcomes>=2`, `informationGain>0`
- Persists inference-round entropy, eliminated IDs, and `low_information`
- Staging quick gate runs `tests/test_candidate_discriminator_contract.py` (`CORE_PYTEST_TARGETS`)
## 8. Follow-up: Skill 10.0.12 (do not rewrite 10.0.11)
Hashed Skill `10.0.11` still contains concrete examples (`2015 年搬家`, `高考发挥失常`, `2016 年前后更像哪一件`). Leave that version immutable.
A later `10.0.12` should replace those with abstract slots only:
- `{timeWindow}`
- `{semanticTarget}`
- `{domain}`
The agent may only rewrite the engine Opportunity. It must not invent a year, domain, or candidate mapping. New cases bind the new version; old cases keep `10.0.11` until explicit adoption.
+65
View File
@@ -1,6 +1,14 @@
import { NextResponse } from "next/server";
import { getTruthSourceRuntimeIdentity } from "@/lib/truth-source-runtime-identity";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import {
databaseHealthFromFilenames,
RECTIFICATION_CONTRACT_VERSION,
REQUIRED_RECTIFICATION_MIGRATIONS,
type DatabaseHealth,
} from "@/lib/health-database-contract";
export const runtime = "nodejs";
type Check = {
status: "ok" | "degraded" | "blocked";
@@ -42,6 +50,56 @@ async function jyotishApiCheck(): Promise<Check> {
}
}
async function rectificationMigrationCheck(): Promise<{
check: Check;
database: DatabaseHealth;
}> {
const empty: DatabaseHealth = {
latestMigration: null,
rectificationContractVersion: RECTIFICATION_CONTRACT_VERSION,
requiredMigrationsPresent: false,
missingMigrations: [...REQUIRED_RECTIFICATION_MIGRATIONS],
};
if (process.env.AUTH_PROVIDER?.trim() !== "self-hosted") {
return { check: { status: "ok", message: "skipped_non_local" }, database: empty };
}
const url = process.env.APP_DATABASE_URL?.trim();
if (!url) {
return { check: { status: "blocked", message: "missing:APP_DATABASE_URL" }, database: empty };
}
const started = Date.now();
const { Client } = await import("pg");
const client = new Client({ connectionString: url, connectionTimeoutMillis: 2000 });
try {
await client.connect();
const result = await client.query<{ filename: string }>(
"select filename from migration.schema_migrations order by filename",
);
const database = databaseHealthFromFilenames(result.rows.map((row) => row.filename));
return {
check: database.requiredMigrationsPresent
? { status: "ok", latencyMs: Date.now() - started }
: {
status: "blocked",
message: `missing_migrations:${database.missingMigrations.join(",")}`,
latencyMs: Date.now() - started,
},
database,
};
} catch (error) {
return {
check: {
status: "blocked",
message: error instanceof Error ? error.name : "database_migration_query_failed",
latencyMs: Date.now() - started,
},
database: empty,
};
} finally {
await client.end().catch(() => undefined);
}
}
async function modelCatalogCheck(): Promise<Check> {
const catalog = await loadLanguageModelCatalog();
const defaults = catalog.models.filter((model) => model.isDefault && model.id === catalog.defaultModelId);
@@ -74,12 +132,14 @@ export async function GET() {
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
};
const migrations = await rectificationMigrationCheck();
const checks = {
web: { status: "ok" } satisfies Check,
...databaseChecks,
modelProviderEncryption: envCheck(["MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY"]),
modelCatalog: await modelCatalogCheck(),
jyotishApi: await jyotishApiCheck(),
rectificationMigrations: migrations.check,
researchTruthSource: {
status: truthSourceIdentity.status,
message: truthSourceIdentity.mountStatus === "mounted" ? undefined : truthSourceIdentity.mountStatus,
@@ -93,6 +153,11 @@ export async function GET() {
deployment: {
gitCommit,
},
database: {
latestMigration: migrations.database.latestMigration,
rectificationContractVersion: migrations.database.rectificationContractVersion,
requiredMigrationsPresent: migrations.database.requiredMigrationsPresent,
},
truthSource: truthSourceIdentity,
checks,
},
@@ -3,6 +3,7 @@ import { z } from "zod";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
evidenceLedgerFingerprint,
loadV9CaseDossier,
loadV9CaseSkillIdentityStatus,
loadV9TurnReceipt,
@@ -10,8 +11,9 @@ import {
RectificationToolServiceError,
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
import { choiceCardFromCaseDossier } from "@/lib/rectification-agentic/v9/interview-state";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } from "@/lib/rectification-agentic/v9/interview-state";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision";
export const runtime = "nodejs";
@@ -83,6 +85,9 @@ function dossierResponse(
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
) {
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
return {
case: {
case_id: dossier.case.caseId,
@@ -109,7 +114,8 @@ function dossierResponse(
receipt: turnReceipt(turn.id, receipts),
})),
evidence: dossier.evidence,
latest_result: dossier.latestResult,
latest_result: dossier.latestResult ? overlayPublicDecision(dossier.latestResult, decision) : null,
interview: publicDecisionFields(decision),
choice_card: choiceCardFromCaseDossier(dossier),
};
}
@@ -0,0 +1,31 @@
/**
* Health contract for rectification schema. gitCommit proves the web image;
* latestMigration proves the business database actually applied the SQL.
*/
export const RECTIFICATION_CONTRACT_VERSION = "v3";
export const REQUIRED_RECTIFICATION_MIGRATIONS = [
"20260826010000_rectification_inference_round_audit.sql",
"20260826020000_rectification_choice_focus_identity.sql",
] as const;
export type DatabaseHealth = Readonly<{
latestMigration: string | null;
rectificationContractVersion: typeof RECTIFICATION_CONTRACT_VERSION;
requiredMigrationsPresent: boolean;
missingMigrations: readonly string[];
}>;
export function databaseHealthFromFilenames(
filenames: readonly string[],
): DatabaseHealth {
const present = new Set(filenames);
const missing = REQUIRED_RECTIFICATION_MIGRATIONS.filter((name) => !present.has(name));
const latest = [...filenames].sort().at(-1) ?? null;
return {
latestMigration: latest,
rectificationContractVersion: RECTIFICATION_CONTRACT_VERSION,
requiredMigrationsPresent: missing.length === 0,
missingMigrations: missing,
};
}
@@ -16,11 +16,12 @@ export type {
export type DecideNextActionInput = Readonly<{
methodCoverageAll: boolean;
proposeAllowed: boolean;
proposeAllowed?: boolean;
confirmationAllowed?: boolean;
userStopped?: boolean;
selectionAllowed?: boolean;
snapshotCurrent?: boolean;
trainingGateOpen?: boolean;
candidateScores: readonly CandidateScoreRow[];
discriminatorProbe?: CandidateDiscriminatorProbe | null;
holdoutValidation?: HoldoutValidationStatus;
@@ -44,6 +45,7 @@ export function decideNextAction(input: DecideNextActionInput): RectificationNex
confirmationAllowed: input.confirmationAllowed,
userStopped: input.userStopped,
snapshotCurrent: input.snapshotCurrent,
trainingGateOpen: input.trainingGateOpen,
candidateScores: input.candidateScores,
discriminatorProbe: input.discriminatorProbe,
holdoutValidation: input.holdoutValidation,
@@ -11,7 +11,11 @@ export * from "./probes-from-engine.ts";
export * from "./decision-fingerprint.ts";
export * from "./compose-receipt.ts";
export * from "./candidate-separation.ts";
export * from "./decide-next-action.ts";
export {
decideNextAction,
type DecideNextActionInput,
type RectificationNextAction,
} from "./decide-next-action.ts";
export * from "./rectification-decision.ts";
export * from "./credible-range.ts";
export * from "./candidate-contrast-packet.ts";
@@ -30,10 +30,18 @@ export type DecisionSessionOutcome =
| "discriminate_candidates"
| "validate_holdout"
| "provisional_range"
| "provisional_range_user_stopped"
| "completed_with_range"
| "validated_range"
| "exact_minute_confirmed"
| "adopt_representative"
| "awaiting_confirmation";
export type CompletionStatus =
| "provisional_range_user_stopped"
| "validated_range"
| "exact_minute_confirmed";
export type DerivedPrecisionStage = "collect_events" | "theme_refine" | "ready_to_adopt";
export type RectificationDecision = Readonly<{
@@ -48,6 +56,8 @@ export type RectificationDecision = Readonly<{
proposeAllowed: boolean;
precisionStage: DerivedPrecisionStage;
activeFocusPolicy: "keep" | "close";
completionStatus: CompletionStatus | null;
validated: boolean;
credibleRange: readonly [string, string] | null;
representativeTime: string | null;
separation: CandidateSeparation;
@@ -60,6 +70,7 @@ export type DecideRectificationInput = Readonly<{
confirmationAllowed?: boolean;
userStopped?: boolean;
snapshotCurrent?: boolean;
trainingGateOpen?: boolean;
candidateScores: readonly CandidateScoreRow[];
discriminatorProbe?: CandidateDiscriminatorProbe | null;
holdoutValidation?: HoldoutValidationStatus;
@@ -99,7 +110,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
canConfirmExactMinute: true,
});
}
if ((!input.methodCoverageAll || input.snapshotCurrent === false)
if ((!input.methodCoverageAll || input.trainingGateOpen === false || input.snapshotCurrent === false)
&& !(userStopped && input.candidateScores.length > 0)) {
return collect(separation, holdout, range, probe);
}
@@ -107,7 +118,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
if (probe && !userStopped) {
return discriminate(separation, holdout, range, probe);
}
return completeWithRange(separation, holdout, range, userStopped);
return completeWithRange(separation, holdout, range, userStopped ? "user_stopped" : "offer");
}
if (holdout === "not_started" && !userStopped) {
return holdoutValidation(separation, range);
@@ -116,7 +127,10 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
if (probe && !userStopped) {
return discriminate(separation, holdout, range, probe);
}
return completeWithRange(separation, holdout, range, true);
return completeWithRange(separation, holdout, range, "exhausted");
}
if (userStopped && holdout !== "passed") {
return completeWithRange(separation, holdout, range, "user_stopped");
}
return finish("adopt_representative", {
input,
@@ -146,6 +160,8 @@ function collect(
proposeAllowed: false,
precisionStage: "collect_events",
activeFocusPolicy: "keep",
completionStatus: null,
validated: false,
credibleRange: range,
representativeTime: separation.representativeTime,
separation,
@@ -172,6 +188,8 @@ function discriminate(
proposeAllowed: false,
precisionStage: "theme_refine",
activeFocusPolicy: "keep",
completionStatus: null,
validated: false,
credibleRange: range,
representativeTime: separation.representativeTime,
separation,
@@ -196,6 +214,8 @@ function holdoutValidation(
proposeAllowed: false,
precisionStage: "theme_refine",
activeFocusPolicy: "keep",
completionStatus: null,
validated: false,
credibleRange: range,
representativeTime: separation.representativeTime,
separation,
@@ -208,13 +228,20 @@ function completeWithRange(
separation: CandidateSeparation,
holdout: HoldoutValidationStatus,
range: readonly [string, string] | null,
terminal: boolean,
kind: "user_stopped" | "offer" | "exhausted",
): RectificationDecision {
const userStopped = kind === "user_stopped";
const terminal = kind !== "offer";
const nextAction = terminal ? "complete_with_range" : "offer_provisional_range";
const sessionOutcome = userStopped
? "provisional_range_user_stopped"
: terminal
? "completed_with_range"
: "provisional_range";
return {
phase: terminal ? "completed" : "discrimination",
nextAction,
sessionOutcome: terminal ? "completed_with_range" : "provisional_range",
sessionOutcome,
resultStatus: "completed_with_range",
canOfferRange: true,
canAdopt: true,
@@ -223,6 +250,8 @@ function completeWithRange(
proposeAllowed: true,
precisionStage: "ready_to_adopt",
activeFocusPolicy: "close",
completionStatus: userStopped ? "provisional_range_user_stopped" : null,
validated: false,
credibleRange: range,
representativeTime: separation.representativeTime,
separation,
@@ -232,7 +261,7 @@ function completeWithRange(
}
function finish(
sessionOutcome: "adopt_representative" | "awaiting_confirmation",
sessionOutcome: "adopt_representative" | "awaiting_confirmation" | "validated_range" | "exact_minute_confirmed",
input: {
input: DecideRectificationInput;
separation: CandidateSeparation;
@@ -242,11 +271,25 @@ function finish(
canConfirmExactMinute: boolean;
},
): RectificationDecision {
const completionStatus: CompletionStatus | null = input.canConfirmExactMinute && input.input.accepted
? "exact_minute_confirmed"
: input.holdout === "passed"
? "validated_range"
: input.input.userStopped === true
? "provisional_range_user_stopped"
: null;
const kind = input.canConfirmExactMinute
? (input.input.accepted ? "exact_minute_confirmed" : "awaiting_confirmation")
: input.holdout === "passed"
? "validated_range"
: sessionOutcome;
return {
phase: "completed",
nextAction: "ready_to_adopt",
sessionOutcome,
resultStatus: sessionOutcome === "awaiting_confirmation" ? "converged" : "completed_with_range",
sessionOutcome: kind,
resultStatus: kind === "awaiting_confirmation" || kind === "exact_minute_confirmed"
? "converged"
: "completed_with_range",
canOfferRange: true,
canAdopt: true,
canConfirmExactMinute: input.canConfirmExactMinute,
@@ -254,6 +297,8 @@ function finish(
proposeAllowed: true,
precisionStage: "ready_to_adopt",
activeFocusPolicy: "close",
completionStatus,
validated: completionStatus === "validated_range" || completionStatus === "exact_minute_confirmed",
credibleRange: input.range,
representativeTime: input.separation.representativeTime,
separation: input.separation,
@@ -278,28 +323,45 @@ export function offerSessionKinds(): readonly string[] {
"adopt_representative",
"awaiting_confirmation",
"provisional_range",
"provisional_range_user_stopped",
"completed_with_range",
"validated_range",
"exact_minute_confirmed",
];
}
export function publicNextAction(decision: RectificationDecision): Readonly<{
type: RectificationNextActionType;
session_outcome: DecisionSessionOutcome;
completion_status: CompletionStatus | null;
validated: boolean;
can_offer_range: boolean;
can_adopt: boolean;
can_confirm_exact_minute: boolean;
selection_allowed: boolean;
propose_allowed: boolean;
precision_stage: DerivedPrecisionStage;
representative_time: string | null;
credible_range: readonly [string, string] | null;
}> {
return {
type: decision.nextAction,
session_outcome: decision.sessionOutcome,
completion_status: decision.completionStatus,
validated: decision.validated,
can_offer_range: decision.canOfferRange,
can_adopt: decision.canAdopt,
can_confirm_exact_minute: decision.canConfirmExactMinute,
selection_allowed: decision.selectionAllowed,
propose_allowed: decision.proposeAllowed,
precision_stage: decision.precisionStage,
representative_time: decision.representativeTime,
credible_range: decision.credibleRange,
};
}
export function publicDecisionFields(
decision: RectificationDecision,
): ReturnType<typeof publicNextAction> {
return publicNextAction(decision);
}
@@ -7,7 +7,9 @@ export type DatedEventInput = Readonly<{
precision: InferenceEvent["precision"];
}>;
/** Reserve a holdout as soon as collection has two dated events. */
/** Reserve a holdout as soon as collection has two dated events.
* Discrimination still waits until 3 training events remain.
*/
export const MIN_EVENTS_TO_RESERVE_HOLDOUT = 2;
/**
@@ -8,11 +8,12 @@
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
import { nextProbe } from "../core/build-state";
import { decideRectification, publicNextAction } from "../core/rectification-decision.ts";
import { publicNextAction } from "../core/rectification-decision.ts";
import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
} from "./inference-adapter";
import { decideAfterInferenceChange } from "./decision-from-dossier";
import type { InferenceState } from "../core/types.ts";
import {
CHOICE_ACTION,
@@ -113,10 +114,11 @@ export async function applyRectificationChoice(
expectedRevision: previous?.revision ?? command.expectedRevision,
inference: null,
narration,
userDisplay: "先这样,先看当前范围",
decisionState: previous,
userStopped: true,
});
userDisplay: "先这样,先看当前范围",
decisionState: previous,
userStopped: true,
dossier,
});
}
if (!previous) {
@@ -136,6 +138,7 @@ export async function applyRectificationChoice(
inference: null,
narration,
userDisplay: userDisplayFromSchema(schema, optionId),
dossier,
});
}
@@ -221,6 +224,7 @@ export async function applyRectificationChoice(
userDisplay: userDisplayFromSchema(schema, optionId),
decisionState: applied.state,
userStopped: false,
dossier,
});
}
@@ -244,6 +248,7 @@ async function persistApplied(
userDisplay: string | null;
decisionState?: InferenceState | null;
userStopped?: boolean;
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"];
},
): Promise<AppliedChoiceReceipt> {
const persisted = await persistV9ChoiceAction(accounting, command.userId, command.caseId, {
@@ -282,10 +287,11 @@ async function persistApplied(
scoring: input.scoring,
appliedInference: input.appliedInference,
});
const nextAction = publicNextAction(decisionAfterChoice(
input.decisionState ?? null,
input.userStopped === true,
));
const nextAction = publicNextAction(decideAfterInferenceChange({
dossier: input.dossier,
state: input.decisionState ?? null,
userStopped: input.userStopped === true,
}));
return {
applied: true,
@@ -310,31 +316,6 @@ async function persistApplied(
};
}
function decisionAfterChoice(state: InferenceState | null, userStopped: boolean) {
if (!state) {
return decideRectification({
methodCoverageAll: false,
candidateScores: [],
userStopped,
});
}
const hasHoldout = state.events.some((item) => item.usage === "holdout");
const holdoutValidation = state.holdout_passed === true
? "passed" as const
: state.holdout_passed === false || state.result_status === "validation_failed"
? "failed" as const
: hasHoldout
? "not_started" as const
: "unavailable" as const;
return decideRectification({
methodCoverageAll: true,
candidateScores: state.candidates.map((item) => ({ time: item.time, score: item.posterior_score })),
holdoutValidation,
inferenceCredibleRange: state.credible_range,
userStopped,
});
}
function optionQuoteFromSchema(schema: Readonly<Record<string, unknown>>, optionId: ChoiceKey): string | null {
const choice = schema.choice && typeof schema.choice === "object" && !Array.isArray(schema.choice)
? schema.choice as Record<string, unknown>
@@ -82,7 +82,7 @@ export function composeChoiceNarration(input: {
appliedInference: boolean;
}): string {
if (input.optionId === "stop") {
return "已按你的选择先看到当前范围。候选比较已保留,正在准备下一步。";
return "已按你的选择先看到当前范围。独立核对尚未完成,这不是最终校正结果。";
}
if (!input.scoring) {
return "已记录你的选择。这是盘外核对,不会改候选分数。正在准备下一步。";
@@ -59,7 +59,10 @@ export type SessionOutcomeKind =
| "discriminate_candidates"
| "validate_holdout"
| "provisional_range"
| "provisional_range_user_stopped"
| "completed_with_range"
| "validated_range"
| "exact_minute_confirmed"
| "adopt_representative"
| "awaiting_confirmation";
@@ -93,12 +96,30 @@ export function sessionOutcomeView(kind: SessionOutcomeKind): SessionOutcome {
user_meaning: "领先候选还要用尚未计分的前事做独立核对,不能直接采用。",
};
}
if (kind === "validated_range") {
return {
kind,
user_meaning: "领先候选已通过尚未计分的前事核对。本会话交付可信区间和代表性工作时间,不是唯一分钟确认。",
};
}
if (kind === "exact_minute_confirmed") {
return {
kind,
user_meaning: "确认门已允许。只有用户明确同意才能写已确认校正时间。",
};
}
if (kind === "provisional_range") {
return {
kind,
user_meaning: "当前几个候选基本并列。给出的是可信区间的代表点,不是已经分出的赢家。",
};
}
if (kind === "provisional_range_user_stopped") {
return {
kind,
user_meaning: "用户主动停止收集。当前可信区间和代表性工作时间可以交付,独立核对尚未完成,不是最终校正结果。",
};
}
if (kind === "completed_with_range") {
return {
kind,
@@ -121,10 +142,10 @@ export function sessionOutcomeFromGate(input: {
if (input.confirmationAllowed) return sessionOutcomeView("awaiting_confirmation");
const userStopped = input.userStopped === true;
const interviewOpen = input.interviewOpen === true && !userStopped;
if (input.proposeAllowed === true && !interviewOpen) {
return sessionOutcomeView("adopt_representative");
}
if (userStopped && input.selectionAllowed) {
return sessionOutcomeView("provisional_range_user_stopped");
}
if (input.proposeAllowed === true && !interviewOpen) {
return sessionOutcomeView("adopt_representative");
}
return sessionOutcomeView("collect_evidence");
@@ -0,0 +1,237 @@
/**
* Shared Case → decideRectification adapter.
*
* GET refresh, turn_decision, choice/stop, and interview cards must feed
* the same inputs. Snapshot selection_allowed is storage, not policy.
*/
import {
askedKeysFromLedgerEvidence,
buildCandidateContrastPacket,
selectDiscriminatorProbe,
volunteeredDomainsFromEvidence,
type CandidateContrastPacket,
} from "../core/candidate-contrast-packet.ts";
import {
decideRectification,
publicDecisionFields,
type RectificationDecision,
} from "../core/rectification-decision.ts";
import type { InferenceState } from "../core/types.ts";
import { askedProbeKeysFromReceipt, previousInferenceFromReceipt } from "./inference-adapter";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
latestUserStoppedCollecting,
} from "./method-followup";
import {
MIN_ACCEPTANCE_DOMAINS,
MIN_ACCEPTANCE_EVENTS,
trainingScoreableGate,
} from "./evidence-model";
import { refinementFromDecisionReceipt } from "./refinement-packet";
import { windowScanFromDecisionReceipt } from "./varga-observations";
export type DecisionDossier = Readonly<{
evidence: readonly Readonly<{
id?: string;
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
summary?: string | null;
}>[];
conversationSummary: {
activeFocus: {
id?: string;
intent: string;
targetDomain: string | null;
targetKind: string | null;
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
} | null;
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
};
latestResult: {
resultId?: string;
decisionReceipt: Readonly<Record<string, unknown>> | null;
selectionAllowed?: boolean;
candidates?: readonly Readonly<{
time: string;
relativeSupport?: number;
posterior_score?: number;
}>[];
evidenceLedgerFingerprint?: string | null;
candidateRangeFingerprint?: string | null;
} | null;
case: {
acceptedTime: string | null;
};
turns?: readonly Readonly<{ role: string; text: string | null }>[];
}>;
function candidateScoresFromDossier(latest: DecisionDossier["latestResult"]) {
const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null);
if (inference && inference.candidates.length > 0) {
return inference.candidates.map((item) => ({ time: item.time, score: item.posterior_score }));
}
return (latest?.candidates ?? []).map((item) => ({
time: item.time,
score: item.relativeSupport ?? item.posterior_score ?? 0,
}));
}
function holdoutStatusFromInference(inference: ReturnType<typeof previousInferenceFromReceipt>) {
if (!inference) return "unavailable" as const;
const hasHoldout = inference.events.some((item) => item.usage === "holdout");
if (!hasHoldout) return "unavailable" as const;
if (inference.holdout_passed === true) return "passed" as const;
if (inference.holdout_passed === false || inference.result_status === "validation_failed") {
return "failed" as const;
}
return "not_started" as const;
}
function holdoutStatusFromState(state: InferenceState) {
const hasHoldout = state.events.some((item) => item.usage === "holdout");
if (state.holdout_passed === true) return "passed" as const;
if (state.holdout_passed === false || state.result_status === "validation_failed") {
return "failed" as const;
}
if (hasHoldout) return "not_started" as const;
return "unavailable" as const;
}
export function contrastPacketFromDossier(dossier: DecisionDossier): CandidateContrastPacket {
const windowScan = windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null);
const refinement = refinementFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null);
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const candidateScores = candidateScoresFromDossier(dossier.latestResult);
return buildCandidateContrastPacket({
candidateSetVersion: inference?.candidate_set_id ?? dossier.latestResult?.resultId ?? "none",
calculationResultId: dossier.latestResult?.resultId ?? null,
engineProbes: refinement.discriminating_event_probes,
vargaDifferences: [
...(windowScan?.d9_candidates_differ && windowScan.d9_sign_names.length >= 2
? [{ layer: "d9", signs: windowScan.d9_sign_names }]
: []),
...(windowScan?.d10_candidates_differ && windowScan.d10_sign_names.length >= 2
? [{ layer: "d10", signs: windowScan.d10_sign_names }]
: []),
],
candidateTimes: candidateScores.map((item) => item.time),
transitions: windowScan?.transitions ?? [],
askedKeys: [
...askedProbeKeysFromReceipt(dossier.latestResult?.decisionReceipt),
...askedKeysFromLedgerEvidence(dossier.evidence),
],
volunteeredDomains: volunteeredDomainsFromEvidence(dossier.evidence),
});
}
function contrastPacketFromState(state: InferenceState): CandidateContrastPacket {
const answered = new Set(state.answered_probes.map((item) => item.probe_id));
return buildCandidateContrastPacket({
candidateSetVersion: state.candidate_set_id,
calculationResultId: null,
engineProbes: state.probes
.filter((item) => !answered.has(item.id))
.map((item) => ({
semantic_key: item.semantic_key,
candidate_split_hash: item.candidate_split_hash,
domain: item.domain,
year: item.year,
user_meaning: item.question,
information_gain: item.information_gain,
expected_outcomes: item.expected_outcomes,
candidate_ids: item.candidate_ids,
})),
candidateTimes: state.candidates
.filter((item) => item.status !== "eliminated")
.map((item) => item.time),
askedKeys: state.answered_probes.map((item) => item.semantic_key),
});
}
export function decideFromDossier(
dossier: DecisionDossier,
options?: { currentEvidenceFingerprint?: string | null },
): RectificationDecision {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const trainingGate = trainingScoreableGate(dossier.evidence);
const collecting = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "collect_evidence",
});
const storedFingerprint = dossier.latestResult?.evidenceLedgerFingerprint ?? "";
const currentFingerprint = options?.currentEvidenceFingerprint ?? storedFingerprint;
const snapshotCurrent = !storedFingerprint || storedFingerprint === currentFingerprint;
return decideRectification({
methodCoverageAll: blockingMethodsCovered(collecting.methods),
trainingGateOpen: trainingGate.open,
confirmationAllowed: false,
userStopped: latestUserStoppedCollecting(dossier.turns ?? []),
snapshotCurrent,
candidateScores: candidateScoresFromDossier(dossier.latestResult),
discriminatorProbe: selectDiscriminatorProbe(contrastPacketFromDossier(dossier)),
holdoutValidation: holdoutStatusFromInference(inference),
accepted: Boolean(dossier.case.acceptedTime),
inferenceCredibleRange: inference?.credible_range ?? null,
});
}
export function decideAfterInferenceChange(input: {
dossier: DecisionDossier;
state: InferenceState | null;
userStopped: boolean;
}): RectificationDecision {
const collecting = buildMethodFollowupPlan({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "collect_evidence",
});
if (!input.state) {
return decideRectification({
methodCoverageAll: blockingMethodsCovered(collecting.methods),
trainingGateOpen: trainingScoreableGate(input.dossier.evidence).open,
candidateScores: [],
userStopped: input.userStopped,
});
}
const training = input.state.events.filter((item) => item.usage === "training");
const trainingDomains = new Set(training.map((item) => item.domain));
return decideRectification({
methodCoverageAll: blockingMethodsCovered(collecting.methods),
trainingGateOpen: training.length >= MIN_ACCEPTANCE_EVENTS
&& trainingDomains.size >= MIN_ACCEPTANCE_DOMAINS,
candidateScores: input.state.candidates.map((item) => ({
time: item.time,
score: item.posterior_score,
})),
discriminatorProbe: selectDiscriminatorProbe(contrastPacketFromState(input.state)),
holdoutValidation: holdoutStatusFromState(input.state),
inferenceCredibleRange: input.state.credible_range,
userStopped: input.userStopped,
accepted: Boolean(input.dossier.case.acceptedTime),
});
}
export function overlayPublicDecision<T extends object>(
snapshot: T,
decision: RectificationDecision,
): T & ReturnType<typeof publicDecisionFields> & {
selectionAllowed: boolean;
validated: boolean;
completionStatus: ReturnType<typeof publicDecisionFields>["completion_status"];
} {
const fields = publicDecisionFields(decision);
return {
...snapshot,
...fields,
selectionAllowed: fields.selection_allowed,
validated: fields.validated,
completionStatus: fields.completion_status,
};
}
@@ -3,6 +3,7 @@
* append-only revision/status machine. IDs are always generated by the server.
*/
import { z } from "zod";
import { splitHoldoutEvents, type DatedEventInput } from "../core/split-holdout.ts";
export const EVIDENCE_KINDS = [
"education_start",
@@ -164,6 +165,11 @@ export function datePrecisionRank(precision: string): number {
return PRECISION_RANK[precision] ?? -1;
}
function datedPrecision(value: string): DatedEventInput["precision"] {
if (value === "day" || value === "month" || value === "year" || value === "unknown") return value;
return "year";
}
/**
* Agent-facing date label. Day precision must never collapse to a year.
*/
@@ -219,7 +225,7 @@ export const NON_PRIMARY_SCORING_DOMAINS: ReadonlySet<string> = new Set([
"other",
]);
/** Same floors as `scripts/rectification/decision_policy.py`. */
/** Same floors as `scripts/rectification/decision_policy.py`. Counted on training events only. */
export const MIN_ACCEPTANCE_EVENTS = 3;
export const MIN_ACCEPTANCE_DOMAINS = 2;
@@ -252,20 +258,56 @@ export function isPrimaryScoreableEvidence(item: {
return true;
}
/** Reverse-inference / conflict probes wait until the engine could accept. */
export function meetsAcceptanceEventQuality(
evidence: readonly Readonly<{
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
}>[],
): boolean {
type ScoreableEvidence = Readonly<{
id?: string;
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
}>;
function yearFromIso(value: string | null): number | null {
if (!value || value.length < 4 || !/^\d{4}/.test(value)) return null;
const year = Number(value.slice(0, 4));
return year >= 1900 && year <= 2100 ? year : null;
}
/**
* Discrimination requires 3 training events / 2 training domains.
* Holdout is reserved from the 2nd dated event but does not count.
* 3 collected events with 1 holdout must keep collecting.
*/
export function trainingScoreableGate(evidence: readonly ScoreableEvidence[]): Readonly<{
trainingCount: number;
trainingDomainCount: number;
holdoutCount: number;
open: boolean;
}> {
const scoreable = evidence.filter(isPrimaryScoreableEvidence);
const domains = new Set(scoreable.map((item) => item.domain));
return scoreable.length >= MIN_ACCEPTANCE_EVENTS && domains.size >= MIN_ACCEPTANCE_DOMAINS;
const dated: DatedEventInput[] = scoreable.map((item, index) => ({
id: item.id && item.id.trim() ? item.id : `scoreable:${index}:${item.domain}:${item.occurredFrom ?? ""}`,
domain: item.domain,
year: yearFromIso(item.occurredFrom) ?? yearFromIso(item.occurredTo),
precision: datedPrecision(item.datePrecision),
}));
const split = splitHoldoutEvents(dated);
const training = split.filter((item) => item.usage === "training");
const domains = new Set(training.map((item) => item.domain));
return {
trainingCount: training.length,
trainingDomainCount: domains.size,
holdoutCount: split.filter((item) => item.usage === "holdout").length,
open: training.length >= MIN_ACCEPTANCE_EVENTS && domains.size >= MIN_ACCEPTANCE_DOMAINS,
};
}
/** Reverse-inference / conflict probes wait until training coverage could accept. */
export function meetsAcceptanceEventQuality(
evidence: readonly ScoreableEvidence[],
): boolean {
return trainingScoreableGate(evidence).open;
}
/** Ledger/engine subject follows the domain. Family events must not stay on the tool default `self`. */
@@ -6,14 +6,13 @@
* Card identity is the persisted focus UUID plus the inference revision.
*/
import {
askedKeysFromLedgerEvidence,
buildCandidateContrastPacket,
selectDiscriminatorProbe,
volunteeredDomainsFromEvidence,
} from "../core/candidate-contrast-packet.ts";
import { decideRectification } from "../core/rectification-decision.ts";
import { askedKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts";
import { askedProbeKeysFromReceipt, previousInferenceFromReceipt } from "./inference-adapter";
import {
contrastPacketFromDossier,
decideFromDossier,
} from "./decision-from-dossier";
import { evidenceLedgerFingerprint } from "./tool-service";
import { latestUserStoppedCollecting, projectRectificationChoiceCard } from "./method-followup";
import { refinementFromDecisionReceipt } from "./refinement-packet";
import {
@@ -22,33 +21,9 @@ import {
} from "./varga-observations";
import type { RectificationChoiceCard } from "./choice-card";
function candidateScoresFromDossier(latest: {
decisionReceipt: Readonly<Record<string, unknown>> | null;
candidates?: readonly Readonly<{ time: string; relativeSupport?: number; posterior_score?: number }>[];
} | null) {
const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null);
if (inference && inference.candidates.length > 0) {
return inference.candidates.map((item) => ({ time: item.time, score: item.posterior_score }));
}
return (latest?.candidates ?? []).map((item) => ({
time: item.time,
score: item.relativeSupport ?? item.posterior_score ?? 0,
}));
}
function holdoutStatusFromInference(inference: ReturnType<typeof previousInferenceFromReceipt>) {
if (!inference) return "unavailable" as const;
const hasHoldout = inference.events.some((item) => item.usage === "holdout");
if (!hasHoldout) return "unavailable" as const;
if (inference.holdout_passed === true) return "passed" as const;
if (inference.holdout_passed === false || inference.result_status === "validation_failed") {
return "failed" as const;
}
return "not_started" as const;
}
export function choiceCardFromCaseDossier(dossier: {
evidence: readonly Readonly<{
id?: string;
status: string;
domain: string;
datePrecision: string;
@@ -72,6 +47,7 @@ export function choiceCardFromCaseDossier(dossier: {
decisionReceipt: Readonly<Record<string, unknown>> | null;
selectionAllowed?: boolean;
candidates?: readonly Readonly<{ time: string; relativeSupport?: number }>[];
evidenceLedgerFingerprint?: string | null;
} | null;
case: {
acceptedTime: string | null;
@@ -82,53 +58,17 @@ export function choiceCardFromCaseDossier(dossier: {
const observations = internalObservationsFromWindowScan(windowScan);
const refinement = refinementFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null);
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const candidateScores = candidateScoresFromDossier(dossier.latestResult);
const askedProbeKeys = [
...askedProbeKeysFromReceipt(dossier.latestResult?.decisionReceipt),
...askedKeysFromLedgerEvidence(dossier.evidence),
];
const contrastPacket = buildCandidateContrastPacket({
candidateSetVersion: inference?.candidate_set_id ?? dossier.latestResult?.resultId ?? "none",
calculationResultId: dossier.latestResult?.resultId ?? null,
engineProbes: refinement.discriminating_event_probes,
vargaDifferences: [
...(windowScan?.d9_candidates_differ && windowScan.d9_sign_names.length >= 2
? [{ layer: "d9", signs: windowScan.d9_sign_names }]
: []),
...(windowScan?.d10_candidates_differ && windowScan.d10_sign_names.length >= 2
? [{ layer: "d10", signs: windowScan.d10_sign_names }]
: []),
],
candidateTimes: candidateScores.map((item) => item.time),
transitions: windowScan?.transitions ?? [],
askedKeys: askedProbeKeys,
volunteeredDomains: volunteeredDomainsFromEvidence(dossier.evidence),
const contrastPacket = contrastPacketFromDossier(dossier);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
});
const userStopped = latestUserStoppedCollecting(dossier.turns ?? []);
const latestAssistantText = [...(dossier.turns ?? [])]
.reverse()
.find((turn) => turn.role === "assistant")
?.text ?? null;
const holdoutValidation = holdoutStatusFromInference(inference);
const holdoutEvents = (inference?.events ?? [])
.filter((item) => item.usage === "holdout")
.map((item) => ({ domain: item.domain, year: item.year }));
const coverageOpen = dossier.evidence.filter((item) => (
item.status === "confirmed"
&& item.occurredFrom
&& item.datePrecision !== "unknown"
&& item.eventKind !== "occupation_note"
)).length < 3;
const decision = decideRectification({
methodCoverageAll: !coverageOpen,
confirmationAllowed: false,
userStopped,
candidateScores,
discriminatorProbe: selectDiscriminatorProbe(contrastPacket),
holdoutValidation,
accepted: Boolean(dossier.case.acceptedTime),
inferenceCredibleRange: inference?.credible_range ?? null,
});
return projectRectificationChoiceCard({
evidence: dossier.evidence,
activeFocus: dossier.conversationSummary.activeFocus,
@@ -145,17 +85,25 @@ export function choiceCardFromCaseDossier(dossier: {
eventProbes: refinement.discriminating_event_probes,
eventClarificationProbes: refinement.event_clarification_probes,
evidenceCollectionProbes: refinement.evidence_collection_probes,
askedProbeKeys,
askedProbeKeys: [
...askedProbeKeysFromReceipt(dossier.latestResult?.decisionReceipt),
...askedKeysFromLedgerEvidence(dossier.evidence),
],
accepted: Boolean(dossier.case.acceptedTime),
selectionAllowed: decision.selectionAllowed,
proposeAllowed: decision.proposeAllowed,
caseRevision: inference?.revision ?? 0,
contrastPacket,
candidateScores,
userStopped,
candidateScores: decision.separation.ranked.map((item) => ({
time: item.time,
score: item.score,
})),
userStopped: latestUserStoppedCollecting(dossier.turns ?? []),
latestAssistantText,
candidatesSeparated: decision.separation.sufficient,
holdoutValidation,
holdoutValidation: decision.holdoutValidation,
holdoutEvents,
});
}
export { decideFromDossier, overlayPublicDecision } from "./decision-from-dossier";
@@ -26,10 +26,12 @@
* Method coverage asks for dated events in natural language.
* Known-event quality probes (exam went badly for a year already
* in the ledger) stamp a choice card as soon as that year is
* recorded. Dasha conflict probes wait until acceptance event
* quality (3 primary scoreable events in 2 domains), then jump
* ahead of remaining method rotation and block offering time cards
* so the window can be filtered.
* recorded. Dasha conflict probes wait until training event
* quality (3 training events in 2 training domains; holdout
* excluded), then jump ahead of remaining method rotation and
* block offering time cards so the window can be filtered.
* If holdout is already reserved but training is still short,
* keep collecting a dated event instead of discriminating.
* Once blocking methods are covered, move into candidate discrimination.
* Coverage complete never means adopt. Horary does not block cards.
* A/B/C/D choice frames attach only when candidates already diverge
@@ -54,7 +56,7 @@ import {
} from "../core/candidate-contrast-packet.ts";
import { candidateIdsFromProbe, isValidDistinguishProbe } from "../core/distinguish-contract.ts";
import type { SessionOutcomeKind } from "./confirmation-gate.ts";
import { meetsAcceptanceEventQuality } from "./evidence-model";
import { meetsAcceptanceEventQuality, trainingScoreableGate } from "./evidence-model";
import type {
DiscriminatingEventProbe,
NakshatraBoundary,
@@ -85,9 +87,9 @@ export type MethodCoverage = Readonly<{
}>;
export type MethodFollowup = Readonly<{
method_id: "dasha_events" | "d9_relationship" | "d10_career" | "d4_home" | "d5_education" | "relatives" | "d2_finance" | "d30_health" | "appearance" | "marks" | "occupation" | "horary" | "active_focus" | "nakshatra_boundary" | "oos_blind" | "reverse_verify";
method_id: "dasha_events" | "d9_relationship" | "d10_career" | "d4_home" | "d5_education" | "relatives" | "d2_finance" | "d30_health" | "appearance" | "marks" | "occupation" | "horary" | "active_focus" | "nakshatra_boundary" | "oos_blind" | "reverse_verify" | "holdout_validation";
intent: string;
ask_theme: "dated_event" | "relationship_style" | "career_style" | "home_change" | "education_style" | "family_event" | "finance_change" | "health_pressure" | "appearance" | "marks" | "occupation" | "horary" | "active_focus" | "nakshatra_trait" | "oos_blind";
ask_theme: "dated_event" | "relationship_style" | "career_style" | "home_change" | "education_style" | "family_event" | "finance_change" | "health_pressure" | "appearance" | "marks" | "occupation" | "horary" | "active_focus" | "nakshatra_trait" | "oos_blind" | "holdout";
domain: string | null;
kind_hint: string | null;
user_prompt_hint: string;
@@ -125,6 +127,7 @@ export type MethodFollowupEvidence = Readonly<{
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
id?: string;
summary?: string | null;
}>;
@@ -230,7 +233,7 @@ function isOccupationNote(item: MethodFollowupEvidence): boolean {
return !item.eventKind || item.eventKind === "occupation_note";
}
function blockingMethodsCovered(methods: readonly MethodCoverage[]): boolean {
export function blockingMethodsCovered(methods: readonly MethodCoverage[]): boolean {
return !methods.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered");
}
@@ -549,7 +552,7 @@ function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDi
};
}
export function conversationalSessionOutcome(input: {
export function decideConversationalSession(input: {
selectionAllowed: boolean;
proposeAllowed: boolean;
confirmationAllowed: boolean;
@@ -557,17 +560,22 @@ export function conversationalSessionOutcome(input: {
methods?: readonly MethodCoverage[];
userStopped?: boolean;
candidateScores?: readonly Readonly<{ time: string; score: number }>[];
trainingGateOpen?: boolean;
evidence?: readonly MethodFollowupEvidence[];
discriminatorProbe?: CandidateDiscriminatorProbe | null;
holdoutValidation?: HoldoutValidationStatus;
snapshotCurrent?: boolean;
}): SessionOutcomeKind {
if (input.confirmationAllowed) return "awaiting_confirmation";
accepted?: boolean;
}): ReturnType<typeof decideRectification> {
const coverageOpen = Boolean(
input.methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered"),
);
const trainingOpen = input.trainingGateOpen
?? (input.evidence ? trainingScoreableGate(input.evidence).open : true);
return decideRectification({
methodCoverageAll: !coverageOpen,
confirmationAllowed: false,
trainingGateOpen: trainingOpen,
confirmationAllowed: input.confirmationAllowed,
userStopped: input.userStopped,
snapshotCurrent: input.snapshotCurrent,
candidateScores: input.candidateScores ?? [],
@@ -575,7 +583,12 @@ export function conversationalSessionOutcome(input: {
? input.discriminatorProbe
: discriminatorFromFollowup(input.nextFollowup),
holdoutValidation: input.holdoutValidation,
}).sessionOutcome;
accepted: input.accepted,
});
}
export function conversationalSessionOutcome(input: Parameters<typeof decideConversationalSession>[0]): SessionOutcomeKind {
return decideConversationalSession(input).sessionOutcome;
}
export function buildNextUserAction(input: {
@@ -619,9 +632,14 @@ export function buildNextUserAction(input: {
if (input.sessionOutcome === "adopt_representative") {
return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt };
}
if (input.sessionOutcome === "provisional_range" || input.sessionOutcome === "completed_with_range") {
if (input.sessionOutcome === "provisional_range"
|| input.sessionOutcome === "completed_with_range"
|| input.sessionOutcome === "provisional_range_user_stopped") {
return { id: provisional.id, user_meaning: provisional.user_meaning, on_user_stop: provisional };
}
if (input.sessionOutcome === "validated_range" || input.sessionOutcome === "exact_minute_confirmed") {
return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt };
}
if (input.sessionOutcome === "validate_holdout" && input.nextFollowup) {
return {
id: "ask_holdout_validation",
@@ -664,7 +682,7 @@ export function buildNextUserAction(input: {
return {
id: "ask_method_followup",
user_meaning: input.nextFollowup.user_prompt_hint,
on_user_stop: input.selectionAllowed ? adopt : explain,
on_user_stop: input.hasLatestResult ? provisional : explain,
};
}
return { id: explain.id, user_meaning: explain.user_meaning, on_user_stop: explain };
@@ -769,7 +787,10 @@ export function buildMethodFollowupPlan(input: {
focus
&& !staleCollectFocus
&& (sessionOutcome !== "adopt_representative"
&& sessionOutcome !== "validated_range"
&& sessionOutcome !== "exact_minute_confirmed"
&& sessionOutcome !== "provisional_range"
&& sessionOutcome !== "provisional_range_user_stopped"
&& sessionOutcome !== "completed_with_range"
|| keepAcceptedFocus)
&& (!input.accepted || keepAcceptedFocus)
@@ -1007,6 +1028,28 @@ export function buildMethodFollowupPlan(input: {
),
source: "method_coverage",
});
} else if (
!meetsAcceptanceEventQuality(input.evidence)
&& !(stage === "d5_refine" && !hasConfirmedDomain(input.evidence, "education") && !declined.has("education"))
&& !(stage === "d9_refine" && !relationshipCovered && !declined.has("relationship"))
&& !(stage === "d10_refine" && !careerCovered && !declined.has("career"))
&& !((stage === "d4_refine" || stage === "theme_refine")
&& !hasConfirmedDomain(input.evidence, "relocation")
&& !declined.has("relocation"))
) {
next = makeFollowup({
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: collect(
"再记一件记得大概时间的经历。当前用来区分候选的训练事件还不够。",
"本命 Dasha + 行运(方法1",
"不要开始点选区分题。",
),
source: "method_coverage",
});
} else if (contrastProbe && !candidatesSeparated) {
const domain = contrastFollowupDomain(contrastProbe.domain);
next = makeFollowup({
@@ -1254,7 +1297,10 @@ export function buildMethodFollowupPlan(input: {
}
const deferAdoption = sessionOutcome === "adopt_representative"
|| sessionOutcome === "validated_range"
|| sessionOutcome === "exact_minute_confirmed"
|| sessionOutcome === "provisional_range"
|| sessionOutcome === "provisional_range_user_stopped"
|| sessionOutcome === "completed_with_range";
return {
methods,
@@ -1288,6 +1334,7 @@ export function projectRectificationChoiceCard(
candidateScores: input.candidateScores,
discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null) ?? undefined,
holdoutValidation: input.holdoutValidation,
evidence: input.evidence,
});
if (sessionOutcome !== (input.sessionOutcome ?? "collect_evidence")) {
plan = buildMethodFollowupPlan({ ...input, sessionOutcome });
@@ -1296,7 +1343,10 @@ export function projectRectificationChoiceCard(
!input.accepted
&& (sessionOutcome === "adopt_representative"
|| sessionOutcome === "awaiting_confirmation"
|| sessionOutcome === "validated_range"
|| sessionOutcome === "exact_minute_confirmed"
|| sessionOutcome === "provisional_range"
|| sessionOutcome === "provisional_range_user_stopped"
|| sessionOutcome === "completed_with_range")
) {
return null;
@@ -579,7 +579,7 @@ export function evidenceLedgerFingerprint(
evidence: V9CaseDossier["evidence"],
): string {
const rows = [...scorableEvidence(evidence)]
.sort((left, right) => left.id.localeCompare(right.id))
.sort((left, right) => String(left.id ?? "").localeCompare(String(right.id ?? "")))
.map((item) =>
[
item.id,
@@ -6,6 +6,8 @@
*/
import { compactInferenceProjection, previousInferenceFromReceipt } from "./inference-adapter";
import { decideFromDossier } from "./decision-from-dossier";
import { evidenceLedgerFingerprint } from "./tool-service";
import type { V9CaseDossier } from "./tool-service";
export const TURN_DECISION_MAX_BYTES = 6 * 1024;
@@ -44,6 +46,9 @@ export function projectTurnDecision(
} = {},
): Record<string, unknown> {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
const candidates = (dossier.latestResult?.candidates ?? []).slice(0, 6).map((item) => ({
time: item.time,
relative_support: item.relativeSupport,
@@ -85,12 +90,20 @@ export function projectTurnDecision(
current_probe: compactInferenceProjection(inference)?.next_probe ?? null,
candidate_summary: {
representative_time: dossier.latestResult?.representativeTime ?? null,
selection_allowed: dossier.latestResult?.selectionAllowed === true,
selection_allowed: decision.selectionAllowed,
completion_status: decision.completionStatus,
validated: decision.validated,
candidates,
entropy: inference?.entropy ?? null,
},
inference: compactInferenceProjection(inference),
next_action: extras.nextAction ?? null,
next_action: extras.nextAction ?? {
type: decision.nextAction,
session_outcome: decision.sessionOutcome,
completion_status: decision.completionStatus,
validated: decision.validated,
selection_allowed: decision.selectionAllowed,
},
followup_hint: extras.followupHint ?? null,
relevant_evidence_summary: evidence,
recent_turns: recentTurns,
@@ -69,6 +69,8 @@ export type RectificationCandidateResult = Readonly<{
precisionStage: PrecisionStage | null;
oosBlindPrompts: readonly OosBlindPrompt[];
confirmationGate: ConfirmationGate;
validated: boolean;
completionStatus: "provisional_range_user_stopped" | "validated_range" | "exact_minute_confirmed" | null;
}>;
function record(value: unknown): Record<string, unknown> | null {
@@ -231,7 +233,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
overallConfidence: snapshot.overallConfidence === "high" || snapshot.overallConfidence === "medium"
? snapshot.overallConfidence
: "low",
selectionAllowed: snapshot.selectionAllowed === true,
selectionAllowed: snapshot.selectionAllowed === true || snapshot.selection_allowed === true,
confirmationAllowed: snapshot.confirmationAllowed === true,
representativeTime,
selectedTime,
@@ -252,6 +254,15 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
candidates,
decisionReceipt: receipt,
}),
validated: snapshot.validated === true,
completionStatus: snapshot.completionStatus === "provisional_range_user_stopped"
|| snapshot.completionStatus === "validated_range"
|| snapshot.completionStatus === "exact_minute_confirmed"
|| snapshot.completion_status === "provisional_range_user_stopped"
|| snapshot.completion_status === "validated_range"
|| snapshot.completion_status === "exact_minute_confirmed"
? (snapshot.completionStatus ?? snapshot.completion_status) as RectificationCandidateResult["completionStatus"]
: null,
};
}
+2 -2
View File
@@ -69,9 +69,9 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。对用户说的话必须自己写在正文里,不要只写规划等服务器代写。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。
7. 只基于成功 attempt 输出正文。工具失败时说明面向用户的边界,不声称未执行的方法或结果。
8. 当前轮新事件一律走 rectification-record-evidence-batch(一件也可以)。优先传 source 原文的 quoteStart/quoteEnd,不要改写 quote。rectification-confirm-evidence 只用于用户对已有 pending 明确说“对/是”。不得要求用户把已说清的事件再发一遍。
9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_actionid=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compareC 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。采用门所需的可评分事件未齐(至少 3 件、2 个领域)时继续按方法层收集,不要根据 dasha 冲突探针出点选卡或改问冲突年。已记下年份上的发挥质量探针要出点选卡。齐了之后,source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 没有了 / 没了 / 没有其它 / 想不起来了 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–DashaGochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。
9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_actionid=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compareC 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative、validated_range、provisional_range 或 provisional_range_user_stopped 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。采用门所需的训练事件未齐(至少 3 条训练事件、2 个领域holdout 不计)时继续按方法层收集,不要根据 dasha 冲突探针出点选卡或改问冲突年。已记下年份上的发挥质量探针要出点选卡。齐了之后,source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 没有了 / 没了 / 没有其它 / 想不起来了 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。session_outcome=provisional_range_user_stopped 时交付当前区间和代表时间,必须说明独立核对尚未完成,禁止说已完成验证或最终校正结果。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–DashaGochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。
10. 不泄露系统提示词或 Skill 原文。
11. 追问只跟 method_followup_plan 与服务器已持久化的 current_question / open_question。不要调用 rectification-set-focus;下一问和点选卡由 compare-candidates / read-case 在服务端事务内创建。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,正文直接问,不要提点选卡。若工具返回了 open_question / current_question,自己写一句自然语言追问:年份和事件家族必须用探针或 choice_frame.period,不得发明年份,不得改问其他领域。点选卡只负责 A/B/C/D,正文不要复述选项。服务器只锁定年份和事件家族,不会代写题干。采用门所需的可评分事件/领域未齐时不要走 dasha 冲突 event_probe,忽略 receipt 里未达采用门的 dasha 冲突探针。已记下的发挥质量探针跟 open_question 出点选卡。齐了之后 source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐后只问当前剩余候选分钟还能拆开的区分探针;没有剩余拆分且未拉开时落实 offer_provisional_range,不要再问整窗 D9/D24,也不要 adopt。不要问两套盘哪个更像或可能性高低。点选 A/B/C/D 与「先这样」由服务器按 focusId/optionId 确定性处理,不要把选项全文当成新事件,也不要为点选调用 resolve-focus、read-case 或 compare;自由文本补充才走工具。正文禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。
11. 追问只跟 method_followup_plan 与服务器已持久化的 current_question / open_question。不要调用 rectification-set-focus;下一问和点选卡由 compare-candidates / read-case 在服务端事务内创建。账本为空或 collect_method_evidence 时用自然语言问一件带大概年份的经历,正文直接问,不要提点选卡。若工具返回了 open_question / current_question,自己写一句自然语言追问:年份和事件家族必须用探针或 choice_frame.period,不得发明年份,不得改问其他领域。点选卡只负责 A/B/C/D,正文不要复述选项。服务器只锁定年份和事件家族,不会代写题干。采用门所需的训练事件/领域未齐时不要走 dasha 冲突 event_probe,忽略 receipt 里未达采用门的 dasha 冲突探针。已记下的发挥质量探针跟 open_question 出点选卡。齐了之后 source=event_probe 只问这一件反推前事用来筛窗,不要继续轮询方法层,不要 offer。覆盖已齐后只问当前剩余候选分钟还能拆开的区分探针;没有剩余拆分且未拉开时落实 offer_provisional_range,不要再问整窗 D9/D24,也不要 adopt。不要问两套盘哪个更像或可能性高低。点选 A/B/C/D 与「先这样」由服务器按 focusId/optionId 确定性处理,不要把选项全文当成新事件,也不要为点选调用 resolve-focus、read-case 或 compare;自由文本补充才走工具。正文禁止复述选项。不得询问外貌、体质、胎记或疤痕,也不得问钟点。不得按 missing_evidence_categories 轮询迁居,也不得先要 10–15 条事件长表。财务与健康只有用户主动说才问。方法覆盖为感情→事业→家人→职业→占问。D9/D10 类型表是校时方法,不是命运承诺。以「盘外核对(不计分)」开头的消息不得调用 record-evidence-batch 或 propose-evidence。
12. 证据有效变化后由服务器重算候选。不要等用户说“没有更多了”才比较,也不要对同一证据指纹再 compare。分钟扫描只在服务端,结果只是候选或平台,不得宣布确认。
13. 落实 start_consultation:前事核对结束或用户先这样后,请用户用当前采用时间看盘;对不上同时请改选其他候选。解释事件–Dasha 账本、双轨是否一致、换升时刻、精度阶段、D9/D10 类型对照和相对支持时,仍必须说候选范围不是出生时间真值。`;
+47 -40
View File
@@ -46,6 +46,7 @@ import {
isDatePrecision,
displayDateLabel,
evidenceSubjectForDomain,
trainingScoreableGate,
} from "@/lib/rectification-agentic/v9/evidence-model";
import {
isHoldoutVerificationQuote,
@@ -58,6 +59,7 @@ import {
buildMethodFollowupPlan,
buildNextUserAction,
conversationalSessionOutcome,
decideConversationalSession,
latestUserStoppedCollecting,
type MethodCoverage,
type MethodFollowup,
@@ -156,10 +158,6 @@ function safeBirthContext(compute: Awaited<ReturnType<typeof loadV9CaseCompute>>
};
}
function readProposeAllowed(decisionReceipt: Readonly<Record<string, unknown>> | null | undefined): boolean {
return decisionReceipt?.propose_allowed === true;
}
function candidateScoresFromLatest(latest: NonNullable<DossierForTools["latestResult"]> | null | undefined) {
const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null);
if (inference && inference.candidates.length > 0) {
@@ -265,6 +263,11 @@ function safeCaseProjection(
const candidateScores = candidateScoresFromLatest(latest);
const holdoutValidation = holdoutStatusFromLatest(latest);
const separation = evaluateCandidateSeparation(candidateScores);
const currentSnapshot = snapshotSourceFromDossier(dossier, compute);
const storedSnapshot = latest ? storedSnapshotSource(latest) : null;
const snapshotCurrent = !storedSnapshot
|| !storedSnapshot.scoreableEvidenceFingerprint
|| scoreableSnapshotIsCurrent(storedSnapshot, currentSnapshot);
const collectingPlan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: dossier.conversationSummary.activeFocus,
@@ -285,17 +288,25 @@ function safeCaseProjection(
holdoutValidation,
holdoutEvents: holdoutEventsFromLatest(latest),
});
const selectionAllowed = latest?.selectionAllowed === true;
const proposeAllowed = readProposeAllowed(latest?.decisionReceipt);
const userStopped = latestUserStoppedCollecting(dossier.turns);
const currentSnapshot = snapshotSourceFromDossier(dossier, compute);
const storedSnapshot = latest ? storedSnapshotSource(latest) : null;
const snapshotCurrent = !storedSnapshot
|| !storedSnapshot.scoreableEvidenceFingerprint
|| scoreableSnapshotIsCurrent(storedSnapshot, currentSnapshot);
const decision = decideConversationalSession({
selectionAllowed: false,
proposeAllowed: false,
confirmationAllowed: false,
nextFollowup: collectingPlan.next_followup,
methods: collectingPlan.methods,
userStopped,
candidateScores,
discriminatorProbe: selectDiscriminatorProbe(contrastPacket),
holdoutValidation,
snapshotCurrent,
evidence: dossier.evidence,
accepted,
});
const sessionOutcome = decision.sessionOutcome;
const latestProjection = latest
? latestResultToolProjection(latest, {
proposeAllowed,
proposeAllowed: decision.proposeAllowed,
nextFollowup: collectingPlan.next_followup,
methods: collectingPlan.methods,
userStopped,
@@ -306,18 +317,6 @@ function safeCaseProjection(
evidence: dossier.evidence,
})
: null;
const sessionOutcome = conversationalSessionOutcome({
selectionAllowed,
proposeAllowed,
confirmationAllowed: latestProjection?.confirmation_allowed === true,
nextFollowup: collectingPlan.next_followup,
methods: collectingPlan.methods,
userStopped,
candidateScores,
discriminatorProbe: selectDiscriminatorProbe(contrastPacket),
holdoutValidation,
snapshotCurrent,
});
const methodFollowupPlan = sessionOutcome === "collect_evidence"
? { ...collectingPlan, session_outcome: sessionOutcome }
: buildMethodFollowupPlan({
@@ -344,7 +343,7 @@ function safeCaseProjection(
scorableCount: dossier.scorable.length,
evidenceCount: dossier.evidence.length,
hasLatestResult: Boolean(latestProjection),
selectionAllowed,
selectionAllowed: decision.selectionAllowed,
sessionOutcome,
nextFollowup: methodFollowupPlan.next_followup,
workingTime: caseRow.acceptedTime
@@ -476,6 +475,7 @@ export function latestResultToolProjection(
discriminatorProbe?: ReturnType<typeof selectDiscriminatorProbe>;
snapshotCurrent?: boolean;
evidence?: DossierForTools["evidence"];
accepted?: boolean;
},
): Record<string, unknown> {
const width = indistinguishableWidthMinutes(latest.candidates);
@@ -485,13 +485,11 @@ export function latestResultToolProjection(
candidates: latest.candidates,
decisionReceipt: latest.decisionReceipt ?? null,
});
const proposeAllowed = session?.proposeAllowed === true
|| readProposeAllowed(latest.decisionReceipt);
const candidateScores = session?.candidateScores ?? candidateScoresFromLatest(latest);
const sessionOutcome = session
? conversationalSessionOutcome({
selectionAllowed: latest.selectionAllowed,
proposeAllowed,
const decision = session
? decideConversationalSession({
selectionAllowed: false,
proposeAllowed: false,
confirmationAllowed: confirmationGate.confirmation_allowed,
nextFollowup: session.nextFollowup ?? null,
methods: session.methods,
@@ -500,8 +498,11 @@ export function latestResultToolProjection(
discriminatorProbe: session.discriminatorProbe,
holdoutValidation: session.holdoutValidation,
snapshotCurrent: session.snapshotCurrent,
evidence: session.evidence,
accepted: session.accepted,
})
: "collect_evidence";
: null;
const sessionOutcome = decision?.sessionOutcome ?? "collect_evidence";
const houseTable = parseRectificationHouseTable(latest.decisionReceipt?.house_table);
const refinement = refinementFromDecisionReceipt(latest.decisionReceipt ?? null);
const separation = evaluateCandidateSeparation(candidateScores);
@@ -509,9 +510,11 @@ export function latestResultToolProjection(
return {
result_id: latest.resultId,
candidates: latest.candidates,
selection_allowed: latest.selectionAllowed,
propose_allowed: proposeAllowed,
selection_allowed: decision ? decision.selectionAllowed : false,
propose_allowed: decision ? decision.proposeAllowed : false,
confirmation_allowed: confirmationGate.confirmation_allowed,
completion_status: decision?.completionStatus ?? null,
validated: decision?.validated ?? false,
representative_time: latest.representativeTime,
selected_time: latest.selectedTime,
selection_kind: latest.selectionKind,
@@ -606,8 +609,8 @@ function sessionAwareFollowupForParsed(
const candidateScores = candidateScoresFromLatest(latest);
const holdoutValidation = holdoutStatusFromLatest(latest);
const sessionOutcome = conversationalSessionOutcome({
selectionAllowed: latest.selectionAllowed,
proposeAllowed: readProposeAllowed(latest.decisionReceipt),
selectionAllowed: false,
proposeAllowed: false,
confirmationAllowed: false,
nextFollowup: collectingPlan.next_followup,
methods: collectingPlan.methods,
@@ -616,6 +619,8 @@ function sessionAwareFollowupForParsed(
discriminatorProbe: selectDiscriminatorProbe(contrastPacket),
holdoutValidation,
snapshotCurrent: options?.snapshotCurrent,
evidence: parsed.evidence,
accepted: Boolean(parsed.case.acceptedTime),
});
if (sessionOutcome === "collect_evidence") {
return {
@@ -1674,12 +1679,15 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
};
const { collectingPlan, persistedFocus, contrastPacket } = await persistPlanFocus(scored.parsed, latest);
const latestProjection = latestResultToolProjection(latest, {
proposeAllowed: readProposeAllowed(latest.decisionReceipt),
nextFollowup: collectingPlan.next_followup,
methods: collectingPlan.methods,
userStopped: latestUserStoppedCollecting(scored.parsed.turns),
candidateScores: candidateScoresFromLatest(latest),
holdoutValidation: holdoutStatusFromLatest(latest),
discriminatorProbe: selectDiscriminatorProbe(contrastPacket),
snapshotCurrent: true,
evidence: scored.parsed.evidence,
accepted: Boolean(scored.parsed.case.acceptedTime),
});
const projection = {
...latestProjection,
@@ -1760,7 +1768,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const offerCandidatesTool = createTool({
id: "rectification-offer-candidates",
description:
"把已持久化的候选快照呈现给用户(当前候选/相对支持度,不是概率或确定性)。仅在 session_outcome 为 adopt_representative、provisional_range、completed_with_range 或 awaiting_confirmation 时允许调用;访谈仍在收集或候选区分、独立核对未完成则拒绝。不会在同一回复中要求继续补证据。",
"把已持久化的候选快照呈现给用户(当前候选/相对支持度,不是概率或确定性)。仅在 session_outcome 为 adopt_representative、validated_range、provisional_range、provisional_range_user_stopped、completed_with_range、exact_minute_confirmed 或 awaiting_confirmation 时允许调用;访谈仍在收集或候选区分、独立核对未完成则拒绝。用户停止给出的是未验证区间,不得写成已完成验证。不会在同一回复中要求继续补证据。",
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
execute: async (input) => {
assertCaseRef(input);
@@ -1774,7 +1782,6 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
}
const latest = parsed.latestResult;
const { plan: collectingPlan, contrastPacket } = sessionAwareFollowupForParsed(parsed, latest);
const proposeAllowed = readProposeAllowed(latest.decisionReceipt);
const userStopped = latestUserStoppedCollecting(parsed.turns);
const candidateScores = candidateScoresFromLatest(latest);
const currentSnapshot = snapshotSourceFromDossier(parsed, null);
@@ -1785,7 +1792,6 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
throw new RectificationToolServiceError("offer_not_allowed");
}
const projection = latestResultToolProjection(latest, {
proposeAllowed,
nextFollowup: collectingPlan.next_followup,
methods: collectingPlan.methods,
userStopped,
@@ -1794,6 +1800,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
discriminatorProbe: selectDiscriminatorProbe(contrastPacket),
snapshotCurrent: true,
evidence: parsed.evidence,
accepted: Boolean(parsed.case.acceptedTime),
});
const sessionKind = (projection.session_outcome as { kind?: string }).kind ?? "";
if (!offerSessionKinds().includes(sessionKind)) {
+27
View File
@@ -12,6 +12,11 @@ import { join } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
databaseHealthFromFilenames,
RECTIFICATION_CONTRACT_VERSION,
REQUIRED_RECTIFICATION_MIGRATIONS,
} from "../src/lib/health-database-contract.ts";
function serviceBlock(compose: string, service: string) {
const match = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][a-z0-9_-]*:|^volumes:)`, "m"));
@@ -30,11 +35,21 @@ test("health endpoint exposes deployment identity for production verification",
new URL("../src/app/api/health/route.ts", import.meta.url),
"utf8",
);
const contract = readFileSync(
new URL("../src/lib/health-database-contract.ts", import.meta.url),
"utf8",
);
assert.match(source, /deployment:/);
assert.match(source, /GITHUB_SHA/);
assert.match(source, /VERCEL_GIT_COMMIT_SHA/);
assert.match(source, /gitCommit/);
assert.match(source, /latestMigration/);
assert.match(source, /rectificationContractVersion/);
assert.match(source, /rectificationMigrations/);
assert.match(contract, /20260826010000_rectification_inference_round_audit\.sql/);
assert.match(contract, /20260826020000_rectification_choice_focus_identity\.sql/);
assert.match(contract, /RECTIFICATION_CONTRACT_VERSION = "v3"/);
assert.match(source, /loadLanguageModelCatalog/);
assert.match(source, /const defaults = catalog\.models\.filter/);
assert.match(source, /defaults\.length === 1/);
@@ -46,6 +61,18 @@ test("health endpoint exposes deployment identity for production verification",
assert.doesNotMatch(source, /anyEnvCheck\(\["LLM_MODELS_JSON"|OPENAI_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY/);
});
test("health database contract requires the rectification identity migrations", () => {
const missing = databaseHealthFromFilenames(["20240101000000_init.sql"]);
assert.equal(missing.requiredMigrationsPresent, false);
assert.equal(missing.rectificationContractVersion, RECTIFICATION_CONTRACT_VERSION);
const present = databaseHealthFromFilenames([
"20240101000000_init.sql",
...REQUIRED_RECTIFICATION_MIGRATIONS,
]);
assert.equal(present.requiredMigrationsPresent, true);
assert.equal(present.latestMigration, REQUIRED_RECTIFICATION_MIGRATIONS[1]);
});
test("GitHub mirror cannot deploy production", () => {
const workflow = readFileSync(
new URL("../../.github/workflows/deploy-production.yml", import.meta.url),
@@ -554,6 +554,9 @@ test("time-selection cards appear under the latest settled agent bubble only aft
assert.doesNotMatch(chat, /send\("message", choiceCard\?\.stop_message/);
assert.match(chat, /CHOICE_STOP_MESSAGE/);
assert.match(caseRoute, /choice_card: choiceCardFromCaseDossier/);
assert.match(caseRoute, /overlayPublicDecision/);
assert.match(caseRoute, /interview: publicDecisionFields/);
assert.doesNotMatch(chat, /已完成验证/);
assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/);
});
@@ -641,10 +644,10 @@ test("the Agent prompt cannot offer candidates while asking for more evidence",
// The hard boundary lives in the prompt; no tool input carries an
// offer_selection boolean anymore.
assert.match(agent, /不得在同一回复中一边要求继续补证据,一边提供候选采用/);
assert.match(agent, /id 不是 adopt_representative 时不得调用 rectification-offer-candidates/);
assert.match(agent, /id 不是 adopt_representative、validated_range、provisional_range 或 provisional_range_user_stopped 时不得调用 rectification-offer-candidates/);
assert.match(agent, /verify_adopted_time/);
assert.match(agent, /event_probe/);
assert.match(agent, /至少 3 件/);
assert.match(agent, /至少 3 条训练事件/);
assert.match(agent, /2 个领域/);
assert.match(agent, /发挥质量/);
assert.match(agent, /selection_allowed 只表示可以采用代表性时间/);
@@ -153,6 +153,21 @@ test("a 25-minute plateau stays confirmation-blocked as an indistinguishable ran
confirmationAllowed: false,
interviewOpen: false,
}).user_meaning, /本会话以代表性时间收口,不确认唯一分钟/);
assert.equal(sessionOutcomeFromGate({
selectionAllowed: true,
confirmationAllowed: false,
userStopped: true,
}).kind, "provisional_range_user_stopped");
assert.match(sessionOutcomeFromGate({
selectionAllowed: true,
confirmationAllowed: false,
userStopped: true,
}).user_meaning, /不是最终校正结果/);
assert.doesNotMatch(sessionOutcomeFromGate({
selectionAllowed: true,
confirmationAllowed: false,
userStopped: true,
}).user_meaning, /已完成验证/);
});
test("holdout not_ready forbids unique-minute copy and still blocks confirm", async () => {
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
@@ -8,6 +9,8 @@ import {
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { evaluateCandidateSeparation } from "../src/lib/rectification-agentic/core/candidate-separation.ts";
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import {
classifySnapshotStaleReason,
scoreableSnapshotIsCurrent,
@@ -238,7 +241,81 @@ test("user stop skips remaining collection and holdout when candidates already e
],
holdoutValidation: "not_started",
});
assert.equal(separated.type, "ready_to_adopt");
assert.equal(separated.type, "complete_with_range");
const stopped = decideRectification({
methodCoverageAll: false,
userStopped: true,
candidateScores: [
{ time: "04:48", score: 58 },
{ time: "04:49", score: 42 },
],
holdoutValidation: "not_started",
});
assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped");
assert.equal(stopped.completionStatus, "provisional_range_user_stopped");
assert.equal(stopped.validated, false);
});
test("discrimination waits for three training events after reserving holdout", () => {
const two = trainingScoreableGate([
{ id: "e1", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ id: "e2", status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-04-01", occurredTo: null },
]);
const three = trainingScoreableGate([
{ id: "e1", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ id: "e2", status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-04-01", occurredTo: null },
{ id: "e3", status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2024-08-01", occurredTo: null },
]);
const four = trainingScoreableGate([
{ id: "e1", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ id: "e2", status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-04-01", occurredTo: null },
{ id: "e3", status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-10-01", occurredTo: null },
{ id: "e4", status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2024-08-01", occurredTo: null },
]);
assert.equal(two.open, false);
assert.equal(two.holdoutCount, 1);
assert.equal(three.open, false);
assert.equal(three.holdoutCount, 1);
assert.equal(three.trainingCount, 2);
assert.equal(four.open, true);
assert.equal(four.trainingCount, 3);
assert.equal(four.holdoutCount, 1);
assert.equal(decideNextAction({
methodCoverageAll: true,
trainingGateOpen: two.open,
proposeAllowed: false,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
}).type, "ask_fact_collection");
assert.equal(decideNextAction({
methodCoverageAll: true,
trainingGateOpen: three.open,
proposeAllowed: false,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
}).type, "ask_fact_collection");
assert.equal(decideNextAction({
methodCoverageAll: true,
trainingGateOpen: four.open,
proposeAllowed: false,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
}).type, "ask_candidate_discriminator");
});
test("holdout passed is a validated range, not a user-stop close", () => {
const validated = decideRectification({
methodCoverageAll: true,
candidateScores: [
{ time: "04:48", score: 58 },
{ time: "04:49", score: 42 },
],
holdoutValidation: "passed",
});
assert.equal(validated.sessionOutcome, "validated_range");
assert.equal(validated.completionStatus, "validated_range");
assert.equal(validated.validated, true);
assert.equal(validated.canConfirmExactMinute, false);
});
test("tied candidates with no remaining discriminator offer a provisional range", () => {
@@ -351,3 +428,23 @@ test("final report does not claim executed techniques without calculationResultI
assert.match(report, /\| D10 \| executed \|/);
assert.doesNotMatch(report, /当前推荐/);
});
test("core barrel re-exports decideNextAction without duplicating session helpers", () => {
const index = readFileSync(new URL("../src/lib/rectification-agentic/core/index.ts", import.meta.url), "utf8");
assert.match(
index,
/export \{\s*decideNextAction,\s*type DecideNextActionInput,\s*type RectificationNextAction,\s*\} from "\.\/decide-next-action\.ts"/,
);
assert.doesNotMatch(index, /export \* from "\.\/decide-next-action\.ts"/);
assert.match(index, /export \* from "\.\/rectification-decision\.ts"/);
});
test("MethodFollowup unions include holdout validation kinds used by next_followup", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8");
const methodId = source.match(/export type MethodFollowup = Readonly<\{[\s\S]*?method_id: ([^;]+);/)?.[1] ?? "";
const askTheme = source.match(/export type MethodFollowup = Readonly<\{[\s\S]*?ask_theme: ([^;]+);/)?.[1] ?? "";
assert.match(methodId, /"holdout_validation"/);
assert.match(askTheme, /"holdout"/);
assert.match(source, /ask_theme: "holdout"/);
assert.match(source, /method_id: "holdout_validation"/);
});
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
decideRectification,
publicDecisionFields,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
import { overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts";
const SEPARATED = [
{ time: "04:48", score: 58 },
{ time: "04:49", score: 42 },
];
function readSource(relative: string) {
return readFileSync(new URL(relative, import.meta.url), "utf8");
}
test("public decision fields are derived from decideRectification", () => {
const fixtures = [
{
methodCoverageAll: false,
trainingGateOpen: false,
candidateScores: SEPARATED,
},
{
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: [
{ time: "05:00", score: 34 },
{ time: "05:01", score: 33 },
{ time: "05:02", score: 33 },
],
},
{
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: SEPARATED,
holdoutValidation: "passed" as const,
},
{
methodCoverageAll: true,
userStopped: true,
candidateScores: SEPARATED,
holdoutValidation: "not_started" as const,
},
];
for (const input of fixtures) {
const decision = decideRectification(input);
const fields = publicDecisionFields(decision);
assert.deepEqual(fields, {
type: decision.nextAction,
session_outcome: decision.sessionOutcome,
completion_status: decision.completionStatus,
validated: decision.validated,
can_offer_range: decision.canOfferRange,
can_adopt: decision.canAdopt,
can_confirm_exact_minute: decision.canConfirmExactMinute,
selection_allowed: decision.selectionAllowed,
propose_allowed: decision.proposeAllowed,
precision_stage: decision.precisionStage,
representative_time: decision.representativeTime,
credible_range: decision.credibleRange,
});
assert.equal(overlayPublicDecision({ selectionAllowed: true }, decision).selectionAllowed, fields.selection_allowed);
assert.equal(overlayPublicDecision({ selectionAllowed: true }, decision).validated, fields.validated);
assert.equal(decideNextAction(input).type, decision.nextAction);
assert.equal(conversationalSessionOutcome({
selectionAllowed: decision.selectionAllowed,
proposeAllowed: decision.proposeAllowed,
confirmationAllowed: false,
nextFollowup: null,
userStopped: input.userStopped,
candidateScores: input.candidateScores,
holdoutValidation: input.holdoutValidation,
trainingGateOpen: input.trainingGateOpen,
}), decision.sessionOutcome);
}
});
test("interview, choice, refresh and next-action all call the same reducer", () => {
const interview = readSource("../src/lib/rectification-agentic/v9/interview-state.ts");
const adapter = readSource("../src/lib/rectification-agentic/v9/decision-from-dossier.ts");
const choice = readSource("../src/lib/rectification-agentic/v9/answer-choice.ts");
const refresh = readSource("../src/lib/rectification-agentic/v9/turn-decision.ts");
const followup = readSource("../src/lib/rectification-agentic/v9/method-followup.ts");
const tools = readSource("../src/mastra/rectification-v9-tools.ts");
const caseRoute = readSource("../src/app/api/rectification/cases/[caseId]/route.ts");
assert.match(interview, /decideFromDossier\(/);
assert.match(refresh, /decideFromDossier\(/);
assert.match(choice, /decideAfterInferenceChange\(/);
assert.match(adapter, /decideRectification\(/);
assert.match(followup, /decideRectification\(/);
assert.match(tools, /decideConversationalSession\(/);
assert.match(caseRoute, /overlayPublicDecision/);
assert.match(caseRoute, /publicDecisionFields\(decision\)/);
assert.doesNotMatch(interview, /sessionOutcomeFromGate/);
assert.doesNotMatch(choice, /sessionOutcomeFromGate/);
assert.doesNotMatch(refresh, /sessionOutcomeFromGate/);
assert.doesNotMatch(tools, /sessionOutcomeFromGate/);
assert.doesNotMatch(interview, /selectionAllowed: dossier\.latestResult/);
assert.doesNotMatch(refresh, /selection_allowed: dossier\.latestResult\?\.selectionAllowed/);
assert.doesNotMatch(tools, /selection_allowed: decision\?\.selectionAllowed \?\? latest/);
assert.doesNotMatch(tools, /propose_allowed: decision\?\.proposeAllowed \?\? proposeAllowed/);
assert.match(tools, /evidence: parsed\.evidence/);
assert.match(followup, /evidence: input\.evidence/);
assert.match(adapter, /trainingGateOpen: trainingGate\.open/);
assert.match(adapter, /blockingMethodsCovered/);
});
+104 -16
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup, latestUserStoppedCollecting } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import {
askedKeysFromLedgerEvidence,
buildCandidateContrastPacket,
@@ -39,6 +40,7 @@ const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
const EDUCATION_ID = "44444444-4444-4444-8444-444444444441";
const RELATIONSHIP_ID = "44444444-4444-4444-8444-444444444442";
const FAMILY_ID = "44444444-4444-4444-8444-444444444443";
const CAREER_ID = "44444444-4444-4444-8444-444444444444";
const UNIQUE_MINUTE_COPY = /±5 分钟确定性/;
const CLASSIC_COVERAGE = [
@@ -222,6 +224,21 @@ const familyEvidence = {
created_at: "2026-08-12T10:00:08.000Z",
};
const careerEvidence = {
id: CAREER_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_entry",
domain: "career",
occurred_from: "2019-07-01",
occurred_to: null,
date_precision: "year",
summary: "2019年开始工作",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:09.000Z",
};
function stubEngine(response: unknown) {
const previous = globalThis.fetch;
globalThis.fetch = (async () => ({
@@ -302,12 +319,13 @@ test("encoded exam quality does not stamp another card and keeps method rotation
assert.equal(plan.next_followup?.choice_frame, null);
});
test("dasha conflict probe jumps after three scoreable events in two domains and blocks offer", () => {
test("dasha conflict probe jumps after four scoreable events leave three training domains", () => {
const plan = buildMethodFollowupPlan({
evidence: [
datedEvidence("education", "2016"),
datedEvidence("education", "2020"),
datedEvidence("relationship", "2018"),
datedEvidence("family", "2023"),
],
eventProbes: [CAREER_CONFLICT_PROBE],
});
@@ -405,7 +423,7 @@ test("user-stop action records stated events when the ledger is empty", () => {
assert.match(action.on_user_stop.user_meaning, /batch/);
});
test("user-stop action explains the window when follow-up remains but the user stops", () => {
test("user-stop action offers a range when candidates already exist", () => {
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
@@ -415,7 +433,7 @@ test("user-stop action explains the window when follow-up remains but the user s
occurredTo: null,
}],
});
const action = buildNextUserAction({
const withCandidates = buildNextUserAction({
scorableCount: 1,
evidenceCount: 1,
hasLatestResult: true,
@@ -424,10 +442,21 @@ test("user-stop action explains the window when follow-up remains but the user s
nextFollowup: plan.next_followup,
workingTime: "12:00",
});
assert.equal(action.id, "ask_method_followup");
assert.equal(action.on_user_stop.id, "explain_current_window");
assert.match(action.on_user_stop.user_meaning, /12:00/);
assert.match(action.on_user_stop.user_meaning, /不要只说会话会保留/);
assert.equal(withCandidates.id, "ask_method_followup");
assert.equal(withCandidates.on_user_stop.id, "offer_provisional_range");
const withoutCandidates = buildNextUserAction({
scorableCount: 0,
evidenceCount: 1,
hasLatestResult: false,
selectionAllowed: false,
sessionOutcome: "collect_evidence",
nextFollowup: plan.next_followup,
workingTime: "12:00",
});
assert.equal(withoutCandidates.id, "ask_method_followup");
assert.equal(withoutCandidates.on_user_stop.id, "explain_current_window");
assert.match(withoutCandidates.on_user_stop.user_meaning, /12:00/);
assert.match(withoutCandidates.on_user_stop.user_meaning, /不要只说会话会保留/);
assert.equal(plan.methods.find((item) => item.method_id === "marks")?.status, "skipped_by_policy");
assert.equal(plan.methods.find((item) => item.method_id === "horary")?.status, "uncovered");
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
@@ -438,7 +467,7 @@ test("user-stop action explains the window when follow-up remains but the user s
assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY);
});
test("selectionAllowed with remaining method follow-up keeps collecting and only adopts on stop", () => {
test("selectionAllowed with remaining method follow-up keeps collecting and offers a range on stop", () => {
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
@@ -459,7 +488,7 @@ test("selectionAllowed with remaining method follow-up keeps collecting and only
});
assert.equal(plan.next_followup?.method_id, "d9_relationship");
assert.equal(action.id, "ask_method_followup");
assert.equal(action.on_user_stop.id, "adopt_representative");
assert.equal(action.on_user_stop.id, "offer_provisional_range");
});
test("adopt_representative defers method follow-up instead of asking this turn", () => {
@@ -751,7 +780,7 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin
);
assert.equal(
(projection as { next_user_action?: { on_user_stop?: { id?: string } } }).next_user_action?.on_user_stop?.id,
"adopt_representative",
"offer_provisional_range",
);
assert.equal(projection.latest_result.session_outcome.kind, "collect_evidence");
assert.equal(projection.internal_observations.find((item) => item.layer === "d9")?.ask_theme, "relationship_style");
@@ -879,8 +908,8 @@ test("evidence batch returns the persisted choice prompt as open_question", asyn
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [educationEvidence, relationshipEvidence, familyEvidence],
evidenceCount: 3,
evidence: [educationEvidence, relationshipEvidence, familyEvidence, careerEvidence],
evidenceCount: 4,
latestResult: null,
}),
get_agentic_rectification_case_compute: () => computeFixture(),
@@ -1685,7 +1714,7 @@ test("没有了 is a user stop", () => {
{ time: "05:06", score: 33 },
{ time: "05:07", score: 33 },
],
}), "completed_with_range");
}), "provisional_range_user_stopped");
});
@@ -1695,6 +1724,7 @@ test("same domain different year still asks a conflict probe", () => {
datedEvidence("education", "2016"),
datedEvidence("relationship", "2018"),
datedEvidence("career", "2015"),
datedEvidence("family", "2023"),
],
eventProbes: [{
...CAREER_CONFLICT_PROBE,
@@ -1706,6 +1736,64 @@ test("same domain different year still asks a conflict probe", () => {
assert.equal(plan.next_followup?.domain, "career");
});
test("three dated events with one holdout keep collecting instead of discriminating", () => {
const evidence = [
datedEvidence("education", "2016"),
datedEvidence("career", "2020"),
datedEvidence("relationship", "2024"),
{
status: "confirmed" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
];
const gate = trainingScoreableGate(evidence);
assert.equal(gate.open, false);
assert.equal(gate.trainingCount, 2);
assert.equal(gate.holdoutCount, 1);
const plan = buildMethodFollowupPlan({
evidence,
declinedTopics: [{ target_domain: "family", status: "declined" }],
precisionStage: "lagna_frame",
eventProbes: [CAREER_CONFLICT_PROBE],
});
assert.equal(plan.next_followup?.source, "method_coverage");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.notEqual(plan.next_followup?.source, "event_probe");
assert.notEqual(plan.next_followup, null);
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
nextFollowup: plan.next_followup,
methods: plan.methods,
evidence,
candidateScores: [
{ time: "05:00", score: 34 },
{ time: "05:01", score: 33 },
{ time: "05:02", score: 33 },
],
discriminatorProbe: {
probeId: "p-cd",
candidateSetVersion: "set-test",
question: "2018 年前后事业是否有明显变化?",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:20"] },
{ outcomeId: "no", supportsCandidateIds: ["05:20"], conflictsCandidateIds: ["05:00"] },
],
candidateSplitHash: "split",
informationGain: 0.4,
sourceFeatures: [{ technique: "dasha_activation", calculationResultId: null }],
domain: "career",
year: 2018,
semanticKey: "career.2018",
},
}), "collect_evidence");
});
test("adjacent education year does not re-ask enrollment after a recorded start", () => {
const plan = buildMethodFollowupPlan({
evidence: [{
@@ -1882,7 +1970,7 @@ test("declining occupation covers the method; declining horary is skipped_by_pol
{ time: "05:02", score: 16 },
],
holdoutValidation: "passed",
}), "adopt_representative");
}), "validated_range");
});
test("horary follow-up does not block propose once occupation is covered", () => {
@@ -1903,7 +1991,7 @@ test("horary follow-up does not block propose once occupation is covered", () =>
{ time: "05:02", score: 16 },
],
holdoutValidation: "passed",
}), "adopt_representative");
}), "validated_range");
});
test("offer-candidates refuses while method coverage remains", async () => {
@@ -1980,7 +2068,7 @@ test("user stop with selection_allowed may offer the escape hatch", async () =>
const projection = await (tools["rectification-offer-candidates"] as unknown as {
execute(input: unknown): Promise<{ session_outcome: { kind: string } }>;
}).execute({ caseId: CASE_ID });
assert.equal(projection.session_outcome.kind, "adopt_representative");
assert.equal(projection.session_outcome.kind, "provisional_range_user_stopped");
assert.equal(
accounting.calls.some((call) =>
call.fn === "transition_agentic_rectification_case_status"
+183 -1
View File
@@ -5,6 +5,8 @@ import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { unionStillValidRange } from "../src/lib/rectification-agentic/core/credible-range.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { composeChoiceNarration } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { isPersistedFocusId } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { posteriorMap } from "../src/lib/rectification-agentic/core/decision-fingerprint.ts";
import { holdoutEventIds } from "../src/lib/rectification-agentic/core/split-holdout.ts";
import { selectDiscriminatorProbe, buildCandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
@@ -164,8 +166,11 @@ test("hidden case walks collection through holdout to a range or representative
assert.ok(closed.nextAction === "ready_to_adopt" || closed.nextAction === "complete_with_range");
assert.equal(closed.canAdopt, true);
assert.equal(closed.canConfirmExactMinute, false);
assert.equal(closed.validated, true);
assert.equal(closed.completionStatus, "validated_range");
assert.ok(closed.credibleRange);
assert.notEqual(closed.sessionOutcome, "discriminate_candidates");
assert.notEqual(closed.sessionOutcome, "provisional_range_user_stopped");
});
test("holdout failure returns to candidate discrimination", () => {
@@ -204,7 +209,6 @@ test("holdout failure returns to candidate discrimination", () => {
user_meaning: retry.question,
information_gain: retry.information_gain,
expected_outcomes: retry.expected_outcomes,
candidate_ids: retry.candidate_ids,
}],
});
const next = decideRectification({
@@ -215,3 +219,181 @@ test("holdout failure returns to candidate discrimination", () => {
});
assert.equal(next.nextAction, "ask_candidate_discriminator");
});
test("mutated years and domains do not keep asking exam-quality copy", () => {
const shifted = probe({
id: "p-health",
domain: "health",
year: 2023,
yesSupports: ["05:00"],
noSupports: ["05:20"],
});
const state = buildInferenceState({
range_start: "04:50",
range_end: "05:30",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 10 },
{ id: "05:20", time: "05:20", relative_support: 4 },
],
events: [
{ id: "e1", domain: "health", year: 2023, precision: "month" },
{ id: "e2", domain: "relocation", year: 2027, precision: "year" },
{ id: "e3", domain: "family", year: 2028, precision: "year" },
{ id: "e4", domain: "career", year: 2033, precision: "year" },
],
probes: [shifted],
});
const packet = buildCandidateContrastPacket({
candidateSetVersion: state.candidate_set_id,
calculationResultId: "11111111-1111-4111-8111-111111111111",
engineProbes: [{
semantic_key: shifted.semantic_key,
candidate_split_hash: shifted.candidate_split_hash,
domain: shifted.domain,
year: shifted.year,
user_meaning: shifted.question,
information_gain: shifted.information_gain,
expected_outcomes: shifted.expected_outcomes,
}],
});
const discriminator = selectDiscriminatorProbe(packet);
assert.ok(discriminator);
assert.equal(discriminator.domain, "health");
assert.equal(discriminator.year, 2023);
assert.doesNotMatch(discriminator.question, /高考|发挥失常|搬家/);
assert.doesNotMatch(JSON.stringify(state.probes), /高考|发挥失常/);
});
test("user stop is an unvalidated range, not a holdout pass", () => {
const stopped = decideRectification({
methodCoverageAll: true,
userStopped: true,
candidateScores: [
{ time: "05:00", score: 34 },
{ time: "05:06", score: 33 },
{ time: "05:07", score: 33 },
],
holdoutValidation: "not_started",
});
assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped");
assert.equal(stopped.validated, false);
assert.equal(stopped.completionStatus, "provisional_range_user_stopped");
});
test("tied candidates without a high-information probe offer a range instead of inventing left/right", () => {
const next = decideRectification({
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: [
{ time: "05:00", score: 34 },
{ time: "05:06", score: 33 },
{ time: "05:07", score: 33 },
],
discriminatorProbe: null,
holdoutValidation: "unavailable",
});
assert.equal(next.nextAction, "offer_provisional_range");
assert.equal(next.sessionOutcome, "provisional_range");
assert.equal(next.validated, false);
assert.equal(next.probe, null);
});
test("narrator failure still leaves the choice applied and does not ask the user to repeat it", () => {
const applied = composeChoiceNarration({
optionId: "A",
scoring: true,
appliedInference: true,
});
assert.match(applied, /已记录你的选择/);
assert.doesNotMatch(applied, /请再选一次|重新回答/);
const stopped = composeChoiceNarration({
optionId: "stop",
scoring: true,
appliedInference: false,
});
assert.match(stopped, /独立核对尚未完成/);
assert.match(stopped, /不是最终校正结果/);
assert.doesNotMatch(stopped, /已完成验证/);
});
test("legacy derived question ids are not treated as persisted focus identity", () => {
assert.equal(isPersistedFocusId("d10_career:career_style:score"), false);
assert.equal(isPersistedFocusId("question-1"), false);
assert.equal(isPersistedFocusId("career-month-question"), false);
assert.equal(isPersistedFocusId("11111111-1111-4111-8111-111111111111"), true);
});
test("case A: two then three events keep collecting; four training events may discriminate", () => {
const events = [
{ id: "e1", domain: "education", year: 2016, precision: "month" as const },
{ id: "e2", domain: "career", year: 2020, precision: "month" as const },
{ id: "e3", domain: "career", year: 2020, precision: "month" as const },
{ id: "e4", domain: "relationship", year: 2024, precision: "month" as const },
{ id: "e5", domain: "career", year: 2026, precision: "day" as const },
];
const discriminator = probe({
id: "p-career",
domain: "career",
year: 2020,
yesSupports: ["05:00"],
noSupports: ["05:20"],
});
function nextFor(count: number) {
const state = buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 10 },
{ id: "05:20", time: "05:20", relative_support: 4 },
],
events: events.slice(0, count),
probes: [discriminator],
});
const packet = buildCandidateContrastPacket({
candidateSetVersion: state.candidate_set_id,
calculationResultId: "11111111-1111-4111-8111-111111111111",
engineProbes: state.probes.map((item) => ({
semantic_key: item.semantic_key,
candidate_split_hash: item.candidate_split_hash,
domain: item.domain,
year: item.year,
user_meaning: item.question,
information_gain: item.information_gain,
expected_outcomes: item.expected_outcomes,
candidate_ids: item.candidate_ids,
})),
});
return decideRectification({
methodCoverageAll: true,
trainingGateOpen: state.events.filter((item) => item.usage === "training").length >= 3
&& new Set(state.events.filter((item) => item.usage === "training").map((item) => item.domain)).size >= 2,
candidateScores: state.candidates.map((item) => ({ time: item.time, score: item.posterior_score })),
discriminatorProbe: selectDiscriminatorProbe(packet),
holdoutValidation: state.events.some((item) => item.usage === "holdout") ? "not_started" : "unavailable",
});
}
assert.equal(nextFor(2).nextAction, "ask_fact_collection");
assert.equal(nextFor(3).nextAction, "ask_fact_collection");
const ready = nextFor(4);
assert.equal(ready.nextAction, "ask_candidate_discriminator");
assert.ok(ready.probe);
assert.ok(ready.probe.informationGain > 0);
assert.ok(ready.probe.expectedOutcomes.length >= 2);
const mapped = new Set(ready.probe.expectedOutcomes.flatMap((row) => [
...row.supportsCandidateIds,
...row.conflictsCandidateIds,
]));
assert.ok(mapped.size >= 2);
const five = nextFor(5);
assert.equal(five.nextAction, "ask_candidate_discriminator");
assert.ok(holdoutEventIds(buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 10 },
{ id: "05:20", time: "05:20", relative_support: 4 },
],
events,
probes: [discriminator],
}).events).size >= 1);
});
@@ -89,7 +89,7 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /盘外核对(不计分)/);
assert.match(prompt, /verify_adopted_time/);
assert.match(prompt, /event_probe/);
assert.match(prompt, /至少 3 件/);
assert.match(prompt, /至少 3 条训练事件/);
assert.match(prompt, /2 个领域/);
assert.match(prompt, /发挥质量/);
assert.doesNotMatch(prompt, /两套盘各自的前事/);
+15 -1
View File
@@ -77,8 +77,22 @@ def scoreable_event_stats(events: Sequence[dict[str, Any]] | None) -> tuple[int,
return len(scoreable), len(domains), domains
def training_scoreable_stats(events: Sequence[dict[str, Any]] | None) -> tuple[int, int, frozenset[str]]:
from scripts.rectification.case_holdout import holdout_event_ids
holdout = holdout_event_ids(events)
training = [
event for event in (events or [])
if isinstance(event, dict)
and is_primary_scoreable_dict(event)
and str(event.get("id") or "") not in holdout
]
domains = frozenset(str(event["domain"]) for event in training)
return len(training), len(domains), domains
def discriminator_gate_open(events: Sequence[dict[str, Any]] | None) -> bool:
count, domain_count, _ = scoreable_event_stats(events)
count, domain_count, _ = training_scoreable_stats(events)
return count >= MIN_DISCRIMINATOR_EVENTS and domain_count >= MIN_DISCRIMINATOR_DOMAINS
+10 -4
View File
@@ -16,6 +16,7 @@ from scripts.rectification.house_table import compact_house_table_from_contexts
from scripts.rectification.horary_observation import build_horary_observation
from scripts.rectification.refinement_packet import build_refinement_packet
from scripts.rectification.candidate_contrast import context_time, select_signature_representatives
from scripts.rectification.case_holdout import holdout_event_ids
from scripts.rectification.scoring_service import precision_weight
from scripts.rectification.sealed_holdout import holdout_passed, load_sealed_minute_holdout
from scripts.rectification_policy import (
@@ -424,11 +425,16 @@ def build_decision_receipt(
diagnostics: dict[str, Any],
) -> dict[str, Any]:
scoreable_events = [event for event in request["events"] if is_primary_scoreable_event(event)]
domains = sorted({event["domain"] for event in scoreable_events})
holdout = holdout_event_ids(request["events"])
training_events = [
event for event in scoreable_events
if str(event.get("id") or "") not in holdout
]
domains = sorted({event["domain"] for event in training_events})
candidate_presence = _gate(bool(candidate_decisions), candidate_count=len(candidate_decisions))
event_quality = _gate(
len(scoreable_events) >= MIN_ACCEPTANCE_EVENTS,
scoreable_event_count=len(scoreable_events),
len(training_events) >= MIN_ACCEPTANCE_EVENTS,
scoreable_event_count=len(training_events),
minimum=MIN_ACCEPTANCE_EVENTS,
)
domain_diversity = _gate(
@@ -437,7 +443,7 @@ def build_decision_receipt(
minimum=MIN_ACCEPTANCE_DOMAINS,
domains=domains,
)
date_quality = _date_quality(scoreable_events)
date_quality = _date_quality(training_events)
top_tied_count = candidate_decisions[0]["tied_minute_count"] if candidate_decisions else 0
unique_top = _gate(top_tied_count == 1, tied_minute_count=top_tied_count)
diagnostic_quality = _diagnostic_quality(diagnostics)
+3 -1
View File
@@ -307,7 +307,9 @@ def score_from_matrix(request: RectificationRequest, built: dict[str, Any]) -> l
continue
if event["id"] in holdout:
continue
contribution = built["matrix"][event["id"]][candidate_time]
contribution = (built.get("matrix") or {}).get(event["id"], {}).get(candidate_time)
if not isinstance(contribution, dict):
continue
evidence.append({
"event_id": event["id"], "domain": event["domain"], "candidate_time": candidate_time,
"rule_ids": contribution["rule_ids"], "points": contribution["points"],
+54 -2
View File
@@ -8,9 +8,11 @@ from scripts.rectification.candidate_contrast import (
MIN_DISCRIMINATOR_DOMAINS,
MIN_DISCRIMINATOR_EVENTS,
SIGNATURE_LAYERS,
discriminator_gate_open,
distinguish_contract_errors,
feature_signature,
select_signature_representatives,
training_scoreable_stats,
)
from scripts.rectification.event_probes import (
candidate_contrast_opportunities,
@@ -90,6 +92,7 @@ def _gate_events() -> list[dict]:
{"id": "e1", "domain": "education", "event_kind": "education_start", "date": "2014-09-01", "precision": "month"},
{"id": "e2", "domain": "education", "event_kind": "education_completion", "date": "2017-06-01", "precision": "month"},
{"id": "e3", "domain": "career", "event_kind": "career_entry", "date": "2018-07-01", "precision": "month"},
{"id": "e4", "domain": "relationship", "event_kind": "relationship_start", "date": "2021-08-01", "precision": "month"},
]
@@ -190,6 +193,45 @@ class DiscriminatorContractTest(unittest.TestCase):
self.assertGreaterEqual(MIN_DISCRIMINATOR_EVENTS, 3)
self.assertGreaterEqual(MIN_DISCRIMINATOR_DOMAINS, 2)
def test_training_gate_needs_four_events_when_one_is_holdout(self) -> None:
two = _gate_events()[:2]
three = _gate_events()[:3]
four = _gate_events()
self.assertFalse(discriminator_gate_open(two))
self.assertFalse(discriminator_gate_open(three))
two_count, _, _ = training_scoreable_stats(two)
three_count, three_domains, _ = training_scoreable_stats(three)
four_count, four_domains, _ = training_scoreable_stats(four)
self.assertLess(two_count, MIN_DISCRIMINATOR_EVENTS)
self.assertLess(three_count, MIN_DISCRIMINATOR_EVENTS)
self.assertGreaterEqual(four_count, MIN_DISCRIMINATOR_EVENTS)
self.assertGreaterEqual(four_domains, MIN_DISCRIMINATOR_DOMAINS)
built = {
"static_contexts": [
_context("05:13", d4_asc=0, sun_house=4, sun_varga_sign=3),
_context("05:40", d4_asc=1, sun_house=10, sun_varga_sign=9),
]
}
three_probes = discriminating_event_probes(
{"birth_date": "1997-08-08", "events": three},
built,
scan=window_scan(built),
candidate_times=["05:13", "05:40"],
representative_time="05:13",
today=date(2026, 8, 22),
)
four_probes = discriminating_event_probes(
{"birth_date": "1997-08-08", "events": four},
built,
scan=window_scan(built),
candidate_times=["05:13", "05:40"],
representative_time="05:13",
today=date(2026, 8, 22),
)
self.assertEqual(three_probes, [])
self.assertTrue(four_probes)
self.assertGreater(three_domains, 0)
def test_signature_clusters_are_not_three_adjacent_minutes(self) -> None:
rows = [
{"time": "05:13", "score": 20},
@@ -286,6 +328,10 @@ class DiscriminatorContractTest(unittest.TestCase):
"05:00": {"points": 100, "rule_ids": []},
"05:20": {"points": 0, "rule_ids": []},
},
"e4": {
"05:00": {"points": 4, "rule_ids": []},
"05:20": {"points": 1, "rule_ids": []},
},
},
"missing_layers": [],
}
@@ -311,9 +357,15 @@ class DiscriminatorContractTest(unittest.TestCase):
}
rows = score_from_matrix(request, built)
by_time = {row["time"]: row for row in rows}
self.assertEqual(by_time["05:00"]["score"], 20)
training_ids = {event["id"] for event in events} - holdout
expected = sum(
built["matrix"][event_id]["05:00"]["points"]
for event_id in training_ids
if event_id in built["matrix"]
)
self.assertEqual(by_time["05:00"]["score"], expected)
self.assertFalse(any(item["event_id"] == holdout_id for item in by_time["05:00"]["evidence"]))
self.assertIn(holdout_id, built["matrix"])
self.assertIn(holdout_id, {event["id"] for event in events})
probes = discriminating_event_probes(
_request(events=events),
@@ -48,6 +48,15 @@ def _request(*, extra_events=()):
"date_start": "2015-06-01",
"date_end": "2015-06-01",
},
{
"id": "00000000-0000-4000-8000-000000000010",
"domain": "career",
"summary": "职责变化",
"event_kind": "career_change",
"precision": "day",
"date_start": "2019-04-01",
"date_end": "2019-04-01",
},
*extra_events,
]
return {"events": events}
+8
View File
@@ -102,6 +102,14 @@ def _gate_events() -> list[dict]:
"date": "2018-07-01",
"precision": "month",
},
{
"id": "00000000-0000-4000-8000-000000000014",
"domain": "relationship",
"event_kind": "relationship_start",
"summary": "相识",
"date": "2021-08-01",
"precision": "month",
},
]
@@ -79,7 +79,8 @@ def request_events() -> dict:
{"id": "00000000-0000-4000-8000-000000000001", "domain": "career", "summary": "入职", "event_kind": "career_entry", "precision": "day", "date_start": "2016-09-15", "date_end": "2016-09-15"},
{"id": "00000000-0000-4000-8000-000000000002", "domain": "relationship", "summary": "开始一段关系", "event_kind": "relationship_start", "precision": "day", "date_start": "2018-03-01", "date_end": "2018-03-01"},
{"id": "00000000-0000-4000-8000-000000000003", "domain": "education", "summary": "毕业", "event_kind": "education_completion", "precision": "day", "date_start": "2015-06-01", "date_end": "2015-06-01"},
]
{"id": "00000000-0000-4000-8000-000000000004", "domain": "family", "summary": "家人变化", "event_kind": "family_event", "precision": "day", "date_start": "2020-01-01", "date_end": "2020-01-01"},
],
}
@@ -263,7 +264,7 @@ class RefinementPacketTest(unittest.TestCase):
self.assertNotIn("points", str(packet["event_dasha_ledger"]))
self.assertEqual(packet["lagna_contrast"]["intervals"][0]["lagna"], "金牛座")
self.assertTrue(packet["nakshatra_boundary"]["near_boundary"])
self.assertEqual(packet["oos_blind_prompts"][0]["domain"], "family")
self.assertEqual(packet["oos_blind_prompts"][0]["domain"], "finance")
self.assertFalse(packet["oos_blind_prompts"][0]["used_for_scoring"])
self.assertFalse(packet["confirmation_allowed"])
encoded = str(packet["window_scan"])
+4 -2
View File
@@ -417,10 +417,11 @@ class RectificationV5ServicesTest(unittest.TestCase):
event(1, "education", "education_start"),
event(2, "career", "promotion", precision="month"),
event(3, "finance", "finance_gain"),
event(5, "family", "family_event"),
event(4, "other", "other", precision="year"),
]
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
scored_ids = [item["id"] for item in normalized["events"][:3]]
scored_ids = [item["id"] for item in normalized["events"] if item["domain"] != "other"]
built = {
"candidate_times": ["05:13", "05:14", "05:15"],
"matrix": {
@@ -495,7 +496,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
self.assertEqual(receipt["representative_time"], "05:13")
entries = first["execution_ledger"]
background = next(item for item in entries if item.get("event_id") == normalized["events"][3]["id"])
background = next(item for item in entries if item.get("event_id") == normalized["events"][4]["id"])
self.assertEqual(background["status"], "retained_not_scored")
executed = next(item for item in entries if item.get("event_id") == normalized["events"][0]["id"])
self.assertEqual(executed["technique_layers"], ["vim_md_domain_house"])
@@ -592,6 +593,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
event(1, "career", "career_entry"),
event(2, "career", "promotion"),
event(3, "education", "education_start"),
event(4, "family", "family_event"),
]
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
built = {