@fayz-ai/plugin-scribe 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
1
  export declare const MIGRATION_001_SCRIBE_BASE = "-- plugin-scribe 001: gravar um atendimento inteiro sem poder perd\u00EA-lo.\n--\n-- O problema que este schema resolve n\u00E3o \u00E9 \"guardar \u00E1udio\". \u00C9 que o profissional\n-- aperta gravar, navega o app por 40 minutos, e QUALQUER coisa pode acontecer no\n-- meio: reload, aba morta, wifi caindo, tampa fechando. Perder a consulta \u00E9 uma\n-- falha irrecuper\u00E1vel \u2014 n\u00E3o d\u00E1 para pedir para o paciente repetir.\n--\n-- Por isso o desenho n\u00E3o tem \"o arquivo da sess\u00E3o\". Tem um MANIFESTO: a sess\u00E3o\n-- \u00E9 uma linha, e cada 30 segundos de \u00E1udio \u00E9 OUTRA linha que sobe sozinha e \u00E9\n-- transcrita sozinha. O que chegou, chegou. O cliente pode morrer entre dois\n-- segmentos e o que j\u00E1 subiu continua \u00EDntegro e j\u00E1 transcrito.\n--\n-- ---------------------------------------------------------------------------\n-- Duas dura\u00E7\u00F5es, e elas discordam de prop\u00F3sito\n-- ---------------------------------------------------------------------------\n-- `wall_duration_ms` \u00E9 o rel\u00F3gio de parede que o browser reportou. Serve para o\n-- cron\u00F4metro na tela e para mais nada.\n--\n-- `audio_duration_ms` \u00E9 SUM(segments.duration_ms) \u2014 soma do \u00E1udio que de fato\n-- existe, com a dura\u00E7\u00E3o vinda da resposta do STT, n\u00E3o de um `setInterval`.\n--\n-- Elas divergem sempre que a aba congela: notebook dormindo por 10 minutos faz\n-- o rel\u00F3gio de parede contar a soneca inteira enquanto o MediaRecorder n\u00E3o\n-- emitiu um byte. Registro cl\u00EDnico, medidor de cota e fatura usam a SEGUNDA.\n-- Confundir as duas \u00E9 cobrar do cliente pelo cochilo do notebook.\n--\n-- ---------------------------------------------------------------------------\n-- O contador nunca \u00E9 escrito pelo browser\n-- ---------------------------------------------------------------------------\n-- `scribe_record_segment` (se\u00E7\u00E3o 5) faz o upsert do segmento e RECALCULA os\n-- contadores da sess\u00E3o a partir da tabela de segmentos. Um cliente que sobe o\n-- mesmo segmento duas vezes (replay depois de queda de rede) converge para o\n-- mesmo n\u00FAmero em vez de somar duas vezes.\n\n-- ---------------------------------------------------------------------------\n-- 1. sessions \u2014 o encontro\n-- ---------------------------------------------------------------------------\n-- `id` N\u00C3O tem DEFAULT gen_random_uuid(). \u00C9 gerado no cliente, antes do primeiro\n-- byte, e usado como chave no IndexedDB e no caminho do Storage. Sem isso a\n-- grava\u00E7\u00E3o teria que esperar um round-trip para saber onde escrever \u2014 e uma\n-- grava\u00E7\u00E3o que depende da rede para COME\u00C7AR j\u00E1 nasce fr\u00E1gil.\nCREATE TABLE IF NOT EXISTS public.plg_scribe_sessions (\n id uuid PRIMARY KEY,\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n\n -- O titular. SET NULL e n\u00E3o CASCADE: apagar a pessoa n\u00E3o pode apagar em\n -- sil\u00EAncio o registro do atendimento que aconteceu.\n subject_id uuid REFERENCES public.people(id) ON DELETE SET NULL,\n context_kind text NOT NULL DEFAULT 'person'\n CHECK (context_kind IN ('person', 'appointment', 'standalone')),\n appointment_id uuid REFERENCES public.appointments(id) ON DELETE SET NULL,\n owner_user_id uuid NOT NULL,\n\n status text NOT NULL DEFAULT 'recording'\n CHECK (status IN ('recording','paused','interrupted','uploading','transcribing',\n 'ready','generating','completed','abandoned','failed')),\n locale text NOT NULL DEFAULT 'pt-BR',\n mime_type text,\n\n started_at timestamptz NOT NULL DEFAULT now(),\n ended_at timestamptz,\n wall_duration_ms integer NOT NULL DEFAULT 0,\n audio_duration_ms integer NOT NULL DEFAULT 0,\n\n segment_count integer NOT NULL DEFAULT 0,\n uploaded_segment_count integer NOT NULL DEFAULT 0,\n transcribed_segment_count integer NOT NULL DEFAULT 0,\n transcript_chars integer NOT NULL DEFAULT 0,\n\n -- Base legal, carimbada antes do primeiro byte. \"Quando\" e \"como\" \u00E9 o que se\n -- prova depois; guardar isso na mesma linha da grava\u00E7\u00E3o \u00E9 o que torna a prova\n -- barata.\n consent_at timestamptz,\n consent_mode text CHECK (consent_mode IN ('verbal','written','implied_contract')),\n\n -- Reten\u00E7\u00E3o: o \u00E1udio some, a transcri\u00E7\u00E3o e o documento assinado ficam.\n retention_until date,\n audio_deleted_at timestamptz,\n\n error text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_scribe_sessions ENABLE ROW LEVEL SECURITY;\n\nCREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_tenant\n ON public.plg_scribe_sessions(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_subject\n ON public.plg_scribe_sessions(subject_id) WHERE subject_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_appointment\n ON public.plg_scribe_sessions(appointment_id) WHERE appointment_id IS NOT NULL;\n\n-- A consulta da recupera\u00E7\u00E3o: \"este usu\u00E1rio tem alguma sess\u00E3o aberta?\". Roda em\n-- todo boot do app, ent\u00E3o \u00E9 \u00EDndice parcial e n\u00E3o varredura.\nCREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_open\n ON public.plg_scribe_sessions(tenant_id, owner_user_id, status)\n WHERE status IN ('recording','paused','interrupted','uploading','transcribing','ready','generating');\n\n-- A consulta do job de reten\u00E7\u00E3o.\nCREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_retention\n ON public.plg_scribe_sessions(retention_until)\n WHERE audio_deleted_at IS NULL AND retention_until IS NOT NULL;\n\n-- ---------------------------------------------------------------------------\n-- 2. segments \u2014 o manifesto dos peda\u00E7os\n-- ---------------------------------------------------------------------------\n-- A PK \u00E9 (session_id, seg_index) e n\u00E3o um uuid: o \u00EDndice do segmento j\u00E1 \u00E9\n-- \u00FAnico dentro da sess\u00E3o, e usar isso como chave faz o re-upload do mesmo\n-- peda\u00E7o ser um ON CONFLICT em vez de uma linha duplicada. Idempot\u00EAncia de\n-- gra\u00E7a, que \u00E9 exatamente o que um pump com retry infinito precisa.\nCREATE TABLE IF NOT EXISTS public.plg_scribe_segments (\n session_id uuid NOT NULL REFERENCES public.plg_scribe_sessions(id) ON DELETE CASCADE,\n seg_index integer NOT NULL,\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n\n storage_path text,\n bytes integer,\n start_offset_ms integer NOT NULL,\n -- Vem da resposta do STT. O rel\u00F3gio do JS n\u00E3o \u00E9 testemunha confi\u00E1vel de\n -- quanto \u00E1udio existe: ele conta tempo, n\u00E3o amostras.\n duration_ms integer,\n\n -- Montado a partir de pieces de um segmento cortado no meio (crash). Vale\n -- transcrever mesmo assim: 22 segundos de consulta \u00E9 melhor que zero.\n partial boolean NOT NULL DEFAULT false,\n\n -- Linha SINT\u00C9TICA: n\u00E3o tem \u00E1udio, marca um buraco (sono, mic perdido,\n -- permiss\u00E3o revogada). Existe para a transcri\u00E7\u00E3o ADMITIR o buraco em vez de\n -- emendar dois momentos como se fossem cont\u00EDnuos. Isso \u00E9 requisito\n -- m\u00E9dico-legal: um registro que esconde a costura \u00E9 pior que um com furo.\n gap boolean NOT NULL DEFAULT false,\n\n upload_state text NOT NULL DEFAULT 'pending'\n CHECK (upload_state IN ('pending','uploaded','missing')),\n uploaded_at timestamptz,\n\n stt_state text NOT NULL DEFAULT 'pending'\n CHECK (stt_state IN ('pending','running','done','failed','skipped')),\n stt_provider text,\n stt_attempts smallint NOT NULL DEFAULT 0,\n stt_error text,\n\n text text,\n -- [{w,s,e,c,sp}] com offsets RELATIVOS a este segmento. Absoluto se calcula\n -- somando start_offset_ms; guardar relativo \u00E9 o que deixa reprocessar um\n -- segmento sozinho sem reescrever os vizinhos.\n words jsonb,\n confidence real,\n\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (session_id, seg_index)\n);\nALTER TABLE public.plg_scribe_segments ENABLE ROW LEVEL SECURITY;\n\nCREATE INDEX IF NOT EXISTS idx_plg_scribe_segments_tenant\n ON public.plg_scribe_segments(tenant_id);\n\n-- A fila do STT. Parcial porque s\u00F3 interessa o que j\u00E1 subiu e ainda n\u00E3o foi\n-- transcrito \u2014 que \u00E9 uma fra\u00E7\u00E3o min\u00FAscula da tabela em regime permanente.\nCREATE INDEX IF NOT EXISTS idx_plg_scribe_segments_stt_queue\n ON public.plg_scribe_segments(tenant_id, stt_state, created_at)\n WHERE upload_state = 'uploaded' AND stt_state IN ('pending','failed');\n\n-- ---------------------------------------------------------------------------\n-- 3. generations \u2014 uma tentativa de documento\n-- ---------------------------------------------------------------------------\n-- DUAS colunas de markdown, e isso n\u00E3o \u00E9 redund\u00E2ncia.\n--\n-- `markdown` sa\u00EDda do modelo, escrita uma vez, nunca mutada\n-- `markdown_edited` a c\u00F3pia de trabalho do humano\n--\n-- A diferen\u00E7a entre elas \u00E9 a \u00FAnica prova de que algu\u00E9m revisou. Ela sustenta\n-- tr\u00EAs coisas: o selo \"revisado pelo profissional\", o diff que diz se o prompt\n-- est\u00E1 bom (documento muito editado = prompt ruim), e a cadeia de proveni\u00EAncia\n-- que um registro m\u00E9dico-legal precisa ter. Guardar s\u00F3 o texto final apaga tudo\n-- isso para economizar uma coluna de texto.\nCREATE TABLE IF NOT EXISTS public.plg_scribe_generations (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n session_id uuid NOT NULL REFERENCES public.plg_scribe_sessions(id) ON DELETE CASCADE,\n\n -- Um dos dois. `template_id` quando o tenant forkou e tem linha pr\u00F3pria;\n -- `template_key` quando \u00E9 preset de c\u00F3digo (que de prop\u00F3sito n\u00E3o tem linha,\n -- para n\u00E3o consumir a cota de templates do plano).\n template_id uuid,\n template_key text,\n\n tab_order integer NOT NULL DEFAULT 0,\n title text,\n status text NOT NULL DEFAULT 'pending'\n CHECK (status IN ('pending','running','ready','failed','committed','discarded')),\n\n markdown text,\n markdown_edited text,\n\n -- Extra\u00E7\u00E3o stage-1 do modo two-stage. A coluna entra agora, vazia, porque a\n -- interface j\u00E1 nasce com esse formato \u2014 quando as abas de multi-documento\n -- chegarem, \u00E9 preencher, n\u00E3o migrar.\n facts jsonb,\n\n model text,\n prompt_version text,\n input_tokens integer,\n output_tokens integer,\n error text,\n\n document_id uuid,\n created_by uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_scribe_generations ENABLE ROW LEVEL SECURITY;\n\nCREATE INDEX IF NOT EXISTS idx_plg_scribe_generations_session\n ON public.plg_scribe_generations(session_id, tab_order);\nCREATE INDEX IF NOT EXISTS idx_plg_scribe_generations_tenant\n ON public.plg_scribe_generations(tenant_id);\n\n-- FKs para plugin-forms e para o arqu\u00E9tipo de documentos s\u00E3o condicionais: o\n-- scribe declara `dependencies: ['custom_forms']`, mas a ordem de aplica\u00E7\u00E3o\n-- entre plugins n\u00E3o \u00E9 garantida e um pool pode aplicar este arquivo antes.\n-- Guardado, o re-apply converge; sem guarda, quebraria o passo inteiro.\nDO $$\nBEGIN\n IF to_regclass('public.plg_forms_templates') IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_scribe_generations_template_fk') THEN\n ALTER TABLE public.plg_scribe_generations\n ADD CONSTRAINT plg_scribe_generations_template_fk\n FOREIGN KEY (template_id) REFERENCES public.plg_forms_templates(id) ON DELETE SET NULL;\n END IF;\n\n IF to_regclass('public.documents') IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_scribe_generations_document_fk') THEN\n ALTER TABLE public.plg_scribe_generations\n ADD CONSTRAINT plg_scribe_generations_document_fk\n FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\n-- ---------------------------------------------------------------------------\n-- 4. RLS \u2014 a forma can\u00F4nica deste repo, tr\u00EAs tabelas x quatro verbos\n-- ---------------------------------------------------------------------------\nDROP POLICY IF EXISTS plg_scribe_sessions_select ON public.plg_scribe_sessions;\nCREATE POLICY plg_scribe_sessions_select ON public.plg_scribe_sessions FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nDROP POLICY IF EXISTS plg_scribe_sessions_insert ON public.plg_scribe_sessions;\nCREATE POLICY plg_scribe_sessions_insert ON public.plg_scribe_sessions FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nDROP POLICY IF EXISTS plg_scribe_sessions_update ON public.plg_scribe_sessions;\nCREATE POLICY plg_scribe_sessions_update ON public.plg_scribe_sessions FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nDROP POLICY IF EXISTS plg_scribe_sessions_delete ON public.plg_scribe_sessions;\nCREATE POLICY plg_scribe_sessions_delete ON public.plg_scribe_sessions FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_scribe_sessions TO authenticated;\n\nDROP POLICY IF EXISTS plg_scribe_segments_select ON public.plg_scribe_segments;\nCREATE POLICY plg_scribe_segments_select ON public.plg_scribe_segments FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nDROP POLICY IF EXISTS plg_scribe_segments_insert ON public.plg_scribe_segments;\nCREATE POLICY plg_scribe_segments_insert ON public.plg_scribe_segments FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nDROP POLICY IF EXISTS plg_scribe_segments_update ON public.plg_scribe_segments;\nCREATE POLICY plg_scribe_segments_update ON public.plg_scribe_segments FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nDROP POLICY IF EXISTS plg_scribe_segments_delete ON public.plg_scribe_segments;\nCREATE POLICY plg_scribe_segments_delete ON public.plg_scribe_segments FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_scribe_segments TO authenticated;\n\nDROP POLICY IF EXISTS plg_scribe_generations_select ON public.plg_scribe_generations;\nCREATE POLICY plg_scribe_generations_select ON public.plg_scribe_generations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nDROP POLICY IF EXISTS plg_scribe_generations_insert ON public.plg_scribe_generations;\nCREATE POLICY plg_scribe_generations_insert ON public.plg_scribe_generations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nDROP POLICY IF EXISTS plg_scribe_generations_update ON public.plg_scribe_generations;\nCREATE POLICY plg_scribe_generations_update ON public.plg_scribe_generations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nDROP POLICY IF EXISTS plg_scribe_generations_delete ON public.plg_scribe_generations;\nCREATE POLICY plg_scribe_generations_delete ON public.plg_scribe_generations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_scribe_generations TO authenticated;\n\n-- ---------------------------------------------------------------------------\n-- 5. scribe_record_segment \u2014 o browser registra, o banco conta\n-- ---------------------------------------------------------------------------\n-- SECURITY INVOKER: a RLS acima j\u00E1 amarra o tenant. N\u00E3o h\u00E1 nada aqui que\n-- precise de privil\u00E9gio elevado, e uma fun\u00E7\u00E3o SECURITY DEFINER que n\u00E3o precisa\n-- ser \u00E9 s\u00F3 superf\u00EDcie de ataque a mais.\n--\n-- O ponto da fun\u00E7\u00E3o \u00E9 a segunda metade: recalcular os contadores da sess\u00E3o a\n-- partir de um SELECT sobre os segmentos, nunca de um `+1` vindo do cliente.\n-- Um pump com retry infinito vai reenviar o mesmo segmento, e um contador\n-- incremental transformaria isso em n\u00FAmeros que crescem sem o \u00E1udio crescer.\nCREATE OR REPLACE FUNCTION public.scribe_record_segment(p_payload jsonb)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY INVOKER\nSET search_path TO 'public'\nAS $function$\nDECLARE\n v_session_id uuid;\n v_seg_index integer;\n v_tenant_id uuid;\n v_result jsonb;\nBEGIN\n v_session_id := (p_payload->>'session_id')::uuid;\n v_seg_index := (p_payload->>'seg_index')::integer;\n\n IF v_session_id IS NULL OR v_seg_index IS NULL THEN\n RAISE EXCEPTION 'session_id and seg_index are required';\n END IF;\n\n -- O tenant vem da SESS\u00C3O, nunca do payload. Se o cliente pudesse informar o\n -- tenant, ele escolheria em que pasta escrever.\n SELECT s.tenant_id INTO v_tenant_id\n FROM plg_scribe_sessions s WHERE s.id = v_session_id;\n\n IF v_tenant_id IS NULL THEN\n -- Sess\u00E3o inexistente OU invis\u00EDvel pela RLS: a mesma resposta nos dois\n -- casos, de prop\u00F3sito \u2014 n\u00E3o vaza a exist\u00EAncia de sess\u00E3o de outro tenant.\n RAISE EXCEPTION 'unknown session';\n END IF;\n\n INSERT INTO plg_scribe_segments (\n session_id, seg_index, tenant_id, storage_path, bytes,\n start_offset_ms, duration_ms, partial, gap, upload_state, uploaded_at, stt_state\n ) VALUES (\n v_session_id, v_seg_index, v_tenant_id,\n p_payload->>'storage_path',\n (p_payload->>'bytes')::integer,\n COALESCE((p_payload->>'start_offset_ms')::integer, 0),\n (p_payload->>'duration_ms')::integer,\n COALESCE((p_payload->>'partial')::boolean, false),\n COALESCE((p_payload->>'gap')::boolean, false),\n CASE WHEN COALESCE((p_payload->>'gap')::boolean, false) THEN 'missing' ELSE 'uploaded' END,\n now(),\n -- Buraco n\u00E3o vai para o STT: n\u00E3o h\u00E1 \u00E1udio para transcrever, e mand\u00E1-lo\n -- queimaria uma chamada para receber string vazia.\n CASE WHEN COALESCE((p_payload->>'gap')::boolean, false) THEN 'skipped' ELSE 'pending' END\n )\n ON CONFLICT (session_id, seg_index) DO UPDATE SET\n storage_path = COALESCE(EXCLUDED.storage_path, plg_scribe_segments.storage_path),\n bytes = COALESCE(EXCLUDED.bytes, plg_scribe_segments.bytes),\n duration_ms = COALESCE(EXCLUDED.duration_ms, plg_scribe_segments.duration_ms),\n partial = EXCLUDED.partial,\n upload_state = EXCLUDED.upload_state,\n uploaded_at = COALESCE(plg_scribe_segments.uploaded_at, EXCLUDED.uploaded_at),\n updated_at = now();\n\n -- Contadores derivados. `audio_duration_ms` soma s\u00F3 o que tem dura\u00E7\u00E3o medida,\n -- ent\u00E3o um segmento que ainda n\u00E3o voltou do STT n\u00E3o infla o total.\n UPDATE plg_scribe_sessions s SET\n segment_count = agg.total,\n uploaded_segment_count = agg.uploaded,\n transcribed_segment_count = agg.transcribed,\n audio_duration_ms = agg.audio_ms,\n transcript_chars = agg.chars,\n updated_at = now()\n FROM (\n SELECT\n count(*)::int AS total,\n count(*) FILTER (WHERE upload_state = 'uploaded')::int AS uploaded,\n count(*) FILTER (WHERE stt_state = 'done')::int AS transcribed,\n COALESCE(sum(duration_ms) FILTER (WHERE NOT gap), 0)::int AS audio_ms,\n COALESCE(sum(length(text)), 0)::int AS chars\n FROM plg_scribe_segments WHERE session_id = v_session_id\n ) agg\n WHERE s.id = v_session_id;\n\n SELECT to_jsonb(x) INTO v_result FROM (\n SELECT s.segment_count, s.uploaded_segment_count,\n s.transcribed_segment_count, s.audio_duration_ms, s.status\n FROM plg_scribe_sessions s WHERE s.id = v_session_id\n ) x;\n\n RETURN v_result;\nEND;\n$function$;\n\nCOMMENT ON FUNCTION public.scribe_record_segment(jsonb) IS\n 'Idempotent upsert of one audio segment + recomputation of the parent session counters from the segments table. Called by the browser upload pump after each successful Storage write.';\n\nGRANT EXECUTE ON FUNCTION public.scribe_record_segment(jsonb) TO authenticated;\n\n-- ---------------------------------------------------------------------------\n-- 6. o bucket\n-- ---------------------------------------------------------------------------\n-- PRIVADO, sem discuss\u00E3o: \u00E9 a voz de uma pessoa falando da pr\u00F3pria sa\u00FAde. URL\n-- p\u00FAblica seria um endere\u00E7o eterno para isso. O backoffice l\u00EA por URL assinada\n-- de curta dura\u00E7\u00E3o; a edge function de transcri\u00E7\u00E3o l\u00EA com service key.\n--\n-- 5 MB por SEGMENTO \u00E9 folgado por design: 30 s de Opus mono a 24 kbps d\u00E1 ~90 KB.\n-- O limite existe para barrar um cliente com bug mandando um arquivo inteiro,\n-- n\u00E3o para apertar o caso normal.\nINSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)\nVALUES (\n 'scribe-audio',\n 'scribe-audio',\n false,\n 5242880,\n ARRAY['audio/webm','audio/ogg','audio/mp4','audio/mpeg','audio/m4a','audio/x-m4a']\n)\nON CONFLICT (id) DO UPDATE SET\n public = EXCLUDED.public,\n file_size_limit = EXCLUDED.file_size_limit,\n allowed_mime_types = EXCLUDED.allowed_mime_types;\n\n-- ---------------------------------------------------------------------------\n-- 7. o caminho \u00E9 v\u00E1lido?\n-- ---------------------------------------------------------------------------\n-- Gram\u00E1tica (o SDK constr\u00F3i, o banco confere):\n--\n-- {tenant_id}/{yyyy}/{mm}/{session_id}/{seg_index:05d}.{ext}\n-- 1 2 3 4 5\n--\n-- 1 tenant_id chave do isolamento \u2014 toda policy pendura aqui\n-- 2,3 yyyy/mm varredura de reten\u00E7\u00E3o por prefixo (LGPD), pasta list\u00E1vel\n-- 4 session_id uuid do cliente: agrupa a sess\u00E3o e \u00E9 imprevis\u00EDvel\n-- 5 seg_index zero-padded, ent\u00E3o list() j\u00E1 volta em ordem cronol\u00F3gica\n--\n-- SECURITY DEFINER porque o EXISTS olha `tenants`, que tem RLS.\nCREATE OR REPLACE FUNCTION public.scribe_audio_path_ok(p_name text)\nRETURNS boolean\nLANGUAGE sql\nSTABLE\nSECURITY DEFINER\nSET search_path TO 'public'\nAS $function$\n SELECT\n p_name IS NOT NULL\n AND length(p_name) <= 200\n AND p_name ~ ('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'\n || '/[0-9]{4}/[0-9]{2}'\n || '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'\n || '/[0-9]{5}\\.(webm|ogg|m4a|mp3)$')\n -- compara\u00E7\u00E3o em texto (nunca `::uuid`): entrada malformada devolve falso em\n -- vez de derrubar a policy com erro de cast.\n AND EXISTS (\n SELECT 1 FROM public.tenants t WHERE t.id::text = split_part(p_name, '/', 1)\n );\n$function$;\n\nCOMMENT ON FUNCTION public.scribe_audio_path_ok(text) IS\n 'True when a storage object name follows the scribe-audio grammar {tenant}/{yyyy}/{mm}/{session}/{segIndex}.{ext} and segment 1 is a real tenant. Used by the INSERT policy on that bucket.';\n\nGRANT EXECUTE ON FUNCTION public.scribe_audio_path_ok(text) TO authenticated;\n\n-- ---------------------------------------------------------------------------\n-- 8. pol\u00EDticas do bucket\n-- ---------------------------------------------------------------------------\nDROP POLICY IF EXISTS plg_scribe_audio_insert ON storage.objects;\nDROP POLICY IF EXISTS plg_scribe_audio_read ON storage.objects;\nDROP POLICY IF EXISTS plg_scribe_audio_delete ON storage.objects;\n\n-- Escrever: s\u00F3 logado (diferente do bucket do CRM, que aceita visitante an\u00F4nimo\n-- \u2014 aqui quem grava \u00E9 sempre um profissional autenticado), dentro da gram\u00E1tica\n-- e da pr\u00F3pria pasta.\n--\n-- N\u00C3O existe policy de UPDATE, e isso \u00E9 a trava mais importante das tr\u00EAs: sem\n-- ela o `upsert` falha, e um caminho j\u00E1 gravado NUNCA pode ser trocado por\n-- outro conte\u00FAdo. \u00C9 o que torna o \u00E1udio uma testemunha e n\u00E3o um rascunho.\nCREATE POLICY plg_scribe_audio_insert ON storage.objects\n FOR INSERT TO authenticated\n WITH CHECK (\n bucket_id = 'scribe-audio'\n AND public.scribe_audio_path_ok(name)\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\nCREATE POLICY plg_scribe_audio_read ON storage.objects\n FOR SELECT TO authenticated\n USING (\n bucket_id = 'scribe-audio'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\n-- Apagar: mesma regra da leitura. \u00C9 o que torna \"apagar o \u00E1udio desta sess\u00E3o\"\n-- execut\u00E1vel por quem \u00E9 dono dele \u2014 o direito de elimina\u00E7\u00E3o do art. 18 VI n\u00E3o\n-- vale nada se ningu\u00E9m no tenant consegue exerc\u00EA-lo.\nCREATE POLICY plg_scribe_audio_delete ON storage.objects\n FOR DELETE TO authenticated\n USING (\n bucket_id = 'scribe-audio'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n";
