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:
parent
a35e1115fb
commit
7d13f93393
10 changed files with 718 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
299
scripts/synthesize/20_entity_summary.py
Normal file
299
scripts/synthesize/20_entity_summary.py
Normal 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:
|
||||
|
||||
- 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": "<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())
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue