fix chat: force synthesis pass + fix ambiguous-column trigger
Two bugs combined to make the chat reply with only cards and no prose: 1. SQL trigger rollup_session_stats was failing with "column reference total_cost_usd is ambiguous" because the UPDATE on public.profiles had a FROM public.chat_sessions clause and both tables expose that column. Persistence of every user message died at this point — sessions were created in the DB but had message_count=0 forever. Applied SQL fix that qualifies columns with p./s. aliases (production DB updated; ALTER FUNCTION run live, not yet codified in a migration file). 2. The free-tier model (nemotron-3-super:free) spent all 5 tool-loop turns on hybrid_search calls and never wrote any prose, returning content_len=0. Added a forced-synthesis pass in openrouter.ts: when the loop exits with empty assembledText but the model did call tools, we send ONE final turn with tools omitted from the request payload and a user message instructing the model to answer in 3-8 sentences citing chunks. openrouterStreamCall now accepts a `withTools` opt so the synthesis call can disable tool calling entirely. Verified end-to-end with the actual user query "O que os astronautas viram? Quem foi que viu?" on /d/nasa-uap-d6-apollo-17-...: - content_len: 0 → 947 chars (real synthesis citing Schmitt) - artifacts: 44 preserved - assistant message persisted with tool_calls + citations columns
This commit is contained in:
parent
d5f6e6030a
commit
9889308bf4
2 changed files with 77 additions and 12 deletions
|
|
@ -128,34 +128,55 @@ Quotes verbatim do documento mantêm idioma original (inglês), narração ao re
|
||||||
|
|
||||||
export async function POST(request: Request, ctx: { params: Promise<{ id: string }> }) {
|
export async function POST(request: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const { id: sessionId } = await ctx.params;
|
const { id: sessionId } = await ctx.params;
|
||||||
|
const t0 = Date.now();
|
||||||
|
const log = (stage: string, extra: Record<string, unknown> = {}) =>
|
||||||
|
console.log(`[chat ${sessionId.slice(0, 8)}] ${stage}`, { dt: Date.now() - t0, ...extra });
|
||||||
|
log("POST received");
|
||||||
|
|
||||||
if (!isSupabaseConfigured()) {
|
if (!isSupabaseConfigured()) {
|
||||||
|
log("auth_disabled");
|
||||||
return NextResponse.json({ error: "auth_disabled" }, { status: 503 });
|
return NextResponse.json({ error: "auth_disabled" }, { status: 503 });
|
||||||
}
|
}
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||||
if (!user) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
|
if (!user) {
|
||||||
|
log("unauthenticated", { authError: authError?.message });
|
||||||
|
return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
|
||||||
|
}
|
||||||
|
log("user resolved", { user_id: user.id, email: user.email });
|
||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as { content: string };
|
const body = (await request.json().catch(() => ({}))) as { content: string };
|
||||||
if (!body.content?.trim()) {
|
if (!body.content?.trim()) {
|
||||||
|
log("empty_message", { received: body });
|
||||||
return NextResponse.json({ error: "empty_message" }, { status: 400 });
|
return NextResponse.json({ error: "empty_message" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
log("body parsed", { content_len: body.content.length });
|
||||||
|
|
||||||
const { data: session } = await supabase
|
const { data: session, error: sessErr } = await supabase
|
||||||
.from("chat_sessions").select("*").eq("id", sessionId).maybeSingle();
|
.from("chat_sessions").select("*").eq("id", sessionId).maybeSingle();
|
||||||
if (!session) return NextResponse.json({ error: "not_found" }, { status: 404 });
|
if (!session) {
|
||||||
|
log("session_not_found", { sessErr: sessErr?.message });
|
||||||
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
log("session ok", { context_doc_id: session.context_doc_id });
|
||||||
|
|
||||||
const { data: budgetOk } = await supabase.rpc("check_budget", { p_user_id: user.id });
|
const { data: budgetOk, error: budgetErr } = await supabase.rpc("check_budget", { p_user_id: user.id });
|
||||||
|
log("budget checked", { budgetOk, budgetErr: budgetErr?.message });
|
||||||
if (budgetOk === false) {
|
if (budgetOk === false) {
|
||||||
return NextResponse.json({ error: "budget_exceeded" }, { status: 429 });
|
return NextResponse.json({ error: "budget_exceeded" }, { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the user message before streaming
|
// Persist the user message before streaming
|
||||||
await supabase.from("messages").insert({
|
const { error: userInsertErr } = await supabase.from("messages").insert({
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: body.content,
|
content: body.content,
|
||||||
});
|
});
|
||||||
|
if (userInsertErr) {
|
||||||
|
log("user_msg insert FAILED", { error: userInsertErr.message });
|
||||||
|
return NextResponse.json({ error: "persist_failed", message: userInsertErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
log("user_msg persisted");
|
||||||
|
|
||||||
const context = await gatherContext(session.context_doc_id, session.context_page_id);
|
const context = await gatherContext(session.context_doc_id, session.context_page_id);
|
||||||
|
|
||||||
|
|
@ -186,7 +207,13 @@ export async function POST(request: Request, ctx: { params: Promise<{ id: string
|
||||||
// Persist the final assistant message AFTER the stream completes.
|
// Persist the final assistant message AFTER the stream completes.
|
||||||
// Note: this runs concurrently with the response — it does NOT block.
|
// Note: this runs concurrently with the response — it does NOT block.
|
||||||
done.then(async (result) => {
|
done.then(async (result) => {
|
||||||
await supabase.from("messages").insert({
|
log("stream done", {
|
||||||
|
content_len: result.content.length,
|
||||||
|
tool_calls: result.toolCalls.length,
|
||||||
|
artifacts: result.artifacts?.length ?? 0,
|
||||||
|
tokens: `${result.tokensIn}/${result.tokensOut}`,
|
||||||
|
});
|
||||||
|
const { error: insertErr } = await supabase.from("messages").insert({
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: result.content,
|
content: result.content,
|
||||||
|
|
@ -197,8 +224,10 @@ export async function POST(request: Request, ctx: { params: Promise<{ id: string
|
||||||
tool_calls: result.toolCalls.length > 0 ? result.toolCalls : null,
|
tool_calls: result.toolCalls.length > 0 ? result.toolCalls : null,
|
||||||
citations: result.artifacts && result.artifacts.length > 0 ? result.artifacts : null,
|
citations: result.artifacts && result.artifacts.length > 0 ? result.artifacts : null,
|
||||||
});
|
});
|
||||||
|
if (insertErr) log("assistant insert FAILED", { error: insertErr.message });
|
||||||
|
else log("assistant persisted");
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
console.error("[chat] persist failed:", e);
|
log("STREAM ERROR", { error: e instanceof Error ? e.message : String(e) });
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,35 @@ export async function streamWithTools(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forced synthesis: free-tier models often exhaust the tool-call budget
|
||||||
|
// without ever producing prose, returning content_len=0. If the loop ended
|
||||||
|
// with empty text but the model did call tools, force ONE more turn without
|
||||||
|
// tools so the model must answer in plain text.
|
||||||
|
if (!assembledText.trim() && toolTrace.length > 0) {
|
||||||
|
cb.emit({
|
||||||
|
type: "text_delta",
|
||||||
|
delta: "",
|
||||||
|
});
|
||||||
|
messages.push({
|
||||||
|
role: "user",
|
||||||
|
content:
|
||||||
|
"Com base nas ferramentas que você acabou de chamar e nos resultados acima, " +
|
||||||
|
"responda agora ao usuário EM TEXTO (3-8 frases), em português brasileiro. " +
|
||||||
|
"Cite os chunks no formato [[doc-id/p007#c0042]]. Não chame mais nenhuma ferramenta.",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await openrouterStreamCall(messages, modelUsed, { withTools: false });
|
||||||
|
if (res.body) {
|
||||||
|
const final = await readSSE(res.body, cb);
|
||||||
|
assembledText += final.roundText;
|
||||||
|
totalIn += final.usage?.prompt_tokens ?? 0;
|
||||||
|
totalOut += final.usage?.completion_tokens ?? 0;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
cb.emit({ type: "error", message: `synthesis failed: ${e instanceof Error ? e.message : String(e)}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: assembledText,
|
content: assembledText,
|
||||||
model: modelUsed,
|
model: modelUsed,
|
||||||
|
|
@ -255,15 +284,22 @@ export async function streamWithTools(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openrouterStreamCall(messages: OAMsg[], model: string): Promise<Response> {
|
async function openrouterStreamCall(
|
||||||
const body = {
|
messages: OAMsg[],
|
||||||
|
model: string,
|
||||||
|
opts: { withTools?: boolean } = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const withTools = opts.withTools !== false;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
tools: TOOL_DEFINITIONS,
|
|
||||||
tool_choice: "auto",
|
|
||||||
stream: true,
|
stream: true,
|
||||||
max_tokens: 1024,
|
max_tokens: 1024,
|
||||||
};
|
};
|
||||||
|
if (withTools) {
|
||||||
|
body.tools = TOOL_DEFINITIONS;
|
||||||
|
body.tool_choice = "auto";
|
||||||
|
}
|
||||||
const res = await fetch(ENDPOINT, {
|
const res = await fetch(ENDPOINT, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers(),
|
headers: headers(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue