ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow

Fase 3 onda 2 — entity synthesis at scale:
- scripts/synthesize/20_entity_summary.py: queries DB for entities with
  total_mentions ≥ threshold + top-K verbatim chunk snippets via
  entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual),
  writes narrative_summary EN+PT-BR + summary_status=synthesized.
  Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no
  snippets). Combined with anchor curation: 20 curated + 158 synthesized
  = 178 entities with real narrative (vs 0 a day ago).

Fase 4 — chat with typed artifacts + persistence:
- lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image,
  entity_card, evidence_card, hypothesis_card, case_card, navigation_offer)
  alongside the existing event types.
- lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6
  citation + crop_image artifacts per query. Provider collects them and
  returns in done.artifacts so the route can persist.
- api/sessions/[id]/messages: persist artifacts to messages.citations.
- components/chat-bubble.tsx: ArtifactCard renders inline cards (citation,
  crop_image, entity_card, navigation_offer) for streamed and persisted
  messages. activeId now persisted in localStorage so navigation between
  pages keeps the same conversation. New sessions are lazy (only when user
  has zero). loadMessages hydrates tools + artifacts from server. CRUD UI:
  rename (✎) + archive (🗑) buttons per session in the list.

Home search:
- doc-list-filters: input now fires hybrid_search (rerank=0 for speed)
  in parallel with the local title filter; chunk hits render above the doc
  grid with snippet + score + classification.
- api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s).

Auth flow:
- infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands
  in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification).
- kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE
  email links don't 404 at the gateway.
- web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type=
  (PKCE); redirect to the public site host before verifyOtp so the session
  cookie lands on the right domain.

Audit deliverables:
- .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code
  analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by
  sa-principal that produced this roadmap. Kept in-tree for traceability.
This commit is contained in:
guto 2026-05-18 03:52:59 -03:00
parent a35e1115fb
commit 7d13f93393
10 changed files with 718 additions and 29 deletions

View file

@ -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

View file

@ -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:

View file

