@@ -7,139 +7,140 @@ export type DescribeItemInput = {
77 unitType : string ;
88} ;
99
10+ type BilingualDescription = {
11+ haitian : string ;
12+ english : string ;
13+ } ;
14+
1015export 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