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.
156 lines
4.5 KiB
TypeScript
156 lines
4.5 KiB
TypeScript
/**
|
|
* Chat orchestrator.
|
|
*
|
|
* Exports:
|
|
* sendChat(req) — non-streaming, no tools (used by tests, fallback paths)
|
|
* streamChat(req, cb) — streaming + tool calling via OpenRouter (Pattern C)
|
|
*
|
|
* CHAT_PROVIDER controls which path:
|
|
* 'openrouter' (default for Pattern C) — full tools + streaming
|
|
* 'claude-code' — simple Q&A via OAuth subprocess, NO tools
|
|
* 'auto' — claude-code first; on rate-limit/error fall back to OpenRouter (no tools)
|
|
*/
|
|
import { claudeCodeProvider } from "./claude-code";
|
|
import { sendOnce, streamWithTools } from "./openrouter";
|
|
import { createEventStream } from "./agui";
|
|
import type { ToolHandlerContext } from "./tools";
|
|
|
|
export type Provider = "claude-code" | "openrouter";
|
|
|
|
const MODE = (process.env.CHAT_PROVIDER || "openrouter") as Provider | "auto";
|
|
|
|
/* ─── Non-streaming (legacy/fallback) ───────────────────────────────────── */
|
|
|
|
export interface SendChatReq {
|
|
system: string;
|
|
messages: Array<{ role: "user" | "assistant" | "system"; content: string }>;
|
|
maxTokens?: number;
|
|
}
|
|
|
|
export interface SendChatResp {
|
|
provider: Provider;
|
|
model: string;
|
|
content: string;
|
|
tokensIn?: number;
|
|
tokensOut?: number;
|
|
costUsd?: number;
|
|
durationMs: number;
|
|
}
|
|
|
|
export async function sendChat(req: SendChatReq): Promise<SendChatResp> {
|
|
const t0 = Date.now();
|
|
|
|
async function viaOpenRouter(): Promise<SendChatResp> {
|
|
const r = await sendOnce({
|
|
system: req.system,
|
|
messages: req.messages,
|
|
maxTokens: req.maxTokens,
|
|
});
|
|
return {
|
|
provider: "openrouter",
|
|
model: r.model,
|
|
content: r.content,
|
|
tokensIn: r.tokensIn,
|
|
tokensOut: r.tokensOut,
|
|
costUsd: 0,
|
|
durationMs: Date.now() - t0,
|
|
};
|
|
}
|
|
|
|
if (MODE === "openrouter") return viaOpenRouter();
|
|
|
|
if (MODE === "claude-code") {
|
|
if (!claudeCodeProvider.isAvailable()) {
|
|
throw new Error("claude-code mode but CLAUDE_CODE_OAUTH_TOKEN not set");
|
|
}
|
|
const r = await claudeCodeProvider.send({
|
|
system: req.system,
|
|
messages: req.messages,
|
|
maxTokens: req.maxTokens,
|
|
});
|
|
return { ...r, durationMs: Date.now() - t0 };
|
|
}
|
|
|
|
// auto
|
|
if (claudeCodeProvider.isAvailable()) {
|
|
try {
|
|
const r = await claudeCodeProvider.send({
|
|
system: req.system,
|
|
messages: req.messages,
|
|
maxTokens: req.maxTokens,
|
|
});
|
|
return { ...r, durationMs: Date.now() - t0 };
|
|
} catch (e) {
|
|
const isRate = (e as Error & { isRateLimit?: boolean }).isRateLimit;
|
|
if (isRate || /401|403|oauth|token/i.test((e as Error).message)) {
|
|
return viaOpenRouter();
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
return viaOpenRouter();
|
|
}
|
|
|
|
/* ─── Streaming + tool calling (Pattern C) ──────────────────────────────── */
|
|
|
|
export interface StreamChatReq {
|
|
system: string;
|
|
history: Array<{ role: "user" | "assistant"; content: string }>;
|
|
userTurn: string;
|
|
ctx: ToolHandlerContext;
|
|
}
|
|
|
|
export interface StreamChatResult {
|
|
stream: ReadableStream<Uint8Array>;
|
|
/** Resolves AFTER the stream completes — usable in a deferred persist step. */
|
|
done: Promise<{
|
|
content: string;
|
|
model: string;
|
|
tokensIn: number;
|
|
tokensOut: number;
|
|
toolCalls: Array<{ name: string; args: Record<string, unknown>; result: unknown }>;
|
|
artifacts: import("./agui").Artifact[];
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Returns immediately with a ReadableStream the caller can pipe to Response.
|
|
* The `done` promise resolves when the full conversation (including all tool
|
|
* rounds) is finished — so the caller can then persist the assistant message
|
|
* to the database.
|
|
*/
|
|
export function streamChat(req: StreamChatReq): StreamChatResult {
|
|
const { stream, emit, close } = createEventStream();
|
|
|
|
const done = (async () => {
|
|
try {
|
|
const result = await streamWithTools(
|
|
{
|
|
system: req.system,
|
|
history: req.history,
|
|
userTurn: req.userTurn,
|
|
ctx: req.ctx,
|
|
},
|
|
{ emit },
|
|
);
|
|
emit({
|
|
type: "done",
|
|
provider: "openrouter",
|
|
model: result.model,
|
|
usage: {
|
|
tokens_in: result.tokensIn,
|
|
tokens_out: result.tokensOut,
|
|
tool_calls: result.toolCalls.length,
|
|
},
|
|
});
|
|
close();
|
|
return result;
|
|
} catch (e) {
|
|
emit({ type: "error", message: e instanceof Error ? e.message : String(e) });
|
|
close();
|
|
throw e;
|
|
}
|
|
})();
|
|
|
|
return { stream, done };
|
|
}
|