Merge pull request 'fix(rectification): preserve assistant turns in dossier' (#35) from codex/fix-rectification-turn-projection-20260812 into main
fix(rectification): preserve assistant turns in dossier (#35)
This commit was merged in pull request #35.
This commit is contained in:
@@ -3005,3 +3005,18 @@
|
||||
- 防复发:服务器确认路径与额外对话轮次必须分开建模;任何 pending 状态不得隐式参与正式候选评分。
|
||||
- 相关记录:BUG-170、BUG-174
|
||||
- 修复版本:本次提交(staging 精确 SHA 以发布记录为准)
|
||||
|
||||
## BUG-176 | V9 普通 Turn 的 Agent 回复在刷新后消失
|
||||
|
||||
- 状态:resolved(本地候选)
|
||||
- 首次发现:2026-08-12
|
||||
- 最近更新:2026-08-12
|
||||
- 影响面:V9 生时校正历史消息、持久化 Activity 与 Agent 下一轮上下文。
|
||||
- 用户现象:事件提交后当轮可以看到 Agent 回复和 Activity,但刷新或重新进入 Case 后只剩用户事件;opening Agent 消息仍可显示。
|
||||
- 触发条件:一条物理 `agentic_rectification_turns` 记录同时保存非空 `user_message` 与 `assistant_message`。
|
||||
- 根因:`get_agentic_rectification_case_dossier()` 把每条物理 Turn 只投影成一条逻辑消息,并用 `coalesce(user_message, assistant_message)` 优先返回用户文本,导致同一行已持久化的 Agent 回复和 receipt 关联在恢复 API 中丢失。
|
||||
- 修复:新增向前业务迁移,将每条物理 Turn 通过 lateral values 展开为按 user、assistant 排序的最多两条逻辑消息;保留同一真实 Turn ID,使 assistant 消息继续读取对应 Activity receipt。
|
||||
- 验证:迁移契约测试锁定双消息展开、空消息过滤、顺序、迁移唯一性和禁止复制到 identity migration tree;staging 需继续验证 Case GET、页面刷新、下一轮上下文及计费不变量。
|
||||
- 防复发:持久化行与对话消息不是一对一时,恢复投影必须显式展开全部逻辑消息,不得用 `coalesce` 静默舍弃其中一侧。
|
||||
- 相关记录:BUG-173、BUG-174、BUG-175
|
||||
- 修复版本:本次提交(staging 精确 SHA 以发布记录为准)
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
-- Preserve both logical messages stored by a completed rectification turn.
|
||||
-- A normal turn stores user_message and assistant_message on the same physical
|
||||
-- row; the dossier must expose both in conversational order. Business schema
|
||||
-- only: do not copy this migration into frontend/db/migrations.
|
||||
|
||||
begin;
|
||||
|
||||
create or replace function public.get_agentic_rectification_case_dossier(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_turns jsonb;
|
||||
v_evidence jsonb;
|
||||
v_result public.agentic_rectification_results%rowtype;
|
||||
v_evidence_count bigint;
|
||||
v_turn_count bigint;
|
||||
begin
|
||||
if p_user_id is null or p_case_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 coalesce(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'role', message.role,
|
||||
'text', message.text,
|
||||
'status', t.status,
|
||||
'created_at', t.created_at,
|
||||
'completed_at', t.completed_at
|
||||
) order by t.created_at, message.ordinal
|
||||
), '[]'::jsonb) into v_turns
|
||||
from public.agentic_rectification_turns t
|
||||
cross join lateral (
|
||||
values
|
||||
(1, 'user'::text, t.user_message),
|
||||
(2, 'assistant'::text, t.assistant_message)
|
||||
) as message(ordinal, role, text)
|
||||
where t.case_id = v_case.id
|
||||
and message.text is not null;
|
||||
|
||||
select coalesce(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', e.id,
|
||||
'source_turn_id', e.source_turn_id,
|
||||
'subject', e.subject,
|
||||
'event_kind', e.event_kind,
|
||||
'domain', e.domain,
|
||||
'occurred_from', e.occurred_from,
|
||||
'occurred_to', e.occurred_to,
|
||||
'date_precision', e.date_precision,
|
||||
'summary', e.summary,
|
||||
'status', e.status,
|
||||
'supersedes_evidence_id', e.supersedes_evidence_id,
|
||||
'created_at', e.created_at
|
||||
) order by e.created_at
|
||||
), '[]'::jsonb) into v_evidence
|
||||
from public.agentic_rectification_evidence e
|
||||
where e.case_id = v_case.id;
|
||||
|
||||
select count(*) into v_evidence_count
|
||||
from public.agentic_rectification_evidence
|
||||
where case_id = v_case.id;
|
||||
select count(*) into v_turn_count
|
||||
from public.agentic_rectification_turns
|
||||
where case_id = v_case.id;
|
||||
|
||||
select * into v_result
|
||||
from public.agentic_rectification_results
|
||||
where case_id = v_case.id
|
||||
and invalidated_at is null
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
|
||||
return jsonb_build_object(
|
||||
'case', jsonb_build_object(
|
||||
'case_id', v_case.id,
|
||||
'session_id', v_case.session_id,
|
||||
'status', v_case.status,
|
||||
'skill_name', v_case.skill_name,
|
||||
'skill_version', v_case.skill_version,
|
||||
'candidate_range', v_case.candidate_range,
|
||||
'accepted_time', v_case.accepted_time,
|
||||
'confirmed_time', v_case.confirmed_time,
|
||||
'completed_at', v_case.completed_at,
|
||||
'closed_reason', v_case.closed_reason,
|
||||
'last_activity_at', v_case.last_activity_at,
|
||||
'evidence_count', v_evidence_count,
|
||||
'turn_count', v_turn_count
|
||||
),
|
||||
'turns', v_turns,
|
||||
'evidence', v_evidence,
|
||||
'latest_result', case
|
||||
when v_result.id is null then null
|
||||
else jsonb_build_object(
|
||||
'result_id', v_result.id,
|
||||
'candidates', v_result.candidates,
|
||||
'overall_confidence', v_result.overall_confidence,
|
||||
'selection_allowed', v_result.selection_allowed,
|
||||
'confirmation_allowed', v_result.confirmation_allowed,
|
||||
'representative_time', v_result.representative_time,
|
||||
'selected_time', v_result.selected_time,
|
||||
'selection_kind', v_result.selection_kind,
|
||||
'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint,
|
||||
'candidate_range_fingerprint', v_result.candidate_range_fingerprint,
|
||||
'skill_version', v_result.skill_version,
|
||||
'algorithm_version', v_result.algorithm_version,
|
||||
'created_at', v_result.created_at,
|
||||
'invalidated_at', v_result.invalidated_at
|
||||
)
|
||||
end
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.get_agentic_rectification_case_dossier(uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.get_agentic_rectification_case_dossier(uuid, uuid)
|
||||
to service_role;
|
||||
|
||||
commit;
|
||||
@@ -399,3 +399,53 @@ test("birth-context activity migration stays out of the identity migration tree"
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 20260813030000_rectification_turn_message_projection.sql
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const turnMessageProjectionMigration = readFileSync(
|
||||
new URL(
|
||||
"../supabase/migrations/20260813030000_rectification_turn_message_projection.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const turnMessageProjectionMigrationCopy = fileURLToPath(
|
||||
new URL(
|
||||
"../db/migrations/20260813030000_rectification_turn_message_projection.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
test("rectification turn projection migration follows the V9 activity migration", () => {
|
||||
assert.ok(
|
||||
"20260813030000_rectification_turn_message_projection.sql" >
|
||||
"20260813020000_rectification_birth_context_activity.sql",
|
||||
);
|
||||
assert.match(turnMessageProjectionMigration, /^-- Preserve both logical messages[\s\S]*\nbegin;[\s\S]*^commit;$/m);
|
||||
});
|
||||
|
||||
test("rectification dossier expands each physical turn into user and assistant messages", () => {
|
||||
assert.match(
|
||||
turnMessageProjectionMigration,
|
||||
/cross join lateral \(\s*values\s*\(1, 'user'::text, t\.user_message\),\s*\(2, 'assistant'::text, t\.assistant_message\)\s*\) as message\(ordinal, role, text\)/,
|
||||
);
|
||||
assert.match(turnMessageProjectionMigration, /'role', message\.role/);
|
||||
assert.match(turnMessageProjectionMigration, /'text', message\.text/);
|
||||
assert.match(turnMessageProjectionMigration, /order by t\.created_at, message\.ordinal/);
|
||||
assert.match(turnMessageProjectionMigration, /and message\.text is not null/);
|
||||
assert.doesNotMatch(
|
||||
turnMessageProjectionMigration,
|
||||
/coalesce\(t\.user_message, t\.assistant_message\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rectification turn projection migration stays out of the identity migration tree", () => {
|
||||
assert.equal(
|
||||
existsSync(turnMessageProjectionMigrationCopy),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user