fix(rectification): reset fresh cases to reported time
This commit is contained in:
@@ -3273,3 +3273,33 @@
|
|||||||
- 防复发:任何 revision state 快路径都必须验证可读性,并保留容器镜像 revision 的只读回退,不得仅以路径存在作为可消费条件。
|
- 防复发:任何 revision state 快路径都必须验证可读性,并保留容器镜像 revision 的只读回退,不得仅以路径存在作为可消费条件。
|
||||||
- 相关记录:BUG-083、BUG-192
|
- 相关记录:BUG-083、BUG-192
|
||||||
- 修复版本:本次 staging 集成提交(精确 SHA 以远端 staging 与部署结果为准)
|
- 修复版本:本次 staging 集成提交(精确 SHA 以远端 staging 与部署结果为准)
|
||||||
|
|
||||||
|
## BUG-194 | 生时校正焦点已写入却被前端误报为未完成
|
||||||
|
|
||||||
|
- 状态:resolved(本地候选,待 forward migration、staging 发布与登录态业务验收)
|
||||||
|
- 首次发现:2026-08-15
|
||||||
|
- 最近更新:2026-08-15
|
||||||
|
- 影响面:V10 生时校正 opening/message 回合的 conversation focus 持久化、运行完成状态、刷新后的 Activity receipt。
|
||||||
|
- 用户现象:Agent 已正常提出首个校正问题,但界面同时显示“设置对话焦点未完成,当前进度已保留”;刷新后失败步骤又可能从执行凭证中消失。
|
||||||
|
- 触发条件:`set_agentic_rectification_conversation_focus` 成功插入记录后返回精简字段,而 TypeScript 工具层按完整 focus row 解析;或 set-focus 真失败后,runner 仍允许已有回答文本进入 completed 路径。
|
||||||
|
- 根因:已部署 RPC 的创建返回值只有 `focus_id/status/idempotent`,与 `parseConversationFocus` 要求的 `id/case_id/question_id/intent/...` 合同不一致,导致成功写入后抛出 `invalid_focus`。同时 turn completion 没有把 set-focus 的最终失败状态作为阻断条件,旧 receipt 也只聚合 completed tools,造成实时与刷新后的失败状态不一致。
|
||||||
|
- 修复:新增 forward-only migration,使创建和幂等分支统一返回完整 `focus` row 与 `idempotent`;set-focus 最终失败时 turn fail closed 为 retryable、不得发送完成回答或结算;turn receipt 按选定 attempt 聚合每个工具最新 terminal 状态并公开安全的 `tool_activities`,客户端使用同一 reducer 恢复 failed tool 与 methods。
|
||||||
|
- 验证:聚焦回归覆盖 RPC 返回合同、失败后不得 completed、同 attempt 重试成功、receipt attempt 隔离、failed tool 持久化及刷新前后 Activity 一致性;与初始化、Case 和 migration 回归合并运行 203 passed、0 failed。远端 migration 尚未应用,仍需 staging 登录态验证最终 NDJSON、持久化 turn/receipt 与免费 opening 计费不变量。
|
||||||
|
- 防复发:数据库 RPC 返回结构必须与 TypeScript parser 共用合同测试;提出用户可见主问题前必须成功持久化对应 focus;完成凭证不得只记录成功工具或从 Agent 文本反推执行状态。
|
||||||
|
- 相关记录:BUG-176、BUG-181、BUG-185、BUG-186
|
||||||
|
- 修复版本:本次功能分支提交(精确 SHA 以提交、远程分支与 staging 发布结果为准)
|
||||||
|
|
||||||
|
## BUG-195 | 新建生时校正错误继承已采用时间和用户误差范围
|
||||||
|
|
||||||
|
- 状态:resolved(本地候选,待 staging 发布与登录态业务验收)
|
||||||
|
- 首次发现:2026-08-15
|
||||||
|
- 最近更新:2026-08-15
|
||||||
|
- 影响面:初始化出生时间采集、`homepage/new` 生时校正 Case 基线与候选搜索范围、历史 Session 恢复。
|
||||||
|
- 用户现象:用户再次新建校正时,系统可能以上一次采用的分钟而不是最初填报时间为中心,并继续继承旧的前后误差;初始化页面还要求用户选择误差分钟或大致时段。
|
||||||
|
- 触发条件:Profile 同时存在 `reported_birth_time`、历史 `active_birth_time` 和 uncertainty,或只有 period/unknown 声明时创建 fresh Case。
|
||||||
|
- 根因:fresh Case 的范围推导优先使用 `active_birth_time`,再直接读取 Profile uncertainty;初始化模型把用户声明误差和引擎搜索窗口混为同一字段,并允许用 period 或全天范围代替具体初始时间。
|
||||||
|
- 修复:初始化 UI 只采集一个具体 `reported_birth_time`,不再提供误差分钟、大致时段、范围线索或跳过入口;新填报的准确时间保存为 `reported + 0/0`,不自动宣称引擎 confirmed。`homepage/new` 只查询和使用 `reported_birth_time`,忽略历史 active minute、uncertainty 与 period;没有合法 reported time 时在调用创建 RPC 前以 `profile_incomplete` fail closed。Case 扫描所需的可移动窗口改为独立的服务器执行策略,目前以填报时间为中心使用前后 15 分钟,不再伪装成用户声明;`intent=session` 继续恢复历史 Case 自身冻结的 baseline/range。
|
||||||
|
- 验证:回归覆盖新初始化只展示单一时间输入、`0/0` 持久化但不确认、旧 uncertainty 不影响 fresh range、旧 active minute 不进入 baseline、period/unknown/无具体时间 legacy profile 不得新建、失败前不调用 RPC,以及 session 恢复不读取当前 Profile;与 Focus、receipt 和 migration 回归合并运行 203 passed、0 failed。
|
||||||
|
- 防复发:`reported_birth_time` 是 fresh Case 唯一用户时间基线;`active_birth_time` 只表示已采用的当前排盘时间,不能反向改写新校正起点;用户声明字段、服务器搜索策略与最终 confirmed truth 必须保持分层。
|
||||||
|
- 相关记录:BUG-127、BUG-177、BUG-187
|
||||||
|
- 修复版本:本次功能分支提交(精确 SHA 以提交、远程分支与 staging 发布结果为准)
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ function turnReceipt(
|
|||||||
engine_version: receipt.engineVersion,
|
engine_version: receipt.engineVersion,
|
||||||
status: receiptStatusFromTurn(receipt.status),
|
status: receiptStatusFromTurn(receipt.status),
|
||||||
phases: receipt.phases.map((phase) => phase.phase),
|
phases: receipt.phases.map((phase) => phase.phase),
|
||||||
|
tool_activities: receipt.toolActivities.map((activity) => ({
|
||||||
|
tool: activity.tool,
|
||||||
|
status: activity.status,
|
||||||
|
methods: activity.methods,
|
||||||
|
})),
|
||||||
tools: receipt.tools,
|
tools: receipt.tools,
|
||||||
methods: receipt.methods,
|
methods: receipt.methods,
|
||||||
started_at: receipt.startedAt,
|
started_at: receipt.startedAt,
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
|
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
|
||||||
import {
|
import {
|
||||||
birthTimeDisplayState,
|
birthTimeDisplayState,
|
||||||
birthTimePeriodOptions,
|
birthTimeSourceDefaults,
|
||||||
birthTimeSourceOptions,
|
birthTimeSourceOptions,
|
||||||
type BirthTimeDraft,
|
type BirthTimeDraft,
|
||||||
type BirthTimeDraftPatch,
|
type BirthTimeDraftPatch,
|
||||||
type BirthTimeSource,
|
|
||||||
} from "@/lib/birth-time-intake-model";
|
} from "@/lib/birth-time-intake-model";
|
||||||
|
|
||||||
type BirthTimeIntakeProps = {
|
type BirthTimeIntakeProps = {
|
||||||
@@ -66,47 +65,11 @@ function BirthClockSelect({ value, onChange }: BirthClockSelectProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceDefaults = {
|
|
||||||
hospital_record: {
|
|
||||||
birthTimePeriod: "",
|
|
||||||
birthTimeClue: "",
|
|
||||||
uncertaintyBeforeMinutes: 2,
|
|
||||||
uncertaintyAfterMinutes: 2,
|
|
||||||
},
|
|
||||||
family_exact: {
|
|
||||||
birthTimePeriod: "",
|
|
||||||
birthTimeClue: "",
|
|
||||||
uncertaintyBeforeMinutes: 10,
|
|
||||||
uncertaintyAfterMinutes: 10,
|
|
||||||
},
|
|
||||||
approximate: {
|
|
||||||
birthTimePeriod: "",
|
|
||||||
birthTimeClue: "",
|
|
||||||
uncertaintyBeforeMinutes: 30,
|
|
||||||
uncertaintyAfterMinutes: 30,
|
|
||||||
},
|
|
||||||
period_only: {
|
|
||||||
reportedTime: "",
|
|
||||||
birthTimeClue: "",
|
|
||||||
uncertaintyBeforeMinutes: null,
|
|
||||||
uncertaintyAfterMinutes: null,
|
|
||||||
},
|
|
||||||
unknown: {
|
|
||||||
reportedTime: "",
|
|
||||||
birthTimePeriod: "",
|
|
||||||
uncertaintyBeforeMinutes: null,
|
|
||||||
uncertaintyAfterMinutes: null,
|
|
||||||
},
|
|
||||||
} as const satisfies Record<Exclude<BirthTimeSource, "" | "legacy_import">, BirthTimeDraftPatch>;
|
|
||||||
|
|
||||||
export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) {
|
export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) {
|
||||||
const groupId = useId();
|
const groupId = useId();
|
||||||
const source = value.birthTimeSource;
|
const source = value.birthTimeSource;
|
||||||
const isConfirmed = value.birthTimeStatus === "confirmed";
|
const isConfirmed = value.birthTimeStatus === "confirmed";
|
||||||
const displayState = birthTimeDisplayState(value);
|
const displayState = birthTimeDisplayState(value);
|
||||||
const knowledgeMode = source === "period_only" || source === "unknown"
|
|
||||||
? "uncertain"
|
|
||||||
: source ? "exact" : "";
|
|
||||||
const usesClockTime = source === "hospital_record"
|
const usesClockTime = source === "hospital_record"
|
||||||
|| source === "family_exact"
|
|| source === "family_exact"
|
||||||
|| source === "approximate"
|
|| source === "approximate"
|
||||||
@@ -151,20 +114,16 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
|
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
|
||||||
<legend>你对出生时间了解多少?</legend>
|
<legend>填写出生时间</legend>
|
||||||
<p className="birth-time-source-intro">不用猜具体分钟,先选一个最符合你的情况的选项。</p>
|
<p className="birth-time-source-intro">请填写你初始化提供的具体出生时间。</p>
|
||||||
<div className="birth-time-source-list">
|
<div className="birth-time-source-list">
|
||||||
{birthTimeSourceOptions.map((option) => (
|
{birthTimeSourceOptions.map((option) => (
|
||||||
<label
|
<label
|
||||||
className={`birth-time-source-option ${option.value === "family_exact"
|
className={`birth-time-source-option ${source === option.value ? "is-selected" : ""}`}
|
||||||
? knowledgeMode === "exact" ? "is-selected" : ""
|
|
||||||
: knowledgeMode === "uncertain" ? "is-selected" : ""}`}
|
|
||||||
key={option.value}
|
key={option.value}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
checked={option.value === "family_exact"
|
checked={source === option.value}
|
||||||
? knowledgeMode === "exact"
|
|
||||||
: knowledgeMode === "uncertain"}
|
|
||||||
name={`birth-time-source-${groupId}`}
|
name={`birth-time-source-${groupId}`}
|
||||||
type="radio"
|
type="radio"
|
||||||
value={option.value}
|
value={option.value}
|
||||||
@@ -172,7 +131,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
|||||||
birthTimeSource: option.value,
|
birthTimeSource: option.value,
|
||||||
birthTimeStatus: "reported",
|
birthTimeStatus: "reported",
|
||||||
time: "",
|
time: "",
|
||||||
...sourceDefaults[option.value],
|
...birthTimeSourceDefaults[option.value],
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
@@ -197,91 +156,6 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
|||||||
{source === "hospital_record" && (
|
{source === "hospital_record" && (
|
||||||
<p className="birth-time-detail-note">系统会无感检查前后 2 分钟的上升与 D9 / D10 稳定性。</p>
|
<p className="birth-time-detail-note">系统会无感检查前后 2 分钟的上升与 D9 / D10 稳定性。</p>
|
||||||
)}
|
)}
|
||||||
{source === "approximate" && (
|
|
||||||
<label>
|
|
||||||
<span>可能误差</span>
|
|
||||||
<Select
|
|
||||||
value={value.uncertaintyBeforeMinutes?.toString() ?? ""}
|
|
||||||
onValueChange={(nextValue) => {
|
|
||||||
if (nextValue === null) return;
|
|
||||||
const minutes = Number(nextValue);
|
|
||||||
onPatch({ uncertaintyBeforeMinutes: minutes, uncertaintyAfterMinutes: minutes });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger aria-label="可能误差">
|
|
||||||
<SelectValue placeholder="选择误差范围">
|
|
||||||
{(selectedValue) => selectedValue ? `前后 ${selectedValue} 分钟` : "选择误差范围"}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[15, 30, 60].map((minutes) => (
|
|
||||||
<SelectItem key={minutes} value={minutes.toString()}>前后 {minutes} 分钟</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{source === "period_only" && (
|
|
||||||
<div className="birth-time-detail-grid birth-time-period-details onboarding-card-reveal">
|
|
||||||
<label>
|
|
||||||
<span>请选择最接近的时间范围</span>
|
|
||||||
<Select
|
|
||||||
required
|
|
||||||
value={value.birthTimePeriod || null}
|
|
||||||
onValueChange={(nextValue) => {
|
|
||||||
if (typeof nextValue === "string") onPatch({ birthTimePeriod: nextValue });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger aria-label="最接近的时间范围">
|
|
||||||
<SelectValue placeholder="请选择大致时段">
|
|
||||||
{(selectedValue) => birthTimePeriodOptions.find((option) => option.value === selectedValue)?.label ?? "请选择大致时段"}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{birthTimePeriodOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>补充描述(可选)</span>
|
|
||||||
<textarea
|
|
||||||
maxLength={240}
|
|
||||||
placeholder="例如:天刚亮、午饭前后、家人记得大约 6—8 点"
|
|
||||||
rows={2}
|
|
||||||
value={value.birthTimeClue}
|
|
||||||
onChange={(event) => onPatch({ birthTimeClue: event.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="button-secondary birth-time-skip-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPatch({
|
|
||||||
birthTimeSource: "unknown",
|
|
||||||
reportedTime: "",
|
|
||||||
birthTimePeriod: "",
|
|
||||||
birthTimeClue: "",
|
|
||||||
uncertaintyBeforeMinutes: null,
|
|
||||||
uncertaintyAfterMinutes: null,
|
|
||||||
birthTimeStatus: "reported",
|
|
||||||
time: "",
|
|
||||||
})}
|
|
||||||
>完全不清楚,跳过出生时间</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{source === "unknown" && (
|
|
||||||
<div className="birth-time-detail-note onboarding-card-reveal" role="status">
|
|
||||||
<p>已跳过具体出生时间。保存后可以直接使用首页功能,生时校正以后需要时再做。</p>
|
|
||||||
<button
|
|
||||||
className="button-secondary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPatch({ birthTimeSource: "period_only", birthTimeStatus: "reported" })}
|
|
||||||
>我可以描述一个时间范围</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ type PersistedTurn = Readonly<{
|
|||||||
receipt?: Readonly<{
|
receipt?: Readonly<{
|
||||||
status: string;
|
status: string;
|
||||||
phases: readonly string[];
|
phases: readonly string[];
|
||||||
|
tool_activities?: readonly Readonly<{
|
||||||
|
tool: string;
|
||||||
|
status: string;
|
||||||
|
methods?: readonly string[];
|
||||||
|
}>[];
|
||||||
tools: readonly string[];
|
tools: readonly string[];
|
||||||
methods?: readonly string[];
|
methods?: readonly string[];
|
||||||
skill_name?: string;
|
skill_name?: string;
|
||||||
@@ -94,6 +99,21 @@ const ACTIVE_TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
|
|||||||
|
|
||||||
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
|
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
|
||||||
if (!receipt) return { steps: [], methods: [] };
|
if (!receipt) return { steps: [], methods: [] };
|
||||||
|
if (Array.isArray(receipt.tool_activities)) {
|
||||||
|
let state = createRectificationActivityReceiptState();
|
||||||
|
for (const activity of receipt.tool_activities) {
|
||||||
|
if (!isPublicRectificationTool(activity.tool)
|
||||||
|
|| (activity.status !== "completed" && activity.status !== "failed")) continue;
|
||||||
|
state = reduceRectificationActivityReceipt(state, {
|
||||||
|
tool: activity.tool,
|
||||||
|
status: activity.status,
|
||||||
|
methods: activity.status === "completed" && Array.isArray(activity.methods)
|
||||||
|
? activity.methods.filter(isPublicRectificationMethod)
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return receiptFromRectificationActivityState(state);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))],
|
steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))],
|
||||||
methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))],
|
methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))],
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ export const accountProfilePatchSchema = z.object({
|
|||||||
ensureNoPeriod();
|
ensureNoPeriod();
|
||||||
} else if (source === "family_exact") {
|
} else if (source === "family_exact") {
|
||||||
if (!time) addIssue("reported_birth_time", "家人记忆需要具体时间");
|
if (!time) addIssue("reported_birth_time", "家人记忆需要具体时间");
|
||||||
if (![5, 10, 15].includes(before ?? -1) || before !== after) {
|
if (![0, 5, 10, 15].includes(before ?? -1) || before !== after) {
|
||||||
addIssue("uncertainty_before_minutes", "家人记忆误差必须为前后 5、10 或 15 分钟");
|
addIssue("uncertainty_before_minutes", "准确时间填报必须前后一致");
|
||||||
}
|
}
|
||||||
ensureNoPeriod();
|
ensureNoPeriod();
|
||||||
} else if (source === "approximate") {
|
} else if (source === "approximate") {
|
||||||
|
|||||||
@@ -83,10 +83,42 @@ export function formatBirthDate(value: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const birthTimeSourceOptions = [
|
export const birthTimeSourceOptions = [
|
||||||
{ value: "family_exact", label: "我知道准确出生时间", hint: "填写后直接用于当前分析,无需先做生时校正" },
|
{ value: "family_exact", label: "我知道准确出生时间", hint: "保存为初始化填报时间,不会自动标记为引擎确认" },
|
||||||
{ value: "period_only", label: "我不确定准确时间", hint: "告诉我们大致时段;完全不清楚也可以直接跳过" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const birthTimeSourceDefaults = {
|
||||||
|
hospital_record: {
|
||||||
|
birthTimePeriod: "",
|
||||||
|
birthTimeClue: "",
|
||||||
|
uncertaintyBeforeMinutes: 2,
|
||||||
|
uncertaintyAfterMinutes: 2,
|
||||||
|
},
|
||||||
|
family_exact: {
|
||||||
|
birthTimePeriod: "",
|
||||||
|
birthTimeClue: "",
|
||||||
|
uncertaintyBeforeMinutes: 0,
|
||||||
|
uncertaintyAfterMinutes: 0,
|
||||||
|
},
|
||||||
|
approximate: {
|
||||||
|
birthTimePeriod: "",
|
||||||
|
birthTimeClue: "",
|
||||||
|
uncertaintyBeforeMinutes: 30,
|
||||||
|
uncertaintyAfterMinutes: 30,
|
||||||
|
},
|
||||||
|
period_only: {
|
||||||
|
reportedTime: "",
|
||||||
|
birthTimeClue: "",
|
||||||
|
uncertaintyBeforeMinutes: null,
|
||||||
|
uncertaintyAfterMinutes: null,
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
reportedTime: "",
|
||||||
|
birthTimePeriod: "",
|
||||||
|
uncertaintyBeforeMinutes: null,
|
||||||
|
uncertaintyAfterMinutes: null,
|
||||||
|
},
|
||||||
|
} as const satisfies Record<Exclude<BirthTimeSource, "" | "legacy_import">, BirthTimeDraftPatch>;
|
||||||
|
|
||||||
export const birthTimePeriodOptions = [
|
export const birthTimePeriodOptions = [
|
||||||
{ value: "early_morning", label: "凌晨 / 清晨(04:00—07:59)" },
|
{ value: "early_morning", label: "凌晨 / 清晨(04:00—07:59)" },
|
||||||
{ value: "morning", label: "上午(08:00—11:59)" },
|
{ value: "morning", label: "上午(08:00—11:59)" },
|
||||||
@@ -163,12 +195,14 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
|
|||||||
return isBirthClockTime(draft.reportedTime || draft.time);
|
return isBirthClockTime(draft.reportedTime || draft.time);
|
||||||
case "family_exact":
|
case "family_exact":
|
||||||
return isBirthClockTime(draft.reportedTime)
|
return isBirthClockTime(draft.reportedTime)
|
||||||
&& [5, 10, 15].includes(draft.uncertaintyBeforeMinutes ?? -1)
|
&& draft.uncertaintyBeforeMinutes === 0
|
||||||
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
|
&& draft.uncertaintyAfterMinutes === 0;
|
||||||
case "approximate":
|
case "approximate":
|
||||||
return isBirthClockTime(draft.reportedTime)
|
return isBirthClockTime(draft.reportedTime)
|
||||||
&& [15, 30, 60].includes(draft.uncertaintyBeforeMinutes ?? -1)
|
&& (draft.uncertaintyBeforeMinutes === null
|
||||||
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
|
|| [15, 30, 60].includes(draft.uncertaintyBeforeMinutes))
|
||||||
|
&& (draft.uncertaintyAfterMinutes === null
|
||||||
|
|| draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes);
|
||||||
case "period_only":
|
case "period_only":
|
||||||
return birthTimePeriodOptions.some((option) => option.value === draft.birthTimePeriod)
|
return birthTimePeriodOptions.some((option) => option.value === draft.birthTimePeriod)
|
||||||
&& !draft.reportedTime
|
&& !draft.reportedTime
|
||||||
@@ -267,9 +301,11 @@ export function birthTimePersistenceValues(draft: BirthTimeDraft) {
|
|||||||
: null;
|
: null;
|
||||||
const uncertainty = draft.birthTimeSource === "hospital_record"
|
const uncertainty = draft.birthTimeSource === "hospital_record"
|
||||||
? 2
|
? 2
|
||||||
: draft.birthTimeSource === "family_exact" || draft.birthTimeSource === "approximate"
|
: draft.birthTimeSource === "family_exact"
|
||||||
? draft.uncertaintyBeforeMinutes
|
? 0
|
||||||
: null;
|
: draft.birthTimeSource === "approximate"
|
||||||
|
? draft.uncertaintyBeforeMinutes ?? birthTimeSourceDefaults.approximate.uncertaintyBeforeMinutes
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
reported_birth_time: reportedTime,
|
reported_birth_time: reportedTime,
|
||||||
birth_time_source: draft.birthTimeSource || null,
|
birth_time_source: draft.birthTimeSource || null,
|
||||||
@@ -289,7 +325,7 @@ export function describeBirthTimeDraft(draft: BirthTimeDraft) {
|
|||||||
case "family_exact":
|
case "family_exact":
|
||||||
return `${date} ${draft.reportedTime}(填报准确时间)`;
|
return `${date} ${draft.reportedTime}(填报准确时间)`;
|
||||||
case "approximate":
|
case "approximate":
|
||||||
return `${date},约 ${draft.reportedTime}(前后 ${draft.uncertaintyBeforeMinutes} 分钟)`;
|
return `${date},约 ${draft.reportedTime}`;
|
||||||
case "period_only":
|
case "period_only":
|
||||||
return `${date},${periodLabels[draft.birthTimePeriod]}${draft.birthTimeClue.trim() ? `(${draft.birthTimeClue.trim()})` : ""}`;
|
return `${date},${periodLabels[draft.birthTimePeriod]}${draft.birthTimeClue.trim() ? `(${draft.birthTimeClue.trim()})` : ""}`;
|
||||||
case "unknown":
|
case "unknown":
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type ToolTerminalStatus = "completed" | "failed";
|
|||||||
|
|
||||||
export type RectificationActivityReceiptState = Readonly<{
|
export type RectificationActivityReceiptState = Readonly<{
|
||||||
completedSteps: readonly PublicRectificationTool[];
|
completedSteps: readonly PublicRectificationTool[];
|
||||||
methods: readonly PublicRectificationMethod[];
|
methodsByTool: Readonly<Partial<Record<PublicRectificationTool, readonly PublicRectificationMethod[]>>>;
|
||||||
terminalStatus: Readonly<Partial<Record<PublicRectificationTool, ToolTerminalStatus>>>;
|
terminalStatus: Readonly<Partial<Record<PublicRectificationTool, ToolTerminalStatus>>>;
|
||||||
failureOrder: readonly PublicRectificationTool[];
|
failureOrder: readonly PublicRectificationTool[];
|
||||||
}>;
|
}>;
|
||||||
@@ -28,7 +28,7 @@ type ReceiptActivityEvent = Readonly<{
|
|||||||
export function createRectificationActivityReceiptState(): RectificationActivityReceiptState {
|
export function createRectificationActivityReceiptState(): RectificationActivityReceiptState {
|
||||||
return {
|
return {
|
||||||
completedSteps: [],
|
completedSteps: [],
|
||||||
methods: [],
|
methodsByTool: {},
|
||||||
terminalStatus: {},
|
terminalStatus: {},
|
||||||
failureOrder: [],
|
failureOrder: [],
|
||||||
};
|
};
|
||||||
@@ -46,7 +46,10 @@ export function reduceRectificationActivityReceipt(
|
|||||||
completedSteps: state.completedSteps.includes(event.tool)
|
completedSteps: state.completedSteps.includes(event.tool)
|
||||||
? state.completedSteps
|
? state.completedSteps
|
||||||
: [...state.completedSteps, event.tool],
|
: [...state.completedSteps, event.tool],
|
||||||
methods: [...new Set([...state.methods, ...(event.methods ?? [])])],
|
methodsByTool: {
|
||||||
|
...state.methodsByTool,
|
||||||
|
[event.tool]: [...new Set(event.methods ?? [])],
|
||||||
|
},
|
||||||
terminalStatus,
|
terminalStatus,
|
||||||
failureOrder: state.failureOrder.filter((tool) => tool !== event.tool),
|
failureOrder: state.failureOrder.filter((tool) => tool !== event.tool),
|
||||||
};
|
};
|
||||||
@@ -54,6 +57,8 @@ export function reduceRectificationActivityReceipt(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
completedSteps: state.completedSteps.filter((tool) => tool !== event.tool),
|
||||||
|
methodsByTool: { ...state.methodsByTool, [event.tool]: [] },
|
||||||
terminalStatus,
|
terminalStatus,
|
||||||
failureOrder: [
|
failureOrder: [
|
||||||
...state.failureOrder.filter((tool) => tool !== event.tool),
|
...state.failureOrder.filter((tool) => tool !== event.tool),
|
||||||
@@ -70,7 +75,7 @@ export function receiptFromRectificationActivityState(
|
|||||||
.find((tool) => state.terminalStatus[tool] === "failed");
|
.find((tool) => state.terminalStatus[tool] === "failed");
|
||||||
return {
|
return {
|
||||||
steps: [...state.completedSteps],
|
steps: [...state.completedSteps],
|
||||||
methods: [...state.methods],
|
methods: [...new Set(state.completedSteps.flatMap((tool) => state.methodsByTool[tool] ?? []))],
|
||||||
...(failedTool ? { failedTool } : {}),
|
...(failedTool ? { failedTool } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const RETRYABLE_ERROR_CODES = new Set([
|
|||||||
"skill_not_loaded",
|
"skill_not_loaded",
|
||||||
"skill_not_bound",
|
"skill_not_bound",
|
||||||
"case_not_loaded",
|
"case_not_loaded",
|
||||||
|
"focus_persistence_failed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function first(value: unknown): unknown {
|
function first(value: unknown): unknown {
|
||||||
@@ -126,6 +127,7 @@ function safeErrorCode(error: unknown): string {
|
|||||||
"skill_not_bound",
|
"skill_not_bound",
|
||||||
"case_not_loaded",
|
"case_not_loaded",
|
||||||
"repeated_tool_call",
|
"repeated_tool_call",
|
||||||
|
"focus_persistence_failed",
|
||||||
]) {
|
]) {
|
||||||
if (message.includes(code)) return code;
|
if (message.includes(code)) return code;
|
||||||
}
|
}
|
||||||
@@ -327,6 +329,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
|||||||
if (!outcome.ok) {
|
if (!outcome.ok) {
|
||||||
await billing.release();
|
await billing.release();
|
||||||
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
|
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
|
||||||
|
for (const event of outcome.events) await emit(event);
|
||||||
await emit({ type: "run.failed" });
|
await emit({ type: "run.failed" });
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -334,8 +337,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
|||||||
turnStatus: outcome.status,
|
turnStatus: outcome.status,
|
||||||
skillLoaded: false,
|
skillLoaded: false,
|
||||||
answerText: "",
|
answerText: "",
|
||||||
phases: [],
|
phases: outcome.phases,
|
||||||
toolsUsed: [],
|
toolsUsed: outcome.toolsUsed,
|
||||||
errorCode: outcome.errorCode,
|
errorCode: outcome.errorCode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -439,6 +442,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
|||||||
const phases: string[] = [];
|
const phases: string[] = [];
|
||||||
const toolsUsed = new Set<string>();
|
const toolsUsed = new Set<string>();
|
||||||
const events: PublicStreamEvent[] = [];
|
const events: PublicStreamEvent[] = [];
|
||||||
|
const toolTerminalStatus = new Map<string, "completed" | "failed">();
|
||||||
const emittedKeys = new Set<string>();
|
const emittedKeys = new Set<string>();
|
||||||
const repeatedCalls = new Map<string, number>();
|
const repeatedCalls = new Map<string, number>();
|
||||||
let phaseSequence = 0;
|
let phaseSequence = 0;
|
||||||
@@ -491,7 +495,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activityEvent = mapStreamChunkToActivity(chunk as never);
|
const activityEvent = mapStreamChunkToActivity(chunk as never);
|
||||||
if (activityEvent) events.push(activityEvent);
|
if (activityEvent) {
|
||||||
|
events.push(activityEvent);
|
||||||
|
if (activityEvent.status === "completed" || activityEvent.status === "failed") {
|
||||||
|
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
const phaseEvent = mapStreamChunkToPhase(chunk as never);
|
const phaseEvent = mapStreamChunkToPhase(chunk as never);
|
||||||
if (phaseEvent) {
|
if (phaseEvent) {
|
||||||
if (phaseEvent.type === "skill.bound") {
|
if (phaseEvent.type === "skill.bound") {
|
||||||
@@ -542,6 +551,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
|||||||
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted");
|
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted");
|
||||||
if (!finished) return failedAttempt(attemptId, "stream_unfinished");
|
if (!finished) return failedAttempt(attemptId, "stream_unfinished");
|
||||||
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
|
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
|
||||||
|
if (toolTerminalStatus.get("rectification-set-focus") === "failed") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: "retryable",
|
||||||
|
errorCode: "focus_persistence_failed",
|
||||||
|
usage: { inputTokens: 0, outputTokens: 0 },
|
||||||
|
answerText: "",
|
||||||
|
answerDeltas: [],
|
||||||
|
phases,
|
||||||
|
toolsUsed: [...toolsUsed],
|
||||||
|
events,
|
||||||
|
skillBound,
|
||||||
|
caseLoaded,
|
||||||
|
attemptId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
|
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
|
||||||
await recordPhase("answer.composed");
|
await recordPhase("answer.composed");
|
||||||
|
|||||||
@@ -102,54 +102,22 @@ function numberOrNull(value: unknown): number | null {
|
|||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const periodRanges: Readonly<Record<string, { start_time: string; end_time: string }>> = {
|
|
||||||
early_morning: { start_time: "04:00", end_time: "07:59" },
|
|
||||||
morning: { start_time: "08:00", end_time: "11:59" },
|
|
||||||
afternoon: { start_time: "12:00", end_time: "17:59" },
|
|
||||||
evening: { start_time: "18:00", end_time: "22:59" },
|
|
||||||
late_night: { start_time: "23:00", end_time: "03:59" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function shiftedTime(time: string, offsetMinutes: number): string {
|
function shiftedTime(time: string, offsetMinutes: number): string {
|
||||||
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
||||||
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
|
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
|
||||||
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackUncertainty(source: string): number {
|
// This is an engine execution boundary, not a user-declared uncertainty. Fresh
|
||||||
if (source === "hospital_record" || source === "hospital") return 2;
|
// Cases always start from reported_birth_time with enough room to rectify.
|
||||||
if (source === "family_exact" || source === "family_clear") return 15;
|
const FRESH_CASE_SEARCH_RADIUS_MINUTES = 15;
|
||||||
if (source === "approximate" || source === "family_vague") return 60;
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveCandidateRange(input: {
|
function deriveCandidateRange(reportedTime: string | null): { start_time: string; end_time: string } {
|
||||||
activeTime: string | null;
|
if (!reportedTime) throw new RectificationCaseServiceError("profile_incomplete");
|
||||||
reportedTime: string | null;
|
return {
|
||||||
source: string;
|
start_time: shiftedTime(reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES),
|
||||||
period: string | null;
|
end_time: shiftedTime(reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES),
|
||||||
uncertaintyBefore: number | null;
|
};
|
||||||
uncertaintyAfter: number | null;
|
|
||||||
}): { start_time: string; end_time: string } {
|
|
||||||
const referenceTime = input.activeTime ?? input.reportedTime;
|
|
||||||
if (referenceTime) {
|
|
||||||
const fallback = fallbackUncertainty(input.source);
|
|
||||||
return {
|
|
||||||
start_time: shiftedTime(referenceTime, -(input.uncertaintyBefore ?? fallback)),
|
|
||||||
end_time: shiftedTime(referenceTime, input.uncertaintyAfter ?? fallback),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (input.source === "period_only" || input.source === "legacy_import") {
|
|
||||||
const period = input.period ? periodRanges[input.period] : undefined;
|
|
||||||
if (period) return period;
|
|
||||||
if (input.source === "period_only") {
|
|
||||||
throw new RectificationCaseServiceError("profile_incomplete");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (input.source === "unknown" || input.source === "legacy_import") {
|
|
||||||
return { start_time: "00:00", end_time: "23:59" };
|
|
||||||
}
|
|
||||||
throw new RectificationCaseServiceError("profile_incomplete");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
|
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
|
||||||
@@ -177,7 +145,7 @@ export async function loadV9RectificationProfile(
|
|||||||
const { data, error } = await accounting
|
const { data, error } = await accounting
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select(
|
.select(
|
||||||
"birth_date,birth_place_label,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset",
|
"birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset",
|
||||||
)
|
)
|
||||||
.eq("id", userId)
|
.eq("id", userId)
|
||||||
.single();
|
.single();
|
||||||
@@ -192,7 +160,6 @@ export async function loadV9RectificationProfile(
|
|||||||
const timezoneOffset = numberOrNull(row.timezone_offset);
|
const timezoneOffset = numberOrNull(row.timezone_offset);
|
||||||
const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : "";
|
const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : "";
|
||||||
const reportedTime = timeValue(row.reported_birth_time);
|
const reportedTime = timeValue(row.reported_birth_time);
|
||||||
const activeTime = timeValue(row.active_birth_time);
|
|
||||||
const period = typeof row.birth_time_period === "string" ? row.birth_time_period : null;
|
const period = typeof row.birth_time_period === "string" ? row.birth_time_period : null;
|
||||||
const uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes);
|
const uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes);
|
||||||
const uncertaintyAfter = numberOrNull(row.uncertainty_after_minutes);
|
const uncertaintyAfter = numberOrNull(row.uncertainty_after_minutes);
|
||||||
@@ -210,7 +177,8 @@ export async function loadV9RectificationProfile(
|
|||||||
birth_time_source: source,
|
birth_time_source: source,
|
||||||
birth_time_period: period,
|
birth_time_period: period,
|
||||||
reported_birth_time: reportedTime,
|
reported_birth_time: reportedTime,
|
||||||
active_birth_time: activeTime,
|
// A fresh Case must never inherit an accepted/active minute as its new baseline.
|
||||||
|
active_birth_time: null,
|
||||||
uncertainty_before_minutes: uncertaintyBefore,
|
uncertainty_before_minutes: uncertaintyBefore,
|
||||||
uncertainty_after_minutes: uncertaintyAfter,
|
uncertainty_after_minutes: uncertaintyAfter,
|
||||||
};
|
};
|
||||||
@@ -219,14 +187,7 @@ export async function loadV9RectificationProfile(
|
|||||||
userId,
|
userId,
|
||||||
baseline,
|
baseline,
|
||||||
baselineFingerprint: baselineFingerprint(baseline),
|
baselineFingerprint: baselineFingerprint(baseline),
|
||||||
candidateRange: deriveCandidateRange({
|
candidateRange: deriveCandidateRange(reportedTime),
|
||||||
activeTime,
|
|
||||||
reportedTime,
|
|
||||||
source,
|
|
||||||
period,
|
|
||||||
uncertaintyBefore,
|
|
||||||
uncertaintyAfter,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -820,6 +820,11 @@ export type V9TurnReceipt = Readonly<{
|
|||||||
skillVersion: string;
|
skillVersion: string;
|
||||||
engineVersion: string | null;
|
engineVersion: string | null;
|
||||||
phases: readonly Readonly<{ phase: string; tool: string | null }>[];
|
phases: readonly Readonly<{ phase: string; tool: string | null }>[];
|
||||||
|
toolActivities: readonly Readonly<{
|
||||||
|
tool: PublicRectificationTool;
|
||||||
|
status: "completed" | "failed";
|
||||||
|
methods: readonly PublicRectificationMethod[];
|
||||||
|
}>[];
|
||||||
tools: readonly string[];
|
tools: readonly string[];
|
||||||
methods: readonly PublicRectificationMethod[];
|
methods: readonly PublicRectificationMethod[];
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
@@ -843,6 +848,21 @@ export async function loadV9TurnReceipt(
|
|||||||
const phaseRow = item as Record<string, unknown>;
|
const phaseRow = item as Record<string, unknown>;
|
||||||
return [{ phase: String(phaseRow.phase ?? ""), tool: rowText(phaseRow.tool) }];
|
return [{ phase: String(phaseRow.phase ?? ""), tool: rowText(phaseRow.tool) }];
|
||||||
});
|
});
|
||||||
|
const toolActivities = rowArray(row.tool_activities)
|
||||||
|
.flatMap<V9TurnReceipt["toolActivities"][number]>((item) => {
|
||||||
|
const activity = rowObject(item);
|
||||||
|
if (!activity) return [];
|
||||||
|
const tool = activity.tool;
|
||||||
|
const status = activity.status;
|
||||||
|
if (!isPublicRectificationTool(tool) || (status !== "completed" && status !== "failed")) return [];
|
||||||
|
return [{
|
||||||
|
tool,
|
||||||
|
status,
|
||||||
|
methods: status === "completed"
|
||||||
|
? rowArray(activity.methods).filter(isPublicRectificationMethod)
|
||||||
|
: [],
|
||||||
|
}];
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
turnId: row.turn_id,
|
turnId: row.turn_id,
|
||||||
attemptId: rowText(row.attempt_id),
|
attemptId: rowText(row.attempt_id),
|
||||||
@@ -851,6 +871,7 @@ export async function loadV9TurnReceipt(
|
|||||||
skillVersion: String(row.skill_version ?? ""),
|
skillVersion: String(row.skill_version ?? ""),
|
||||||
engineVersion: rowText(row.engine_version),
|
engineVersion: rowText(row.engine_version),
|
||||||
phases,
|
phases,
|
||||||
|
toolActivities,
|
||||||
tools: rowArray(row.tools).map((item) => String(item)),
|
tools: rowArray(row.tools).map((item) => String(item)),
|
||||||
methods: rowArray(row.methods).filter(isPublicRectificationMethod),
|
methods: rowArray(row.methods).filter(isPublicRectificationMethod),
|
||||||
startedAt: String(row.started_at ?? ""),
|
startedAt: String(row.started_at ?? ""),
|
||||||
|
|||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
-- Rectification focus RPC and persisted activity receipt consistency.
|
||||||
|
-- Created 2026-08-15. Forward-only repair for the deployed V10 contract.
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
create or replace function public.set_agentic_rectification_conversation_focus(
|
||||||
|
p_user_id uuid,
|
||||||
|
p_case_id uuid,
|
||||||
|
p_question_id text,
|
||||||
|
p_intent text,
|
||||||
|
p_target_evidence_id uuid,
|
||||||
|
p_target_domain text,
|
||||||
|
p_target_kind text,
|
||||||
|
p_expected_answer_schema jsonb
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_case public.agentic_rectification_cases%rowtype;
|
||||||
|
v_focus public.agentic_rectification_conversation_focuses%rowtype;
|
||||||
|
begin
|
||||||
|
if p_user_id is null or p_case_id is null
|
||||||
|
or length(btrim(coalesce(p_question_id, ''))) = 0
|
||||||
|
or length(btrim(coalesce(p_intent, ''))) = 0
|
||||||
|
or p_expected_answer_schema is null
|
||||||
|
or jsonb_typeof(p_expected_answer_schema) <> 'object' then
|
||||||
|
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_case
|
||||||
|
from public.agentic_rectification_cases
|
||||||
|
where id = p_case_id and user_id = p_user_id
|
||||||
|
for update;
|
||||||
|
if not found then
|
||||||
|
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
|
||||||
|
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
if p_target_evidence_id is not null and not exists (
|
||||||
|
select 1 from public.agentic_rectification_evidence
|
||||||
|
where id = p_target_evidence_id and case_id = p_case_id
|
||||||
|
) then
|
||||||
|
raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_focus
|
||||||
|
from public.agentic_rectification_conversation_focuses
|
||||||
|
where case_id = p_case_id and question_id = p_question_id;
|
||||||
|
if found then
|
||||||
|
if v_focus.status = 'active'
|
||||||
|
and v_focus.intent = p_intent
|
||||||
|
and v_focus.target_evidence_id is not distinct from p_target_evidence_id
|
||||||
|
and v_focus.target_domain is not distinct from p_target_domain
|
||||||
|
and v_focus.target_kind is not distinct from p_target_kind
|
||||||
|
and v_focus.expected_answer_schema = p_expected_answer_schema then
|
||||||
|
return jsonb_build_object(
|
||||||
|
'focus', to_jsonb(v_focus),
|
||||||
|
'idempotent', true
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
raise exception 'agentic_rectification_focus_idempotency_conflict' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.agentic_rectification_conversation_focuses
|
||||||
|
set status = 'superseded',
|
||||||
|
resolved_at = pg_catalog.now(),
|
||||||
|
updated_at = pg_catalog.now()
|
||||||
|
where case_id = p_case_id and status = 'active';
|
||||||
|
|
||||||
|
insert into public.agentic_rectification_conversation_focuses (
|
||||||
|
case_id, question_id, intent, target_evidence_id, target_domain, target_kind,
|
||||||
|
expected_answer_schema, status, asked_at
|
||||||
|
) values (
|
||||||
|
p_case_id, p_question_id, p_intent, p_target_evidence_id, p_target_domain, p_target_kind,
|
||||||
|
p_expected_answer_schema, 'active', pg_catalog.now()
|
||||||
|
) returning * into v_focus;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'focus', to_jsonb(v_focus),
|
||||||
|
'idempotent', false
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.set_agentic_rectification_conversation_focus(
|
||||||
|
uuid, uuid, text, text, uuid, text, text, jsonb
|
||||||
|
) from public, anon, authenticated;
|
||||||
|
grant execute on function public.set_agentic_rectification_conversation_focus(
|
||||||
|
uuid, uuid, text, text, uuid, text, text, jsonb
|
||||||
|
) to service_role;
|
||||||
|
|
||||||
|
create or replace function public.get_agentic_rectification_turn_receipt(
|
||||||
|
p_user_id uuid,
|
||||||
|
p_case_id uuid,
|
||||||
|
p_turn_id uuid
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_turn public.agentic_rectification_turns%rowtype;
|
||||||
|
v_case public.agentic_rectification_cases%rowtype;
|
||||||
|
v_attempt_id uuid;
|
||||||
|
v_phases jsonb;
|
||||||
|
v_tool_activities jsonb;
|
||||||
|
v_tools jsonb;
|
||||||
|
v_methods jsonb;
|
||||||
|
v_engine_version text;
|
||||||
|
begin
|
||||||
|
if p_user_id is null or p_case_id is null or p_turn_id is null then
|
||||||
|
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
select * into v_case
|
||||||
|
from public.agentic_rectification_cases
|
||||||
|
where id = p_case_id and user_id = p_user_id;
|
||||||
|
if not found then
|
||||||
|
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
select * into v_turn
|
||||||
|
from public.agentic_rectification_turns
|
||||||
|
where id = p_turn_id and case_id = p_case_id;
|
||||||
|
if not found then
|
||||||
|
raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_attempt_id := v_turn.successful_attempt_id;
|
||||||
|
if v_attempt_id is null then
|
||||||
|
select id into v_attempt_id
|
||||||
|
from public.agentic_rectification_run_attempts
|
||||||
|
where turn_id = p_turn_id
|
||||||
|
order by attempt_number desc
|
||||||
|
limit 1;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coalesce(jsonb_agg(
|
||||||
|
jsonb_build_object('phase', rp.phase, 'tool', rp.tool_name)
|
||||||
|
order by rp.sequence, rp.created_at, rp.id
|
||||||
|
), '[]'::jsonb) into v_phases
|
||||||
|
from public.agentic_rectification_run_phases rp
|
||||||
|
where rp.turn_id = p_turn_id
|
||||||
|
and (
|
||||||
|
(v_attempt_id is not null and rp.attempt_id = v_attempt_id)
|
||||||
|
or (v_attempt_id is null and rp.attempt_id is null)
|
||||||
|
);
|
||||||
|
|
||||||
|
with latest_terminal as materialized (
|
||||||
|
select distinct on (tr.tool_name)
|
||||||
|
tr.id,
|
||||||
|
tr.tool_name,
|
||||||
|
tr.status,
|
||||||
|
tr.executed_methods,
|
||||||
|
tr.started_at
|
||||||
|
from public.agentic_rectification_tool_receipts tr
|
||||||
|
where tr.turn_id = p_turn_id
|
||||||
|
and tr.status in ('completed', 'failed')
|
||||||
|
and (
|
||||||
|
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
|
||||||
|
or (v_attempt_id is null and tr.attempt_id is null)
|
||||||
|
)
|
||||||
|
order by tr.tool_name, tr.started_at desc, tr.id desc
|
||||||
|
)
|
||||||
|
select
|
||||||
|
coalesce(jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'tool', terminal.tool_name,
|
||||||
|
'status', terminal.status,
|
||||||
|
'methods', case
|
||||||
|
when terminal.status = 'completed' then terminal.executed_methods
|
||||||
|
else '[]'::jsonb
|
||||||
|
end
|
||||||
|
) order by terminal.started_at, terminal.id
|
||||||
|
), '[]'::jsonb),
|
||||||
|
coalesce(jsonb_agg(terminal.tool_name order by terminal.started_at, terminal.id)
|
||||||
|
filter (where terminal.status = 'completed'), '[]'::jsonb)
|
||||||
|
into v_tool_activities, v_tools
|
||||||
|
from latest_terminal terminal;
|
||||||
|
|
||||||
|
with latest_terminal as materialized (
|
||||||
|
select distinct on (tr.tool_name)
|
||||||
|
tr.tool_name,
|
||||||
|
tr.status,
|
||||||
|
tr.executed_methods
|
||||||
|
from public.agentic_rectification_tool_receipts tr
|
||||||
|
where tr.turn_id = p_turn_id
|
||||||
|
and tr.status in ('completed', 'failed')
|
||||||
|
and (
|
||||||
|
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
|
||||||
|
or (v_attempt_id is null and tr.attempt_id is null)
|
||||||
|
)
|
||||||
|
order by tr.tool_name, tr.started_at desc, tr.id desc
|
||||||
|
)
|
||||||
|
select coalesce(jsonb_agg(method order by method), '[]'::jsonb) into v_methods
|
||||||
|
from (
|
||||||
|
select distinct jsonb_array_elements_text(terminal.executed_methods) as method
|
||||||
|
from latest_terminal terminal
|
||||||
|
where terminal.status = 'completed'
|
||||||
|
) methods;
|
||||||
|
|
||||||
|
select max(tr.engine_version) into v_engine_version
|
||||||
|
from public.agentic_rectification_tool_receipts tr
|
||||||
|
where tr.turn_id = p_turn_id and tr.engine_version is not null
|
||||||
|
and (
|
||||||
|
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
|
||||||
|
or (v_attempt_id is null and tr.attempt_id is null)
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'turn_id', v_turn.id,
|
||||||
|
'attempt_id', v_attempt_id,
|
||||||
|
'status', v_turn.status,
|
||||||
|
'skill_name', v_case.skill_name,
|
||||||
|
'skill_version', v_case.skill_version,
|
||||||
|
'engine_version', v_engine_version,
|
||||||
|
'phases', v_phases,
|
||||||
|
'tool_activities', v_tool_activities,
|
||||||
|
'tools', v_tools,
|
||||||
|
'methods', v_methods,
|
||||||
|
'started_at', v_turn.created_at,
|
||||||
|
'completed_at', v_turn.completed_at
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid)
|
||||||
|
from public, anon, authenticated;
|
||||||
|
grant execute on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid)
|
||||||
|
to service_role;
|
||||||
|
|
||||||
|
commit;
|
||||||
@@ -57,8 +57,8 @@ test("profile patch schema validates calendar, clock, source requirements, and l
|
|||||||
birth_time_source: "family_exact",
|
birth_time_source: "family_exact",
|
||||||
birth_time_period: null,
|
birth_time_period: null,
|
||||||
birth_time_clue: null,
|
birth_time_clue: null,
|
||||||
uncertainty_before_minutes: 10,
|
uncertainty_before_minutes: 0,
|
||||||
uncertainty_after_minutes: 10,
|
uncertainty_after_minutes: 0,
|
||||||
country_code: "CN",
|
country_code: "CN",
|
||||||
province_code: "130000",
|
province_code: "130000",
|
||||||
city_code: "130400",
|
city_code: "130400",
|
||||||
@@ -69,9 +69,10 @@ test("profile patch schema validates calendar, clock, source requirements, and l
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(accountProfilePatchSchema.safeParse(valid).success, true);
|
assert.equal(accountProfilePatchSchema.safeParse(valid).success, true);
|
||||||
|
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, uncertainty_before_minutes: 10, uncertainty_after_minutes: 10 }).success, true);
|
||||||
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, birth_date: "2001-02-29" }).success, false);
|
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, birth_date: "2001-02-29" }).success, false);
|
||||||
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, reported_birth_time: "24:00" }).success, false);
|
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, reported_birth_time: "24:00" }).success, false);
|
||||||
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, uncertainty_before_minutes: 7 }).success, false);
|
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, uncertainty_before_minutes: 7, uncertainty_after_minutes: 7 }).success, false);
|
||||||
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, latitude: 91 }).success, false);
|
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, latitude: 91 }).success, false);
|
||||||
assert.equal(accountProfilePatchSchema.safeParse({ reported_birth_time: "05:30" }).success, false);
|
assert.equal(accountProfilePatchSchema.safeParse({ reported_birth_time: "05:30" }).success, false);
|
||||||
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, longitude: null }).success, false);
|
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, longitude: null }).success, false);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
assistantIntentCopy,
|
assistantIntentCopy,
|
||||||
birthTimeDisplayState,
|
birthTimeDisplayState,
|
||||||
birthTimePersistenceValues,
|
birthTimePersistenceValues,
|
||||||
|
birthTimeSourceDefaults,
|
||||||
|
birthTimeSourceOptions,
|
||||||
describeBirthTimeDraft,
|
describeBirthTimeDraft,
|
||||||
formatBirthDate,
|
formatBirthDate,
|
||||||
isDeclaredBirthProfileComplete,
|
isDeclaredBirthProfileComplete,
|
||||||
@@ -49,10 +51,39 @@ test("birth time intake requires only the fields selected by the source", () =>
|
|||||||
|
|
||||||
assert.equal(isBirthTimeDraftReady(hospital), true);
|
assert.equal(isBirthTimeDraftReady(hospital), true);
|
||||||
assert.equal(isBirthTimeDraftReady(period), true);
|
assert.equal(isBirthTimeDraftReady(period), true);
|
||||||
assert.equal(isBirthTimeDraftReady(incompleteApproximate), false);
|
assert.equal(isBirthTimeDraftReady(incompleteApproximate), true);
|
||||||
assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true);
|
assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("family exact intake defaults and persists as reported 0/0 without engine confirmation", () => {
|
||||||
|
assert.deepEqual(birthTimeSourceDefaults.family_exact, {
|
||||||
|
birthTimePeriod: "",
|
||||||
|
birthTimeClue: "",
|
||||||
|
uncertaintyBeforeMinutes: 0,
|
||||||
|
uncertaintyAfterMinutes: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const declaredExact = {
|
||||||
|
...emptyDraft,
|
||||||
|
reportedTime: "05:00",
|
||||||
|
birthTimeSource: "family_exact",
|
||||||
|
uncertaintyBeforeMinutes: 0,
|
||||||
|
uncertaintyAfterMinutes: 0,
|
||||||
|
birthTimeStatus: "reported",
|
||||||
|
} satisfies BirthTimeDraft;
|
||||||
|
|
||||||
|
assert.equal(isBirthTimeDraftReady(declaredExact), true);
|
||||||
|
assert.equal(isBirthTimeReadyForConsultation(declaredExact), false);
|
||||||
|
assert.deepEqual(birthTimePersistenceValues(declaredExact), {
|
||||||
|
reported_birth_time: "05:00",
|
||||||
|
birth_time_source: "family_exact",
|
||||||
|
birth_time_period: null,
|
||||||
|
birth_time_clue: null,
|
||||||
|
uncertainty_before_minutes: 0,
|
||||||
|
uncertainty_after_minutes: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("an unconfirmed candidate working time remains in rectification onboarding", () => {
|
test("an unconfirmed candidate working time remains in rectification onboarding", () => {
|
||||||
const candidate = {
|
const candidate = {
|
||||||
...emptyDraft,
|
...emptyDraft,
|
||||||
@@ -71,8 +102,8 @@ test("declared birth data completes onboarding without an active or confirmed mi
|
|||||||
...emptyDraft,
|
...emptyDraft,
|
||||||
reportedTime: "05:30",
|
reportedTime: "05:30",
|
||||||
birthTimeSource: "family_exact",
|
birthTimeSource: "family_exact",
|
||||||
uncertaintyBeforeMinutes: 10,
|
uncertaintyBeforeMinutes: 0,
|
||||||
uncertaintyAfterMinutes: 10,
|
uncertaintyAfterMinutes: 0,
|
||||||
birthTimeStatus: "reported",
|
birthTimeStatus: "reported",
|
||||||
} satisfies BirthTimeDraft;
|
} satisfies BirthTimeDraft;
|
||||||
const declaredPeriod = {
|
const declaredPeriod = {
|
||||||
@@ -93,8 +124,8 @@ test("declared completeness validates the actual calendar date, clock, source fi
|
|||||||
...emptyDraft,
|
...emptyDraft,
|
||||||
birthTimeSource: "family_exact",
|
birthTimeSource: "family_exact",
|
||||||
reportedTime: "05:30",
|
reportedTime: "05:30",
|
||||||
uncertaintyBeforeMinutes: 10,
|
uncertaintyBeforeMinutes: 0,
|
||||||
uncertaintyAfterMinutes: 10,
|
uncertaintyAfterMinutes: 0,
|
||||||
} satisfies BirthTimeDraft;
|
} satisfies BirthTimeDraft;
|
||||||
|
|
||||||
assert.equal(isDeclaredBirthProfileComplete({ ...exact, date: "2000-02-29" }), true);
|
assert.equal(isDeclaredBirthProfileComplete({ ...exact, date: "2000-02-29" }), true);
|
||||||
@@ -245,7 +276,7 @@ test("birth time intake describes uncertainty without claiming false precision",
|
|||||||
uncertaintyAfterMinutes: 30,
|
uncertaintyAfterMinutes: 30,
|
||||||
} satisfies BirthTimeDraft;
|
} satisfies BirthTimeDraft;
|
||||||
|
|
||||||
assert.equal(describeBirthTimeDraft(approximate), "1993年4月17日,约 14:30(前后 30 分钟)");
|
assert.equal(describeBirthTimeDraft(approximate), "1993年4月17日,约 14:30");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
assistantIntentCopy("present_saved_candidate_range"),
|
assistantIntentCopy("present_saved_candidate_range"),
|
||||||
"目前只能保存候选范围,还没有足够证据应用到具体分钟。",
|
"目前只能保存候选范围,还没有足够证据应用到具体分钟。",
|
||||||
@@ -277,9 +308,13 @@ test("persisted birth dates normalize database ISO values without accepting inva
|
|||||||
assert.equal(normalizePersistedBirthDate("1997-08-08junk"), "");
|
assert.equal(normalizePersistedBirthDate("1997-08-08junk"), "");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("candidate copy does not claim an unconfirmed minute is automatically in use", () => {
|
test("fresh intake only offers a concrete reported time and does not auto-confirm it", () => {
|
||||||
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
|
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
assert.deepEqual(birthTimeSourceOptions.map((option) => option.value), ["family_exact"]);
|
||||||
assert.doesNotMatch(source, /已用于当前排盘/);
|
assert.doesNotMatch(source, /已用于当前排盘/);
|
||||||
|
assert.doesNotMatch(source, /可能误差|选择误差范围|最接近的时间范围|大致时段|描述一个时间范围/);
|
||||||
|
assert.doesNotMatch(source, /birthTimePeriodOptions|birthTimeSource: "period_only"|birthTimeSource: "unknown"/);
|
||||||
|
assert.match(source, /birthTimeStatus: "reported"/);
|
||||||
assert.match(source, /birthTimeConsultationOptionsCopy\(value\)/);
|
assert.match(source, /birthTimeConsultationOptionsCopy\(value\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,12 +59,36 @@ test("a later failure overrides an earlier completion for the same tool", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(receiptFromRectificationActivityState(state), {
|
assert.deepEqual(receiptFromRectificationActivityState(state), {
|
||||||
steps: ["rectification-read-case"],
|
steps: [],
|
||||||
methods: [],
|
methods: [],
|
||||||
failedTool: "rectification-read-case",
|
failedTool: "rectification-read-case",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a later failure removes methods contributed by the failed tool", () => {
|
||||||
|
let state = createRectificationActivityReceiptState();
|
||||||
|
state = reduceRectificationActivityReceipt(state, {
|
||||||
|
tool: "rectification-compare-candidates",
|
||||||
|
status: "completed",
|
||||||
|
methods: ["d1-rashi", "vimshottari-dasha"],
|
||||||
|
});
|
||||||
|
state = reduceRectificationActivityReceipt(state, {
|
||||||
|
tool: "rectification-read-diagnostics",
|
||||||
|
status: "completed",
|
||||||
|
methods: ["d1-rashi", "shadbala"],
|
||||||
|
});
|
||||||
|
state = reduceRectificationActivityReceipt(state, {
|
||||||
|
tool: "rectification-compare-candidates",
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(receiptFromRectificationActivityState(state), {
|
||||||
|
steps: ["rectification-read-diagnostics"],
|
||||||
|
methods: ["d1-rashi", "shadbala"],
|
||||||
|
failedTool: "rectification-compare-candidates",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const chatSource = readFileSync(
|
const chatSource = readFileSync(
|
||||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadV9TurnReceipt,
|
||||||
|
setV10ConversationFocus,
|
||||||
|
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||||
|
import {
|
||||||
|
createRectificationActivityReceiptState,
|
||||||
|
receiptFromRectificationActivityState,
|
||||||
|
reduceRectificationActivityReceipt,
|
||||||
|
} from "../src/lib/rectification-activity-receipt.ts";
|
||||||
|
import {
|
||||||
|
CASE_ID,
|
||||||
|
EVIDENCE_ID,
|
||||||
|
FOCUS_ID,
|
||||||
|
TURN_ID,
|
||||||
|
USER_ID,
|
||||||
|
activeFocusFixture,
|
||||||
|
fakeAccounting,
|
||||||
|
} from "./rectification-v9-test-support.ts";
|
||||||
|
|
||||||
|
const migrationFilename = "20260815010000_rectification_focus_receipt_consistency.sql";
|
||||||
|
const migration = readFileSync(
|
||||||
|
new URL(`../supabase/migrations/${migrationFilename}`, import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const migrationCopy = fileURLToPath(
|
||||||
|
new URL(`../db/migrations/${migrationFilename}`, import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
function migrationFunction(name: string, nextMarker: string): string {
|
||||||
|
const start = migration.indexOf(`create or replace function public.${name}`);
|
||||||
|
const end = migration.indexOf(nextMarker, start);
|
||||||
|
assert.notEqual(start, -1, `missing migration function ${name}`);
|
||||||
|
assert.notEqual(end, -1, `missing boundary after migration function ${name}`);
|
||||||
|
return migration.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("focus/receipt consistency migration is forward-only and business-tree only", () => {
|
||||||
|
assert.ok(migrationFilename > "20260814060000_product_feature_flags.sql");
|
||||||
|
assert.match(migration, /^-- Rectification focus RPC[\s\S]*\nbegin;[\s\S]*^commit;$/m);
|
||||||
|
assert.equal(existsSync(migrationCopy), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SQL-shaped focus contract returns the same complete row on create and idempotent replay", () => {
|
||||||
|
const focusFunction = migrationFunction(
|
||||||
|
"set_agentic_rectification_conversation_focus",
|
||||||
|
"revoke all on function public.set_agentic_rectification_conversation_focus",
|
||||||
|
);
|
||||||
|
assert.equal((focusFunction.match(/'focus', to_jsonb\(v_focus\)/g) ?? []).length, 2);
|
||||||
|
assert.match(focusFunction, /'focus', to_jsonb\(v_focus\),\s*'idempotent', true/);
|
||||||
|
assert.match(focusFunction, /'focus', to_jsonb\(v_focus\),\s*'idempotent', false/);
|
||||||
|
assert.doesNotMatch(focusFunction, /'focus_id'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tool service accepts the identical SQL focus envelope for create and replay", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const focus = activeFocusFixture({
|
||||||
|
targetEvidenceId: EVIDENCE_ID,
|
||||||
|
expectedAnswerSchema: { required: ["month"] },
|
||||||
|
});
|
||||||
|
const accounting = fakeAccounting({
|
||||||
|
set_agentic_rectification_conversation_focus: () => {
|
||||||
|
callCount += 1;
|
||||||
|
return { focus, idempotent: callCount === 2 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
questionId: "career-month-question",
|
||||||
|
intent: "clarify_event_date",
|
||||||
|
targetEvidenceId: EVIDENCE_ID,
|
||||||
|
targetDomain: "career",
|
||||||
|
targetKind: "career_entry",
|
||||||
|
expectedAnswerSchema: { required: ["month"] },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const created = await setV10ConversationFocus(accounting.client, USER_ID, CASE_ID, input);
|
||||||
|
const replayed = await setV10ConversationFocus(accounting.client, USER_ID, CASE_ID, input);
|
||||||
|
|
||||||
|
assert.deepEqual(replayed.focus, created.focus);
|
||||||
|
assert.equal(created.focus.id, FOCUS_ID);
|
||||||
|
assert.equal(created.idempotent, false);
|
||||||
|
assert.equal(replayed.idempotent, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persisted receipt selects the latest attempt and exposes latest terminal tool activity", async () => {
|
||||||
|
const receiptFunction = migrationFunction(
|
||||||
|
"get_agentic_rectification_turn_receipt",
|
||||||
|
"revoke all on function public.get_agentic_rectification_turn_receipt",
|
||||||
|
);
|
||||||
|
assert.match(receiptFunction, /where turn_id = p_turn_id\s+order by attempt_number desc\s+limit 1/);
|
||||||
|
assert.match(receiptFunction, /tr\.status in \('completed', 'failed'\)/);
|
||||||
|
assert.match(receiptFunction, /select distinct on \(tr\.tool_name\)/);
|
||||||
|
assert.match(receiptFunction, /'tool_activities', v_tool_activities/);
|
||||||
|
assert.doesNotMatch(receiptFunction, /where tr\.turn_id = p_turn_id and tr\.status = 'completed'/);
|
||||||
|
|
||||||
|
const accounting = fakeAccounting({
|
||||||
|
get_agentic_rectification_turn_receipt: () => ({
|
||||||
|
turn_id: TURN_ID,
|
||||||
|
attempt_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
status: "retryable",
|
||||||
|
skill_name: "jyotish-birth-time-rectification",
|
||||||
|
skill_version: "9.0.0",
|
||||||
|
engine_version: null,
|
||||||
|
phases: [{ phase: "run.failed", tool: null }],
|
||||||
|
tool_activities: [
|
||||||
|
{ tool: "rectification-read-case", status: "completed", methods: [] },
|
||||||
|
{ tool: "rectification-set-focus", status: "failed", methods: ["d1-rashi"] },
|
||||||
|
],
|
||||||
|
tools: ["rectification-read-case"],
|
||||||
|
methods: [],
|
||||||
|
started_at: "2026-08-15T01:00:00.000Z",
|
||||||
|
completed_at: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const receipt = await loadV9TurnReceipt(accounting.client, USER_ID, CASE_ID, TURN_ID);
|
||||||
|
assert.ok(receipt);
|
||||||
|
assert.deepEqual(receipt.toolActivities, [
|
||||||
|
{ tool: "rectification-read-case", status: "completed", methods: [] },
|
||||||
|
{ tool: "rectification-set-focus", status: "failed", methods: [] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const projectActivities = (activities: typeof receipt.toolActivities) => {
|
||||||
|
let state = createRectificationActivityReceiptState();
|
||||||
|
for (const activity of activities) {
|
||||||
|
state = reduceRectificationActivityReceipt(state, activity);
|
||||||
|
}
|
||||||
|
return receiptFromRectificationActivityState(state);
|
||||||
|
};
|
||||||
|
const persistedView = projectActivities(receipt.toolActivities);
|
||||||
|
const liveView = projectActivities([
|
||||||
|
{ tool: "rectification-read-case", status: "completed", methods: [] },
|
||||||
|
{ tool: "rectification-set-focus", status: "failed", methods: [] },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(persistedView, liveView);
|
||||||
|
assert.deepEqual(persistedView, {
|
||||||
|
steps: ["rectification-read-case"],
|
||||||
|
methods: [],
|
||||||
|
failedTool: "rectification-set-focus",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const caseRoute = readFileSync(
|
||||||
|
new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const chat = readFileSync(
|
||||||
|
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("refresh projection rebuilds the same activity receipt from persisted terminal states", () => {
|
||||||
|
assert.match(caseRoute, /tool_activities: receipt\.toolActivities\.map/);
|
||||||
|
assert.match(chat, /Array\.isArray\(receipt\.tool_activities\)/);
|
||||||
|
assert.match(chat, /reduceRectificationActivityReceipt\(state/);
|
||||||
|
assert.match(chat, /receiptFromRectificationActivityState\(state\)/);
|
||||||
|
});
|
||||||
@@ -48,7 +48,7 @@ const completeProfile = {
|
|||||||
birth_date: "1997-08-08",
|
birth_date: "1997-08-08",
|
||||||
birth_place_label: "河北省邯郸市",
|
birth_place_label: "河北省邯郸市",
|
||||||
reported_birth_time: "05:00",
|
reported_birth_time: "05:00",
|
||||||
active_birth_time: null,
|
active_birth_time: "05:08",
|
||||||
birth_time_source: "family_exact",
|
birth_time_source: "family_exact",
|
||||||
birth_time_period: null,
|
birth_time_period: null,
|
||||||
uncertainty_before_minutes: 10,
|
uncertainty_before_minutes: 10,
|
||||||
@@ -67,15 +67,19 @@ function openRpc(data: unknown): RpcHandler {
|
|||||||
return async () => ({ data, error: null });
|
return async () => ({ data, error: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
test("loadV9RectificationProfile derives baseline snapshot, fingerprint and range server-side", async () => {
|
test("loadV9RectificationProfile uses a server-owned radius around reported time", async () => {
|
||||||
const accounting = fakeAccounting({ profile: completeProfile });
|
const accounting = fakeAccounting({ profile: completeProfile });
|
||||||
const profile = await loadV9RectificationProfile(accounting, "user-1");
|
const profile = await loadV9RectificationProfile(accounting, "user-1");
|
||||||
assert.equal(profile.userId, "user-1");
|
assert.equal(profile.userId, "user-1");
|
||||||
assert.equal(profile.baseline.birth_date, "1997-08-08");
|
assert.equal(profile.baseline.birth_date, "1997-08-08");
|
||||||
assert.equal(profile.baseline.birth_place_label, "河北省邯郸市");
|
assert.equal(profile.baseline.birth_place_label, "河北省邯郸市");
|
||||||
assert.equal(profile.baseline.timezone_id, "Asia/Shanghai");
|
assert.equal(profile.baseline.timezone_id, "Asia/Shanghai");
|
||||||
|
assert.equal(profile.baseline.reported_birth_time, "05:00");
|
||||||
|
assert.equal(profile.baseline.active_birth_time, null);
|
||||||
assert.equal(profile.baselineFingerprint.length, 64);
|
assert.equal(profile.baselineFingerprint.length, 64);
|
||||||
assert.deepEqual(profile.candidateRange, { start_time: "04:50", end_time: "05:10" });
|
assert.equal(profile.baseline.uncertainty_before_minutes, 10);
|
||||||
|
assert.equal(profile.baseline.uncertainty_after_minutes, 10);
|
||||||
|
assert.deepEqual(profile.candidateRange, { start_time: "04:45", end_time: "05:15" });
|
||||||
assert.ok(!("password" in profile.baseline));
|
assert.ok(!("password" in profile.baseline));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +93,21 @@ test("loadV9RectificationProfile normalizes PostgreSQL Date birth dates", async
|
|||||||
assert.equal(profile.baseline.birth_date, "1997-08-08");
|
assert.equal(profile.baseline.birth_date, "1997-08-08");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("family exact 0/0 remains profile truth while the Case keeps a movable search window", async () => {
|
||||||
|
const accounting = fakeAccounting({
|
||||||
|
profile: {
|
||||||
|
...completeProfile,
|
||||||
|
uncertainty_before_minutes: 0,
|
||||||
|
uncertainty_after_minutes: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const profile = await loadV9RectificationProfile(accounting, "user-1");
|
||||||
|
|
||||||
|
assert.equal(profile.baseline.uncertainty_before_minutes, 0);
|
||||||
|
assert.equal(profile.baseline.uncertainty_after_minutes, 0);
|
||||||
|
assert.deepEqual(profile.candidateRange, { start_time: "04:45", end_time: "05:15" });
|
||||||
|
});
|
||||||
|
|
||||||
test("loadV9RectificationProfile rejects incomplete profiles without creating a case", async () => {
|
test("loadV9RectificationProfile rejects incomplete profiles without creating a case", async () => {
|
||||||
for (const incomplete of [
|
for (const incomplete of [
|
||||||
{ ...completeProfile, latitude: null, longitude: null, timezone_offset: null },
|
{ ...completeProfile, latitude: null, longitude: null, timezone_offset: null },
|
||||||
@@ -104,48 +123,80 @@ test("loadV9RectificationProfile rejects incomplete profiles without creating a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("period-only profiles derive a period range; unknown derives the full day", async () => {
|
test("homepage and new fail closed when a fresh profile has no reported time", async () => {
|
||||||
const period = fakeAccounting({
|
const cases = [
|
||||||
profile: {
|
{ intent: "homepage", source: "period_only", period: "morning" },
|
||||||
...completeProfile,
|
{ intent: "new", source: "unknown", period: null },
|
||||||
reported_birth_time: null,
|
{ intent: "homepage", source: "legacy_import", period: "evening" },
|
||||||
birth_time_source: "period_only",
|
] as const;
|
||||||
birth_time_period: "morning",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const periodProfile = await loadV9RectificationProfile(period, "user-1");
|
|
||||||
assert.deepEqual(periodProfile.candidateRange, { start_time: "08:00", end_time: "11:59" });
|
|
||||||
|
|
||||||
const unknown = fakeAccounting({
|
for (const item of cases) {
|
||||||
profile: {
|
let rpcCalled = false;
|
||||||
...completeProfile,
|
const accounting = fakeAccounting({
|
||||||
reported_birth_time: null,
|
profile: {
|
||||||
birth_time_source: "unknown",
|
...completeProfile,
|
||||||
birth_time_period: null,
|
reported_birth_time: null,
|
||||||
},
|
birth_time_source: item.source,
|
||||||
});
|
birth_time_period: item.period,
|
||||||
const unknownProfile = await loadV9RectificationProfile(unknown, "user-1");
|
},
|
||||||
assert.deepEqual(unknownProfile.candidateRange, { start_time: "00:00", end_time: "23:59" });
|
rpc: async () => {
|
||||||
|
rpcCalled = true;
|
||||||
|
return { data: null, error: null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => openRectificationCase(accounting, "user-1", { intent: item.intent, requestId }),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof RectificationCaseServiceError && error.code === "profile_incomplete",
|
||||||
|
);
|
||||||
|
assert.equal(rpcCalled, false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("homepage open with no history creates one case and one session", async () => {
|
test("homepage and new opens ignore active time and legacy uncertainty for their fresh range", async () => {
|
||||||
const accounting = fakeAccounting({
|
for (const intent of ["homepage", "new"] as const) {
|
||||||
profile: completeProfile,
|
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
|
||||||
rpc: openRpc({
|
const accounting = fakeAccounting({
|
||||||
disposition: "created",
|
profile: completeProfile,
|
||||||
case_id: caseId,
|
rpc: async (_fn, args) => {
|
||||||
session_id: sessionId,
|
capturedArgs.value = args;
|
||||||
status: "draft",
|
return {
|
||||||
should_start_opening: true,
|
data: {
|
||||||
skill_version: "9.0.0",
|
disposition: "created",
|
||||||
}),
|
case_id: caseId,
|
||||||
});
|
session_id: sessionId,
|
||||||
const request: OpenRectificationCaseRequest = { intent: "homepage", requestId };
|
status: "draft",
|
||||||
const response = await openRectificationCase(accounting, "user-1", request);
|
should_start_opening: true,
|
||||||
assert.equal(response.disposition, "created");
|
skill_version: "9.0.0",
|
||||||
assert.equal(response.caseId, caseId);
|
},
|
||||||
assert.equal(response.sessionId, sessionId);
|
error: null,
|
||||||
assert.equal(response.shouldStartOpening, true);
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request: OpenRectificationCaseRequest = { intent, requestId };
|
||||||
|
const response = await openRectificationCase(accounting, "user-1", request);
|
||||||
|
|
||||||
|
assert.equal(response.disposition, "created");
|
||||||
|
assert.equal(response.caseId, caseId);
|
||||||
|
assert.equal(response.sessionId, sessionId);
|
||||||
|
assert.equal(response.shouldStartOpening, true);
|
||||||
|
assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "04:45", end_time: "05:15" });
|
||||||
|
assert.deepEqual(capturedArgs.value?.p_baseline_birth_snapshot, {
|
||||||
|
birth_date: "1997-08-08",
|
||||||
|
birth_place_label: "河北省邯郸市",
|
||||||
|
latitude: 36.420487,
|
||||||
|
longitude: 114.209936,
|
||||||
|
timezone_id: "Asia/Shanghai",
|
||||||
|
timezone_offset: 8,
|
||||||
|
birth_time_source: "family_exact",
|
||||||
|
birth_time_period: null,
|
||||||
|
reported_birth_time: "05:00",
|
||||||
|
active_birth_time: null,
|
||||||
|
uncertainty_before_minutes: 10,
|
||||||
|
uncertainty_after_minutes: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("homepage open may create a new case even when resumable history exists", async () => {
|
test("homepage open may create a new case even when resumable history exists", async () => {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import {
|
|||||||
} from "./rectification-v9-test-support.ts";
|
} from "./rectification-v9-test-support.ts";
|
||||||
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||||
import { safeToolErrorCode } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
import { safeToolErrorCode } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||||
|
import {
|
||||||
|
createRectificationActivityReceiptState,
|
||||||
|
receiptFromRectificationActivityState,
|
||||||
|
reduceRectificationActivityReceipt,
|
||||||
|
} from "../src/lib/rectification-activity-receipt.ts";
|
||||||
|
|
||||||
type StreamChunk = {
|
type StreamChunk = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -748,6 +753,139 @@ for (const failureCode of ["stream_aborted", "stream_unfinished", "empty_stream"
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("a failed set-focus cannot complete a question turn even when the agent emits answer text", async () => {
|
||||||
|
let buildCount = 0;
|
||||||
|
const accounting = fakeAccounting({
|
||||||
|
...receiptHandlers,
|
||||||
|
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||||||
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||||
|
finalize_agentic_rectification_turn: (_fn, args) => ({
|
||||||
|
turn_id: TURN_ID,
|
||||||
|
status: args.p_status,
|
||||||
|
idempotent: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const failedFocusAttempt = () => attemptStream([
|
||||||
|
chunk("start"),
|
||||||
|
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
|
||||||
|
chunk("tool-result", { toolName: "skill" }),
|
||||||
|
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||||
|
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||||
|
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
|
||||||
|
chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("invalid_focus") }),
|
||||||
|
chunk("text-delta", { text: "主问题:请确认这段经历发生在哪个月?" }),
|
||||||
|
chunk("finish"),
|
||||||
|
], { inputTokens: 41, outputTokens: 23 });
|
||||||
|
const { options, emitted, billing } = runOptions({
|
||||||
|
accounting: accounting.client,
|
||||||
|
buildAgent: async () => {
|
||||||
|
buildCount += 1;
|
||||||
|
return failedFocusAttempt() as never;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runV9AgentTurn(options);
|
||||||
|
|
||||||
|
assert.equal(buildCount, 2);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.turnStatus, "retryable");
|
||||||
|
assert.equal(result.errorCode, "focus_persistence_failed");
|
||||||
|
assert.equal(result.answerText, "");
|
||||||
|
assert.deepEqual(result.toolsUsed, ["rectification-read-case", "rectification-set-focus"]);
|
||||||
|
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||||
|
assert.equal(emitted.some((event) => event.type === "answer.delta"), false);
|
||||||
|
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
|
||||||
|
assert.equal(
|
||||||
|
emitted.some((event) => event.type === "tool.activity"
|
||||||
|
&& (event as { tool?: string; status?: string }).tool === "rectification-set-focus"
|
||||||
|
&& (event as { tool?: string; status?: string }).status === "failed"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(emitted.at(-1)?.type, "run.failed");
|
||||||
|
|
||||||
|
const attemptFinalizations = accounting.calls
|
||||||
|
.filter((call) => call.fn === "finalize_agentic_rectification_run_attempt")
|
||||||
|
.map((call) => ({
|
||||||
|
status: call.args.p_status,
|
||||||
|
errorCode: call.args.p_error_code,
|
||||||
|
usage: call.args.p_usage,
|
||||||
|
}));
|
||||||
|
assert.deepEqual(attemptFinalizations, [
|
||||||
|
{ status: "retryable", errorCode: "focus_persistence_failed", usage: { inputTokens: 0, outputTokens: 0 } },
|
||||||
|
{ status: "retryable", errorCode: "focus_persistence_failed", usage: { inputTokens: 0, outputTokens: 0 } },
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
accounting.calls.some((call) => call.fn === "insert_agentic_rectification_run_phase"
|
||||||
|
&& call.args.p_phase === "run.completed"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
|
||||||
|
assert.deepEqual(finalizedTurn?.args, {
|
||||||
|
p_user_id: USER_ID,
|
||||||
|
p_case_id: CASE_ID,
|
||||||
|
p_turn_id: TURN_ID,
|
||||||
|
p_attempt_id: SECOND_ATTEMPT_ID,
|
||||||
|
p_status: "retryable",
|
||||||
|
p_assistant_message: null,
|
||||||
|
p_successful_attempt_id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a same-attempt set-focus retry that finishes completed can commit the question turn", async () => {
|
||||||
|
const accounting = fakeAccounting({
|
||||||
|
...receiptHandlers,
|
||||||
|
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||||||
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||||
|
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
|
||||||
|
});
|
||||||
|
const { options, emitted, billing } = runOptions({
|
||||||
|
accounting: accounting.client,
|
||||||
|
buildAgent: async () => attemptStream([
|
||||||
|
chunk("start"),
|
||||||
|
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
|
||||||
|
chunk("tool-result", { toolName: "skill" }),
|
||||||
|
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||||
|
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||||
|
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
|
||||||
|
chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("invalid_focus") }),
|
||||||
|
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
|
||||||
|
chunk("tool-result", { toolName: "rectification-set-focus" }),
|
||||||
|
chunk("text-delta", { text: "主问题:请确认这段经历发生在哪个月?" }),
|
||||||
|
chunk("finish"),
|
||||||
|
], { inputTokens: 43, outputTokens: 29 }) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runV9AgentTurn(options);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.turnStatus, "completed");
|
||||||
|
assert.equal(result.errorCode, null);
|
||||||
|
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
|
||||||
|
assert.deepEqual(
|
||||||
|
emitted.filter((event) => event.type === "answer.delta"),
|
||||||
|
[{ type: "answer.delta", text: "主问题:请确认这段经历发生在哪个月?" }],
|
||||||
|
);
|
||||||
|
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
|
||||||
|
|
||||||
|
let receiptState = createRectificationActivityReceiptState();
|
||||||
|
const focusStatuses: string[] = [];
|
||||||
|
for (const event of emitted) {
|
||||||
|
const activity = event as { type: string; tool?: string; status?: string };
|
||||||
|
if (activity.type !== "tool.activity" || activity.tool !== "rectification-set-focus") continue;
|
||||||
|
if (activity.status !== "started" && activity.status !== "completed" && activity.status !== "failed") continue;
|
||||||
|
focusStatuses.push(activity.status);
|
||||||
|
receiptState = reduceRectificationActivityReceipt(receiptState, {
|
||||||
|
tool: "rectification-set-focus",
|
||||||
|
status: activity.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.deepEqual(focusStatuses, ["started", "failed", "started", "completed"]);
|
||||||
|
assert.deepEqual(receiptFromRectificationActivityState(receiptState), {
|
||||||
|
steps: ["rectification-set-focus"],
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("an unclaimed V10 attempt never starts the model", async () => {
|
test("an unclaimed V10 attempt never starts the model", async () => {
|
||||||
let buildCount = 0;
|
let buildCount = 0;
|
||||||
const accounting = fakeAccounting({
|
const accounting = fakeAccounting({
|
||||||
|
|||||||
Reference in New Issue
Block a user