export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase' export interface PurchaseInterpretation { decision: PurchaseInterpretationDecision amountMinor: bigint | null currency: 'GEL' | 'USD' | null itemDescription: string | null confidence: number parserMode: 'llm' clarificationQuestion: string | null } export type PurchaseMessageInterpreter = ( rawText: string, options: { defaultCurrency: 'GEL' | 'USD' } ) => Promise interface OpenAiStructuredResult { decision: PurchaseInterpretationDecision amountMinor: string | null currency: 'GEL' | 'USD' | null itemDescription: string | null confidence: number clarificationQuestion: string | null } function asOptionalBigInt(value: string | null): bigint | null { if (value === null || !/^[0-9]+$/.test(value)) { return null } const parsed = BigInt(value) return parsed > 0n ? parsed : null } function normalizeOptionalText(value: string | null | undefined): string | null { const trimmed = value?.trim() return trimmed && trimmed.length > 0 ? trimmed : null } function normalizeCurrency(value: string | null): 'GEL' | 'USD' | null { return value === 'GEL' || value === 'USD' ? value : null } export function createOpenAiPurchaseInterpreter( apiKey: string | undefined, model: string ): PurchaseMessageInterpreter | undefined { if (!apiKey) { return undefined } return async (rawText, options) => { const response = await fetch('https://api.openai.com/v1/responses', { method: 'POST', headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ model, input: [ { role: 'system', content: [ 'You classify a single Telegram message from a household shared-purchases topic.', 'Decide whether the message is a real shared purchase, needs clarification, or is not a shared purchase at all.', `The household default currency is ${options.defaultCurrency}, but do not assume that omitted currency means ${options.defaultCurrency}.`, 'Use clarification when the amount, currency, item, or overall intent is missing or uncertain.', 'Return a clarification question in the same language as the user message when clarification is needed.', 'Return only JSON that matches the schema.' ].join(' ') }, { role: 'user', content: rawText } ], text: { format: { type: 'json_schema', name: 'purchase_interpretation', schema: { type: 'object', additionalProperties: false, properties: { decision: { type: 'string', enum: ['purchase', 'clarification', 'not_purchase'] }, amountMinor: { anyOf: [{ type: 'string' }, { type: 'null' }] }, currency: { anyOf: [ { type: 'string', enum: ['GEL', 'USD'] }, { type: 'null' } ] }, itemDescription: { anyOf: [{ type: 'string' }, { type: 'null' }] }, confidence: { type: 'number', minimum: 0, maximum: 100 }, clarificationQuestion: { anyOf: [{ type: 'string' }, { type: 'null' }] } }, required: [ 'decision', 'amountMinor', 'currency', 'itemDescription', 'confidence', 'clarificationQuestion' ] } } } }) }) if (!response.ok) { return null } const payload = (await response.json()) as { output_text?: string } if (!payload.output_text) { return null } let parsedJson: OpenAiStructuredResult try { parsedJson = JSON.parse(payload.output_text) as OpenAiStructuredResult } catch { return null } if ( parsedJson.decision !== 'purchase' && parsedJson.decision !== 'clarification' && parsedJson.decision !== 'not_purchase' ) { return null } const clarificationQuestion = normalizeOptionalText(parsedJson.clarificationQuestion) if (parsedJson.decision === 'clarification' && !clarificationQuestion) { return null } return { decision: parsedJson.decision, amountMinor: asOptionalBigInt(parsedJson.amountMinor), currency: normalizeCurrency(parsedJson.currency), itemDescription: normalizeOptionalText(parsedJson.itemDescription), confidence: Math.max(0, Math.min(100, Math.round(parsedJson.confidence))), parserMode: 'llm', clarificationQuestion } } }