import type { AdNeed, AdContext } from "./types.ts"; import type { ConversationMessage } from "./classify.ts"; const token = (v: unknown) => typeof v === "string" && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(v); export function validateNeed(v: unknown): AdNeed | null { if (!v || typeof v !== "object") return null; const r = v as Record; if (!token(r["productType"])) return null; for (const k of ["requiredAttributes", "excludedAttributes"]) if ( r[k] !== undefined && (!Array.isArray(r[k]) || (r[k] as unknown[]).length > 20 || !(r[k] as unknown[]).every(token)) ) return null; const budget = r["budget"] as Record | undefined; if ( budget !== undefined && (!budget || typeof budget !== "object" || Array.isArray(budget)) ) return null; if ( budget && (!Number.isSafeInteger(budget["maxMinor"]) || Number(budget["maxMinor"]) < 1 || Number(budget["maxMinor"]) > 1e12 || typeof budget["currency"] !== "string" || !/^[A-Z]{3}$/.test(budget["currency"])) ) return null; if ( r["fulfilment"] !== undefined && !["delivery", "pickup", "online", "onsite"].includes( String(r["fulfilment"]), ) ) return null; return { productType: r["productType"] as string, requiredAttributes: (r["requiredAttributes"] ?? []) as string[], excludedAttributes: (r["excludedAttributes"] ?? []) as string[], ...(budget ? { budget: budget as unknown as AdNeed["budget"] } : {}), ...(r["fulfilment"] ? { fulfilment: r["fulfilment"] as AdNeed["fulfilment"] } : {}), }; } const products: Array<[string, RegExp]> = [ [ "opportunity_platform", /(?:платформ|сервис|сайт).{0,60}(?:возможност|стипенди|хакатон)|(?:найди|ищу|подбер).{0,35}(?:стипенди|хакатон)|scholarship|hackathon|шәкіртақы/iu, ], ["language_course", /языков.{0,10}курс|курсы? английск|language course/iu], ["course", /учебн.{0,8}курс|training course/iu], ["tutoring", /репетитор|tutoring/iu], ["laptop", /ноутбук|laptop/iu], ["smartphone", /смартфон|smartphone/iu], ["headphones", /наушник|headphones/iu], ["courier", /курьер|courier/iu], ["freight", /грузоперевоз|freight/iu], ["cleaning", /клининг|заказать уборку|cleaning service/iu], ["web_development", /разработк.{0,15}сайт|web development/iu], ["app_development", /разработк.{0,15}приложен|app development/iu], ["sofa", /диван|sofa|couch/iu], ["furniture", /мебел|жиһаз|furniture/iu], [ "marking_printer", /маркиратор|маркиров.{0,35}(?:завод|оборудован|производств)|industrial marking|marking printer|таңбалау жабды/iu, ], [ "restaurant", /ресторан|кафе|поужин|поесть|пообед|мейрамхана|тамақ|restaurant|cafe|where to eat/iu, ], ["flight", /авиабилет|рейс|ұшақ билет|\bflight\b|airfare/iu], ["hotel", /отел|қонақүй|hotel/iu], ["crm", /\bcrm\b/iu], ["property", /квартир|новострой|real estate/iu], ["construction_service", /строительств|құрылыс|construction/iu], ["shoes", /обув|кроссовк|аяқ киім|shoes|sneakers/iu], ["image_tool", /генератор изображений|image generator|сурет генератор/iu], ["video_tool", /генератор видео|video generator|бейне генератор/iu], ["music_tool", /генератор музыки|music generator|музыка генератор/iu], ]; const attributes: Array<[string, RegExp, RegExp]> = [ [ "removable_cover", /с[ъь]?[её]мн.{0,12}чех|removable cover|алынбалы қап/iu, /нес[ъь]?[её]мн|без.{0,20}с[ъь]?[её]мн|without.{0,20}removable/iu, ], [ "waterproof", /водостой|водонепрониц|waterproof|суға төзімді/iu, /без.{0,20}водостой|not waterproof|without.{0,20}waterproof/iu, ], [ "laser", /лазер|laser/iu, /без.{0,15}лазер|не лазер|not laser|without laser/iu, ], [ "inkjet", /струйн|inkjet|сиялы/iu, /без.{0,15}струйн|не струйн|not inkjet|without inkjet/iu, ], ["api", /\bapi\b/iu, /без\s+api|without\s+api/iu], [ "free_trial", /пробн.{0,15}период|free trial|тегін сынақ/iu, /без.{0,15}пробн|without.{0,15}free trial/iu, ], [ "vegetarian", /вегетариан|vegetarian/iu, /без.{0,15}вегетариан|not vegetarian/iu, ], ]; /** Conservative extraction of explicit user constraints. Never reads assistant suggestions as requirements. */ export function extractNeed( messages: ConversationMessage[], category: AdContext["category"], ): { need?: AdNeed | undefined; ambiguous: boolean } { const users = messages.filter((m) => m.role === "user").slice(-4); let kind: string | undefined, start = 0; for (let i = users.length - 1; i >= 0; i--) { const text = users[i]!.content; const matches = products.filter(([, p]) => p.test(text)); const specific = matches.filter(([k]) => k !== "furniture"); const found = specific.length ? specific : matches; if (found.length > 1) return { ambiguous: true }; if (found.length) { kind = found[0]![0]; break; } } if (!kind) return { ambiguous: false }; for (let i = users.length - 1; i >= 0; i--) { const found = products.filter( ([k, p]) => k !== kind && k !== "furniture" && p.test(users[i]!.content), ); if (found.length) { start = i + 1; break; } } const relevant = users.slice(start), text = relevant.map((m) => m.content).join("\n"); if (category === "creative_tools" && !kind.endsWith("_tool")) return { ambiguous: true }; const need: AdNeed = { productType: kind, requiredAttributes: [], excludedAttributes: [], }; for (const [name, pattern, negative] of attributes) if (pattern.test(text)) { if (negative.test(text)) need.excludedAttributes!.push(name); else need.requiredAttributes!.push(name); } for (const message of relevant) { const matches = [ ...message.content.matchAll( /(?:до|up to|maximum|max\b|бюджет(?:ом)?|budget)\s*[:—-]?\s*(\d[\d\s,.]*)\s*(тыс(?:яч)?\.?|мың|k(?![a-z]))?/giu, ), ]; const m = matches.at(-1); if (!m) continue; const currencies = [ [/тенге|₸|\bKZT\b/iu, "KZT"], [/\$|\bUSD\b|доллар/iu, "USD"], [/€|\bEUR\b|евро/iu, "EUR"], ] as const; const found = currencies.filter(([p]) => p.test(message.content)); if (found.length > 1) return { ambiguous: true }; const currency = found[0]?.[1] ?? need.budget?.currency; const number = Number(m[1]!.replace(/\s/g, "").replace(",", ".")) * (m[2] ? 1000 : 1), amount = Math.round(number * 100); if ( !currency || !Number.isSafeInteger(amount) || amount < 1 || amount > 1e12 ) return { ambiguous: true }; need.budget = { maxMinor: amount, currency }; } if (/доставк|delivery|жеткізу/iu.test(text)) need.fulfilment = "delivery"; else if (/самовывоз|pickup|өзі алып/iu.test(text)) need.fulfilment = "pickup"; return { need, ambiguous: false }; }