mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 00:34:02 +00:00
feat(bot): add confirmed purchase proposals in topic ingestion
This commit is contained in:
173
apps/bot/src/openai-purchase-interpreter.ts
Normal file
173
apps/bot/src/openai-purchase-interpreter.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user