Skip to content

Commit ca64e44

Browse files
committed
refactored LLM API Endpoints
1 parent f83920f commit ca64e44

3 files changed

Lines changed: 358 additions & 361 deletions

File tree

‎src/app/api/suggest/description/route.ts‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,15 @@ import UserService from "@/services/userService";
66
import { DescriptionService } from "@/services/descriptionService";
77

88
const bodySchema = z.object({
9-
items: z.array(
10-
z.object({
11-
title: z.string().min(1),
12-
type: z.string().min(1),
13-
unitType: z.string().min(1),
14-
})
15-
).min(1),
16-
language: z
17-
.string()
18-
.regex(/^[A-Za-z-]{2,10}$/)
19-
.optional(),
9+
items: z
10+
.array(
11+
z.object({
12+
title: z.string().min(1),
13+
type: z.string().min(1),
14+
unitType: z.string().min(1),
15+
})
16+
)
17+
.min(1),
2018
});
2119

2220
export async function POST(req: NextRequest) {
@@ -36,7 +34,7 @@ export async function POST(req: NextRequest) {
3634
throw new ArgumentError(parsed.error.message);
3735
}
3836

39-
const descriptions = await DescriptionService.getOrGenerateDescriptions(parsed.data.items, parsed.data.language);
37+
const descriptions = await DescriptionService.getOrGenerateDescriptions(parsed.data.items);
4038

4139
return NextResponse.json({ items: descriptions }, { status: 200 });
4240
} catch (error) {

‎src/services/descriptionService.ts‎

Lines changed: 112 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -7,139 +7,140 @@ export type DescribeItemInput = {
77
unitType: string;
88
};
99

10+
type BilingualDescription = {
11+
haitian: string;
12+
english: string;
13+
};
14+
1015
export class DescriptionService {
11-
static makeBaseId(item: DescribeItemInput) {
16+
static makeCacheId(item: DescribeItemInput) {
1217
return `${item.title} | ${item.type} | ${item.unitType}`;
1318
}
1419

15-
static makeCacheId(item: DescribeItemInput, language?: string) {
16-
const base = DescriptionService.makeBaseId(item);
17-
return language && language.length > 0 ? `${base} | lang:${language}` : base;
18-
}
20+
static async getOrGenerateDescriptions(items: DescribeItemInput[]): Promise<string[]> {
21+
if (items.length === 0) return [];
1922

20-
static async getOrGenerateDescriptions(items: DescribeItemInput[], language?: string): Promise<string[]> {
21-
// De-duplicate identical requests within the batch
22-
const baseIds = items.map(DescriptionService.makeBaseId);
23-
const targetIds = items.map((it) => DescriptionService.makeCacheId(it, language));
24-
const idsToFetch = new Set<string>();
25-
// Always fetch the target ids; if language provided, also fetch base ids to allow fallback
26-
targetIds.forEach((id) => idsToFetch.add(id));
27-
if (language && language.length > 0) baseIds.forEach((id) => idsToFetch.add(id));
28-
const uniqueIdsToFetch = Array.from(idsToFetch);
29-
30-
// Fetch existing descriptions
23+
const ids = items.map(DescriptionService.makeCacheId);
3124
const existing = await db.descriptionLookup.findMany({
32-
where: { id: { in: uniqueIdsToFetch } },
25+
where: { id: { in: ids } },
3326
});
34-
const existingMap = new Map(existing.map((e) => [e.id, e.description] as const));
35-
36-
// Determine which need generation
37-
const toGenerate: { id: string; item: DescribeItemInput }[] = [];
38-
targetIds.forEach((tid, index) => {
39-
if (!existingMap.has(tid)) {
40-
// If language provided and base exists, we can use base without generating
41-
const baseId = baseIds[index];
42-
const hasBase = existingMap.has(baseId);
43-
if (!hasBase) {
44-
toGenerate.push({ id: tid, item: items[index] });
45-
}
46-
}
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] });
4732
});
4833

49-
// If no LLM needed, return in original order
50-
if (toGenerate.length === 0) {
51-
return targetIds.map((tid, idx) => existingMap.get(tid) || existingMap.get(baseIds[idx]) || "");
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+
);
5251
}
5352

54-
const { client } = getOpenAIClient();
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+
}
5556

