Spaces:
Running
Running
| 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<string, unknown> | Array<unknown>; | |
| 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<string, string> = { | |
| 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<T extends JsonValue>(filePath: string): Promise<T | null> { | |
| try { | |
| const file = await fs.readFile(filePath, 'utf8'); | |
| return JSON.parse(file) as T; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| async function fileExists(filePath: string): Promise<boolean> { | |
| 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<string, unknown> { | |
| 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<Response> { | |
| return jsonResponse({ | |
| status: 'ok', | |
| service: 'IRIS IR Intelligence', | |
| institution: 'Emirates NBD', | |
| mode: 'next-cache-fallback', | |
| backend_live: true, | |
| }); | |
| } | |
| async function fallbackDocuments(): Promise<Response> { | |
| const documents = await readJsonFile<Array<unknown>>(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<Response> { | |
| 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<Record<string, unknown>>(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<Response> { | |
| 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<Promise<string | null>>(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<Response> { | |
| 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<Response> { | |
| 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<Response> { | |
| 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; | |