Files
household-bot/apps/bot/src/openai-purchase-interpreter.ts

174 lines
5.1 KiB
TypeScript

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<PurchaseInterpretation | null>
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
}
}
}