A 👎 now saves, server side, the rated turn plus the context window the model read for it (reconstructed from the stored session with the same consultationHistoryWindow the consult route uses), model and run facts. 👍 is only counted. Switching to 👍 or clearing deletes the snapshot. Bodies are blanked after 90 days; the row cascades on session delete and on account deletion. - Optional 不满意原因 panel under the answer after a 👎 (five reasons, 200-char note, "会把这一轮对话发给我们排查"). - Admin 对话质量记录: 👍/👎 stats by day and model, list without text, audited snapshot open, 处理状态 + note (support.quality.read/write). - Privacy draft: what a 👎 keeps, why, 90 days, deletion. - Migration 20260930050000 is add-only; set_reply_rating() replaced with the same signature. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
421 lines
17 KiB
PL/PgSQL
421 lines
17 KiB
PL/PgSQL
-- 对话质量记录 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;
|