56-
// If Azure is not configured, fall back to a simple deterministic template
57-
const generatedMap = new Map<string, string>();
58-
if (!client) {
59-
// No hardcoded language mapping; if base exists, prefer it. Otherwise, use a neutral template in English.
60-
for (const { id, item } of toGenerate) {
61-
const baseId = DescriptionService.makeBaseId(item);
62-
const base = existingMap.get(baseId);
63-
const desc = base ?? `"${item.title}" is a ${item.type.toLowerCase()} provided in ${item.unitType.toLowerCase()} units. It is intended for general use and distribution.`;
64-
generatedMap.set(id, desc);
65-
}
66-
} else {
67-
// Batch prompt engineering: generate concise, safe descriptions for multiple items at once
68-
const system = `You are a helpful assistant that writes neutral product descriptions.
69-
- Keep each description to 1-2 short sentences.
70-
- Avoid clinical claims, dosing, or medical advice.
71-
- Use plain language and mention the item's purpose at a high level.
72-
- If a field is generic, keep wording generic.
73-
- Do not include the name of the medication in the description`;
74-
75-
const userLines = toGenerate
76-
.map(({ item }, idx) => `${idx + 1}. title="${item.title}", type="${item.type}", unitType="${item.unitType}"`)
77-
.join("\n");
78-
const languageLine = language && language.length > 0 ? `Respond in language: ${language}.` : "";
79-
const user = `Write basic descriptions for these items, one per line and in order. ${languageLine} Return JSON only that matches the provided schema.\n${userLines}`;
80-
81-
const response = await client.chat.completions.create({
82-
// For Azure OpenAI with OpenAI SDK: pass the deployment name to `model`
83-
model: process.env.AZURE_OPENAI_DEPLOYMENT || "omni-moderate",
84-
messages: [
85-
{ role: "system", content: system },
86-
{ role: "user", content: user },
87-
],
88-
response_format: {
89-
type: "json_schema",
90-
json_schema: {
91-
name: "descriptions_schema",
92-
schema: {
93-
type: "object",
94-
properties: {
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",
9588
items: {
96-
type: "array",
97-
items: { type: "string" },
89+
type: "object",
90+
properties: {
91+
haitian: { type: "string" },
92+
english: { type: "string" },
93+
},
94+
required: ["haitian", "english"],
95+
additionalProperties: false,
9896
},
9997
},
100-
required: ["items"],
101-
additionalProperties: false,
10298
},
103-
strict: true,
99+
required: ["items"],
100+
additionalProperties: false,
104101
},
102+
strict: true,
105103
},
106-
});
107-
108-
const content = response.choices?.[0]?.message?.content ?? "";
109-
let arr: string[] = [];
110-
try {
111-
const parsed = JSON.parse(content);
112-
if (Array.isArray(parsed?.items)) {
113-
arr = parsed.items as string[];
114-
}
115-
} catch {
104+
},
105+
});
116106

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+
);
117114
}
115+
} catch {
116+
// Fall through to deterministic fallback below.
117+
}
118118

119-
if (arr.length !== toGenerate.length) {
120-
while (arr.length < toGenerate.length) arr.push("");
121-
arr = arr.slice(0, toGenerate.length);
122-
}
119+
return items.map(DescriptionService.defaultDescription);
120+
}
123121

124-
toGenerate.forEach((entry, idx) => {
125-
const raw = (arr[idx] ?? "").toString().trim();
126-
const safe = raw.length > 0 ? raw : `"${entry.item.title}" is a ${entry.item.type.toLowerCase()} provided in ${entry.item.unitType.toLowerCase()} units.`;
127-
generatedMap.set(entry.id, safe);
128-
});
129-
}
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+
}
130142

131-
// Persist newly generated ones
132-
const writes = Array.from(generatedMap.entries()).map(([id, description]) =>
133-
db.descriptionLookup.upsert({
134-
where: { id },
135-
update: { description },
136-
create: { id, description },
137-
})
138-
);
139-
if (writes.length) await Promise.all(writes);
140-
141-
// Merge existing + newly generated, return in original order
142-
const finalMap = new Map<string, string>([...existingMap, ...generatedMap]);
143-
return targetIds.map((tid, idx) => finalMap.get(tid) || finalMap.get(baseIds[idx]) || "");
143+
private static cleanText(value: string | undefined): string {
144+
return (value ?? "").replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
144145
}
145146
}

0 commit comments

Comments
 (0)