|
| 1 | +import { db } from "@/db"; |
| 2 | +import { getOpenAIClient } from "@/lib/azureOpenAI"; |
| 3 | + |
| 4 | +export type DescribeItemInput = { |
| 5 | + title: string; |
| 6 | + type: string; |
| 7 | + unitType: string; |
| 8 | +}; |
| 9 | + |
| 10 | +type BilingualDescription = { |
| 11 | + haitian: string; |
| 12 | + english: string; |
| 13 | +}; |
| 14 | + |
| 15 | +export class DescriptionService { |
| 16 | + static makeCacheId(item: DescribeItemInput) { |
| 17 | + return `${item.title} | ${item.type} | ${item.unitType}`; |
| 18 | + } |
| 19 | + |
| 20 | + static async getOrGenerateDescriptions(items: DescribeItemInput[]): Promise<string[]> { |
| 21 | + if (items.length === 0) return []; |
| 22 | + |
| 23 | + const ids = items.map(DescriptionService.makeCacheId); |
| 24 | + const existing = await db.descriptionLookup.findMany({ |
| 25 | + where: { id: { in: ids } }, |
| 26 | + }); |
| 27 | + const existingMap = new Map(existing.map((entry: { id: string; description: string }) => [entry.id, entry.description])); |
| 28 | + |
| 29 | + const missing: { id: string; item: DescribeItemInput }[] = []; |
| 30 | + ids.forEach((id, index) => { |
| 31 | + if (!existingMap.has(id)) missing.push({ id, item: items[index] }); |
| 32 | + }); |
| 33 | + |
| 34 | + const generated = await DescriptionService.generateDescriptions(missing.map((entry) => entry.item)); |
| 35 | + const generatedMap = new Map<string, string>(); |
| 36 | + missing.forEach((entry, index) => { |
| 37 | + const combined = DescriptionService.combineDescriptions(generated[index] ?? DescriptionService.defaultDescription(entry.item)); |
| 38 | + generatedMap.set(entry.id, combined); |
| 39 | + }); |
| 40 | + |
| 41 | + if (generatedMap.size > 0) { |
| 42 | + await Promise.all( |
| 43 | + Array.from(generatedMap.entries()).map(([id, description]) => |
| 44 | + db.descriptionLookup.upsert({ |
| 45 | + where: { id }, |
| 46 | + update: { description }, |
| 47 | + create: { id, description }, |
| 48 | + }) |
| 49 | + ) |
| 50 | + ); |
| 51 | + } |
| 52 | + |
| 53 | + const finalMap = new Map([...existingMap, ...generatedMap]) as Map<string, string>; |
| 54 | + return ids.map((id, index) => finalMap.get(id) || DescriptionService.combineDescriptions(DescriptionService.defaultDescription(items[index]))); |
| 55 | + } |
| 56 | + |
| 57 | + private static async generateDescriptions(items: DescribeItemInput[]): Promise<BilingualDescription[]> { |
| 58 | + if (items.length === 0) return []; |
| 59 | + |
| 60 | + const { client } = getOpenAIClient(); |
| 61 | + if (!client) return items.map(DescriptionService.defaultDescription); |
| 62 | + |
| 63 | + const system = `You write short, neutral product descriptions. |
| 64 | +- Produce one Haitian Creole sentence and one English sentence for each item. |
| 65 | +- Avoid medical claims, dosages, or promises of outcomes. |
| 66 | +- Keep language simple and suitable for a broad audience.`; |
| 67 | + |
| 68 | + const userLines = items |
| 69 | + .map((item, index) => `${index + 1}. title="${item.title}", type="${item.type}", unitType="${item.unitType}"`) |
| 70 | + .join("\n"); |
| 71 | + const user = `Return a JSON object with an \'items\' array. Each element must include \'haitian\' and \'english\' string fields that describe the matching item. Haitian Creole should be natural and come from the Haitian perspective; English should be clear and simple.\n${userLines}`; |
| 72 | + |
| 73 | + const response = await client.chat.completions.create({ |
| 74 | + model: process.env.AZURE_OPENAI_DEPLOYMENT || "omni-moderate", |
| 75 | + messages: [ |
| 76 | + { role: "system", content: system }, |
| 77 | + { role: "user", content: user }, |
| 78 | + ], |
| 79 | + response_format: { |
| 80 | + type: "json_schema", |
| 81 | + json_schema: { |
| 82 | + name: "bilingual_descriptions", |
| 83 | + schema: { |
| 84 | + type: "object", |
| 85 | + properties: { |
| 86 | + items: { |
| 87 | + type: "array", |
| 88 | + items: { |
| 89 | + type: "object", |
| 90 | + properties: { |
| 91 | + haitian: { type: "string" }, |
| 92 | + english: { type: "string" }, |
| 93 | + }, |
| 94 | + required: ["haitian", "english"], |
| 95 | + additionalProperties: false, |
| 96 | + }, |
| 97 | + }, |
| 98 | + }, |
| 99 | + required: ["items"], |
| 100 | + additionalProperties: false, |
| 101 | + }, |
| 102 | + strict: true, |
| 103 | + }, |
| 104 | + }, |
| 105 | + }); |
| 106 | + |
| 107 | + const content = response.choices?.[0]?.message?.content ?? ""; |
| 108 | + try { |
| 109 | + const parsed = JSON.parse(content); |
| 110 | + if (Array.isArray(parsed?.items)) { |
| 111 | + return parsed.items.map((entry: BilingualDescription, index: number) => |
| 112 | + DescriptionService.normalizeDescription(entry, items[index]) |
| 113 | + ); |
| 114 | + } |
| 115 | + } catch { |
| 116 | + // Fall through to deterministic fallback below. |
| 117 | + } |
| 118 | + |
| 119 | + return items.map(DescriptionService.defaultDescription); |
| 120 | + } |
| 121 | + |
| 122 | + private static normalizeDescription(value: Partial<BilingualDescription> | undefined, item: DescribeItemInput): BilingualDescription { |
| 123 | + const fallback = DescriptionService.defaultDescription(item); |
| 124 | + const haitian = DescriptionService.cleanText(value?.haitian) || fallback.haitian; |
| 125 | + const english = DescriptionService.cleanText(value?.english) || fallback.english; |
| 126 | + return { haitian, english }; |
| 127 | + } |
| 128 | + |
| 129 | + private static defaultDescription(item: DescribeItemInput): BilingualDescription { |
| 130 | + const lowerType = item.type.toLowerCase(); |
| 131 | + const lowerUnit = item.unitType.toLowerCase(); |
| 132 | + const english = `"${item.title}" is a ${lowerType} offered in ${lowerUnit} units.`; |
| 133 | + const haitian = `"${item.title}" se yon ${lowerType} ki disponib nan inite ${lowerUnit}.`; |
| 134 | + return { haitian, english }; |
| 135 | + } |
| 136 | + |
| 137 | + private static combineDescriptions(value: BilingualDescription): string { |
| 138 | + const haitian = DescriptionService.cleanText(value.haitian); |
| 139 | + const english = DescriptionService.cleanText(value.english); |
| 140 | + return `${haitian} / ${english}`; |
| 141 | + } |
| 142 | + |
| 143 | + private static cleanText(value: string | undefined): string { |
| 144 | + return (value ?? "").replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim(); |
| 145 | + } |
| 146 | +} |
0 commit comments