Files
Jyotisha/frontend/supabase/migrations/20260926010000_rectification_telemetry.sql
T
Jesse_ChenandClaude Opus 5.5 d0bfc1fc3d feat(rectification): anonymous aggregate telemetry + admin summary page
One row per rectification Case, written once when the range card is first
delivered (GET /api/rectification/cases/[caseId], fire-and-forget after the
response is built). Numbers and closed enums only: no user / case / session
id, birth data, names, text or timestamps finer than the ISO week. Dedupe via
a separate case_id ledger that cascades with the Case (and account deletion).

Migration 20260926010000 is additive: two RLS tables with no runtime table
grants, SECURITY DEFINER write (service_role), purge (service_role) and
aggregate-only summary (admin_runtime) functions; 180-day retention.

Admin: 「校正统计」 page + GET /api/admin/rectification-telemetry
(admin.customers.read), aggregates only, no per-row view or export.

TASK-rectification-telemetry-20260926. test:db not run locally (no Docker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 15:36:29 +08:00

406 lines
16 KiB
PL/PgSQL

-- Rectification anonymous aggregate telemetry
-- (docs/tasks/TASK-rectification-telemetry-20260926.md).
--
-- One row per rectification Case, written once, when its range card is first
-- delivered. The row holds numbers and closed enums only. It carries no user,
-- Case or session id, no birth data, no names, no conversation text, no model
-- output, and no timestamp finer than the ISO week (`recorded_week`).
--
-- Dedupe lives in a separate ledger keyed by case_id. The ledger cascades with
-- the Case (and so with account deletion) and has no column that points at a
-- stats row, so a stats row cannot be joined back to a person.
--
-- Access:
-- * both tables enable RLS and grant no table privilege to any runtime role;
-- * the web server writes only through record_rectification_telemetry
-- (SECURITY DEFINER, service_role only);
-- * the admin reads only aggregates through rectification_telemetry_summary
-- (SECURITY DEFINER, admin_runtime only). There is no per-row read path.
--
-- Retention: 180 days. Every write first deletes rows whose week started more
-- than 180 days ago; the summary never reads past 180 days either; and
-- purge_expired_rectification_telemetry() lets an operator run the same delete.
--
-- Backward compatibility: additive only. Two new tables and three new
-- functions; no existing table, column, function or grant changes, so the code
-- that is deployed before this migration keeps working unchanged.
begin;
create table if not exists public.rectification_telemetry (
id uuid primary key default gen_random_uuid(),
recorded_week date not null
default (pg_catalog.date_trunc('week', pg_catalog.timezone('UTC', pg_catalog.now())))::date
check (extract(isodow from recorded_week) = 1),
window_radius_minutes integer check (window_radius_minutes between 0 and 720),
birth_time_source text not null
check (birth_time_source in ('hospital_record', 'approximate', 'period_only', 'unknown')),
questions_total integer not null check (questions_total between 0 and 1000),
questions_targeted integer not null check (questions_targeted between 0 and 1000),
questions_guided integer not null check (questions_guided between 0 and 1000),
questions_dated_probe integer not null check (questions_dated_probe between 0 and 1000),
questions_personality integer not null check (questions_personality between 0 and 1000),
questions_open integer not null check (questions_open between 0 and 1000),
experiences_added integer not null check (experiences_added between 0 and 1000),
range_width_minutes integer check (range_width_minutes between 0 and 1440),
candidate_count integer not null check (candidate_count between 0 and 1440),
top_two_gap_points integer check (top_two_gap_points between 0 and 100),
stop_reason text not null check (stop_reason in (
'converged',
'pool_exhausted',
'user_no_more',
'round_cap',
'user_stopped',
'error'
)),
precision_gate_met boolean,
duration_seconds integer not null check (duration_seconds between 0 and 31536000),
algorithm_version text check (algorithm_version ~ '^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$'),
policy_version text check (policy_version ~ '^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$'),
skill_version text check (skill_version ~ '^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$'),
check (
questions_targeted + questions_guided + questions_dated_probe
+ questions_personality + questions_open <= questions_total
)
);
create index if not exists rectification_telemetry_week_idx
on public.rectification_telemetry (recorded_week);
create table if not exists public.rectification_telemetry_reported_cases (
case_id uuid primary key
references public.agentic_rectification_cases(id) on delete cascade
);
alter table public.rectification_telemetry enable row level security;
alter table public.rectification_telemetry_reported_cases enable row level security;
revoke all on table public.rectification_telemetry from public, anon, authenticated, service_role;
revoke all on table public.rectification_telemetry_reported_cases 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.rectification_telemetry from app_runtime;
revoke all on table public.rectification_telemetry_reported_cases from app_runtime;
end if;
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
revoke all on table public.rectification_telemetry from admin_runtime;
revoke all on table public.rectification_telemetry_reported_cases from admin_runtime;
end if;
end;
$$;
-- ---------------------------------------------------------------------------
-- Write path: server only. The ids are used for the ownership check and the
-- dedupe ledger; neither is written into the stats row.
-- ---------------------------------------------------------------------------
create or replace function public.record_rectification_telemetry(
p_user_id uuid,
p_case_id uuid,
p_window_radius_minutes integer,
p_birth_time_source text,
p_questions_total integer,
p_questions_targeted integer,
p_questions_guided integer,
p_questions_dated_probe integer,
p_questions_personality integer,
p_questions_open integer,
p_experiences_added integer,
p_range_width_minutes integer,
p_candidate_count integer,
p_top_two_gap_points integer,
p_stop_reason text,
p_precision_gate_met boolean,
p_duration_seconds integer,
p_algorithm_version text,
p_policy_version text,
p_skill_version text
)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_marked uuid;
begin
if p_user_id is null or p_case_id is null then
raise exception 'rectification_telemetry_invalid' using errcode = '22023';
end if;
perform 1 from public.agentic_rectification_cases c
where c.id = p_case_id and c.user_id = p_user_id;
if not found then
raise exception 'rectification_telemetry_case_not_found' using errcode = 'P0002';
end if;
delete from public.rectification_telemetry
where recorded_week < (pg_catalog.timezone('UTC', pg_catalog.now()))::date - 180;
insert into public.rectification_telemetry_reported_cases (case_id)
values (p_case_id)
on conflict (case_id) do nothing
returning case_id into v_marked;
if v_marked is null then
return false;
end if;
insert into public.rectification_telemetry (
window_radius_minutes,
birth_time_source,
questions_total,
questions_targeted,
questions_guided,
questions_dated_probe,
questions_personality,
questions_open,
experiences_added,
range_width_minutes,
candidate_count,
top_two_gap_points,
stop_reason,
precision_gate_met,
duration_seconds,
algorithm_version,
policy_version,
skill_version
) values (
p_window_radius_minutes,
p_birth_time_source,
p_questions_total,
p_questions_targeted,
p_questions_guided,
p_questions_dated_probe,
p_questions_personality,
p_questions_open,
p_experiences_added,
p_range_width_minutes,
p_candidate_count,
p_top_two_gap_points,
p_stop_reason,
p_precision_gate_met,
p_duration_seconds,
p_algorithm_version,
p_policy_version,
p_skill_version
);
return true;
end;
$$;
create or replace function public.purge_expired_rectification_telemetry()
returns integer
language plpgsql
security definer
set search_path = ''
as $$
declare
v_deleted integer;
begin
delete from public.rectification_telemetry
where recorded_week < (pg_catalog.timezone('UTC', pg_catalog.now()))::date - 180;
get diagnostics v_deleted = row_count;
return v_deleted;
end;
$$;
-- ---------------------------------------------------------------------------
-- Read path: aggregates only (medians, distributions, weekly trend). Weeks
-- are capped at 26 and rows older than 180 days are never read.
-- ---------------------------------------------------------------------------
create or replace function public.rectification_telemetry_summary(p_weeks integer default 12)
returns jsonb
language sql
stable
security definer
set search_path = ''
as $$
with bounds as (
select
greatest(1, least(coalesce(p_weeks, 12), 26)) as weeks,
(pg_catalog.timezone('UTC', pg_catalog.now()))::date as today
), window_start as (
select
b.weeks,
greatest(
(pg_catalog.date_trunc('week', b.today::timestamp))::date - (b.weeks - 1) * 7,
b.today - 180
) as since
from bounds b
), recent as (
select t.*
from public.rectification_telemetry t, window_start w
where t.recorded_week >= w.since
), width_buckets(key, lo, hi, ord) as (
values ('0', 0, 0, 1), ('1-5', 1, 5, 2), ('6-10', 6, 10, 3), ('11-20', 11, 20, 4),
('21-30', 21, 30, 5), ('31-60', 31, 60, 6), ('61+', 61, null, 7)
), question_buckets(key, lo, hi, ord) as (
values ('0-3', 0, 3, 1), ('4-6', 4, 6, 2), ('7-9', 7, 9, 3), ('10-12', 10, 12, 4),
('13-15', 13, 15, 5), ('16+', 16, null, 6)
), gap_buckets(key, lo, hi, ord) as (
values ('0-2', 0, 2, 1), ('3-4', 3, 4, 2), ('5-9', 5, 9, 3), ('10+', 10, null, 4)
), stop_reasons(key, ord) as (
values ('converged', 1), ('pool_exhausted', 2), ('user_no_more', 3),
('round_cap', 4), ('user_stopped', 5), ('error', 6)
), sources(key, ord) as (
values ('hospital_record', 1), ('approximate', 2), ('period_only', 3), ('unknown', 4)
), weeks as (
select (pg_catalog.date_trunc('week', b.today::timestamp))::date - g.n * 7 as week
from bounds b, pg_catalog.generate_series(0, (select weeks from bounds) - 1) as g(n)
)
select pg_catalog.jsonb_build_object(
'weeks', (select weeks from window_start),
'since', (select since from window_start),
'retention_days', 180,
'sessions', (select count(*) from recent),
'medians', (
select pg_catalog.jsonb_build_object(
'range_width_minutes_p50', round((percentile_cont(0.5) within group (order by range_width_minutes))::numeric, 1),
'range_width_minutes_p90', round((percentile_cont(0.9) within group (order by range_width_minutes))::numeric, 1),
'questions_total_p50', round((percentile_cont(0.5) within group (order by questions_total))::numeric, 1),
'questions_total_p90', round((percentile_cont(0.9) within group (order by questions_total))::numeric, 1),
'experiences_added_p50', round((percentile_cont(0.5) within group (order by experiences_added))::numeric, 1),
'candidate_count_p50', round((percentile_cont(0.5) within group (order by candidate_count))::numeric, 1),
'window_radius_minutes_p50', round((percentile_cont(0.5) within group (order by window_radius_minutes))::numeric, 1),
'duration_seconds_p50', round((percentile_cont(0.5) within group (order by duration_seconds))::numeric, 1),
'duration_seconds_p90', round((percentile_cont(0.9) within group (order by duration_seconds))::numeric, 1)
)
from recent
),
'question_types', (
select pg_catalog.jsonb_build_object(
'targeted', round(coalesce(avg(questions_targeted), 0)::numeric, 2),
'guided', round(coalesce(avg(questions_guided), 0)::numeric, 2),
'dated_probe', round(coalesce(avg(questions_dated_probe), 0)::numeric, 2),
'personality', round(coalesce(avg(questions_personality), 0)::numeric, 2),
'open', round(coalesce(avg(questions_open), 0)::numeric, 2),
'other', round(coalesce(avg(
questions_total - questions_targeted - questions_guided
- questions_dated_probe - questions_personality - questions_open
), 0)::numeric, 2),
'guided_asked_sessions', count(*) filter (where questions_guided > 0)
)
from recent
),
'width_distribution', (
select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', x.key, 'count', x.n) order by x.ord)
from (
select wb.key, wb.ord, count(r.id) as n
from width_buckets wb
left join recent r
on r.range_width_minutes >= wb.lo
and (wb.hi is null or r.range_width_minutes <= wb.hi)
group by wb.key, wb.ord
union all
select 'unknown', 99, count(*) from recent where range_width_minutes is null
) x
),
'question_distribution', (
select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', x.key, 'count', x.n) order by x.ord)
from (
select qb.key, qb.ord, count(r.id) as n
from question_buckets qb
left join recent r
on r.questions_total >= qb.lo
and (qb.hi is null or r.questions_total <= qb.hi)
group by qb.key, qb.ord
) x
),
'gap_distribution', (
select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', x.key, 'count', x.n) order by x.ord)
from (
select gb.key, gb.ord, count(r.id) as n
from gap_buckets gb
left join recent r
on r.top_two_gap_points >= gb.lo
and (gb.hi is null or r.top_two_gap_points <= gb.hi)
group by gb.key, gb.ord
union all
select 'single', 99, count(*) from recent where top_two_gap_points is null
) x
),
'stop_reasons', (
select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', s.key, 'count', (
select count(*) from recent r where r.stop_reason = s.key
)) order by s.ord)
from stop_reasons s
),
'birth_time_sources', (
select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', s.key, 'count', (
select count(*) from recent r where r.birth_time_source = s.key
)) order by s.ord)
from sources s
),
'precision_gate', (
select pg_catalog.jsonb_build_object(
'met', count(*) filter (where precision_gate_met is true),
'not_met', count(*) filter (where precision_gate_met is false),
'unknown', count(*) filter (where precision_gate_met is null)
)
from recent
),
'weekly', (
select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object(
'week', w.week,
'sessions', (select count(*) from recent r where r.recorded_week = w.week),
'range_width_minutes_p50', (
select round((percentile_cont(0.5) within group (order by r.range_width_minutes))::numeric, 1)
from recent r where r.recorded_week = w.week
),
'questions_total_p50', (
select round((percentile_cont(0.5) within group (order by r.questions_total))::numeric, 1)
from recent r where r.recorded_week = w.week
),
'precision_gate_met', (
select count(*) from recent r where r.recorded_week = w.week and r.precision_gate_met is true
),
'precision_gate_known', (
select count(*) from recent r where r.recorded_week = w.week and r.precision_gate_met is not null
)
) order by w.week)
from weeks w
where w.week >= (select since from window_start) - 6
),
'versions', (
select coalesce(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object(
'algorithm_version', v.algorithm_version,
'policy_version', v.policy_version,
'skill_version', v.skill_version,
'count', v.n
) order by v.n desc, v.algorithm_version, v.policy_version, v.skill_version), '[]'::jsonb)
from (
select algorithm_version, policy_version, skill_version, count(*) as n
from recent
group by algorithm_version, policy_version, skill_version
order by count(*) desc
limit 10
) v
)
);
$$;
revoke all on function public.record_rectification_telemetry(
uuid, uuid, integer, text, integer, integer, integer, integer, integer, integer,
integer, integer, integer, integer, text, boolean, integer, text, text, text
) from public, anon, authenticated;
revoke all on function public.purge_expired_rectification_telemetry() from public, anon, authenticated;
revoke all on function public.rectification_telemetry_summary(integer) from public, anon, authenticated;
grant execute on function public.record_rectification_telemetry(
uuid, uuid, integer, text, integer, integer, integer, integer, integer, integer,
integer, integer, integer, integer, text, boolean, integer, text, text, text
) to service_role;
grant execute on function public.purge_expired_rectification_telemetry() to service_role;
do $$
begin
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.rectification_telemetry_summary(integer) to admin_runtime;
end if;
end;
$$;
commit;