Skip to content

Commit c04f714

Browse files
committed
merged llm endpoints
2 parents c519e22 + ca64e44 commit c04f714

9 files changed

Lines changed: 879 additions & 239 deletions

File tree

‎package-lock.json‎

Lines changed: 270 additions & 239 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"email:dev": "email dev --dir ./src/email --port 4000"
1818
},
1919
"dependencies": {
20+
"openai": "^4.63.0",
2021
"@azure/storage-blob": "^12.27.0",
2122
"@headlessui/react": "^2.2.1",
2223
"@phosphor-icons/react": "^2.1.7",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- CreateTable
2+
CREATE TABLE "DescriptionLookup" (
3+
"id" TEXT NOT NULL,
4+
"description" TEXT NOT NULL,
5+
6+
CONSTRAINT "DescriptionLookup_pkey" PRIMARY KEY ("id")
7+
);

‎prisma/schema.prisma‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,9 @@ model Wishlist {
297297
partnerId Int
298298
partner User @relation(fields: [partnerId], references: [id], onDelete: Cascade)
299299
}
300+
301+
model DescriptionLookup {
302+
// Composite identity constructed as `${title} | ${type} | ${unitType}`
303+
id String @id
304+
description String
305+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { auth } from "@/auth";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { z } from "zod";
4+
import { errorResponse, AuthenticationError, AuthorizationError, ArgumentError } from "@/util/errors";
5+
import UserService from "@/services/userService";
6+
import { DescriptionService } from "@/services/descriptionService";
7+
8+
const bodySchema = z.object({
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),
18+
});
19+
20+
export async function POST(req: NextRequest) {
21+
try {
22+
const session = await auth();
23+
if (!session?.user) {
24+
throw new AuthenticationError("Session required");
25+
}
26+
27+
if (!UserService.isStaff(session.user.type)) {
28+
throw new AuthorizationError("Must be STAFF, ADMIN or SUPER_ADMIN");
29+
}
30+
31+
const json = await req.json().catch(() => ({}));
32+
const parsed = bodySchema.safeParse(json);
33+
if (!parsed.success) {
34+
throw new ArgumentError(parsed.error.message);
35+
}
36+
37+
const descriptions = await DescriptionService.getOrGenerateDescriptions(parsed.data.items);
38+
39+
return NextResponse.json({ items: descriptions }, { status: 200 });
40+
} catch (error) {
41+
return errorResponse(error);
42+
}
43+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { auth } from "@/auth";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { z } from "zod";
4+
import { errorResponse, AuthenticationError, AuthorizationError, ArgumentError } from "@/util/errors";
5+
import UserService from "@/services/userService";
6+
import { UnfinalizedSuggestionService } from "@/services/unfinalizedSuggestionService";
7+
8+
const bodySchema = z.object({
9+
generalItems: z.array(
10+
z.object({
11+
title: z.string().min(1),
12+
type: z.string().min(1),
13+
description: z.string().min(1),
14+
expirationDate: z.string().min(1), // ISO date string
15+
unitType: z.string().min(1),
16+
quantityPerUnit: z.number().int().positive(),
17+
totalQuantity: z.number().int().nonnegative(),
18+
requests: z.array(
19+
z.object({
20+
partnerId: z.number().int(),
21+
quantity: z.number().int().nonnegative(),
22+
})
23+
).min(1),
24+
})
25+
).min(1),
26+
includeDetails: z.boolean().optional(),
27+
});
28+
29+
export async function POST(req: NextRequest) {
30+
try {
31+
const session = await auth();
32+
if (!session?.user) {
33+
throw new AuthenticationError("Session required");
34+
}
35+
36+
if (!UserService.isStaff(session.user.type)) {
37+
throw new AuthorizationError("Must be STAFF, ADMIN or SUPER_ADMIN");
38+
}
39+
40+
const json = await req.json().catch(() => ({}));
41+
const parsed = bodySchema.safeParse(json);
42+
if (!parsed.success) {
43+
throw new ArgumentError(parsed.error.message);
44+
}
45+
46+
const result = await UnfinalizedSuggestionService.suggestAllocations(parsed.data.generalItems, { includeDetails: parsed.data.includeDetails });
47+
return NextResponse.json(result, { status: 200 });
48+
} catch (error) {
49+
return errorResponse(error);
50+
}
51+
}

‎src/lib/azureOpenAI.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import OpenAI from "openai";
2+
3+
// Azure OpenAI configuration via environment variables
4+
// See: https://ai.azure.com/doc/azure/ai-foundry/openai/supported-languages
5+
// Expected env vars:
6+
// - AZURE_OPENAI_ENDPOINT (e.g., https://<resource-name>.openai.azure.com)
7+
// - AZURE_OPENAI_API_KEY
8+
// - AZURE_OPENAI_DEPLOYMENT (model deployment name, e.g., gpt-4o-mini)
9+
10+
export type AzureOpenAIConfig = {
11+
endpoint?: string;
12+
apiKey?: string;
13+
deployment?: string;
14+
};
15+
16+
export function getAzureOpenAIConfig(): AzureOpenAIConfig {
17+
return {
18+
endpoint: process.env.AZURE_OPENAI_ENDPOINT,
19+
apiKey: process.env.AZURE_OPENAI_API_KEY,
20+
deployment: process.env.AZURE_OPENAI_DEPLOYMENT,
21+
};
22+
}
23+
24+
export function getOpenAIClient(): { client: OpenAI | null; deployment: string | null; reason?: string } {
25+
const { endpoint, apiKey, deployment } = getAzureOpenAIConfig();
26+
if (!endpoint || !apiKey || !deployment) {
27+
return {
28+
client: null,
29+
deployment: deployment ?? null,
30+
reason: "Missing Azure OpenAI configuration",
31+
};
32+
}
33+
34+
// The OpenAI SDK supports Azure by setting baseURL and api-version header
35+
const apiVersion = process.env.AZURE_OPENAI_API_VERSION || "2024-08-01-preview";
36+
const baseEndpoint = endpoint.replace(/\/+$/, ""); // trim trailing slashes
37+
const client = new OpenAI({
38+
apiKey,
39+
baseURL: `${baseEndpoint}/openai/deployments/${deployment}`,
40+
defaultHeaders: {
41+
"api-key": apiKey,
42+
},
43+
// Azure requires api-version as a query parameter
44+
defaultQuery: {
45+
"api-version": apiVersion,
46+
},
47+
});
48+
49+
return { client, deployment };
50+
}

‎src/services/descriptionService.ts‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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

Comments
 (0)