import { promises as fs } from 'fs'; import path from 'path'; import type { NextRequest } from 'next/server'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; type RouteContext = { params: Promise<{ path: string[] }>; }; type ChatRequest = { question?: string; doc_ids?: string[]; }; type JsonValue = Record | Array; const BACKEND_INTERNAL_URL = process.env.BACKEND_INTERNAL_URL || 'http://127.0.0.1:8000'; const PRIMARY_DOC_ID = 'emiratesnbd_investor_presentation_2026_q1'; const DATA_DIR = path.join(process.cwd(), 'backend', 'data'); const CACHE_DIR = path.join(DATA_DIR, 'response_cache', PRIMARY_DOC_ID); const DOCS_FILE = path.join(DATA_DIR, 'documents.json'); const PAGES_DIR = path.join(DATA_DIR, 'pages'); const UPSTREAM_TIMEOUT_MS = 1200; const HOP_BY_HOP_HEADERS = new Set([ 'connection', 'content-encoding', 'content-length', 'host', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ]); const INTENT_CACHE_FILES: Record = { capital: 'capital.json', cost_efficiency: 'cost-efficiency.json', credit_quality: 'credit-quality.json', deposits: 'deposits.json', ecl_scenario: 'ecl-scenario.json', esg: 'esg.json', hyperinflation: 'hyperinflation.json', income: 'income.json', liquidity: 'liquidity.json', loans_sector: 'loans-sector.json', loans: 'loans.json', macro: 'macro.json', net_interest_margin: 'net-interest-margin.json', non_funded_income: 'non-funded-income.json', profitability: 'profitability.json', segment: 'segment.json', }; const INTENT_PATTERNS: Array<[string, RegExp[]]> = [ ['esg', [ /\besg\b/i, /\benvironmental social governance\b/i, /\bsustainab/i, /\bgreen\b/i, /\bclimate\b/i, /\bemissions?\b/i, /\bghg\b/i, /\bsustainable finance\b/i, /\bsustainalytics\b/i, /\bmsci\b/i, ]], ['segment', [ /\bbusiness segment/i, /\bsegment performance\b/i, /\bsegmental performance\b/i, /\brbwm\b/i, /\bcib\b/i, /\bgm&t\b/i, /\bglobal markets\b/i, /\btreasury\b/i, /\bdenizbank\b/i, /\bretail banking\b/i, /\bwealth management\b/i, /\bdivision/i, ]], ['non_funded_income', [ /\bnon[- ]?funded\b/i, /\bnfi\b/i, /\bfee/i, /\bcommission/i, /\bclient flow/i, /\btrading income/i, /\bfx\b/i, ]], ['net_interest_margin', [ /\bnim\b/i, /\bnet interest margin\b/i, /\binterest margin\b/i, ]], ['capital', [ /\bcapital adequacy\b/i, /\bcet[- ]?1\b/i, /\bcar\b/i, /\brwa\b/i, /\bbasel\b/i, /\bcapital ratio/i, ]], ['liquidity', [ /\bliquidity\b/i, /\bliquidity coverage ratio\b/i, /\blcr\b/i, /\badr\b/i, /\bfunding\b/i, /\bdebt maturit/i, ]], ['credit_quality', [ /\bcost of risk\b/i, /\bcredit quality\b/i, /\bnpl\b/i, /\bcoverage ratio\b/i, /\bimpairment/i, /\bprovision/i, ]], ['ecl_scenario', [ /\becl\b/i, /\bexpected credit loss\b/i, /\bscenario/i, /\bstage\s*[123]\b/i, ]], ['cost_efficiency', [ /\bcost[- ]?to[- ]?income\b/i, /\bcir\b/i, /\bcost efficiency\b/i, /\boperating expense/i, ]], ['loans_sector', [ /\bloans? by sector\b/i, /\bsector mix\b/i, /\bsector concentration/i, /\bloan portfolio.*sector\b/i, ]], ['loans', [ /\bloans?\b/i, /\badvances?\b/i, /\blending\b/i, /\bloan growth\b/i, /\bloans? and deposits?\b/i, /\bloan.*deposit\b/i, ]], ['deposits', [ /\bdeposits?\b/i, /\bcasa\b/i, /\btime deposits?\b/i, /\bfunding base\b/i, ]], ['hyperinflation', [ /\bhyperinflation\b/i, /\bias\s*29\b/i, /\bturkiye cpi\b/i, /\bmonetary correction\b/i, ]], ['macro', [ /\bmacro/i, /\beconomic environment\b/i, /\bgdp\b/i, /\binflation\b/i, /\btourism\b/i, /\breal estate\b/i, /\bpopulation\b/i, /\bproject awards?\b/i, ]], ['profitability', [ /\bnet profit\b/i, /\btotal profit\b/i, /\bprofitability\b/i, /\bprofit perform\b/i, /\bprofit growth\b/i, /\bprofit before tax\b/i, /\bpbt\b/i, /\brote\b/i, /\bmain drivers?\b/i, /\bkey drivers?\b/i, /\bperformance highlights?\b/i, /\bresults? highlights?\b/i, /\boverall performance\b/i, ]], ['income', [ /\btotal income\b/i, /\bincome statement\b/i, /\boperating income\b/i, /\bnet interest income\b/i, /\bnii\b/i, /\brevenue\b/i, ]], ]; function jsonResponse(body: JsonValue, init?: ResponseInit): Response { return Response.json(body, { ...init, headers: { 'Cache-Control': 'no-store', ...(init?.headers || {}), }, }); } async function readJsonFile(filePath: string): Promise { try { const file = await fs.readFile(filePath, 'utf8'); return JSON.parse(file) as T; } catch { return null; } } async function fileExists(filePath: string): Promise { try { await fs.access(filePath); return true; } catch { return false; } } function detectIntent(question: string): string | null { for (const [intent, patterns] of INTENT_PATTERNS) { if (patterns.some(pattern => pattern.test(question))) { return intent; } } return null; } function insufficientResponse(question: string, latencyMs: number): Record { return { response_type: 'insufficient', question, executive_summary: ( 'I do not have enough evidence in the indexed Emirates NBD Q1 2026 presentation cache to answer this question.' ), sources: [], financial_kpis: [], key_drivers: [], visual_evidence: [], latency_ms: latencyMs, model_used: 'next-cache-fallback', }; } async function fallbackHealth(): Promise { return jsonResponse({ status: 'ok', service: 'IRIS IR Intelligence', institution: 'Emirates NBD', mode: 'next-cache-fallback', backend_live: true, }); } async function fallbackDocuments(): Promise { const documents = await readJsonFile>(DOCS_FILE); if (documents) { return jsonResponse(documents); } return jsonResponse([{ doc_id: PRIMARY_DOC_ID, name: 'Emiratesnbd Investor Presentation 2026 Q1', doc_type: 'Investor Presentation', period: '2026', institution: 'Emirates NBD', total_pages: 36, status: 'indexed', filename: 'emiratesnbd_investor_presentation_2026_q1.pdf', }]); } async function fallbackChat(request: Request): Promise { const start = Date.now(); let payload: ChatRequest = {}; try { payload = await request.json() as ChatRequest; } catch { return jsonResponse({ detail: 'Invalid JSON body' }, { status: 400 }); } const question = payload.question?.trim() || ''; if (!question) { return jsonResponse({ detail: 'Question is required' }, { status: 400 }); } const intent = detectIntent(question); if (intent) { const cacheFile = INTENT_CACHE_FILES[intent]; const cached = await readJsonFile>(path.join(CACHE_DIR, cacheFile)); if (cached) { return jsonResponse({ ...cached, question, latency_ms: Math.max(1, Date.now() - start), model_used: cached.model_used || 'next-cache-fallback', }); } } return jsonResponse(insufficientResponse(question, Math.max(1, Date.now() - start))); } async function fallbackVisual(pathParts: string[]): Promise { const docId = pathParts[2]; const pageNumber = Number.parseInt(pathParts[3] || '', 10); if (!docId || Number.isNaN(pageNumber)) { return jsonResponse({ detail: 'Invalid visual path' }, { status: 400 }); } const pagesDir = path.join(PAGES_DIR, docId, 'pages'); const pageStem = `page_${String(pageNumber).padStart(4, '0')}`; const candidates = [ path.join(pagesDir, `${pageStem}.png`), path.join(pagesDir, `${pageStem}_colpali.png`), path.join(pagesDir, `${pageStem}_colpali_index.png`), ]; const imagePath = await candidates.reduce>(async (previous, candidate) => { const found = await previous; if (found) return found; return (await fileExists(candidate)) ? candidate : null; }, Promise.resolve(null)); if (!imagePath) { return jsonResponse({ detail: `Page image not found: ${docId}/${pageNumber}` }, { status: 404 }); } const image = await fs.readFile(imagePath); return new Response(image, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600', }, }); } async function fallbackLegacyPage(pathParts: string[]): Promise { const docId = pathParts[1]; const filename = pathParts.length >= 4 && pathParts[2] === 'pages' ? path.basename(pathParts[3]) : null; if (!docId || !filename || !filename.endsWith('.png')) { return jsonResponse({ detail: 'Invalid page image path' }, { status: 400 }); } const imagePath = path.join(PAGES_DIR, docId, 'pages', filename); if (!(await fileExists(imagePath))) { return jsonResponse({ detail: `Page image not found: ${docId}/${filename}` }, { status: 404 }); } const image = await fs.readFile(imagePath); return new Response(image, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600', }, }); } async function fallbackRequest(request: Request, pathParts: string[]): Promise { const route = `/${pathParts.join('/')}`.replace(/\/$/, ''); if (request.method === 'GET' && route === '/api/health') { return fallbackHealth(); } if (request.method === 'GET' && route === '/api/documents') { return fallbackDocuments(); } if (request.method === 'POST' && route === '/api/chat/query') { return fallbackChat(request); } if (request.method === 'GET' && pathParts[0] === 'api' && pathParts[1] === 'visuals') { return fallbackVisual(pathParts); } if (request.method === 'GET' && pathParts[0] === 'pages') { return fallbackLegacyPage(pathParts); } return jsonResponse({ detail: 'Backend route unavailable', route, }, { status: 503 }); } async function proxyRequest(request: NextRequest, context: RouteContext): Promise { const { path: pathParts } = await context.params; const upstream = new URL(`/${pathParts.join('/')}`, BACKEND_INTERNAL_URL); upstream.search = request.nextUrl.search; const fallbackRequestClone = request.clone(); const headers = new Headers(request.headers); for (const header of HOP_BY_HOP_HEADERS) { headers.delete(header); } try { const response = await fetch(upstream, { method: request.method, headers, body: request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body, cache: 'no-store', signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), duplex: 'half', } as RequestInit & { duplex: 'half' }); if (response.ok) { const responseHeaders = new Headers(response.headers); for (const header of HOP_BY_HOP_HEADERS) { responseHeaders.delete(header); } return new Response(response.body, { status: response.status, statusText: response.statusText, headers: responseHeaders, }); } } catch { // Fall through to the cache-backed route below. } return fallbackRequest(fallbackRequestClone, pathParts); } export const GET = proxyRequest; export const POST = proxyRequest; export const PUT = proxyRequest; export const PATCH = proxyRequest; export const DELETE = proxyRequest; export const HEAD = proxyRequest; export const OPTIONS = proxyRequest;