import { CATEGORIES } from "./catalog.ts"; import { validateNeed } from "./need.ts"; import type { AdNeed } from "./types.ts"; import type { AdContext } from "./types.ts"; /** Optional local rules. No network, no prompt logging. Ambiguous/unknown topics return null. * This is a starter taxonomy, not a safety classifier. The host still supplies age/consent/sensitive. */ const topics: Array<[AdContext["category"], RegExp]> = [ [ "education", /стипенди|хакатон|грант на обуч|языков.{0,12}курс|учебн.{0,8}курс|репетитор|scholarship|hackathon|language course|tutoring|шәкіртақы|оқу курс/iu, ], ["electronics", /ноутбук|смартфон|наушник|laptop|smartphone|headphones/iu], ["logistics", /курьер|грузоперевоз|courier|freight/iu], ["cleaning", /клининг|заказать уборку|cleaning service/iu], [ "it_services", /разработк.{0,15}(?:сайт|приложен)|web development|app development/iu, ], [ "industrial_marking", /маркиров|таңбалау|(?:нанос|печат|кодир).{0,60}(?:код|упаков|этикет)|(?:коды|штрихкод|этикетк).{0,60}(?:товар|продук|упаков)|industrial marking|product label|datamatrix|data matrix|serialization/iu, ], [ "construction", /застройщик|новостройк|квартир|строительств|құрылыс|үй сал|construction|real estate|build a house/iu, ], [ "interior", /интерьер|мебел|диван|кресл|sofa|couch|жиһаз|interior|furniture|kitchen design|дизайн кухни/iu, ], [ "restaurants", /ресторан|кафе|где поесть|поужин|пообед|мейрамхана|қайда тамақ|restaurant|cafe|where to eat/iu, ], [ "business_software", /\bcrm\b|\berp\b|бухгалтер|автоматизаци[яию] бизнеса|business software|accounting software/iu, ], [ "travel", /авиабилет|рейс|отел[ьяи]|қонақүй|ұшақ билет|flight ticket|\bflight\b|airfare|hotel/iu, ], ["retail", /одежд|кроссовк|киім|аяқ киім|sneakers|clothing|shoes/iu], [ "creative_tools", /генератор изображений|генератор видео|генератор музыки|image generator|video generator|music generator/iu, ], ]; export function classifyLocal( text: string, ): { category: AdContext["category"]; intent: AdContext["intent"] } | null { if (text.length > 16000) return null; const matched = topics.filter(([, pattern]) => pattern.test(text)); if (matched.length !== 1) return null; const intent = /сгенерир|нарисуй|создай|сурет сал|жасап бер|generate|draw|create/iu.test( text, ) ? "create" : /купить|купи(?=\s|$|[,!?])|заказать|закажи|нанять|сатып ал|тапсырыс|buy|purchase|hire|book a/iu.test( text, ) ? "buy" : "research"; return { category: matched[0]![0], intent }; } export interface ConversationMessage { role: "user" | "assistant"; content: string; } export interface ContextOpportunity { need?: AdNeed | undefined; category: AdContext["category"]; intent: AdContext["intent"]; confidence: number; shouldShow: boolean; source: "rules" | "model"; } const SENSITIVE = /болит|лечени|диагноз|депресс|суиц|насили|секс|политик|религи|банкрот|кредит|health|medical|suicid|sexual|politic|religio|\bloan\b|ауру|денсаулық|саясат|қарыз/iu; const COMMERCIAL = /платформ.{0,40}(?:возможност|хакатон|стипенди)|(?:найди|ищу|подбер|find|looking for).{0,45}(?:стипенди|хакатон|scholarship|hackathon)|поужин|поесть|пообед|нуж[еннаоы]|ищу|выб[оррат]|подбер|посовет|сравн|цен[аыу]|стоимост|бюджет|заказ|куп|услуг|оборудован|автоматиз|поставщик|need|looking for|recommend|compar|price|budget|buy|purchase|hire|керек|іздеп|сатып|баға|салыстыр/iu; const FOLLOWUP = /^(а(?:\s|,|$)|ал(?:\s|,|$)|and\b|what about\b)|подешевле|дешевле|вариант|бюджет|до\s*\d|подойд[её]т|для моего|подробнее|cheaper|alternative|арзан|басқа/iu; const OFFTOPIC = /^(привет|салам|сәлем|спасибо|рахмет|thanks|hello|hi\b|как дела)|реши\s+(задач|пример)|напиши\s+(эссе|стих)|объясни|что такое|что значит|определени|what is|define\b/iu; const REFUSAL = /вне моей компетенции|не могу помочь|не специализир|cannot help|can't help|outside my scope/iu; const GREETING = /^(?:привет|салам|сәлем|здравствуй(?:те)?|hello|hi)(?:\s*[,!.:—-]\s*|\s+|$)/iu; // Conservative exclusions for tasks the starter offer taxonomy cannot fulfill. // These rules reduce known mistakes; they do not establish semantic accuracy. const NONCOMMERCIAL = /доклад|реферат|домашн(?:ее|яя|юю) задан|для школы|\b(?:essay|homework|school report)\b|баяндама|үй тапсырма/iu; const NO_PURCHASE = /не\s+(?:хочу|нужно|надо|собираюсь).{0,60}(?:куп|покуп|заказ)|(?:куп|покуп|заказ).{0,30}не\s+(?:хочу|нужно|надо)|\b(?:don't|do not|not looking to).{0,40}\b(?:buy|purchase|order)\b|сатып алғым келмейді/iu; /** Reads a short recent window locally. No raw conversation is sent to Contexta. */ export function analyzeConversation( messages: ConversationMessage[], ): ContextOpportunity | null { const turns = messages .filter((m) => m.role === "user" || m.role === "assistant") .slice(-8); if ( turns.some((m) => typeof m.content !== "string" || m.content.length > 8000) ) return null; const users = turns.filter((m) => m.role === "user"); const last = users.at(-1); const lastText = last?.content.trim().replace(GREETING, "").trim() ?? ""; if ( !last || SENSITIVE.test(turns.map((m) => m.content).join("\n")) || OFFTOPIC.test(lastText) || NONCOMMERCIAL.test(lastText) || NO_PURCHASE.test(lastText) || (/ремонт|перетяжк|\brepair\b/iu.test(lastText) && classifyLocal(lastText)?.category === "interior") ) return null; const latest = turns.at(-1); if (latest?.role === "assistant" && REFUSAL.test(latest.content)) return null; const direct = classifyLocal(last.content); if (direct && (COMMERCIAL.test(last.content) || direct.intent === "create")) return { ...direct, confidence: 0.92, shouldShow: true, source: "rules" }; if (direct) { const previousNeed = users.at(-2); const lastUserIndex = turns.lastIndexOf(last); const clarification = turns.slice(0, lastUserIndex).at(-1); const previousTopic = previousNeed ? classifyLocal(previousNeed.content) : null; if ( previousNeed && COMMERCIAL.test(previousNeed.content) && clarification?.role === "assistant" && /уточн|како[йеую]|что именно|what|which/iu.test(clarification.content) && (!previousTopic || previousTopic.category === direct.category) ) { return { ...direct, confidence: 0.85, shouldShow: true, source: "rules" }; } } if (FOLLOWUP.test(last.content)) { // Previous *user* topics only; do not adopt advertising suggestions from assistant text. for (const previous of users.slice(-4, -1).reverse()) { if (OFFTOPIC.test(previous.content) || SENSITIVE.test(previous.content)) break; const topic = classifyLocal(previous.content); if ( topic && (COMMERCIAL.test(previous.content) || topic.intent === "create") ) return { ...topic, confidence: 0.85, shouldShow: true, source: "rules", }; // Explicit different topic breaks continuity rather than searching indefinitely. if (previous.content.trim().length > 80) break; } } return null; } export function validateOpportunity(value: unknown): ContextOpportunity | null { if (!value || typeof value !== "object") return null; const r = value as Record; const categories: readonly string[] = CATEGORIES; if ( !categories.includes(r["category"] as AdContext["category"]) || !["buy", "research", "create"].includes(String(r["intent"])) || typeof r["shouldShow"] !== "boolean" || typeof r["confidence"] !== "number" || !Number.isFinite(r["confidence"]) || r["confidence"] < 0 || r["confidence"] > 1 ) return null; const need = r["need"] === undefined ? undefined : validateNeed(r["need"]); if (need === null) return null; return { ...(need ? { need } : {}), category: r["category"] as AdContext["category"], intent: r["intent"] as AdContext["intent"], shouldShow: r["shouldShow"], confidence: r["confidence"], source: "model", }; }