fix: preserve trusted rectification history

This commit is contained in:
Jesse_Chen
2026-07-23 14:58:56 +08:00
parent 685f322981
commit 0850619eaf
5 changed files with 209 additions and 2 deletions
+16
View File
@@ -633,3 +633,19 @@
- 相关记录:BUG-032、BUG-034、BUG-036
- 复发自:BUG-032
- 修复版本:待提交
## BUG-038 | 老案例摘要被误标成原始对话且第七条事件后无法继续
- 状态:resolved
- 首次发现:2026-07-23
- 最近更新:2026-07-23
- 影响面:生产生时校正老案例恢复、累计 7–8 条可评分事件后的继续问答
- 用户现象:部署历史恢复修复后,老案例刷新仍显示“已记录这段经历”;继续回答一条明确事件后,页面提示暂时无法继续并把文本退回草稿。
- 触发条件:案例来自原始消息持久化上线之前,且旧事件 evidence 能提供规范化 `raw_text`;或累计可评分事件超过 6 条。
- 根因:首版迁移把旧 evidence 的规范化摘要回填到 `user_message`,却没有标记它不是逐字捕获的聊天原文,恢复 RPC 因而把合成摘要当成真实历史;产品收敛上限允许 8 条事件,但 Python 事件评分 API 仍只接受最多 6 条,前端第 7 条后稳定收到 400。
- 修复:为 turn 增加 `user_message_captured` 来源标记,只有 answer 事务当场保存的逐字用户文本才进入可见历史;旧案例没有可靠原文时只显示真实最新 Agent narrative,不再展示伪造气泡。事件评分 API 上限与产品常量统一为 8,并增加八事件回归。
- 验证:迁移从零应用并检查来源标记与 RPC 权限;八事件 API 测试、聚焦对话测试、生产构建与真实生产浏览器继续问答/刷新/重新进入 smoke。
- 防复发:消息历史必须携带来源可信度,事件 evidence 不得默认等价于聊天原文;跨服务的事件数量上限必须由同一契约测试锁定。
- 相关记录:BUG-034、BUG-037
- 复发自:BUG-037
- 修复版本:待提交
@@ -0,0 +1,152 @@
-- Only render message history that was captured verbatim at answer time.
-- Evidence rows created before the history contract contain normalized event
-- summaries, not guaranteed user-authored chat text, so they must not be used
-- to reconstruct a conversation.
alter table public.birth_time_rectification_turns
add column if not exists user_message_captured boolean not null default false;
create or replace function public.load_conversational_rectification_case_with_history(
p_user_id uuid,
p_case_id uuid default null
)
returns jsonb
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
v_loaded jsonb;
v_case_id uuid;
begin
v_loaded := public.load_conversational_rectification_case(p_user_id, p_case_id);
if v_loaded is null then
return null;
end if;
v_case_id := (v_loaded ->> 'case_id')::uuid;
return v_loaded || pg_catalog.jsonb_build_object(
'message_history', coalesce((
select pg_catalog.jsonb_agg(
pg_catalog.jsonb_build_object(
'turnVersion', turn_row.turn_version,
'userMessage', turn_row.user_message,
'narrative', turn_row.narrative
) order by turn_row.turn_version
)
from (
select history.turn_version, history.user_message, history.narrative
from public.birth_time_rectification_turns history
where history.case_id = v_case_id
and history.user_message_captured is true
order by history.turn_version desc
limit 200
) turn_row
), '[]'::jsonb)
);
end;
$$;
create or replace function public.save_conversational_rectification_turn_with_history(
p_user_id uuid,
p_case_id uuid,
p_expected_version bigint,
p_action_id uuid,
p_turn jsonb,
p_evidence jsonb,
p_validation_receipt jsonb,
p_private_candidate jsonb,
p_command_fingerprint text,
p_user_message text
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_response jsonb;
v_saved_user_message text;
begin
if p_user_message is null
or public.conversational_rectification_text_utf16_length(p_user_message) not between 1 and 4000
or public.conversational_rectification_text_is_nonblank(p_user_message) is not true then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
v_response := public.save_conversational_rectification_turn(
p_user_id, p_case_id, p_expected_version, p_action_id, p_turn, p_evidence,
p_validation_receipt, p_private_candidate, p_command_fingerprint
);
update public.birth_time_rectification_turns
set user_message = coalesce(user_message, p_user_message),
user_message_captured = true
where case_id = p_case_id
and turn_version = p_expected_version + 1
returning user_message into v_saved_user_message;
if not found or v_saved_user_message is distinct from p_user_message then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return v_response;
end;
$$;
create or replace function public.complete_conversational_rectification_with_range_and_history(
p_user_id uuid,
p_case_id uuid,
p_expected_version bigint,
p_action_id uuid,
p_turn jsonb,
p_evidence jsonb,
p_validation_receipt jsonb,
p_private_candidate jsonb,
p_command_fingerprint text,
p_user_message text
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_response jsonb;
v_saved_user_message text;
begin
if p_user_message is null
or public.conversational_rectification_text_utf16_length(p_user_message) not between 1 and 4000
or public.conversational_rectification_text_is_nonblank(p_user_message) is not true then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
v_response := public.complete_conversational_rectification_with_range(
p_user_id, p_case_id, p_expected_version, p_action_id, p_turn, p_evidence,
p_validation_receipt, p_private_candidate, p_command_fingerprint
);
update public.birth_time_rectification_turns
set user_message = coalesce(user_message, p_user_message),
user_message_captured = true
where case_id = p_case_id
and turn_version = p_expected_version + 1
returning user_message into v_saved_user_message;
if not found or v_saved_user_message is distinct from p_user_message then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return v_response;
end;
$$;
revoke all on function public.load_conversational_rectification_case_with_history(uuid, uuid)
from public, anon, authenticated;
revoke all on function public.save_conversational_rectification_turn_with_history(
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
) from public, anon, authenticated;
revoke all on function public.complete_conversational_rectification_with_range_and_history(
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
) from public, anon, authenticated;
grant execute on function public.load_conversational_rectification_case_with_history(uuid, uuid)
to service_role;
grant execute on function public.save_conversational_rectification_turn_with_history(
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
) to service_role;
grant execute on function public.complete_conversational_rectification_with_range_and_history(
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
) to service_role;
@@ -29,6 +29,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260715000000_account_credits\.sql/);
assert.match(migration.stdout, /applied 20260721150000_align_conversational_finance_domain\.sql/);
assert.match(migration.stdout, /applied 20260723010000_restore_conversational_message_history\.sql/);
assert.match(migration.stdout, /applied 20260723020000_mark_captured_conversational_messages\.sql/);
assert.equal(
fixture.psql(`
@@ -40,6 +41,16 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
`),
"YES:text",
);
assert.equal(
fixture.psql(`
select is_nullable || ':' || data_type || ':' || column_default
from information_schema.columns
where table_schema = 'public'
and table_name = 'birth_time_rectification_turns'
and column_name = 'user_message_captured'
`),
"NO:boolean:f",
);
assert.equal(
fixture.psql(`
select
+2 -2
View File
@@ -6938,8 +6938,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
lon = self._get_float(body, 'lon', 0, -180, 180)
tz = self._get_float(body, 'tz', 0, -14, 14)
events = body.get('events')
if not isinstance(events, list) or not 3 <= len(events) <= 6:
raise BadRequest('events must contain between 3 and 6 items')
if not isinstance(events, list) or not 3 <= len(events) <= 8:
raise BadRequest('events must contain between 3 and 8 items')
normalized_events = []
allowed_domains = {'education', 'relocation', 'relationship', 'career', 'finance', 'health_pressure'}
formats = {'year': '%Y', 'month': '%Y-%m', 'day': '%Y-%m-%d'}
+28
View File
@@ -454,3 +454,31 @@ def test_high_rigor_event_rectification_queues_vedastro_packet(monkeypatch) -> N
assert contract["canonical_input_hash"]
assert contract["gates"]["public_holdout_release"]["status"] == "blocked"
assert result["calculation_contract"]["events"][0]["summary"] == "2011 年 9 月离开家乡开始大学生活"
def test_active_rectification_events_accepts_product_limit_of_eight() -> None:
events = [
{
"id": f"00000000-0000-4000-a000-{index:012d}",
"domain": "education" if index % 2 == 0 else "career",
"date": f"{2011 + index}",
"precision": "year",
"summary": f"event {index}",
}
for index in range(8)
]
result = _handler()._compute_active_rectification_events(
{
"birth_date": "1993-04-17",
"start_time": "14:29",
"end_time": "14:31",
"lat": 36.683333,
"lon": 114.35,
"tz": 8,
"events": events,
}
)
assert result["success"] is True
assert result["event_count"] == 8