diff --git a/infra/disclosure-stack/docker-compose.yml b/infra/disclosure-stack/docker-compose.yml index 2f68ff3..5fc8e94 100644 --- a/infra/disclosure-stack/docker-compose.yml +++ b/infra/disclosure-stack/docker-compose.yml @@ -68,6 +68,9 @@ services: GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres?search_path=auth GOTRUE_SITE_URL: https://${DOMAIN_MAIN} + # Explicit external URL so confirmation links land on the public site + # (Next.js /auth/callback), not on the Kong gateway host. + GOTRUE_API_EXTERNAL_URL: https://${DOMAIN_MAIN} GOTRUE_URI_ALLOW_LIST: https://${DOMAIN_MAIN},https://www.${DOMAIN_MAIN} GOTRUE_DISABLE_SIGNUP: "false" GOTRUE_JWT_ADMIN_ROLES: service_role diff --git a/infra/disclosure-stack/kong.yml b/infra/disclosure-stack/kong.yml index f922e90..be4b234 100644 --- a/infra/disclosure-stack/kong.yml +++ b/infra/disclosure-stack/kong.yml @@ -24,6 +24,21 @@ acls: group: admin services: + # GoTrue (Supabase v2.x) bakes the API_EXTERNAL_URL host into PKCE email + # confirmation links. Since our API gateway is api.disclosure.top but the + # /auth/callback handler lives on the Next.js site disclosure.top, emails + # would 404 at Kong. We catch the bare /auth/callback path on the api host + # and proxy it to the Next.js web service — the browser stays on the api + # subdomain but the Next.js handler still runs. + - name: auth-callback-proxy + url: http://web:3000 + routes: + - name: auth-callback-proxy + paths: [/auth/callback] + strip_path: false + plugins: + - name: cors + - name: auth-v1-open url: http://auth:9999/verify routes: diff --git a/scripts/synthesize/20_entity_summary.py b/scripts/synthesize/20_entity_summary.py new file mode 100644 index 0000000..c64fce8 --- /dev/null +++ b/scripts/synthesize/20_entity_summary.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +20_entity_summary.py — Synthesise narrative_summary EN+PT-BR for entities with +total_mentions >= threshold, via Claude Code OAuth subprocess (Sonnet). + +Strategy per entity: + 1. Query DB for top-K verbatim chunk snippets that mention the entity + (joined via public.entity_mentions + public.chunks). K=10 default. + 2. Build a Holmes-Watson voice prompt with the entity's canonical_name, + class, alias list, and the verbatim snippets. + 3. Call `claude -p --model sonnet --output-format json` → JSON with + narrative_summary + narrative_summary_pt_br. + 4. Update wiki/entities//.md frontmatter: + - narrative_summary, narrative_summary_pt_br + - summary_status: 'synthesized' + - summary_confidence: 'medium' + - last_lint: now() + +Idempotent: entities with summary_status in {'synthesized','curated','red_teamed'} +are skipped. Re-run safely advances any new ones. + +Throttle: 1 entity at a time (sequential). Max 20x plan: 5h window. + +Usage: + ./20_entity_summary.py --min-mentions 20 --limit 200 # top entities + ./20_entity_summary.py --classes person,organization # subset + ./20_entity_summary.py --dry-run --limit 5 # preview +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +try: + import yaml + import psycopg +except ImportError as e: + sys.stderr.write(f"pip3 install pyyaml psycopg[binary] # missing: {e}\n") + sys.exit(1) + +UFO_ROOT = Path(__file__).resolve().parents[2] +ENTITIES_BASE = UFO_ROOT / "wiki" / "entities" +LOG_PATH = UFO_ROOT / "wiki" / "log.md" + +DATABASE_URL = os.getenv("DATABASE_URL") or os.getenv("SUPABASE_DB_URL") + +# Map DB entity_class → filesystem folder +CLASS_FOLDER = { + "person": "people", + "organization": "organizations", + "location": "locations", + "event": "events", + "uap_object": "uap-objects", + "vehicle": "vehicles", + "operation": "operations", + "concept": "concepts", +} + + +def utc_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +PROMPT_TEMPLATE = """You are writing an encyclopedic entry for an investigative UAP/UFO wiki ("The Disclosure Bureau"). Voice rules: + +- Holmes–Watson narrator: precise, fact-dense, no hype, no breathless language. +- Open with what this entity is and how it figures in the corpus. Cite who/where/when. Optionally mention notable patterns across the snippets. +- 3–6 sentences. No editorial speculation beyond what the snippets support. +- Original-language verbatim quotes stay as-is; the EN summary is in English, the PT-BR summary in Brazilian Portuguese (with full UTF-8 accents). +- If snippets contradict each other or are sparse, say so plainly. +- NEVER include placeholder text like "Will be enriched in Phase N", "[REDACTED]", or markdown headings — pure prose only. + +ENTITY: +- Class: {entity_class} +- Canonical name: {name} +- Aliases: {aliases} +- Total mentions across corpus: {total_mentions} +- Documents that mention it: {documents_count} + +TOP {n_snippets} VERBATIM SNIPPETS FROM THE CORPUS: +{snippets} + +OUTPUT (STRICT JSON, no markdown fences, no commentary): +{{ + "narrative_summary": "", + "narrative_summary_pt_br": "" +}}""" + + +def call_sonnet(prompt: str, timeout_s: int = 180) -> dict: + """claude -p --model sonnet --output-format json subprocess.""" + try: + res = subprocess.run( + ["claude", "-p", "--model", "sonnet", "--output-format", "json"], + input=prompt, capture_output=True, text=True, + timeout=timeout_s, check=False, + ) + except subprocess.TimeoutExpired: + raise RuntimeError(f"claude subprocess timed out after {timeout_s}s") + if res.returncode != 0: + raise RuntimeError(f"claude exit {res.returncode}: {res.stderr[:300]}") + try: + env = json.loads(res.stdout) + except json.JSONDecodeError as e: + raise RuntimeError(f"unparseable claude envelope: {e} :: {res.stdout[:300]}") + txt = env.get("result") or env.get("response") or env.get("content") or "" + txt = re.sub(r"^```(?:json)?\s*|\s*```$", "", txt.strip(), flags=re.MULTILINE).strip() + try: + return json.loads(txt) + except json.JSONDecodeError: + m = re.search(r"\{.*?\"narrative_summary\".*\}", txt, flags=re.DOTALL) + if not m: + raise RuntimeError(f"no JSON object in claude output: {txt[:300]}") + return json.loads(m.group(0)) + + +def load_md(path: Path) -> tuple[dict, str]: + raw = path.read_text(encoding="utf-8") + if not raw.startswith("---"): + return {}, raw + end = raw.find("---", 4) + fm = yaml.safe_load(raw[3:end].strip()) or {} + body = raw[end + 3:].lstrip("\n") + return fm, body + + +def write_md(path: Path, fm: dict, body: str) -> None: + yaml_str = yaml.dump(fm, allow_unicode=True, sort_keys=False, default_flow_style=False) + sep = "" if body.startswith("\n") else "\n" + path.write_text(f"---\n{yaml_str}---\n{sep}{body}", encoding="utf-8") + + +def fetch_top_entities(conn, min_mentions: int, limit: int, classes: list[str] | None, + require_status_none: bool): + sql = """ + SELECT entity_pk, entity_class, entity_id, canonical_name, + COALESCE(aliases, ARRAY[]::text[]) AS aliases, + total_mentions, documents_count + FROM public.entities + WHERE total_mentions >= %s + """ + params: list = [min_mentions] + if classes: + sql += " AND entity_class = ANY(%s)" + params.append(classes) + sql += " ORDER BY total_mentions DESC LIMIT %s" + params.append(limit) + with conn.cursor() as cur: + cur.execute(sql, params) + return cur.fetchall() + + +def fetch_snippets(conn, entity_pk: int, k: int = 10) -> list[str]: + """Top-K longest chunks (by content length) mentioning the entity.""" + sql = """ + SELECT c.content_pt, c.content_en, c.doc_id, c.page, c.type + FROM public.entity_mentions em + JOIN public.chunks c ON c.chunk_pk = em.chunk_pk + WHERE em.entity_pk = %s + ORDER BY GREATEST(COALESCE(LENGTH(c.content_pt),0), COALESCE(LENGTH(c.content_en),0)) DESC + LIMIT %s + """ + with conn.cursor() as cur: + cur.execute(sql, (entity_pk, k)) + rows = cur.fetchall() + out = [] + for pt, en, doc, page, typ in rows: + body = (pt or en or "").strip() + if not body: + continue + # Cap each snippet so the prompt stays compact + body = body[:600] + out.append(f"- ({doc} p{page} · {typ}) {body}") + return out + + +def resolve_path(entity_class: str, entity_id: str) -> Path: + folder = CLASS_FOLDER.get(entity_class) + if not folder: + raise ValueError(f"unknown entity_class {entity_class}") + return ENTITIES_BASE / folder / f"{entity_id}.md" + + +def synthesise_one(conn, row, dry_run: bool, verbose: bool) -> str: + entity_pk, entity_class, entity_id, canonical_name, aliases, total_mentions, documents_count = row + path = resolve_path(entity_class, entity_id) + if not path.exists(): + return "skipped (file missing)" + fm, body = load_md(path) + status = fm.get("summary_status") + if status in ("synthesized", "curated", "red_teamed"): + return f"skipped (already {status})" + + snippets = fetch_snippets(conn, entity_pk, k=10) + if not snippets: + return "skipped (no snippets)" + + prompt = PROMPT_TEMPLATE.format( + entity_class=entity_class, + name=canonical_name, + aliases=", ".join((aliases or [])[:8]) or "—", + total_mentions=total_mentions, + documents_count=documents_count, + n_snippets=len(snippets), + snippets="\n".join(snippets), + ) + + if dry_run: + return f"ok (dry — {len(snippets)} snippets, {len(prompt)} chars prompt)" + + if verbose: + print(f" → calling sonnet ({len(snippets)} snippets, {len(prompt)} chars)...", flush=True) + out = call_sonnet(prompt) + narr_en = (out.get("narrative_summary") or "").strip() + narr_pt = (out.get("narrative_summary_pt_br") or "").strip() + if len(narr_en) < 40 or len(narr_pt) < 40: + return f"empty/short output (en={len(narr_en)}, pt={len(narr_pt)})" + + fm["narrative_summary"] = narr_en + fm["narrative_summary_pt_br"] = narr_pt + fm["summary_status"] = "synthesized" + fm["summary_confidence"] = "medium" + fm["last_lint"] = utc_iso() + # Refresh canonical mention counts from DB so the wiki agrees with retrieval + fm["total_mentions"] = int(total_mentions) + fm["documents_count"] = int(documents_count) + write_md(path, fm, body) + return "ok" + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--min-mentions", type=int, default=20) + p.add_argument("--limit", type=int, default=200) + p.add_argument("--classes", default=None, + help="comma-separated subset (e.g. 'person,organization,location')") + p.add_argument("--dry-run", action="store_true") + p.add_argument("--verbose", action="store_true") + p.add_argument("--sleep", type=float, default=0.5, + help="seconds between calls (respect Max 20x rate)") + args = p.parse_args() + + if not DATABASE_URL: + sys.stderr.write("DATABASE_URL not set\n") + return 1 + + classes = [c.strip() for c in args.classes.split(",")] if args.classes else None + + print(f"connecting → {DATABASE_URL.split('@')[-1]}") + with psycopg.connect(DATABASE_URL) as conn: + rows = fetch_top_entities(conn, args.min_mentions, args.limit, classes, require_status_none=True) + print(f"candidates: {len(rows)} (min_mentions={args.min_mentions}, limit={args.limit})") + done = 0 + skipped = 0 + errors = 0 + for i, row in enumerate(rows, 1): + entity_pk, entity_class, entity_id, canonical_name, _, total_mentions, _ = row + label = f"[{i:>3}/{len(rows)}] {entity_class}/{entity_id} ({total_mentions}m) — {canonical_name[:40]}" + try: + msg = synthesise_one(conn, row, args.dry_run, args.verbose) + except Exception as e: + errors += 1 + print(f" ✗ {label} — ERROR: {e}", flush=True) + continue + if msg.startswith("ok"): + done += 1 + print(f" ✓ {label} — {msg}", flush=True) + else: + skipped += 1 + print(f" · {label} — {msg}", flush=True) + if not args.dry_run and args.sleep > 0: + time.sleep(args.sleep) + + print(f"\ndone={done} skipped={skipped} errors={errors}") + + if not args.dry_run and done > 0: + with LOG_PATH.open("a", encoding="utf-8") as f: + f.write( + f"\n## {utc_iso()} · SYNTHESIZE_ENTITY_SUMMARIES\n" + f"- script: scripts/synthesize/20_entity_summary.py\n" + f"- min_mentions: {args.min_mentions}\n" + f"- limit: {args.limit}\n" + f"- synthesised: {done}\n" + f"- skipped: {skipped}\n" + f"- errors: {errors}\n" + f"- model: claude-sonnet (via CLAUDE_CODE_OAUTH_TOKEN)\n" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/app/api/sessions/[id]/messages/route.ts b/web/app/api/sessions/[id]/messages/route.ts index 9ec06b4..0a7c1c0 100644 --- a/web/app/api/sessions/[id]/messages/route.ts +++ b/web/app/api/sessions/[id]/messages/route.ts @@ -195,6 +195,7 @@ export async function POST(request: Request, ctx: { params: Promise<{ id: string tokens_out: result.tokensOut || null, cost_usd: 0, tool_calls: result.toolCalls.length > 0 ? result.toolCalls : null, + citations: result.artifacts && result.artifacts.length > 0 ? result.artifacts : null, }); }).catch((e) => { console.error("[chat] persist failed:", e); diff --git a/web/app/auth/callback/route.ts b/web/app/auth/callback/route.ts index 2f7a8de..1e0cde4 100644 --- a/web/app/auth/callback/route.ts +++ b/web/app/auth/callback/route.ts @@ -1,25 +1,86 @@ /** - * Magic-link callback. The link emailed to the user lands here with a `code` — - * we exchange it for a Supabase session cookie and bounce to the requested - * page (or home). + * Auth callback. The link emailed by Supabase / GoTrue can arrive in one of + * two shapes depending on flow: + * + * 1. OAuth code exchange: /auth/callback?code=<>&next=/... + * 2. PKCE email confirm: /auth/callback?token=<>&type=signup|magiclink|recovery|invite&next=/... + * (also: token_hash variant on newer Supabase JS) + * + * We handle both, exchange for a session, and bounce to `next` (or /). */ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; +type OtpType = "signup" | "magiclink" | "recovery" | "invite" | "email_change"; + +const VALID_OTP_TYPES: Set = new Set([ + "signup", "magiclink", "recovery", "invite", "email_change", +]); + export async function GET(request: Request) { - const { searchParams, origin } = new URL(request.url); + const url = new URL(request.url); + const { searchParams, origin } = url; + + // Always bounce to the public site origin, not whatever Host the + // request happened to come in on (e.g. api.disclosure.top via the Kong + // proxy fallback). NEXT_PUBLIC_SITE_URL is baked at build time. + const siteOrigin = process.env.NEXT_PUBLIC_SITE_URL || origin; + + // CRITICAL: cookies are scoped to the request host. If the link in the + // email landed on the API host (api.disclosure.top, proxied via Kong), + // any session cookie we set here would be on api.* and the user would + // appear logged out when they bounce to disclosure.top. Redirect first + // so verifyOtp runs on the site domain. + // + // `url.host` returns the internal listen host inside the Next.js server + // (typically `localhost:3000`), NOT the public host the browser used. + // We must read the proxy-forwarded host header to know where the user + // actually is. Traefik sets X-Forwarded-Host; Kong preserves Host. + const forwardedHost = (request.headers.get("x-forwarded-host") || + request.headers.get("host") || "").toLowerCase(); + try { + const siteUrl = new URL(siteOrigin); + if (forwardedHost && forwardedHost !== siteUrl.host.toLowerCase()) { + const redirected = new URL("/auth/callback", siteOrigin); + searchParams.forEach((v, k) => redirected.searchParams.set(k, v)); + return NextResponse.redirect(redirected.toString()); + } + } catch { + /* siteOrigin malformed — fall through and try local verify */ + } + const code = searchParams.get("code"); + const tokenHash = searchParams.get("token_hash") || searchParams.get("token"); + const typeRaw = searchParams.get("type"); const next = searchParams.get("next") ?? "/"; - if (!code) { - return NextResponse.redirect(`${origin}/auth/signin?error=missing_code`); - } - const supabase = await createClient(); - const { error } = await supabase.auth.exchangeCodeForSession(code); - if (error) { - return NextResponse.redirect(`${origin}/auth/signin?error=${encodeURIComponent(error.message)}`); + + // Path 1 — PKCE / OTP token (token + type) + if (tokenHash && typeRaw && VALID_OTP_TYPES.has(typeRaw as OtpType)) { + const { error } = await supabase.auth.verifyOtp({ + type: typeRaw as OtpType, + token_hash: tokenHash, + }); + if (error) { + return NextResponse.redirect( + `${siteOrigin}/auth/signin?error=${encodeURIComponent(error.message)}`, + ); + } + return NextResponse.redirect(`${siteOrigin}${next}`); } - return NextResponse.redirect(`${origin}${next}`); + // Path 2 — OAuth code exchange + if (code) { + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (error) { + return NextResponse.redirect( + `${siteOrigin}/auth/signin?error=${encodeURIComponent(error.message)}`, + ); + } + return NextResponse.redirect(`${siteOrigin}${next}`); + } + + // Neither shape provided + return NextResponse.redirect(`${siteOrigin}/auth/signin?error=missing_token`); } diff --git a/web/components/chat-bubble.tsx b/web/components/chat-bubble.tsx index ea03b91..6f6c0f0 100644 --- a/web/components/chat-bubble.tsx +++ b/web/components/chat-bubble.tsx @@ -11,6 +11,8 @@ import Link from "next/link"; import { createClient, isSupabaseConfigured } from "@/lib/supabase/client"; import type { User } from "@supabase/supabase-js"; import { MarkdownBody } from "./markdown-body"; +import type { Artifact } from "@/lib/chat/agui"; +import Image from "next/image"; interface ChatBubbleProps { context: { doc_id?: string; page_id?: string }; @@ -45,6 +47,7 @@ interface Msg { content: string; tools?: ToolBlock[]; navs?: NavOffer[]; + artifacts?: Artifact[]; streaming?: boolean; } @@ -78,11 +81,38 @@ export function ChatBubble({ context }: ChatBubbleProps) { setSessions(d.sessions); }, [user]); + // Hydrate a session's full transcript from the server. Maps persisted + // tool_calls + citations back into the inline `tools` / `artifacts` shape + // the renderer uses, so reopening an old session shows the same cards as + // when it was streamed live. const loadMessages = useCallback(async (sessionId: string) => { const r = await fetch(`/api/sessions/${sessionId}`); - if (!r.ok) return; - const d = (await r.json()) as { messages: Msg[] }; - setMessages(d.messages); + if (!r.ok) { + // Stale local activeId (e.g. session deleted server-side) — clear so + // the next interaction creates a fresh one. + if (r.status === 404) { + setActiveId(null); + try { localStorage.removeItem("chat:lastSessionId"); } catch { /* ignore */ } + } + return; + } + const d = (await r.json()) as { + messages: Array<{ + id?: string; + role: Msg["role"]; + content: string; + tool_calls?: ToolBlock[] | null; + citations?: Artifact[] | null; + }>; + }; + const hydrated: Msg[] = (d.messages ?? []).map((m) => ({ + id: m.id, + role: m.role, + content: m.content || "", + tools: Array.isArray(m.tool_calls) ? m.tool_calls : undefined, + artifacts: Array.isArray(m.citations) ? m.citations : undefined, + })); + setMessages(hydrated); }, []); const newSession = useCallback(async () => { @@ -97,6 +127,7 @@ export function ChatBubble({ context }: ChatBubbleProps) { if (!r.ok) return null; const d = (await r.json()) as { session: SessionRow }; setActiveId(d.session.id); + try { localStorage.setItem("chat:lastSessionId", d.session.id); } catch { /* ignore */ } setMessages([{ role: "assistant", content: context.page_id @@ -109,12 +140,53 @@ export function ChatBubble({ context }: ChatBubbleProps) { return d.session; }, [context]); + // Wrap setActiveId so we also persist + hydrate on switch. + const selectSession = useCallback((id: string) => { + setActiveId(id); + try { localStorage.setItem("chat:lastSessionId", id); } catch { /* ignore */ } + loadMessages(id); + setView("chat"); + }, [loadMessages]); + + // On open: list sessions, then either resume the persisted activeId + // (if it still belongs to the user) or pick the most recent one. + // Only spawn a brand-new session when the user has zero sessions yet. useEffect(() => { - if (open && user) { - loadSessions(); - if (!activeId) newSession(); - } - }, [open, user, activeId, loadSessions, newSession]); + if (!open || !user) return; + let cancelled = false; + + (async () => { + const r = await fetch("/api/sessions"); + if (!r.ok) return; + const d = (await r.json()) as { sessions: SessionRow[] }; + if (cancelled) return; + setSessions(d.sessions); + + // Resume the most-recently-active session this user had in this browser + let resumeId: string | null = null; + try { resumeId = localStorage.getItem("chat:lastSessionId"); } catch { /* ignore */ } + const knownIds = new Set(d.sessions.map((s) => s.id)); + const stillValid = resumeId && knownIds.has(resumeId) ? resumeId : null; + + if (stillValid) { + if (activeId !== stillValid) { + setActiveId(stillValid); + loadMessages(stillValid); + } + } else if (d.sessions.length > 0) { + // No persisted choice but the user has sessions — open the latest. + const latest = d.sessions[0].id; + setActiveId(latest); + loadMessages(latest); + try { localStorage.setItem("chat:lastSessionId", latest); } catch { /* ignore */ } + } else if (!activeId) { + // First-ever session for this user — create it. + await newSession(); + } + })(); + + return () => { cancelled = true; }; + }, [open, user, activeId, loadMessages, newSession]); // Auto-scroll on new content useEffect(() => { @@ -222,6 +294,11 @@ export function ChatBubble({ context }: ChatBubbleProps) { } else if (evName === "navigate") { const offer: NavOffer = { target: String(payload.target), label: String(payload.label) }; apply((m) => { (m.navs ??= []).push(offer); }); + } else if (evName === "artifact") { + const a = payload.artifact as Artifact | undefined; + if (a && typeof a === "object" && a.kind) { + apply((m) => { (m.artifacts ??= []).push(a); }); + } } else if (evName === "done") { apply((m) => { m.streaming = false; }); } else if (evName === "error") { @@ -309,10 +386,10 @@ export function ChatBubble({ context }: ChatBubbleProps) {
    {sessions.map((s) => ( -
  • +
  • + +
  • ))} + {sessions.length === 0 && ( +
  • + sem conversas ainda — crie uma nova +
  • + )}
) : ( @@ -387,6 +498,13 @@ function MessageBubble({ msg, onNavigate }: { msg: Msg; onNavigate: (t: string) {msg.streaming && } )} + {msg.artifacts && msg.artifacts.length > 0 && ( +
+ {msg.artifacts.map((a, i) => ( + + ))} +
+ )} {msg.navs && msg.navs.length > 0 && (
{msg.navs.map((n, i) => ( @@ -407,6 +525,94 @@ function MessageBubble({ msg, onNavigate }: { msg: Msg; onNavigate: (t: string) ); } +function ArtifactCard({ a, onNavigate }: { a: Artifact; onNavigate: (t: string) => void }) { + if (a.kind === "citation") { + const href = `/d/${a.doc_id}#${a.chunk_id}`; + const cls = (a.classification ?? "").split("//")[0].trim(); + return ( + + ); + } + if (a.kind === "crop_image") { + const href = a.chunk_id ? `/d/${a.doc_id}#${a.chunk_id}` : `/d/${a.doc_id}/p${String(a.page).padStart(3, "0")}`; + return ( + + ); + } + if (a.kind === "entity_card") { + const folder: Record = { + person: "people", organization: "organizations", location: "locations", + event: "events", uap_object: "uap-objects", vehicle: "vehicles", + operation: "operations", concept: "concepts", + }; + const href = `/e/${folder[a.entity_class]}/${a.entity_id}`; + return ( + + ); + } + if (a.kind === "navigation_offer") { + return ( + + ); + } + // evidence_card / hypothesis_card / case_card — render as placeholder until + // those routes exist in Fase 2. + return null; +} + interface ChunkHit { chunk_id?: string; doc_id?: string; diff --git a/web/lib/chat/agui.ts b/web/lib/chat/agui.ts index b18483e..ab58807 100644 --- a/web/lib/chat/agui.ts +++ b/web/lib/chat/agui.ts @@ -1,28 +1,76 @@ /** - * AG-UI-style SSE event helpers. + * AG-UI v1-aligned SSE event helpers (ADR-001, Phase 4). * - * We don't implement the full AG-UI protocol — we use a simplified event set - * that maps cleanly to our chat UX: + * Implements the AG-UI v1 protocol shape — events and typed artifacts — so the + * same backend can feed the in-app chat and future MCP / external clients. + * Wire-format remains SSE for browser compatibility. * + * Events: * text_delta — append text to the current assistant message * tool_start — model is calling a tool (renders collapsible block) * tool_result — local handler finished (fills the block) - * navigate — render a clickable navigation button inline + * navigate — clickable navigation button inline (legacy; prefer the + * navigation_offer artifact below) + * artifact — typed rich object the UI renders inline. See `Artifact`. * done — terminal event; carries usage + final assistant message id * error — terminal event; carries error detail * - * Each is emitted as one SSE message: + * SSE wire format per message: * * event: * data: * * */ + +export interface BBox { + x: number; + y: number; + w: number; + h: number; +} + +/** Inline-rendered, typed payloads. The chat UI renders each kind as a card. */ +export type Artifact = + | { + kind: "citation"; + chunk_id: string; + doc_id: string; + page: number; + type?: string; + classification?: string | null; + bbox?: BBox | null; + snippet?: string; + score?: number; + } + | { + kind: "crop_image"; + src: string; // /api/crop?doc=…&page=…&x=… + doc_id: string; + page: number; + chunk_id?: string; + alt_en?: string; + alt_pt?: string; + } + | { + kind: "entity_card"; + entity_class: "person" | "organization" | "location" | "event" | "uap_object" | "vehicle" | "operation" | "concept"; + entity_id: string; + canonical_name: string; + total_mentions?: number; + documents_count?: number; + } + | { kind: "evidence_card"; evidence_id: string; title?: string; grade?: string } + | { kind: "hypothesis_card"; hypothesis_id: string; title?: string; posterior?: number } + | { kind: "case_card"; case_id: string; title?: string } + | { kind: "navigation_offer"; target: string; label_en: string; label_pt: string }; + export type AGUIEvent = | { type: "text_delta"; delta: string } | { type: "tool_start"; id: string; name: string; args: Record } | { type: "tool_result"; id: string; result: unknown; durationMs?: number } | { type: "navigate"; target: string; label: string } + | { type: "artifact"; artifact: Artifact } | { type: "done"; provider: string; model: string; usage?: Record; messageId?: string } | { type: "error"; message: string }; diff --git a/web/lib/chat/index.ts b/web/lib/chat/index.ts index 854854f..751bdfd 100644 --- a/web/lib/chat/index.ts +++ b/web/lib/chat/index.ts @@ -109,6 +109,7 @@ export interface StreamChatResult { tokensIn: number; tokensOut: number; toolCalls: Array<{ name: string; args: Record; result: unknown }>; + artifacts: import("./agui").Artifact[]; }>; } diff --git a/web/lib/chat/openrouter.ts b/web/lib/chat/openrouter.ts index dd5a43c..0e89297 100644 --- a/web/lib/chat/openrouter.ts +++ b/web/lib/chat/openrouter.ts @@ -140,8 +140,19 @@ export async function streamWithTools( tokensIn: number; tokensOut: number; toolCalls: Array<{ name: string; args: Record; result: unknown }>; + artifacts: import("./agui").Artifact[]; }> { const maxTurns = req.maxTurns ?? 5; + // Capture every artifact emitted in this turn so the caller can persist + // them alongside the assistant message. The emit() bridge is unchanged. + const collectedArtifacts: import("./agui").Artifact[] = []; + const originalEmit = cb.emit; + cb = { + emit: (ev) => { + if (ev.type === "artifact") collectedArtifacts.push(ev.artifact); + originalEmit(ev); + }, + }; const messages: OAMsg[] = [ { role: "system", content: req.system }, ...req.history.map((m): OAMsg => ({ @@ -193,9 +204,15 @@ export async function streamWithTools( const handler = TOOL_HANDLERS[tc.function.name]; const t0 = Date.now(); let result: unknown; + // Per-tool artifact sink: bridges typed artifacts produced by the + // handler into the SSE stream as `artifact` events. + const ctxWithSink: ToolHandlerContext = { + ...req.ctx, + emitArtifact: (artifact) => cb.emit({ type: "artifact", artifact }), + }; try { if (!handler) throw new Error(`unknown tool: ${tc.function.name}`); - result = await handler(argsObj, req.ctx); + result = await handler(argsObj, ctxWithSink); } catch (e) { result = { error: e instanceof Error ? e.message : String(e) }; } @@ -234,6 +251,7 @@ export async function streamWithTools( tokensIn: totalIn, tokensOut: totalOut, toolCalls: toolTrace, + artifacts: collectedArtifacts, }; } diff --git a/web/lib/chat/tools.ts b/web/lib/chat/tools.ts index 7f95f65..96439eb 100644 --- a/web/lib/chat/tools.ts +++ b/web/lib/chat/tools.ts @@ -59,6 +59,10 @@ export interface ToolHandlerContext { page_id?: string | null; /** UI language preference (pt | en). */ lang?: "pt" | "en"; + /** Optional sink for inline AG-UI artifacts (citations, crops, entity cards). + * When provided, tools may push typed artifacts that the UI renders inline + * alongside the tool block. Safe to leave undefined for non-streaming callers. */ + emitArtifact?: (artifact: import("./agui").Artifact) => void; } export interface ToolHandler { @@ -395,6 +399,39 @@ async function handleHybridSearch( ufo_only: Boolean(args.ufo_only), top_k, }); + // Emit one citation (+ optional crop_image) artifact per hit so the UI can + // render inline cards next to the assistant text. Limit to top 6 to avoid + // flooding the chat with crops when top_k is large. + if (ctx.emitArtifact) { + for (const h of hits.slice(0, 6)) { + ctx.emitArtifact({ + kind: "citation", + chunk_id: h.chunk_id, + doc_id: h.doc_id, + page: h.page, + type: h.type, + classification: h.classification, + bbox: h.bbox ?? null, + snippet: ((lang === "en" ? h.content_en : h.content_pt) || "").slice(0, 300), + score: Number((h.rerank_score ?? h.score).toFixed(4)), + }); + if (h.bbox && h.bbox.w > 0 && h.bbox.h > 0) { + const bb = h.bbox; + const src = + `/api/crop?doc=${encodeURIComponent(h.doc_id)}` + + `&page=${h.page}&x=${bb.x}&y=${bb.y}&w=${bb.w}&h=${bb.h}&w_px=640`; + ctx.emitArtifact({ + kind: "crop_image", + src, + doc_id: h.doc_id, + page: h.page, + chunk_id: h.chunk_id, + alt_en: (h.content_en || h.chunk_id).slice(0, 120), + alt_pt: (h.content_pt || h.chunk_id).slice(0, 120), + }); + } + } + } return { query, lang, count: hits.length, hits: hits.map((h) => compactHit(h, lang)) }; } catch (e) { return {