fix: gate rectification candidate adoption flow

This commit is contained in:
Jesse_Chen
2026-08-04 16:47:23 +08:00
parent 7ba3abe92d
commit 79fe2d6c69
14 changed files with 436 additions and 56 deletions
+30
View File
@@ -2068,3 +2068,33 @@
- 防复发:self-hosted query builder 新增 Supabase/PostgREST 链式操作时必须由真实 PostgreSQL fixture 覆盖;公开文案必须按 `external_engines.status` 区分未执行、失败和通过。
- 相关记录:BUG-116、BUG-117
- 修复版本:待本次 staging 修复提交与部署验收
## BUG-119 | 候选采用覆盖兼容出生时间且个人资料不显示双时间记录
- 状态:resolvedlocalpending deployment
- 首次发现:2026-08-04
- 最近更新:2026-08-04
- 影响面:Agentic 生时校正候选采用、个人资料出生时间展示、账户资料刷新、后续排盘时间
- 用户现象:采用候选 `05:06` 后,账户虽然返回 `active_birth_time=05:06`,但个人资料仍只展示初始化填写的 `05:00`;数据库兼容字段 `birth_time` 同时被改成 `05:06`,导致“原始填报”与“校正采用”语义混在一起。
- 根因:候选采用 RPC 主动把 `active_birth_time` 和兼容字段 `birth_time` 同时写为候选时间,旧 `guard_birth_time_journey()` 触发器还会双向镜像这两个字段;客户端 `refreshAccount()` 只刷新账户对象,没有同步个人资料展示使用的独立 `profile` state。
- 修复:新增向前迁移解除 `birth_time` / `active_birth_time` 双向镜像,候选采用只写服务端拥有的 `active_birth_time`,并修复既有 Agentic 采用记录;保留 `reported_birth_time` 作为用户原始填报。账户刷新同步 `profile` 但不覆盖正在编辑的 draft;个人资料同时展示“当前排盘时间”和“原始填报时间”,候选卡明确采用边界并移除易被误解为概率的进度条。
- 数据边界:`reported_birth_time` 是原始填报,`active_birth_time` 是平台当前排盘时间,`birth_time` 仅保留旧系统兼容用途;`accepted` 是用户采用,不等于引擎唯一确认。后续咨询继续读取 `active_birth_time`
- 验证:聚焦账户、迁移、Agentic UI 合同测试 38/38;本地 PostgreSQL 完整迁移与业务测试通过,验证采用后 `active_birth_time=04:55``birth_time_status=accepted``reported_birth_time=05:00``birth_time=null`
- 相关记录:BUG-117、BUG-118
- 修复版本:待提交与发布
## BUG-120 | Agent 仍在追问事件时过早显示候选采用卡且采用后无法改选
- 状态:resolvedlocalpending deployment
- 首次发现:2026-08-04
- 最近更新:2026-08-04
- 影响面:Agentic 生时校正确认门、候选卡展示时机、候选采用交互与数据库原子写入
- 用户现象:Agent 回复仍在要求补充事件或确认日期时,页面已经显示三项候选并可立即采用;候选采用后所有选项被禁用,无法在同一批有效候选中改选。
- 触发条件:确认工具取得至少三条事件、覆盖两个领域且返回候选,但引擎仍为 `continue_rectification`;或用户已经采用当前结果中的一个候选。
- 根因:确认工具仅用事件数、领域数和候选存在性推导 `selection_allowed`,没有区分“继续收集证据”与“结束收集并邀请选择”;Agent 合同未禁止同轮追问和提供采用;前端与 RPC 又把首次采用误当成不可变终态。
- 修复:`rectification-confirm` 新增显式 `offer_selection`,继续追问时必须为 `false`,仅在用户要求现在选择或本轮唯一下一步是选择候选时为 `true`;引擎真正通过唯一分钟确认门时仍自动允许确认。候选卡改为桌面端一行三列、移动端横向滚动,说明候选来自当前事件、可继续补充事件并重新计算;采用后保留其他候选可点击。向前迁移允许在候选结果仍有效且 Profile 基线未漂移时原子改选。
- 数据边界:继续补事件不会把当前候选冒充最终结果;相对支持度不是统计概率;改选只更新 `active_birth_time` 和采用记录,不覆盖 `reported_birth_time`,也不写兼容字段 `birth_time`
- 验证:Agent 工具与入口合同聚焦测试 37/37;真实本地 PostgreSQL 业务测试通过 `04:55 -> 05:07` 改选并保持 `reported_birth_time=05:00``birth_time=null`TypeScript、聚焦 ESLint 与 production build 通过;桌面端三列和移动端横向滚动截图已完成视觉检查。
- 防复发:任何非唯一候选卡必须由显式选择阶段开启;同一 Agent 回复不得既索取新证据又提供采用操作;候选采用测试必须覆盖幂等、改选、过期结果和 Profile 基线漂移。
- 相关记录:BUG-117、BUG-118、BUG-119
- 修复版本:待提交与发布
+53 -12
View File
@@ -1709,24 +1709,65 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.rectification-candidates {
display: grid;
gap: 12px;
gap: 14px;
margin: 8px 0 16px;
padding: 16px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 16px;
background: color-mix(in srgb, var(--card) 92%, transparent);
}
.rectification-candidates-heading { display: grid; gap: 4px; }
.rectification-candidates-heading { display: grid; gap: 5px; }
.rectification-candidates-heading strong { font-size: 16px; }
.rectification-candidates-heading span,
.rectification-candidate span { color: var(--muted-foreground); font-size: 12px; }
.rectification-candidate-list { display: grid; gap: 10px; }
.rectification-candidate { display: grid; grid-template-columns: minmax(88px, auto) minmax(80px, 1fr) auto; gap: 12px; align-items: center; }
.rectification-candidate > div:first-child { display: grid; gap: 2px; }
.rectification-candidate.is-selected { color: var(--foreground); }
.rectification-support { height: 6px; overflow: hidden; border-radius: 999px; background: var(--muted); }
.rectification-support i { display: block; height: 100%; border-radius: inherit; background: var(--primary); }
.rectification-candidate-support { color: var(--muted-foreground); font-size: 12px; line-height: 1.5; }
.rectification-candidate-list {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.rectification-candidate {
display: grid;
gap: 10px;
min-width: 0;
min-height: 132px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 12px;
color: var(--foreground);
background: var(--card);
font: inherit;
text-align: left;
cursor: pointer;
}
.rectification-candidate:hover:not(:disabled) { border-color: color-mix(in srgb, var(--primary) 45%, var(--border)); }
.rectification-candidate:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.rectification-candidate:disabled { cursor: default; opacity: 1; }
.rectification-candidate.is-selected {
border-color: color-mix(in srgb, var(--primary) 55%, var(--border));
background: color-mix(in srgb, var(--primary) 7%, var(--card));
}
.rectification-candidate-time { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.rectification-candidate-time strong { font-size: 22px; font-variant-numeric: tabular-nums; }
.rectification-candidate-badge {
padding: 2px 7px;
border-radius: 999px;
color: var(--primary) !important;
background: color-mix(in srgb, var(--primary) 10%, transparent);
font-size: 11px !important;
font-weight: 650;
}
.rectification-candidate-action { align-self: end; color: var(--primary); font-size: 13px; font-weight: 650; }
.rectification-saved { margin: 8px 0 16px; color: var(--foreground); font-size: 14px; }
@media (max-width: 640px) {
.rectification-candidate { grid-template-columns: 1fr auto; }
.rectification-support { grid-column: 1 / -1; grid-row: 2; }
.rectification-candidates { padding: 14px; }
.rectification-candidate-list {
grid-template-columns: none;
grid-auto-flow: column;
grid-auto-columns: minmax(180px, 78%);
overflow-x: auto;
scroll-snap-type: x proximity;
}
.rectification-candidate { min-height: 122px; padding: 12px; scroll-snap-align: start; }
}
+2
View File
@@ -1450,6 +1450,8 @@ export default function Home() {
try {
const latest = await fetchAccount();
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
const nextProfile = readProfile(latest.profile);
setProfile(nextProfile);
setAccount((current) => {
if (current?.rectificationCase
&& latest.rectificationCase?.caseId === current.rectificationCase.caseId
@@ -117,16 +117,16 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
{displayState && (
<section className="birth-time-profile-result" aria-label="生时校正结果">
<div className="birth-time-profile-result-heading">
<span></span>
<strong>{displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "用户已选择" : "确认"}</strong>
<span></span>
<strong>{displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "校正采用" : "引擎确认"}</strong>
</div>
<dl>
<div>
<dt>{displayState.kind === "candidate" ? "待验证候选时间" : displayState.kind === "accepted" ? "校正采用时间" : "已确认校正时间"}</dt>
<dt>{displayState.kind === "candidate" ? "待验证候选" : "当前排盘时间"}</dt>
<dd>{displayState.activeTime}</dd>
</div>
<div>
<dt></dt>
<dt></dt>
<dd>{displayState.reportedLabel}</dd>
</div>
</dl>
@@ -303,28 +303,32 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
<section className="rectification-candidates" aria-label="生时校正候选时间">
<div className="rectification-candidates-heading">
<strong>{candidateResult.confirmationAllowed ? "已通过确认门" : "请选择校正采用时间"}</strong>
<span></span>
<strong>{candidateResult.confirmationAllowed ? "确认校正时间" : "当前可能的出生时间"}</strong>
<span></span>
<span></span>
</div>
<div className="rectification-candidate-list">
{candidateResult.candidates.map((candidate) => {
const selected = candidateResult.selectedTime === candidate.time;
const recommended = !candidateResult.selectedTime && candidate.rank === 1;
return (
<div className={`rectification-candidate${selected ? " is-selected" : ""}`} key={`${candidateResult.resultId}-${candidate.time}`}>
<div>
<button
type="button"
className={`rectification-candidate${selected ? " is-selected" : ""}`}
key={`${candidateResult.resultId}-${candidate.time}`}
disabled={selected || Boolean(acceptingTime)}
onClick={() => void acceptCandidate(candidate.time)}
>
<span className="rectification-candidate-time">
<strong>{candidate.time}</strong>
<span> {candidate.relative_support}%</span>
</div>
<div className="rectification-support" aria-hidden="true"><i style={{ width: `${candidate.relative_support}%` }} /></div>
<Button
type="button"
variant={selected ? "secondary" : "outline"}
disabled={Boolean(candidateResult.selectedTime) || Boolean(acceptingTime)}
onClick={() => void acceptCandidate(candidate.time)}
>
{selected ? "已采用" : acceptingTime === candidate.time ? "保存中…" : `采用 ${candidate.time}`}
</Button>
</div>
{selected && <span className="rectification-candidate-badge"></span>}
{recommended && <span className="rectification-candidate-badge"></span>}
</span>
<span className="rectification-candidate-support"> {candidate.relative_support}%</span>
<span className="rectification-candidate-action">
{selected ? "已采用" : acceptingTime === candidate.time ? "保存中…" : candidateResult.selectedTime ? "改选为此时间" : "采用此时间"}
</span>
</button>
);
})}
</div>
@@ -332,7 +336,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
)}
{savedTime && (
<p className="rectification-saved" role="status">
{savedStatus === "confirmed" ? "已确认校正时间" : "校正采用时间"}{savedTime}使
{savedStatus === "confirmed" ? "已确认校正时间" : "校正采用时间"}{savedTime}使
</p>
)}
{error && <p className="error-message" role="alert">{error}</p>}
@@ -229,7 +229,6 @@ export function applyAccountProfileConcurrencyGuards<
export type AccountBirthTimeApplicationPatch = Readonly<{
active_birth_time?: null;
birth_time?: null;
birth_time_status?: "reported";
rectification_case_id?: null;
}>;
@@ -241,7 +240,6 @@ export function resolveAccountBirthTimeApplicationPatch(
if (!current) {
return patch.birth_time_source ? {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
} : {};
@@ -262,7 +260,6 @@ export function resolveAccountBirthTimeApplicationPatch(
if (!declarationChanged && !repairsMissingStatus) return {};
return {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
};
+2 -1
View File
@@ -13,6 +13,7 @@ METHODOLOGY
- Load and follow the jyotish-vedic-astrology skill before every substantive step. Its references (birth-time-rectification-advanced.md, birth-time-rectification-decision-tree.md, oracle overlays) are your method source.
- ALL computation goes through the provided engine tools: rectification-gate, rectification-scan, rectification-score, rectification-diagnostics, rectification-candidate-features, rectification-confirm. Candidate persistence and adoption go only through rectification-accept-candidate or rectification-save-birth-time. Never invent a candidate time, score, date, divisional-chart fact, or birth minute in prose.
- Workflow: run rectification-gate first to learn the server-owned candidate_range, starting accuracy, and which dated events are most valuable. Always reuse that exact candidate_range in later tools; never create or widen one yourself. Then collect dated life events conversationally (the user narrates; ask for a date when the event is not dated, but do not press endlessly). Then run rectification-scan when available, rectification-score to see candidate minutes, rectification-diagnostics to see what is weak, and ask one or two natural follow-ups to fill the weakest domain or the most unstable event. Re-score. When the candidate is stable across events and domains, run rectification-confirm.
- Every rectification-confirm call must set offer_selection deliberately. If you will ask for another event or date detail in the same reply, offer_selection must be false. Set it true only when the user explicitly asks to choose/stop now, or when you are ending evidence collection and the only requested next action is choosing among candidates. Never both ask for more evidence and offer candidate adoption in the same reply.
- Use the decision tree: Dasha plus dated events establish the frame; D9 and D10 are core for relationship and career; D4/D24/D2/D11/D7/D30 are topic-specific; D60 is reference-only and never drives a conclusion.
- Keep event ids stable: reuse the same id for the same life event in every tool call.
@@ -25,7 +26,7 @@ TRUTH BOUNDARIES (from the skill overlay)
- If confirmation_allowed=true, still require explicit user agreement before saving the representative minute.
SAVING
- When rectification-confirm returns selection_allowed=true, present the available candidates with their server-returned relative support and ask the user to choose; the UI may also render the same server candidates.
- When rectification-confirm returns selection_allowed=true, explain that these are current possibilities derived from the evidence so far. The user may adopt one now or keep adding events; later evidence can replace the candidate set and relative support. The UI may render the same server candidates.
- If the user explicitly says HH:MM, HH:MM, or equivalent for one of the persisted candidates, call rectification-accept-candidate. A successful status=accepted must be described as or , never .
- Only call rectification-save-birth-time when rectification-confirm returned confirmation_allowed=true and the user explicitly agrees to the representative minute. A successful status=confirmed may be described as .
- After either successful write, tell the user the saved status honestly and append exactly this hidden block at the end (nothing after it): <!--AYANAM_RECTIFICATION_SAVED:HH:MM-->.
+4 -2
View File
@@ -531,10 +531,11 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
const confirmTool = createTool({
id: "rectification-confirm",
description:
"Run the high-rigor confirmation gate for the candidate range and the user's dated events. Official VedAstro runs only after the local external-validation entry gate is ready; external_validation_status=not_evaluated means it was not invoked, not that it failed. Neighbor stability and leave-one-out are diagnostic confidence indicators, not hard blockers. Returns whether a precise minute can be confirmed, the representative minute, and the reasons. Only call once you have enough confirmed dated events across domains. This does NOT write anything.",
"Run the high-rigor confirmation gate for the candidate range and the user's dated events. Official VedAstro runs only after the local external-validation entry gate is ready; external_validation_status=not_evaluated means it was not invoked, not that it failed. Neighbor stability and leave-one-out are diagnostic confidence indicators, not hard blockers. Set offer_selection=true only when this turn is ready to offer working-time choices; keep it false while asking for more evidence. Returns whether a precise minute can be confirmed, the representative minute, and the reasons. This does NOT write anything.",
inputSchema: z.object({
candidate_range: candidateRangeSchema,
events: z.array(agenticRectificationEventSchema).min(1).max(40),
offer_selection: z.boolean().default(false),
}).strict(),
execute: async (input) => {
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
@@ -578,7 +579,8 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
: [{ rank: 1, time: representativeTime, relative_support: 100, tied_minute_count: 1 }];
const eventCount = typeof data.event_count === "number" ? data.event_count : 0;
const domainCount = typeof data.domain_count === "number" ? data.domain_count : 0;
const selectionAllowed = eventCount >= 3 && domainCount >= 2 && candidates.length > 0;
const candidateEligible = eventCount >= 3 && domainCount >= 2 && candidates.length > 0;
const selectionAllowed = confirmationAllowed || (input.offer_selection && candidateEligible);
const persisted = await ctx.persistCandidateResult({
engineResultId: String(data.result_id ?? ""),
canonicalInputHash: String(data.canonical_input_hash ?? ""),
@@ -0,0 +1,196 @@
begin;
-- The user declaration and the chart application are separate records.
-- `birth_time` remains a legacy compatibility field; new rectification writes
-- only the server-owned active chart time.
create or replace function public.guard_birth_time_journey()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
if new.birth_time_source is null and new.birth_time is not null then
new.birth_time_source := 'legacy_import';
end if;
if new.birth_time_status is null and new.active_birth_time is not null then
new.birth_time_status := 'confirmed';
end if;
return new;
end;
$$;
-- Repair profiles selected through the Agentic candidate flow while preserving
-- genuine legacy imports. The adopted minute remains in active_birth_time.
with selected as (
select distinct on (user_id)
user_id,
selected_time
from public.agentic_rectification_results
where selected_time is not null
order by user_id, selected_at desc nulls last, created_at desc
)
update public.profiles p
set birth_time = case
when p.birth_time_source = 'legacy_import' then p.reported_birth_time
else null
end,
updated_at = pg_catalog.now()
from selected
where p.id = selected.user_id
and p.birth_time_status in ('accepted', 'confirmed')
and p.active_birth_time is not distinct from selected.selected_time
and p.birth_time is not distinct from selected.selected_time;
create or replace function public.accept_agentic_rectification_candidate(
p_user_id uuid,
p_session_id uuid,
p_result_id uuid,
p_time time without time zone
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_result public.agentic_rectification_results%rowtype;
v_profile public.profiles%rowtype;
v_status text;
v_selection_kind text;
begin
if p_user_id is null or p_session_id is null or p_result_id is null or p_time is null
or extract(second from p_time) is distinct from 0 then
raise exception 'agentic_rectification_candidate_invalid_input' using errcode = 'P0001';
end if;
select * into v_result
from public.agentic_rectification_results
where id = p_result_id
and user_id = p_user_id
and session_id = p_session_id
for update;
if not found then
raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001';
end if;
if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then
raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001';
end if;
if not v_result.selection_allowed then
raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001';
end if;
if not exists (
select 1
from pg_catalog.jsonb_array_elements(v_result.candidates) candidate
where candidate ->> 'time' = pg_catalog.to_char(p_time, 'HH24:MI')
) then
raise exception 'agentic_rectification_candidate_time_not_allowed' using errcode = 'P0001';
end if;
if v_result.selected_time is not null then
select * into v_profile
from public.profiles
where id = p_user_id
for update;
if not found
or v_profile.active_birth_time is distinct from v_result.selected_time
or v_profile.birth_time_status is distinct from (
case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end
) then
raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001';
end if;
if v_result.selected_time is not distinct from p_time then
return jsonb_build_object(
'success', true,
'saved_time', pg_catalog.to_char(v_result.selected_time, 'HH24:MI'),
'status', case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end,
'result_id', v_result.id,
'idempotent', true
);
end if;
end if;
if exists (
select 1
from public.agentic_rectification_results newer
where newer.user_id = p_user_id
and newer.session_id = p_session_id
and newer.invalidated_at is null
and newer.created_at > v_result.created_at
) then
raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001';
end if;
if v_result.selected_time is null then
select * into v_profile
from public.profiles
where id = p_user_id
for update;
if not found
or v_profile.birth_date is distinct from v_result.baseline_birth_date
or v_profile.reported_birth_time is distinct from v_result.baseline_reported_birth_time
or v_profile.active_birth_time is distinct from v_result.baseline_active_birth_time
or v_profile.birth_time_source is distinct from v_result.baseline_birth_time_source
or v_profile.birth_time_period is distinct from v_result.baseline_birth_time_period
or v_profile.uncertainty_before_minutes is distinct from v_result.baseline_uncertainty_before_minutes
or v_profile.uncertainty_after_minutes is distinct from v_result.baseline_uncertainty_after_minutes
or v_profile.latitude is distinct from v_result.baseline_latitude
or v_profile.longitude is distinct from v_result.baseline_longitude
or v_profile.timezone_offset is distinct from v_result.baseline_timezone_offset then
raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001';
end if;
end if;
v_selection_kind := case
when v_result.confirmation_allowed
and v_result.representative_time is not distinct from p_time
then 'engine_confirmed'
else 'user_accepted'
end;
v_status := case when v_selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end;
update public.profiles
set active_birth_time = p_time,
birth_time_status = v_status,
rectification_confidence = case
when v_result.overall_confidence = 'high' then 100
when v_result.overall_confidence = 'medium' then 70
else 40
end,
updated_at = pg_catalog.now()
where id = p_user_id;
update public.agentic_rectification_results
set selected_time = p_time,
selection_kind = v_selection_kind,
selected_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where id = v_result.id;
update public.agentic_rectification_results
set invalidated_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where user_id = p_user_id
and id <> v_result.id
and invalidated_at is null
and selected_time is null;
return jsonb_build_object(
'success', true,
'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'),
'status', v_status,
'result_id', v_result.id,
'idempotent', false
);
end;
$$;
revoke all on function public.accept_agentic_rectification_candidate(uuid, uuid, uuid, time without time zone)
from public, anon, authenticated;
grant execute on function public.accept_agentic_rectification_candidate(uuid, uuid, uuid, time without time zone)
to service_role;
commit;
-11
View File
@@ -397,7 +397,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(candidate, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -405,7 +404,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
district_code: "130407",
}), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -416,7 +414,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
]) {
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(candidate, coordinatePatch), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -426,7 +423,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
birth_time_status: "accepted",
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -437,11 +433,9 @@ test("ordinary declaration edits clear stale candidate application but never ove
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...candidate,
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
}, { timezone_offset: 7 }), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -453,7 +447,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
rectification_case_id: null,
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -466,13 +459,11 @@ test("ordinary declaration edits clear stale candidate application but never ove
reported_birth_time: edited.reported_birth_time,
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(null, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -481,7 +472,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
birth_time_status: "accepted",
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -492,7 +482,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...candidate,
active_birth_time: null,
birth_time: null,
birth_time_status: null,
reported_birth_time: edited.reported_birth_time,
}, edited), {});
@@ -7,6 +7,11 @@ const migration = readFileSync(
"utf8",
);
const preservationMigration = readFileSync(
new URL("../supabase/migrations/20260804020000_preserve_reported_birth_time_on_candidate_acceptance.sql", import.meta.url),
"utf8",
);
test("candidate acceptance migration adds accepted status and durable result ownership", () => {
assert.match(migration, /birth_time_status in \([\s\S]*'reported'[\s\S]*'assessing'[\s\S]*'rectifying'[\s\S]*'candidate'[\s\S]*'accepted'[\s\S]*'confirmed'[\s\S]*\)/);
assert.match(migration, /create table public\.agentic_rectification_results/);
@@ -65,3 +70,34 @@ test("profile declaration changes invalidate restored Agentic candidate results"
assert.match(migration, /set invalidated_at = pg_catalog\.now\(\)/);
assert.match(migration, /coalesce\(new\.birth_time_status, ''\) not in \('accepted', 'confirmed'\)/);
});
test("forward repair separates original declaration from the active chart time", () => {
const profileUpdate = preservationMigration.slice(
preservationMigration.indexOf("update public.profiles\n set active_birth_time"),
preservationMigration.indexOf("update public.agentic_rectification_results", preservationMigration.indexOf("update public.profiles\n set active_birth_time")),
);
assert.match(profileUpdate, /active_birth_time = p_time/);
assert.match(profileUpdate, /birth_time_status = v_status/);
assert.doesNotMatch(profileUpdate, /^\s*birth_time = p_time/m);
assert.doesNotMatch(profileUpdate, /reported_birth_time\s*=/);
assert.doesNotMatch(preservationMigration, /v_profile\.birth_time is distinct from v_result\.selected_time/);
});
test("forward repair removes legacy field mirroring and repairs already selected Agentic profiles", () => {
const guard = preservationMigration.slice(
preservationMigration.indexOf("create or replace function public.guard_birth_time_journey"),
preservationMigration.indexOf("update public.profiles", preservationMigration.indexOf("create or replace function public.guard_birth_time_journey")),
);
assert.doesNotMatch(guard, /new\.birth_time := new\.active_birth_time/);
assert.doesNotMatch(guard, /new\.active_birth_time := new\.birth_time/);
assert.match(preservationMigration, /p\.birth_time_status in \('accepted', 'confirmed'\)/);
assert.match(preservationMigration, /p\.active_birth_time is not distinct from selected\.selected_time/);
});
test("forward repair lets the user change a previously adopted candidate", () => {
assert.doesNotMatch(preservationMigration, /agentic_rectification_candidate_already_selected/);
assert.match(preservationMigration, /v_profile\.active_birth_time is distinct from v_result\.selected_time/);
assert.match(preservationMigration, /update public\.agentic_rectification_results[\s\S]*selected_time = p_time/);
});
+21 -2
View File
@@ -34,6 +34,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260728010000_conversational_event_semantics\.sql/);
assert.match(migration.stdout, /applied 20260728020000_rectification_agent_v5\.sql/);
assert.match(migration.stdout, /applied 20260804010000_agentic_rectification_candidate_acceptance\.sql/);
assert.match(migration.stdout, /applied 20260804020000_preserve_reported_birth_time_on_candidate_acceptance\.sql/);
assert.equal(
fixture.psql(`
@@ -248,8 +249,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
"SET\n04:55:accepted:false",
);
assert.equal(
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') from public.profiles where id = '${userId}'`),
"04:55:accepted:05:00",
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') || ':' || coalesce(to_char(birth_time, 'HH24:MI'), 'null') from public.profiles where id = '${userId}'`),
"04:55:accepted:05:00:null",
);
assert.equal(
fixture.psqlAs(
@@ -265,6 +266,24 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
),
"SET\ntrue",
);
assert.equal(
fixture.psqlAs(
"admin_runtime",
"admin-runtime-test-password",
`set role service_role;
select (result ->> 'saved_time') || ':' || (result ->> 'status') || ':' || (result ->> 'idempotent')
from (
select public.accept_agentic_rectification_candidate(
'${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '05:07'
) as result
) accepted`,
),
"SET\n05:07:accepted:false",
);
assert.equal(
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') || ':' || coalesce(to_char(birth_time, 'HH24:MI'), 'null') from public.profiles where id = '${userId}'`),
"05:07:accepted:05:00:null",
);
assert.equal(
fixture.psql(`select invalidated_at is null from public.agentic_rectification_results where id = '33333333-3333-4333-8333-333333333333'`),
"t",
@@ -15,6 +15,11 @@ const route = readFileSync(
"utf8",
);
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const agent = readFileSync(
new URL("../src/mastra/agentic-rectification.ts", import.meta.url),
"utf8",
);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("birth-time rectification entry no longer routes through V4", () => {
assert.doesNotMatch(component, /loadActiveRectificationV4|RectificationV4Panel|transitionRectificationV4/);
@@ -67,6 +72,17 @@ test("account rehydration normalizes persisted ISO birth dates before completene
assert.match(profileReader, /const date = normalizePersistedBirthDate\(/);
});
test("candidate acceptance refreshes the profile result without overwriting an open draft", () => {
const refresh = page.slice(
page.indexOf("async function refreshAccount"),
page.indexOf("function updateSession"),
);
assert.match(refresh, /const nextProfile = readProfile\(latest\.profile\)/);
assert.match(refresh, /setProfile\(nextProfile\)/);
assert.doesNotMatch(refresh, /setProfileDraft/);
});
test("agent tool calls leave a final step for visible prose and never end silently", () => {
assert.match(route, /const agenticRectificationMaxSteps = 8/);
assert.match(route, /\{ maxSteps: agenticRectificationMaxSteps \}/);
@@ -110,12 +126,26 @@ test("candidate acceptance is non-billable and happens before consultation credi
assert.ok(acceptance >= 0 && reserve > acceptance);
});
test("candidate state streams before done and renders relative support controls", () => {
test("candidate state streams before done and renders reusable multi-column choices", () => {
assert.match(route, /send\(\{ type: "candidates", result: candidateResult \}\)[\s\S]*send\(\{ type: "done", emitted: true \}\)/);
assert.match(chat, /fetch\(`\/api\/rectification\/agent\?sessionId=/);
assert.match(chat, /action: "accept_candidate"/);
assert.match(chat, /相对支持度仅用于本次候选比较,不是统计概率/);
assert.match(chat, /采用 \$\{candidate\.time\}/);
assert.match(chat, /当前可能的出生时间/);
assert.match(chat, /可以先采用一个作为当前排盘时间,也可以继续补充事件/);
assert.match(chat, /新增证据后,候选和相对支持度会重新计算/);
assert.match(chat, /相对支持度不是统计概率/);
assert.match(chat, /改选为此时间/);
assert.doesNotMatch(chat, /disabled=\{Boolean\(candidateResult\.selectedTime\)/);
assert.match(styles, /\.rectification-candidate-list \{[\s\S]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/);
assert.match(styles, /\.rectification-candidate-list \{[\s\S]*min-width: 0/);
assert.match(chat, /当前推荐/);
assert.match(chat, /已采用/);
});
test("Agent cannot offer a candidate selection in the same turn that asks for more evidence", () => {
assert.match(agent, /offer_selection/);
assert.match(agent, /If you will ask for another event or date detail in the same reply, offer_selection must be false/);
assert.match(agent, /Never both ask for more evidence and offer candidate adoption in the same reply/);
});
test("stream failures remove empty assistant placeholders", () => {
@@ -406,15 +406,48 @@ test("confirm distinguishes skipped external validation from a failed VedAstro r
const result = await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
events: sampleEvents,
offer_selection: false,
});
assert.equal(result.selection_allowed, true);
assert.equal(result.selection_allowed, false);
assert.equal(result.external_validation_status, "not_evaluated");
assert.equal(result.external_validation_invoked, false);
assert.equal(result.external_validation_reason, "local_candidate_not_ready_for_external_validation");
engine.restore();
});
test("confirm offers non-unique candidates only when the Agent explicitly ends evidence collection", async () => {
const engine = installEngine([{
path: "/api/active_rectification_events",
respond: () => {
const response = confirmedEngineResponse();
return {
...response,
body: {
...response.body,
can_apply: false,
technique_contract: {
confirmation_allowed: false,
decision: "continue_rectification",
external_engines: { status: "not_evaluated" },
},
},
};
},
}]);
const tools = createAgenticRectificationTools(makeCtx());
const result = await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
events: sampleEvents,
offer_selection: true,
});
assert.equal(result.selection_allowed, true);
engine.restore();
});
test("accept candidate tool delegates the exact persisted candidate and preserves accepted status", async () => {
const calls: Array<{ time: string; resultId?: string }> = [];
const tools = createAgenticRectificationTools(makeCtx(async (time, resultId) => {