import { extractNeed } from "./need.ts"; import { analyzeDiscovery, type DiscoveryVocabulary } from "./discovery.ts"; import { inferCity, resolveCity } from "./geo.ts"; import { CATEGORIES } from "./catalog.ts"; import { analyzeConversation, validateOpportunity } from "./classify.ts"; import type { ConversationMessage } from "./classify.ts"; import type { AdContext, AdDecision } from "./types.ts"; export type { AdContext, AdDecision }; export interface DecisionResponse { ad: AdDecision | null; reason?: string; } /** Server-only client: keep publisher API keys out of browser bundles and mobile apps. */ export function createContexta(options: { baseUrl: string; apiKey: string; timeoutMs?: number; fetch?: typeof fetch; classifier?: (messages: ConversationMessage[]) => Promise; }) { if (typeof window !== "undefined") throw new Error( "Contexta publisher keys are server-only. Use a backend proxy.", ); const base = options.baseUrl.replace(/\/$/, ""); if (new URL(base).protocol !== "https:") throw new Error("HTTPS endpoint required"); const transport = options.fetch ?? fetch; async function call(path: string, init: RequestInit = {}): Promise { const r = await transport(base + "/api/v1/ads/" + path, { ...init, signal: AbortSignal.timeout(options.timeoutMs ?? 6000), headers: { "content-type": "application/json", authorization: `Bearer ${options.apiKey}`, }, }); if (!r.ok) throw new Error(`Contexta API ${r.status}`); return r.json() as Promise; } let cachedVocabulary: DiscoveryVocabulary = {}, vocabularyUntil = 0; let vocabularyPending: Promise | undefined; async function vocabulary(): Promise { if (Date.now() < vocabularyUntil) return cachedVocabulary; if (vocabularyPending) return vocabularyPending; vocabularyPending = (async () => { try { const data = await call<{ items?: unknown[] }>("catalog"); const next: DiscoveryVocabulary = {}; for (const raw of (Array.isArray(data.items) ? data.items : []).slice( 0, 250, )) { if (!raw || typeof raw !== "object") continue; const item = raw as Record; if ( typeof item["category"] !== "string" || !CATEGORIES.some((c) => c === item["category"]) || typeof item["productType"] !== "string" || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(item["productType"]) ) continue; const group = (next[item["category"]] ??= { productTypes: [], attributes: [], brands: {}, }); group.productTypes.push(item["productType"]); group.brands![item["productType"]] = ( Array.isArray(item["brands"]) ? item["brands"] : [] ) .filter( (v): v is string => typeof v === "string" && v.length <= 120, ) .slice(0, 30); } cachedVocabulary = next; vocabularyUntil = Date.now() + 60000; } catch { cachedVocabulary = {}; vocabularyUntil = Date.now() + 5000; } return cachedVocabulary; })(); try { return await vocabularyPending; } finally { vocabularyPending = undefined; } } return { async decide(context: AdContext): Promise { // Fail open: advertising must not interrupt the host application's primary task. try { return await call("decide", { method: "POST", body: JSON.stringify(context), }); } catch { return { ad: null, reason: "unavailable" }; } }, async decideConversation(input: { context: Omit< AdContext, "category" | "intent" | "opportunity" | "userTurn" >; messages: ConversationMessage[]; userTurn?: number; }): Promise { const turn = input.userTurn ?? input.messages.filter((m) => m.role === "user").length; if (!Number.isInteger(turn) || turn < 1) return { ad: null, reason: "insufficient_context" }; if ( !input.context.adsAllowed || input.context.age !== "adult" || input.context.sensitive ) return { ad: null, reason: "policy" }; try { const recent = input.messages .filter((m) => m.role === "user" || m.role === "assistant") .slice(-8) .map((m) => ({ ...m, content: m.content.slice(0, 8000) })); let opportunity = options.classifier ? validateOpportunity(await options.classifier(recent)) : analyzeConversation(recent); if ( !options.classifier && (!opportunity?.shouldShow || opportunity.confidence < 0.8) ) opportunity = analyzeDiscovery(recent, await vocabulary()) ?? opportunity; if (!opportunity?.shouldShow || opportunity.confidence < 0.8) return { ad: null, reason: "low_confidence" }; const { category, intent, need: modelNeed, ...eligibility } = opportunity; const extracted = extractNeed(recent, category); if ( extracted.ambiguous || (intent === "create" && category !== "creative_tools") ) return { ad: null, reason: "insufficient_context" }; if ( modelNeed && extracted.need && modelNeed.productType !== extracted.need.productType ) return { ad: null, reason: "insufficient_context" }; let need = modelNeed ?? extracted.need; if (modelNeed && extracted.need) { if (modelNeed.fulfilment && extracted.need.fulfilment && modelNeed.fulfilment !== extracted.need.fulfilment) return { ad: null, reason: "insufficient_context" }; if ( modelNeed.budget && extracted.need.budget && modelNeed.budget.currency !== extracted.need.budget.currency ) return { ad: null, reason: "insufficient_context" }; need = { ...modelNeed, ...extracted.need, requiredAttributes: [ ...new Set([ ...(modelNeed.requiredAttributes ?? []), ...(extracted.need.requiredAttributes ?? []), ]), ], excludedAttributes: [ ...new Set([ ...(modelNeed.excludedAttributes ?? []), ...(extracted.need.excludedAttributes ?? []), ]), ], }; if (modelNeed.budget && extracted.need.budget) need.budget = { currency: modelNeed.budget.currency, maxMinor: Math.min( modelNeed.budget.maxMinor, extracted.need.budget.maxMinor, ), }; } if ( need?.requiredAttributes?.some((x) => need?.excludedAttributes?.includes(x), ) ) return { ad: null, reason: "insufficient_context" }; return await call("decide", { method: "POST", body: JSON.stringify({ ...input.context, city: resolveCity(recent, input.context.city), country: inferCity(recent) ? "KZ" : input.context.country, category, intent, ...(need ? { need } : {}), userTurn: turn, opportunity: eligibility, }), }); } catch { return { ad: null, reason: "unavailable" }; } }, catalog() { return call("catalog"); }, diagnose(context: AdContext) { return call("diagnose", { method: "POST", body: JSON.stringify(context), }); }, reportLead(decisionId: string, reference: string) { return call<{ leadId: string; status: "pending" }>("leads", { method: "POST", body: JSON.stringify({ decisionId, reference }), }); }, report(from: string, to: string) { return call( `report?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, ); }, }; }