fix: preserve trusted rectification history
This commit is contained in:
@@ -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
|
||||
- 修复版本:待提交
|
||||
|
||||
+152
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user