-- 对话质量记录 from 负向回复评价 (compliance round 2026-09-30). -- -- A 👎 on an assistant reply keeps a 故障上下文快照 of that turn: the user's -- question, the full answer and the context the model read for it (built by -- the server from the stored session, never from client text), plus the -- optional 不满意原因. 👍 is only counted in reply_ratings; it keeps no text. -- -- Retention: the snapshot body (question, answer, context) is blanked after 90 -- days by expire_reply_quality_snapshot_bodies(); date, model, reasons and the -- 处理状态 remain for statistics. The row belongs to the user (FK to -- auth.users, not in account_deletion_kept_tables()), so 注销 deletes it. -- -- Add-only: new table, new functions, set_reply_rating() replaced with the -- same signature so 👍 / clearing also removes a snapshot. begin; create table if not exists public.reply_quality_snapshots ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, session_id uuid not null references public.chat_sessions(id) on delete cascade, message_index integer not null check (message_index between 0 and 100000), session_type text not null default 'consultation' check (session_type in ('consultation', 'birth_time_rectification')), model_id text check (model_id is null or char_length(model_id) <= 120), request_id text check (request_id is null or char_length(request_id) <= 200), reasons text[] not null default '{}'::text[] check (reasons <@ array['off_topic', 'inaccurate', 'too_long_or_empty', 'tone', 'other']::text[]), reason_note text check (reason_note is null or char_length(reason_note) <= 200), question text check (question is null or char_length(question) <= 16000), answer text check (answer is null or char_length(answer) <= 16000), context jsonb check (context is null or jsonb_typeof(context) = 'object'), run_facts jsonb not null default '{}'::jsonb check (jsonb_typeof(run_facts) = 'object'), body_expired_at timestamptz, status text not null default 'new' check (status in ('new', 'in_progress', 'resolved', 'ignored')), admin_note text check (admin_note is null or char_length(admin_note) <= 2000), handled_by uuid, created_at timestamptz not null default clock_timestamp(), updated_at timestamptz not null default clock_timestamp(), unique (user_id, session_id, message_index) ); create index if not exists reply_quality_snapshots_created_idx on public.reply_quality_snapshots (created_at desc); create index if not exists reply_quality_snapshots_status_created_idx on public.reply_quality_snapshots (status, created_at desc); alter table public.reply_quality_snapshots enable row level security; revoke all on table public.reply_quality_snapshots from public, anon, authenticated, service_role; do $$ begin if exists (select 1 from pg_roles where rolname = 'app_runtime') then revoke all on table public.reply_quality_snapshots from app_runtime; end if; if exists (select 1 from pg_roles where rolname = 'admin_runtime') then revoke all on table public.reply_quality_snapshots from admin_runtime; end if; end; $$; -- --------------------------------------------------------------------------- -- User side (service_role only; the route passes the authenticated user id) -- --------------------------------------------------------------------------- -- Same signature as 20260930030000; now 👍 or clearing also drops the snapshot. create or replace function public.set_reply_rating( p_user_id uuid, p_session_id uuid, p_message_index integer, p_rating text, p_answer_sha256 text ) returns boolean language plpgsql security definer set search_path = '' as $$ begin if not exists ( select 1 from public.chat_sessions s where s.id = p_session_id and s.user_id = p_user_id ) then raise exception 'rating_session_not_owned' using errcode = '42501'; end if; if p_rating is null or p_rating <> 'down' then delete from public.reply_quality_snapshots where user_id = p_user_id and session_id = p_session_id and message_index = p_message_index; end if; if p_rating is null then delete from public.reply_ratings where user_id = p_user_id and session_id = p_session_id and message_index = p_message_index; return true; end if; insert into public.reply_ratings (user_id, session_id, message_index, rating, answer_sha256) values (p_user_id, p_session_id, p_message_index, p_rating, p_answer_sha256) on conflict (user_id, session_id, message_index) do update set rating = excluded.rating, answer_sha256 = excluded.answer_sha256, updated_at = clock_timestamp(); return true; end; $$; -- Written only while the reply is rated 👎; a re-rated 👎 replaces the body -- (the answer may have been regenerated) and keeps reasons and status. create or replace function public.save_reply_quality_snapshot( p_user_id uuid, p_session_id uuid, p_message_index integer, p_session_type text, p_model_id text, p_request_id text, p_question text, p_answer text, p_context jsonb, p_run_facts jsonb ) returns uuid language plpgsql security definer set search_path = '' as $$ declare v_id uuid; begin if not exists ( select 1 from public.reply_ratings r where r.user_id = p_user_id and r.session_id = p_session_id and r.message_index = p_message_index and r.rating = 'down' ) then raise exception 'reply_quality_not_down' using errcode = '22023'; end if; insert into public.reply_quality_snapshots ( user_id, session_id, message_index, session_type, model_id, request_id, question, answer, context, run_facts ) values ( p_user_id, p_session_id, p_message_index, coalesce(p_session_type, 'consultation'), p_model_id, p_request_id, p_question, p_answer, p_context, coalesce(p_run_facts, '{}'::jsonb) ) on conflict (user_id, session_id, message_index) do update set session_type = excluded.session_type, model_id = excluded.model_id, request_id = excluded.request_id, question = excluded.question, answer = excluded.answer, context = excluded.context, run_facts = excluded.run_facts, body_expired_at = null, updated_at = clock_timestamp() returning id into v_id; return v_id; end; $$; create or replace function public.set_reply_quality_reason( p_user_id uuid, p_session_id uuid, p_message_index integer, p_reasons text[], p_note text ) returns boolean language plpgsql security definer set search_path = '' as $$ begin update public.reply_quality_snapshots q set reasons = coalesce(p_reasons, '{}'::text[]), reason_note = nullif(btrim(coalesce(p_note, '')), ''), updated_at = clock_timestamp() where q.user_id = p_user_id and q.session_id = p_session_id and q.message_index = p_message_index; if not found then raise exception 'reply_quality_not_found' using errcode = '22023'; end if; return true; end; $$; -- Retention: bodies older than p_days are blanked; statistics stay. create or replace function public.expire_reply_quality_snapshot_bodies(p_days integer default 90) returns integer language plpgsql security definer set search_path = '' as $$ declare v_count integer; begin update public.reply_quality_snapshots q set question = null, answer = null, context = null, body_expired_at = clock_timestamp(), updated_at = clock_timestamp() where q.body_expired_at is null and q.created_at < clock_timestamp() - make_interval(days => greatest(1, coalesce(p_days, 90))); get diagnostics v_count = row_count; return v_count; end; $$; -- --------------------------------------------------------------------------- -- Admin side (admin_runtime only, permission-checked; opening a body and every -- status change are audited) -- --------------------------------------------------------------------------- insert into public.admin_permissions (permission_key, description) values ('support.quality.read', '查看对话质量记录'), ('support.quality.write', '处理对话质量记录') on conflict (permission_key) do update set description = excluded.description; with role_grants(role_code, permission_key) as (values ('owner', 'support.quality.read'), ('owner', 'support.quality.write'), ('operations', 'support.quality.read'), ('operations', 'support.quality.write'), ('support', 'support.quality.read'), ('support', 'support.quality.write'), ('auditor', 'support.quality.read') ) insert into public.admin_role_permissions (role_id, permission_id) select r.id, p.id from role_grants g join public.admin_roles r on r.code = g.role_code join public.admin_permissions p on p.permission_key = g.permission_key on conflict do nothing; -- The list never carries the body; opening one record does (and is audited). create or replace function public.admin_list_reply_quality( p_actor_user_id uuid, p_status text, p_reason text, p_model_id text, p_from timestamptz, p_to timestamptz, p_query text, p_limit integer, p_offset integer ) returns table ( id uuid, user_id uuid, email text, session_id uuid, message_index integer, session_type text, model_id text, reasons text[], reason_note text, has_body boolean, status text, admin_note text, handled_by uuid, created_at timestamptz, updated_at timestamptz, total_count bigint ) language plpgsql stable security definer set search_path = '' as $$ begin if not public.admin_has_permission(p_actor_user_id, 'support.quality.read') then raise exception 'admin_permission_denied' using errcode = '42501'; end if; return query select q.id, q.user_id, u.email, q.session_id, q.message_index, q.session_type, q.model_id, q.reasons, q.reason_note, (q.body_expired_at is null) as has_body, q.status, q.admin_note, q.handled_by, q.created_at, q.updated_at, count(*) over() as total_count from public.reply_quality_snapshots q left join identity.users u on u.id = q.user_id where (p_status is null or q.status = p_status) and (p_reason is null or p_reason = any (q.reasons)) and (p_model_id is null or q.model_id = p_model_id) and (p_from is null or q.created_at >= p_from) and (p_to is null or q.created_at < p_to) and (p_query is null or u.email ilike p_query or q.user_id::text ilike p_query or q.reason_note ilike p_query) order by (q.status = 'new') desc, q.created_at desc limit greatest(1, least(coalesce(p_limit, 20), 100)) offset greatest(0, coalesce(p_offset, 0)); end; $$; create or replace function public.admin_open_reply_quality( p_actor_user_id uuid, p_snapshot_id uuid, p_request_id text ) returns table ( id uuid, question text, answer text, context jsonb, run_facts jsonb, body_expired_at timestamptz ) language plpgsql security definer set search_path = '' as $$ declare v_actor_email text; begin if not public.admin_has_permission(p_actor_user_id, 'support.quality.read') then raise exception 'admin_permission_denied' using errcode = '42501'; end if; if not exists (select 1 from public.reply_quality_snapshots q where q.id = p_snapshot_id) then raise exception 'reply_quality_not_found' using errcode = '22023'; end if; select lower(btrim(u.email)) into v_actor_email from identity.users u where u.id = p_actor_user_id; insert into audit.admin_audit_logs ( actor_user_id, actor_email, actor_role, action, target_type, target_id, before_value, after_value, request_id, permission_used, reason ) values ( p_actor_user_id, coalesce(v_actor_email, 'unknown@invalid'), 'admin', 'reply_quality.open', 'reply_quality_snapshot', p_snapshot_id, null, null, p_request_id, 'support.quality.read', 'quality review' ) on conflict do nothing; return query select q.id, q.question, q.answer, q.context, q.run_facts, q.body_expired_at from public.reply_quality_snapshots q where q.id = p_snapshot_id; end; $$; create or replace function public.admin_update_reply_quality( p_actor_user_id uuid, p_snapshot_id uuid, p_status text, p_admin_note text, p_request_id text ) returns table (id uuid, status text, admin_note text, handled_by uuid, updated_at timestamptz) language plpgsql security definer set search_path = '' as $$ declare v_actor_email text; v_before jsonb; begin if not public.admin_has_permission(p_actor_user_id, 'support.quality.write') then raise exception 'admin_permission_denied' using errcode = '42501'; end if; if p_status not in ('new', 'in_progress', 'resolved', 'ignored') then raise exception 'reply_quality_status_invalid' using errcode = '22023'; end if; if p_admin_note is not null and char_length(p_admin_note) > 2000 then raise exception 'reply_quality_note_too_long' using errcode = '22023'; end if; select jsonb_build_object('status', q.status, 'admin_note', q.admin_note) into v_before from public.reply_quality_snapshots q where q.id = p_snapshot_id for update; if v_before is null then raise exception 'reply_quality_not_found' using errcode = '22023'; end if; select lower(btrim(u.email)) into v_actor_email from identity.users u where u.id = p_actor_user_id; update public.reply_quality_snapshots q set status = p_status, admin_note = nullif(btrim(coalesce(p_admin_note, '')), ''), handled_by = p_actor_user_id, updated_at = clock_timestamp() where q.id = p_snapshot_id; insert into audit.admin_audit_logs ( actor_user_id, actor_email, actor_role, action, target_type, target_id, before_value, after_value, request_id, permission_used, reason ) values ( p_actor_user_id, coalesce(v_actor_email, 'unknown@invalid'), 'admin', 'reply_quality.update', 'reply_quality_snapshot', p_snapshot_id, v_before, jsonb_build_object('status', p_status), p_request_id, 'support.quality.write', 'quality review' ) on conflict do nothing; return query select q.id, q.status, q.admin_note, q.handled_by, q.updated_at from public.reply_quality_snapshots q where q.id = p_snapshot_id; end; $$; -- 👍 / 👎 counts by day and by the session's model, from reply_ratings (the -- rating row carries no model; the session's model is the one that answered). create or replace function public.admin_reply_quality_stats(p_actor_user_id uuid, p_days integer) returns table (bucket_kind text, bucket text, up_count bigint, down_count bigint) language plpgsql stable security definer set search_path = '' as $$ declare v_since timestamptz := clock_timestamp() - make_interval(days => greatest(1, least(coalesce(p_days, 14), 90))); begin if not public.admin_has_permission(p_actor_user_id, 'support.quality.read') then raise exception 'admin_permission_denied' using errcode = '42501'; end if; return query select 'day'::text, to_char(r.updated_at at time zone 'Asia/Shanghai', 'YYYY-MM-DD'), count(*) filter (where r.rating = 'up'), count(*) filter (where r.rating = 'down') from public.reply_ratings r where r.updated_at >= v_since group by 2 union all select 'model'::text, coalesce(s.model_id, '未知'), count(*) filter (where r.rating = 'up'), count(*) filter (where r.rating = 'down') from public.reply_ratings r join public.chat_sessions s on s.id = r.session_id where r.updated_at >= v_since group by 2 order by 1, 2; end; $$; revoke all on function public.set_reply_rating(uuid, uuid, integer, text, text) from public, anon, authenticated; revoke all on function public.save_reply_quality_snapshot(uuid, uuid, integer, text, text, text, text, text, jsonb, jsonb) from public, anon, authenticated; revoke all on function public.set_reply_quality_reason(uuid, uuid, integer, text[], text) from public, anon, authenticated; revoke all on function public.expire_reply_quality_snapshot_bodies(integer) from public, anon, authenticated; revoke all on function public.admin_list_reply_quality(uuid, text, text, text, timestamptz, timestamptz, text, integer, integer) from public, anon, authenticated; revoke all on function public.admin_open_reply_quality(uuid, uuid, text) from public, anon, authenticated; revoke all on function public.admin_update_reply_quality(uuid, uuid, text, text, text) from public, anon, authenticated; revoke all on function public.admin_reply_quality_stats(uuid, integer) from public, anon, authenticated; grant execute on function public.set_reply_rating(uuid, uuid, integer, text, text) to service_role; grant execute on function public.save_reply_quality_snapshot(uuid, uuid, integer, text, text, text, text, text, jsonb, jsonb) to service_role; grant execute on function public.set_reply_quality_reason(uuid, uuid, integer, text[], text) to service_role; grant execute on function public.expire_reply_quality_snapshot_bodies(integer) to service_role; do $$ begin if exists (select 1 from pg_roles where rolname = 'admin_runtime') then grant execute on function public.admin_list_reply_quality(uuid, text, text, text, timestamptz, timestamptz, text, integer, integer) to admin_runtime; grant execute on function public.admin_open_reply_quality(uuid, uuid, text) to admin_runtime; grant execute on function public.admin_update_reply_quality(uuid, uuid, text, text, text) to admin_runtime; grant execute on function public.admin_reply_quality_stats(uuid, integer) to admin_runtime; end if; end; $$; commit;