2
+ export declare const MIGRATION_002_THE_DEFINER_FUNCTION_STOPS_TRUSTING_THE_PATH = "-- ---------------------------------------------------------------------------\n-- 002_the_definer_function_stops_trusting_the_path \u2014 every SECURITY DEFINER function this plugin\n-- owns stops resolving names the caller's way (#283).\n--\n-- The shape being closed: a definer function runs as its OWNER but resolves\n-- unqualified names using the CALLER's `search_path`. Anyone who can put an\n-- object earlier in that path gets it executed with the owner's rights. 157\n-- removed the ability to plant one in `public`; this removes the other half,\n-- which is the function trusting the path at all.\n--\n-- \u2500\u2500 Why ALTER and not a re-emitted CREATE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- These functions are live in pools. Re-emitting each body to add one clause\n-- would copy hundreds of lines for a one-word change, and every copy is a\n-- chance to alter behaviour by accident. `ALTER FUNCTION \u2026 SET search_path`\n-- changes exactly the property in question and nothing else.\n--\n-- \u2500\u2500 Why `public, pg_catalog` and not '' \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- The empty path is stricter and demands every name be schema-qualified \u2014 the\n-- right shape for something written today, and a rewrite of bodies written\n-- before the rule. What closes the hole is that the path is FIXED rather than\n-- inherited; these bodies already read `public` objects by bare name, so\n-- pinning to what they were written against keeps them correct and stops the\n-- caller having any say. It decides whether an upload path is acceptable \u2014 a check worth nothing if the caller can choose how its names resolve.\n-- ---------------------------------------------------------------------------\n\nDO $pin$\nDECLARE\n v_fn record;\n v_pinned integer := 0;\nBEGIN\n FOR v_fn IN\n SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args\n FROM pg_proc p\n JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public'\n AND p.prosecdef\n AND p.proname = ANY (ARRAY['scribe_audio_path_ok'])\n AND NOT EXISTS (\n SELECT 1 FROM unnest(coalesce(p.proconfig, ARRAY[]::text[])) AS cfg\n WHERE cfg LIKE 'search\\_path=%'\n )\n LOOP\n EXECUTE format('ALTER FUNCTION public.%I(%s) SET search_path = public, pg_catalog',\n v_fn.proname, v_fn.args);\n v_pinned := v_pinned + 1;\n END LOOP;\n IF v_pinned > 0 THEN\n RAISE NOTICE 'plugin-scribe: pinned search_path on % definer function(s)', v_pinned;\n END IF;\nEND $pin$;\n";
3
+ export declare const MIGRATION_003_THE_TEMPLATE_LINK_STOPS_BEING_A_FOREIGN_KEY = "-- ---------------------------------------------------------------------------\n-- 003_the_template_link_stops_being_a_foreign_key.sql \u2014 scribe stops reaching\n-- into another plugin's tables (#283, ADR 0015).\n--\n-- 001 put a FOREIGN KEY from `plg_scribe_generations.template_id` to\n-- `plg_forms_templates(id)`. It was guarded, so it never broke an install \u2014 and\n-- it was still the wrong instrument. A foreign key across two plugin prefixes\n-- couples two release cycles in the database: forms cannot drop or rename that\n-- table while scribe is installed, uninstalling forms fails with a dependency\n-- error rather than a decision, and a cluster that has scribe but not forms\n-- carries a link that means nothing.\n--\n-- What replaces it is not laxity. The column keeps its meaning and keeps\n-- pointing at the same rows; what goes is the DATABASE-LEVEL coupling. Reads go\n-- through forms' own view, and a template that disappears leaves a generation\n-- whose `template_id` no longer resolves \u2014 which the view reports as absent,\n-- exactly what `ON DELETE SET NULL` was arranging more expensively.\n--\n-- The general rule, stated once: a plugin owns its prefix. Anything it needs\n-- from another plugin comes through that plugin's view, its RPC, or an event \u2014\n-- three doors that can all be closed without a migration.\n-- ---------------------------------------------------------------------------\n\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_scribe_generations_template_fk') THEN\n ALTER TABLE public.plg_scribe_generations\n DROP CONSTRAINT plg_scribe_generations_template_fk;\n RAISE NOTICE 'plugin-scribe 003: dropped the cross-prefix FK into plg_forms_templates';\n END IF;\nEND $$;\n\nCOMMENT ON COLUMN public.plg_scribe_generations.template_id IS\n 'The forms template this generation came from, by id and not by foreign key (003, ADR 0015). A soft reference on purpose: forms owns that table and must be able to release it without scribe''s permission. A row whose template is gone reads as absent, which is what the old ON DELETE SET NULL was arranging.';\n\n-- The read scribe actually needs, in one place, so no caller is tempted to join\n-- the other plugin's table by hand and re-create the coupling in SQL.\nDO $$\nBEGIN\n IF to_regclass('public.plg_scribe_generations') IS NULL THEN RETURN; END IF;\n\n IF to_regclass('public.plg_forms_templates') IS NOT NULL THEN\n EXECUTE $v$\n CREATE OR REPLACE VIEW public.v_scribe_generations\n WITH (security_invoker = true) AS\n SELECT g.*,\n t.id IS NOT NULL AS template_exists,\n t.name AS template_name\n FROM public.plg_scribe_generations g\n LEFT JOIN public.plg_forms_templates t ON t.id = g.template_id\n $v$;\n ELSE\n -- No forms on this cluster: the same columns, honestly empty, so a caller\n -- written against the view keeps working instead of failing to compile.\n EXECUTE $v$\n CREATE OR REPLACE VIEW public.v_scribe_generations\n WITH (security_invoker = true) AS\n SELECT g.*,\n false AS template_exists,\n NULL::text AS template_name\n FROM public.plg_scribe_generations g\n $v$;\n END IF;\n\n EXECUTE 'GRANT SELECT ON public.v_scribe_generations TO authenticated, service_role';\nEND $$;\n\nCOMMENT ON VIEW public.v_scribe_generations IS\n 'Generations with their forms template resolved by LEFT JOIN rather than by foreign key (003). `template_exists` is the honest answer on a cluster where forms is not installed, or where the template was deleted.';\n";
2
4
  export declare const MIGRATIONS: Array<{
3
5
  id: string;
4
6
  sql: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,yBAAyB,kxyBA6drC,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAEzD,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,yBAAyB,kxyBA6drC,CAAA;AAED,eAAO,MAAM,0DAA0D,m3FAkDtE,CAAA;AAED,eAAO,MAAM,yDAAyD,smHAqErE,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAIzD,CAAA"}
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@fayz-ai/plugin-scribe",
3
3
  "fayz": {
4
- "status": "experimental"
4
+ "status": "experimental",
5
+ "dependencies": [
6
+ "custom_forms"
7
+ ]
5
8
  },
6
- "version": "0.12.0",
9
+ "version": "0.12.1",
7
10
  "description": "Fayz SDK — ambient capture: record a session, transcribe it, generate a narrative document",
8
11
  "type": "module",
9
12
  "main": "./dist/index.js",
@@ -26,9 +29,9 @@
26
29
  "dependencies": {
27
30
  "lucide-react": "^0.400.0",
28
31
  "zustand": "^4.5.0",
29
- "@fayz-ai/core": "^0.15.0",
30
- "@fayz-ai/admin": "^0.15.0",
31
- "@fayz-ai/ui": "^0.15.0"
32
+ "@fayz-ai/core": "^0.16.0",
33
+ "@fayz-ai/admin": "^0.16.0",
34
+ "@fayz-ai/ui": "^0.16.0"
32
35
  },
33
36
  "devDependencies": {
34
37
  "@types/react": "^18.3.0",