mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 21:04:03 +00:00
174 lines
5.1 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|