@ -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/<class>/<id>.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:
- HolmesWatson 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.
- 36 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": "<EN, 3-6 sentences>",
"narrative_summary_pt_br": "<PT-BR brasileiro, 3-6 sentences>"
}}"""
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())

View file

@ -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);

View file

@ -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<OtpType> = 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`);
}

View file

@ -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) {
</div>
<ul>
{sessions.map((s) => (
<li key={s.id}>
<li key={s.id} className="group flex items-stretch border-b border-[rgba(0,255,156,0.08)]">
<button
onClick={() => { setActiveId(s.id); loadMessages(s.id); setView("chat"); }}
className={`w-full text-left px-3 py-2 border-b border-[rgba(0,255,156,0.08)]
onClick={() => selectSession(s.id)}
className={`flex-1 text-left px-3 py-2
hover:bg-[rgba(0,255,156,0.04)] ${s.id === activeId ? "bg-[rgba(0,255,156,0.08)]" : ""}`}
>
<div className="font-mono text-xs text-[#7fdbff] truncate">
@ -322,8 +399,42 @@ export function ChatBubble({ context }: ChatBubbleProps) {
{s.message_count} msgs · {new Date(s.updated_at).toLocaleDateString()}
</div>
</button>
<button
onClick={async () => {
const next = window.prompt("Renomear conversa para:", s.title ?? "");
if (next === null) return;
const r = await fetch(`/api/sessions/${s.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: next.trim() || null }),
});
if (r.ok) loadSessions();
}}
title="renomear"
className="opacity-0 group-hover:opacity-100 px-2 text-[#5a6678] hover:text-[#7fdbff]"
></button>
<button
onClick={async () => {
if (!window.confirm("Arquivar esta conversa?")) return;
const r = await fetch(`/api/sessions/${s.id}`, { method: "DELETE" });
if (!r.ok) return;
if (s.id === activeId) {
setActiveId(null);
setMessages([]);
try { localStorage.removeItem("chat:lastSessionId"); } catch { /* ignore */ }
}
loadSessions();
}}
title="arquivar"
className="opacity-0 group-hover:opacity-100 px-2 text-[#5a6678] hover:text-[#ff6b6b]"
>🗑</button>
</li>
))}
{sessions.length === 0 && (
<li className="px-3 py-6 text-center font-mono text-xs text-[#5a6678]">
sem conversas ainda crie uma nova
</li>
)}
</ul>
</div>
) : (
@ -387,6 +498,13 @@ function MessageBubble({ msg, onNavigate }: { msg: Msg; onNavigate: (t: string)
{msg.streaming && <span className="text-[#5a6678]"> </span>}
</div>
)}
{msg.artifacts && msg.artifacts.length > 0 && (
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
{msg.artifacts.map((a, i) => (
<ArtifactCard key={i} a={a} onNavigate={onNavigate} />
))}
</div>
)}
{msg.navs && msg.navs.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{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 (
<button
onClick={() => onNavigate(href)}
className="text-left p-2 border border-[rgba(127,219,255,0.20)] hover:border-[#00ff9c]
bg-[#060a13] rounded transition"
>
<div className="flex items-center gap-1.5 mb-1 font-mono text-[9px] uppercase tracking-widest">
{cls && <span className="text-[#ff8a4d]">{cls}</span>}
<span className="text-[#7fdbff]">{a.chunk_id}</span>
<span className="text-[#5a6678]">· p{a.page} · {a.type}</span>
{typeof a.score === "number" && (
<span className="ml-auto text-[#00ff9c]">{a.score.toFixed(3)}</span>
)}
</div>
{a.snippet && (
<div className="text-[12px] text-[#c8d4e6] leading-snug line-clamp-3">{a.snippet}</div>
)}
<div className="font-mono text-[9px] text-[#5a6678] mt-1 truncate"> {a.doc_id}</div>
</button>
);
}
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 (
<button
onClick={() => onNavigate(href)}
className="block p-1 border border-[rgba(0,255,156,0.20)] hover:border-[#00ff9c]
bg-[#060a13] rounded transition overflow-hidden"
>
<Image
src={a.src}
alt={a.alt_pt || a.alt_en || a.chunk_id || "crop"}
width={320}
height={200}
sizes="(max-width:640px) 90vw, 280px"
className="block w-full h-auto rounded-sm"
/>
<div className="font-mono text-[9px] text-[#5a6678] mt-1 px-1 truncate">
{a.doc_id} · p{a.page}{a.chunk_id ? ` · ${a.chunk_id}` : ""}
</div>
</button>
);
}
if (a.kind === "entity_card") {
const folder: Record<string, string> = {
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 (
<button
onClick={() => onNavigate(href)}
className="text-left p-2 border border-[rgba(167,139,250,0.30)] hover:border-[#a78bfa]
bg-[#060a13] rounded transition"
>
<div className="font-mono text-[9px] uppercase tracking-widest text-[#a78bfa] mb-1">
{a.entity_class}
</div>
<div className="text-[13px] text-[#c8d4e6] font-bold leading-snug">{a.canonical_name}</div>
<div className="font-mono text-[9px] text-[#5a6678] mt-1">
{a.total_mentions ?? 0} menções · {a.documents_count ?? 0} docs
</div>
</button>
);
}
if (a.kind === "navigation_offer") {
return (
<button
onClick={() => onNavigate(a.target)}
className="text-[10px] font-mono uppercase tracking-widest
bg-[rgba(0,255,156,0.12)] hover:bg-[rgba(0,255,156,0.24)]
border border-[#00ff9c] text-[#00ff9c] rounded
px-2 py-1 flex items-center gap-1"
>
<ArrowUpRight size={11} /> {a.label_pt || a.label_en}
</button>
);
}
// 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;

View file

@ -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: <name>
* data: <json>
*
* <blank line>
*/
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<string, unknown> }
| { 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<string, unknown>; messageId?: string }
| { type: "error"; message: string };

View file

@ -109,6 +109,7 @@ export interface StreamChatResult {
tokensIn: number;
tokensOut: number;
toolCalls: Array<{ name: string; args: Record<string, unknown>; result: unknown }>;
artifacts: import("./agui").Artifact[];
}>;
}

View file

@ -140,8 +140,19 @@ export async function streamWithTools(
tokensIn: number;
tokensOut: number;
toolCalls: Array<{ name: string; args: Record<string, unknown>; 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,
};
}

View file

@ -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 {