"use strict"; const { parseFlightSearchRequest } = require("./naturalLanguageFlightParser"); function tryParseJsonObject(text) { if (typeof text !== "string") { throw new Error("LLM response is not a string"); } const trimmed = text.trim(); try { return JSON.parse(trimmed); } catch (_error) { // Continue to bracket-based recovery. } const firstBrace = trimmed.indexOf("{"); const lastBrace = trimmed.lastIndexOf("}"); if (firstBrace < 0 || lastBrace < 0 || firstBrace >= lastBrace) { throw new Error("LLM response did not include a valid JSON object"); } const sliced = trimmed.slice(firstBrace, lastBrace + 1); return JSON.parse(sliced); } function toIntegerOrNull(value) { if (value === null || value === undefined) return null; const n = Number(value); if (!Number.isFinite(n)) return null; return Math.round(n); } function sanitizeDateWindow(value) { if (!value || typeof value !== "object") return null; const from = typeof value.from === "string" ? value.from : null; const to = typeof value.to === "string" ? value.to : null; if (!from || !to) return null; return { from, to }; } function sanitizeStayDuration(value) { if (!value || typeof value !== "object") return null; const minDays = toIntegerOrNull(value.minDays); const maxDays = toIntegerOrNull(value.maxDays); if (minDays === null || maxDays === null) return null; return { minDays: Math.min(minDays, maxDays), maxDays: Math.max(minDays, maxDays) }; } function sanitizeSegments(value) { if (!Array.isArray(value)) return null; const segments = value .map((segment) => { if (!segment || typeof segment !== "object") return null; const from = typeof segment.from === "string" ? segment.from.trim() : ""; const to = typeof segment.to === "string" ? segment.to.trim() : ""; if (!from || !to) return null; return { from: from.toUpperCase(), to: to.toUpperCase() }; }) .filter(Boolean); return segments.length > 0 ? segments : null; } function sanitizePassengers(value) { if (!value || typeof value !== "object") return null; const byCabinSource = value.byCabin || {}; const byCabin = { economy: Math.max(0, toIntegerOrNull(byCabinSource.economy) || 0), premium_economy: Math.max(0, toIntegerOrNull(byCabinSource.premium_economy) || 0), business: Math.max(0, toIntegerOrNull(byCabinSource.business) || 0), first: Math.max(0, toIntegerOrNull(byCabinSource.first) || 0), }; const computedTotal = Object.values(byCabin).reduce((acc, n) => acc + n, 0); const providedTotal = toIntegerOrNull(value.total); const total = providedTotal !== null ? Math.max(providedTotal, computedTotal) : computedTotal; if (total <= 0) return null; return { byCabin, total }; } function sanitizeMaxJourneyHours(value) { if (!value || typeof value !== "object") return null; const hours = toIntegerOrNull(value.hours); const operator = value.operator === "<=" ? "<=" : value.operator === "<" ? "<" : null; if (hours === null || operator === null) return null; return { hours, operator }; } function sanitizeConstraints(value, fallbackConstraints) { const source = value && typeof value === "object" ? value : {}; return { sameFlightForAllPassengers: typeof source.sameFlightForAllPassengers === "boolean" ? source.sameFlightForAllPassengers : fallbackConstraints.sameFlightForAllPassengers, itineraryCount: source.itineraryCount === null ? null : toIntegerOrNull(source.itineraryCount) ?? fallbackConstraints.itineraryCount, maxStops: source.maxStops === null ? null : toIntegerOrNull(source.maxStops) ?? fallbackConstraints.maxStops, maxJourneyHours: sanitizeMaxJourneyHours(source.maxJourneyHours) || fallbackConstraints.maxJourneyHours, }; } function uniqueStrings(values) { if (!Array.isArray(values)) return []; const seen = new Set(); const result = []; for (const item of values) { if (typeof item !== "string") continue; if (seen.has(item)) continue; seen.add(item); result.push(item); } return result; } function inferTripType(segments) { if (!segments || segments.length < 2) return "unknown"; const first = segments[0]; const second = segments[1]; if (first.from === second.to && first.to === second.from) return "round_trip"; if (first.from === second.to && first.to !== second.from) return "open_jaw"; return "multi_city"; } function recomputeMissingFields(params) { const missingFields = []; if (!params.departureDateWindow) missingFields.push("departureDateWindow"); if (!params.stayDurationDays) missingFields.push("stayDurationDays"); if (!params.passengers) missingFields.push("passengers"); if (!params.segments) missingFields.push("segments"); if (!params.constraints.maxJourneyHours) missingFields.push("maxJourneyHours"); return missingFields; } function mergeWithFallback(llmObject, fallbackParams, input, now) { const source = llmObject && typeof llmObject === "object" ? llmObject : {}; const departureDateWindow = sanitizeDateWindow(source.departureDateWindow) || fallbackParams.departureDateWindow; const stayDurationDays = sanitizeStayDuration(source.stayDurationDays) || fallbackParams.stayDurationDays; const segments = sanitizeSegments(source.segments) || fallbackParams.segments; const passengers = sanitizePassengers(source.passengers) || fallbackParams.passengers; const constraints = sanitizeConstraints(source.constraints, fallbackParams.constraints); const warnings = uniqueStrings([ ...fallbackParams.warnings, ...uniqueStrings(source.warnings), ]); const tripType = typeof source.tripType === "string" && source.tripType.trim() ? source.tripType : inferTripType(segments); const parsed = { rawInput: input, parsedAt: new Date(now).toISOString(), tripType, departureDateWindow, stayDurationDays, segments, passengers, constraints, warnings, }; parsed.missingFields = recomputeMissingFields(parsed); return parsed; } function buildPrompt(input, nowDate) { return [ "Extract flight-search parameters from user text and return JSON only.", "Use this JSON schema keys exactly:", "{", ' "departureDateWindow": {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"} | null,', ' "stayDurationDays": {"minDays": number, "maxDays": number} | null,', ' "segments": [{"from":"IATA or city code","to":"IATA or city code"}] | null,', ' "passengers": {"total":number,"byCabin":{"economy":number,"premium_economy":number,"business":number,"first":number}} | null,', ' "constraints": {"sameFlightForAllPassengers":boolean,"itineraryCount":number|null,"maxStops":number|null,"maxJourneyHours":{"hours":number,"operator":"<|<="}|null},', ' "tripType": "round_trip|open_jaw|multi_city|unknown",', ' "warnings": [string],', ' "missingFields": [string]', "}", "When information is missing, set null and add key names in missingFields.", `Today date is ${new Date(nowDate).toISOString().slice(0, 10)}.`, "", `User input: ${input}`, ].join("\n"); } function createOpenAIClient(options = {}) { const apiKey = options.apiKey || process.env.OPENAI_API_KEY; if (!apiKey) return null; const baseUrl = options.baseUrl || process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"; const model = options.model || process.env.OPENAI_MODEL || "gpt-4.1-mini"; const fetchImpl = options.fetch || global.fetch; if (typeof fetchImpl !== "function") { throw new Error("global fetch is unavailable. Node.js 18+ is required."); } const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`; return async ({ input, now }) => { const response = await fetchImpl(endpoint, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model, temperature: 0, response_format: { type: "json_object" }, messages: [ { role: "system", content: "You are a parser. Output valid JSON only. Do not wrap in markdown or prose.", }, { role: "user", content: buildPrompt(input, now), }, ], }), }); if (!response.ok) { const message = await response.text(); throw new Error(`OpenAI API request failed (${response.status}): ${message}`); } const payload = await response.json(); const content = payload?.choices?.[0]?.message?.content; return tryParseJsonObject(content); }; } async function extractFlightSearchRequest(input, options = {}) { if (typeof input !== "string" || input.trim() === "") { throw new Error("input must be a non-empty string"); } const now = options.now || new Date(); const fallbackParams = parseFlightSearchRequest(input, { now }); if (options.preferRuleParser) { return { source: "rule_parser", params: fallbackParams }; } try { const llmClient = options.llmClient || createOpenAIClient(options); if (!llmClient) { return { source: "rule_parser", params: fallbackParams }; } const llmRaw = await llmClient({ input, now }); const merged = mergeWithFallback(llmRaw, fallbackParams, input, now); return { source: "llm", params: merged }; } catch (error) { return { source: "rule_parser", params: { ...fallbackParams, warnings: [ ...fallbackParams.warnings, `LLM extraction fallback triggered: ${error.message}`, ], }, }; } } module.exports = { createOpenAIClient, extractFlightSearchRequest, };