From 0850619eaf5002736542463394720b0ab1949ce9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 23 Jul 2026 14:58:56 +0800 Subject: [PATCH] fix: preserve trusted rectification history --- docs/BUG_HISTORY.md | 16 ++ ..._mark_captured_conversational_messages.sql | 152 ++++++++++++++++++ .../tests/database-local-business.test.ts | 11 ++ scripts/jyotish_api_server.py | 4 +- tests/test_active_rectification_api.py | 28 ++++ 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 frontend/supabase/migrations/20260723020000_mark_captured_conversational_messages.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index aa6799eb..3deded74 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 修复版本:待提交 diff --git a/frontend/supabase/migrations/20260723020000_mark_captured_conversational_messages.sql b/frontend/supabase/migrations/20260723020000_mark_captured_conversational_messages.sql new file mode 100644 index 00000000..056f18d2 --- /dev/null +++ b/frontend/supabase/migrations/20260723020000_mark_captured_conversational_messages.sql @@ -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; diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 02efb316..f647131c 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -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 diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index dbd6fa36..eacdd86e 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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'} diff --git a/tests/test_active_rectification_api.py b/tests/test_active_rectification_api.py index 396a9219..de56206a 100644 --- a/tests/test_active_rectification_api.py +++ b/tests/test_active_rectification_api.py @@ -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