{"version":3,"file":"topic.mjs","names":[],"sources":["../../../../../../../../ai/src/guard/detectors/topic.ts"],"sourcesContent":["import type {\n  GuardrailMatch,\n  GuardrailVerdict,\n  SyncGuardrailDetector,\n  TopicFilterOptions,\n} from \"../contracts\";\n\n/** Detector name, used as the namespace prefix on every {@link GuardrailMatch.rule}. */\nconst DETECTOR_NAME = \"topic\";\n\n/**\n * Locate the first occurrence of `term` in `text`. A `string` matches\n * case-insensitively as a substring; a `RegExp` is tested as-is (its own\n * flags decide case-sensitivity). Returns the inclusive `[start, end]`\n * span on a hit, or `undefined` when the term is absent.\n */\nfunction locate(text: string, term: string | RegExp): readonly [number, number] | undefined {\n  if (typeof term === \"string\") {\n    if (term.length === 0) {\n      return undefined;\n    }\n\n    const index = text.toLowerCase().indexOf(term.toLowerCase());\n\n    if (index === -1) {\n      return undefined;\n    }\n\n    return [index, index + term.length - 1];\n  }\n\n  // RegExp: run a non-global copy so a caller-supplied `/g` term cannot leak\n  // `lastIndex` between calls and so `.exec` reports a deterministic first hit.\n  const probe = new RegExp(term.source, term.flags.replace(/[gy]/g, \"\"));\n  const match = probe.exec(text);\n\n  if (match === null) {\n    return undefined;\n  }\n\n  return [match.index, match.index + match[0].length - 1];\n}\n\n/** A human-readable label for a deny/allow term, used in the match rule + reason. */\nfunction describeTerm(term: string | RegExp): string {\n  return typeof term === \"string\" ? term : term.source;\n}\n\n/**\n * Build the built-in **topic filter** (`ai.guardrail.topic`) — a\n * zero-runtime-dependency {@link GuardrailDetector} that gates text against a\n * deny list, an allow list, or both.\n *\n * - **`deny`** — any term that appears triggers `onMatch`. A `string`\n *   matches case-insensitively as a substring; a `RegExp` is tested as-is.\n *   The deny list is checked first; the first hit decides the verdict.\n * - **`allow`** — when set, text matching **none** of the allow terms\n *   triggers `onMatch` (an allow-list miss). Text matching at least one\n *   allow term passes the allow gate.\n *\n * `onMatch` is `\"block\"` (default) or `\"flag\"`. With neither list supplied\n * the detector is a no-op that always allows.\n *\n * @example\n * ai.guardrail.topic({ deny: [\"medical advice\", /diagnos\\w+/i] });\n *\n * @example\n * // Stay on-topic: anything not about billing is flagged.\n * ai.guardrail.topic({ allow: [\"billing\", \"invoice\", \"refund\"], onMatch: \"flag\" });\n */\nexport function topic(options: TopicFilterOptions): SyncGuardrailDetector {\n  const deny = options.deny ?? [];\n  const allow = options.allow ?? [];\n  const onMatch = options.onMatch ?? \"block\";\n\n  return {\n    name: DETECTOR_NAME,\n    check(text: string): GuardrailVerdict {\n      // Deny list: the first present term decides the verdict.\n      for (const term of deny) {\n        const span = locate(text, term);\n\n        if (span !== undefined) {\n          const label = describeTerm(term);\n          const match: GuardrailMatch = {\n            rule: `${DETECTOR_NAME}.deny.${label}`,\n            span,\n            label,\n          };\n          const reason = options.reason ?? `Denied topic matched: ${label}.`;\n\n          return verdict(onMatch, reason, [match]);\n        }\n      }\n\n      // Allow list: matching NONE of the terms is a miss → trigger onMatch.\n      if (allow.length > 0) {\n        const matchedAny = allow.some(term => locate(text, term) !== undefined);\n\n        if (!matchedAny) {\n          const match: GuardrailMatch = {\n            rule: `${DETECTOR_NAME}.allow.miss`,\n            label: \"allow-miss\",\n          };\n          const reason =\n            options.reason ?? \"Text matched none of the allowed topics.\";\n\n          return verdict(onMatch, reason, [match]);\n        }\n      }\n\n      return { type: \"allow\" };\n    },\n  };\n}\n\n/**\n * Fold the resolved action into a `block` or `flag` verdict. Topic never\n * redacts — it cannot meaningfully rewrite a whole-text policy miss — so the\n * action is constrained to `\"block\" | \"flag\"` at the type level.\n */\nfunction verdict(\n  action: \"block\" | \"flag\",\n  reason: string,\n  matches: readonly GuardrailMatch[],\n): GuardrailVerdict {\n  if (action === \"block\") {\n    return { type: \"block\", reason, matches };\n  }\n\n  return { type: \"flag\", reason, matches };\n}\n"],"mappings":";;AAQA,MAAM,gBAAgB;;;;;;;AAQtB,SAAS,OAAO,MAAc,MAA8D;CAC1F,IAAI,OAAO,SAAS,UAAU;EAC5B,IAAI,KAAK,WAAW,GAClB;EAGF,MAAM,QAAQ,KAAK,YAAY,CAAC,CAAC,QAAQ,KAAK,YAAY,CAAC;EAE3D,IAAI,UAAU,IACZ;EAGF,OAAO,CAAC,OAAO,QAAQ,KAAK,SAAS,CAAC;CACxC;CAKA,MAAM,QAAQ,IADI,OAAO,KAAK,QAAQ,KAAK,MAAM,QAAQ,SAAS,EAAE,CAClD,CAAC,CAAC,KAAK,IAAI;CAE7B,IAAI,UAAU,MACZ;CAGF,OAAO,CAAC,MAAM,OAAO,MAAM,QAAQ,MAAM,EAAE,CAAC,SAAS,CAAC;AACxD;;AAGA,SAAS,aAAa,MAA+B;CACnD,OAAO,OAAO,SAAS,WAAW,OAAO,KAAK;AAChD;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,MAAM,SAAoD;CACxE,MAAM,OAAO,QAAQ,QAAQ,CAAC;CAC9B,MAAM,QAAQ,QAAQ,SAAS,CAAC;CAChC,MAAM,UAAU,QAAQ,WAAW;CAEnC,OAAO;EACL,MAAM;EACN,MAAM,MAAgC;GAEpC,KAAK,MAAM,QAAQ,MAAM;IACvB,MAAM,OAAO,OAAO,MAAM,IAAI;IAE9B,IAAI,SAAS,QAAW;KACtB,MAAM,QAAQ,aAAa,IAAI;KAC/B,MAAM,QAAwB;MAC5B,MAAM,GAAG,cAAc,QAAQ;MAC/B;MACA;KACF;KAGA,OAAO,QAAQ,SAFA,QAAQ,UAAU,yBAAyB,MAAM,IAEhC,CAAC,KAAK,CAAC;IACzC;GACF;GAGA,IAAI,MAAM,SAAS,GAGjB;QAAI,CAFe,MAAM,MAAK,SAAQ,OAAO,MAAM,IAAI,MAAM,MAE/C,GAAG;KACf,MAAM,QAAwB;MAC5B,MAAM,GAAG,cAAc;MACvB,OAAO;KACT;KAIA,OAAO,QAAQ,SAFb,QAAQ,UAAU,4CAEY,CAAC,KAAK,CAAC;IACzC;;GAGF,OAAO,EAAE,MAAM,QAAQ;EACzB;CACF;AACF;;;;;;AAOA,SAAS,QACP,QACA,QACA,SACkB;CAClB,IAAI,WAAW,SACb,OAAO;EAAE,MAAM;EAAS;EAAQ;CAAQ;CAG1C,OAAO;EAAE,MAAM;EAAQ;EAAQ;CAAQ;AACzC"}