/** * ai-visibility-provider.ts — the API-spine seam wiring (in-house-ai- * visibility, Sprint 3, widened Sprint 4; arch-20260717-in-house-oss-ai- * visibility-architecture.md:42,50-63,67-77,297-314; ADR-3, ADR-5). * * Mirrors `serp-provider.ts` in shape (a port implementer + a factory * function living alongside the seam, `serp-provider.ts:55-64`): composes * ONLY viable per-engine `ApiSpineEngineProvider` arms (Sprint 2) into an * `AiVisibilityMultiplexer` and hands the result to `selectSource`, which * routes it into the LOCKED `AiVisibilityAdapter` (`sources/ai-visibility- * adapter.ts`) — the port, the `AiVisibilityRow` shape, and the adapter body * are all untouched here (nonGoals). * * Sprint 4 adds the `CitationVerifier` + `ContentSanitizer` each arm needs * (sc-4-3, sc-4-4 D1-Recommended): this is the SOLE production site that * constructs `ApiSpineEngineProvider`, and `egress` is already in scope here * (a parameter of this function), so a single `DamcrawlerCitationVerifier * (egress)` is constructed once and shared across every arm — the verifier * self-gates the `site-crawl` axis on every `verify()` call, so sharing one * instance across engines is safe (it holds no per-arm state). * * The `ContentSanitizer` here wraps `defaultGroundedTextSanitizeFn` (below), * NOT a damcrawler-backed `sanitize` export. Two reasons: (1) the text this * boundary sanitizes — an LLM's grounded `answerText` + its citation urls — * is NOT damcrawler-scraped, it comes straight from `GroundedSearchClient * .search()`; damcrawler's `sanitize` remains the load-bearing sanitizer for * the actual scraped citation BODY, inside `DamcrawlerCitationVerifier` * itself, gated by `site-crawl` (`./sources/citation-verifier.ts`). (2) * `ContentSanitizer` requires a SYNCHRONOUS `SanitizeFn` * (`content-sanitizer.ts:29`), while any damcrawler export can only be * reached via an ASYNC lazy `import()` — and this factory is itself * synchronous (no `Promise` in its return type), so a damcrawler-backed * sanitizer could not be constructed here even behind a gate. This second, * always-on boundary is a genuine (not a no-op) layer: it strips recognized * prompt-injection role/instruction markers the grounding LLM could echo * back from a page it read during its own web search. * * No scrape arm, scorer, or LLM judge land in this module — those are later * sprints (Sprint 8, 10 nonGoals). The Perplexity engine (Sprint 7, * `PerplexitySonarClient`, `../providers/grounded-search.js`) composes * automatically through the loop below once `deps.makeClient("perplexity")` * returns a client — no logic change here was needed. */ import type { BoberConfig } from "../config/schema.js"; import type { SeoEgressGuard } from "./egress.js"; import type { AiVisibilityProvider } from "./sources/ai-visibility-adapter.js"; import type { AiVisibilityRow } from "./data-source.js"; import type { GroundedEngine, GroundedSearchClient } from "../providers/grounded-search.js"; import type { MentionCitationExtractor } from "./sources/mention-citation-extractor.js"; import type { LLMClient } from "../providers/types.js"; import type { ScrapeThrottle } from "./scrape-throttle.js"; import type { DamcrawlerScrapeLoader } from "./sources/scrape-arm-provider.js"; /** * Default `SanitizeFn` for `ApiSpineEngineProvider`'s `answerText`/citation- * url boundary (sc-4-3). `String.prototype.replace` resets a global * pattern's `lastIndex` on every call, so reusing the shared, module-level * `RegExp` instances across calls is safe. */ export declare function defaultGroundedTextSanitizeFn(raw: string): { content: string; hadThreats: boolean; }; /** * Injected seam (mirrors the `deps` shape in the architecture doc's * `resolveAiVisibilityProvider` section). `makeClient` returns `undefined` * when an engine has no usable key/credential — that arm is skipped * entirely, never composed (the no-key viability check, ADR-3/ADR-5). * Production wiring builds a real `LiveGroundedSearchClient` per keyed * engine (`runner.ts`'s `defaultAiVisibilityDeps`); tests inject fakes so * this factory — and every caller of it — stays network-free (sc-3-4). * * `scrapeThrottle` (Sprint 10) is OPTIONAL — `resolveAiVisibilityProvider` * composes the damcrawler UI-scrape arm(s) ONLY when this dep is present AND * the `ai-visibility-scrape` axis is on AND a matching engine is configured. * As of Sprint 11, `defaultAiVisibilityDeps` (`../runner.ts`) constructs a * REAL `ScrapeThrottle` from `config`/`projectRoot` in production — this dep * is no longer test-only (sc-11-2). * * `makeJudgeLlm` (Sprint 11, sc-11-3) is OPTIONAL — when present AND * `config.seo.aiVisibility.judge.enabled` is true, `resolveAiVisibilityProvider` * wraps each API arm's extractor in a `LlmJudgeMentionCitationExtractor` * built from the returned `{ client, model }`. Returning `undefined` (e.g. * no API key) keeps the plain `deps.extractor` in use — no-key-safe, mirrors * `makeClient`'s viability-check idiom. * * `scrapeLoad` (Sprint 11) is OPTIONAL test injection for the scrape arm's * `DamcrawlerScrapeLoader` seam — `undefined` in production lets each * `ScrapeArmEngineProvider` fall back to its own real lazy `damcrawler` * import (`./sources/scrape-arm-provider.ts`'s `defaultLoader`); tests * inject a fake module so a scrape-composition test never needs the real * dependency installed. */ export interface AiVisibilityDeps { makeClient: (engine: GroundedEngine) => GroundedSearchClient | undefined; extractor: MentionCitationExtractor; scrapeThrottle?: ScrapeThrottle; makeJudgeLlm?: () => { client: LLMClient; model: string; } | undefined; scrapeLoad?: DamcrawlerScrapeLoader; } /** * Fans one `probe()` call out to every configured arm and CONCATENATES * their rows — arms are NEVER merged, cross-arm signal is never combined * (architecture:44; each row already carries its own `provider` label, so a * consumer can always tell which engine produced which observation). * * `estCostUsdPerPrompt` is the plain sum of each arm's ALREADY N-baked price * (`ApiSpineEngineProvider.estCostUsdPerPrompt = perCallUsd * samplesPerPrompt`, * Sprint 2, ADR-3) — this class must NOT re-multiply by N. */ export declare class AiVisibilityMultiplexer implements AiVisibilityProvider { private readonly arms; readonly name = "ai-visibility-multiplexer"; readonly estCostUsdPerPrompt: number; constructor(arms: AiVisibilityProvider[]); /** * One arm rejecting => its rows are simply omitted, the other arms still * emit (architecture:277). If EVERY arm rejects (and there is at least one * arm), rethrow instead of resolving `[]` — the LOCKED `AiVisibilityAdapter` * converts any probe throw into `abstain` + books nothing * (`ai-visibility-adapter.ts:141-143`), which is exactly the "all fail => * abstain, nothing booked" invariant `ApiSpineEngineProvider.probe` already * upholds one level down (sc-2-4). Resolving `[]` here instead would let * the adapter book USD for zero rows on a total outage — the rethrow keeps * that impossible. */ probe(target: string, prompts: string[], locale?: string): Promise; } /** * Select which per-engine arms are viable and compose them into an * `AiVisibilityMultiplexer` — mirrors `resolveSerpProvider` * (`serp-provider.ts:55-64`) in DI style: this factory does NO gating of its * own beyond the viability checks below; each returned provider is already * fully constructed from already-built dependencies. The API spine and the * scrape arm(s) (Sprint 10/11) land in the SAME `arms` array and share the * ONE multiplexer — they stay unmixable because each row carries its own * `provider` label (sc-10-4; `AiVisibilityMultiplexer` never merges rows). * * An arm is added when EITHER: * (a) the `ai-visibility` axis is on AND a configured engine is keyed * (`deps.makeClient` returns a client) — the existing API-spine loop, or * (b) the `ai-visibility-scrape` axis is on AND `deps.scrapeThrottle` is * provided (the scrape arm needs an already-built throttle, see * `AiVisibilityDeps` docstring) — one arm is composed per engine in * `cfg.scrape.engines` (`"chatgpt-ui"` and/or `"perplexity-ui"`, * sc-11-1), each with its matching `EngineScrapeParser`. * Both API and scrape arms reuse the SAME shared `DamcrawlerCitationVerifier` * (self-gates `site-crawl` per call, holds no per-arm state — safe to share). * * `apiAxisOn` (Sprint 11, sc-11-3): each API arm's extractor is * `deps.extractor` (the plain deterministic pass) UNLESS BOTH * `cfg.judge?.enabled` is true AND `deps.makeJudgeLlm()` returns a usable * `{ client, model }` — in which case every API arm gets a SHARED * `LlmJudgeMentionCitationExtractor` wrapping a fresh * `DeterministicMentionCitationExtractor` (the judge always composes the * deterministic pass, never replaces it). The scrape arm(s) always stay on * `deps.extractor` — the judge is API-arms only (contract: "for each API * arm"). judge-disabled (or no llm resolvable) is BYTE-IDENTICAL to before: * `apiExtractor` stays exactly `deps.extractor`, no judge/extractor is * constructed at all (sc-11-3, Pitfall 5). * * Returns `undefined` (never an empty-arms multiplexer) when NEITHER axis is * on, `config.seo.aiVisibility` is absent, or no arm ends up viable (no key / * no scrape engine configured / no throttle dep). `selectSource` falls back * to the offline `LocalExportSource` in that case (no-key-safe, * byte-identical-when-off). */ export declare function resolveAiVisibilityProvider(config: BoberConfig, egress: SeoEgressGuard, deps: AiVisibilityDeps): AiVisibilityProvider | undefined; //# sourceMappingURL=ai-visibility-provider.d.ts.map