import { NextResponse } from "next/server"; import { randomUUID } from "node:crypto"; import type { SendMessageResponse } from "@duoc-thu/shared-types"; import { buildAssistantMessage, type RagAnswerRaw } from "../_lib/ragResponse"; export const runtime = "nodejs"; // RAG never goes through api-gateway. The gateway proxies `/auth/*` only -- // apps/api-gateway/src/proxy/ holds a single AuthProxyController -- so // preferring AI_SERVICE_URL here pointed every RAG call at a service with no // such route the moment apiGateway was enabled, breaking chat, suggest, // history, sections, section-text and feedback at once. Resolve ai-service // directly and let AI_SERVICE_URL mean what its name says: auth only. const AI_SERVICE_URL = process.env.AI_SERVICE_URL ?? "http://localhost:8000"; interface RagResponse extends RagAnswerRaw { trace_id: string; correlation_id?: string; otel_trace_id?: string | null; } export async function POST(request: Request) { let content: string; let conversationId: string | null = null; let responseMode: "ai" | "monograph" = "ai"; try { const body = await request.json(); content = typeof body?.content === "string" ? body.content.trim() : ""; conversationId = typeof body?.conversationId === "string" ? body.conversationId : null; responseMode = body?.responseMode === "monograph" ? "monograph" : "ai"; } catch { return NextResponse.json({ error: "invalid_body" }, { status: 400 }); } if (!content) { return NextResponse.json({ error: "empty_query" }, { status: 400 }); } if (content.length > 4000) { return NextResponse.json({ error: "query_too_long" }, { status: 400 }); } if (conversationId && conversationId.length > 128) { return NextResponse.json({ error: "conversation_id_too_long" }, { status: 400 }); } const incomingCorrelationId = request.headers.get("x-correlation-id")?.trim(); const correlationId = incomingCorrelationId && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(incomingCorrelationId) ? incomingCorrelationId : randomUUID(); let responseCorrelationId = correlationId; let responseTraceId: string | null = null; let rag: RagResponse; try { const targetUrl = AI_SERVICE_URL.includes("/v1/rag") ? AI_SERVICE_URL : `${AI_SERVICE_URL}/v1/rag/query`; const upstreamHeaders: Record = { "Content-Type": "application/json", "X-Correlation-ID": correlationId, "X-Client-Version": "1.0.0", }; const traceparent = request.headers.get("traceparent"); const tracestate = request.headers.get("tracestate"); if (traceparent) upstreamHeaders.traceparent = traceparent; if (tracestate) upstreamHeaders.tracestate = tracestate; const upstream = await fetch(targetUrl, { method: "POST", headers: upstreamHeaders, body: JSON.stringify({ query: content, subject_scope: "human", intent: "fact_lookup", conversation_id: conversationId, response_mode: responseMode, }), cache: "no-store", // Propagate a browser disconnect/Stop action to the upstream fetch. // The synchronous Bedrock call already in flight may finish, but this // prevents the BFF itself from keeping an orphaned HTTP request open. signal: request.signal, }); responseCorrelationId = upstream.headers.get("x-correlation-id") ?? correlationId; responseTraceId = upstream.headers.get("x-trace-id"); if (!upstream.ok) { rag = { trace_id: `fallback-${Date.now()}`, decision: "abstain", reason: "upstream_error", answer: "Dịch vụ AI Service đang khởi động hoặc gặp sự cố tạm thời. Vui lòng thử lại trong giây lát.", resolved_drug_id: null, citations: [], }; } else { rag = (await upstream.json()) as RagResponse; } } catch { rag = { trace_id: `fallback-${Date.now()}`, decision: "abstain", reason: "upstream_unreachable", answer: `Không thể kết nối đến AI Service (${AI_SERVICE_URL}). Vui lòng đảm bảo AI Service đã được bật.`, resolved_drug_id: null, citations: [], }; } // The RagAgent orchestrator (F-03) puts a specific, already-Vietnamese // message into `answer` for most abstain cases too (e.g. "Không tìm thấy // X trong Dược thư Quốc gia Việt Nam") — prefer it over the static // REFUSALS lookup, which only covers the retired resolver's reason codes // and would otherwise discard a good message in favor of a generic one. // REFUSALS/GENERIC_REFUSAL (in `_lib/ragResponse`) are now purely the // fallback for the genuinely answer-less case (retrieval abstained with // no message to show). const message: SendMessageResponse["message"] = buildAssistantMessage( rag, rag.trace_id || `msg-${Date.now()}`, new Date().toISOString() ); const responseHeaders = new Headers({ "X-Correlation-ID": responseCorrelationId, }); if (responseTraceId) responseHeaders.set("X-Trace-ID", responseTraceId); return NextResponse.json( { message, sessionId: conversationId ?? undefined } satisfies SendMessageResponse, { headers: responseHeaders, } ); }