""" Per-ad structured evidence ledger. Both the Investigator and the Fraudster benefit from a structured cross-ad view of the underlying account / payment / landing-page signals. The Investigator uses it to decide ``link_accounts`` (collisions on ``payment_id`` or ``targeting_fingerprint`` indicate a ring); the Fraudster uses it to decide which of its own pending proposals to ``modify_pending_ad`` based on which signals correlate with the Investigator's rejections. We deliberately mix HIGH-signal columns (payment_id, targeting_fingerprint) with LOW-signal columns (country, category, account_age_days) so neither LLM can shortcut to "any collision = ring" — it has to *learn* which columns are discriminative. That's the "more parameters in the summary so the model picks the right ones" framing the user asked for. The ledger is *derived* from already-revealed data only: - For the Investigator, a field appears only after the matching ``investigate`` target has been pulled on that ad. - For the Fraudster, fields are present for every Fraudster-proposed ad because the env gates Fraudster proposals through ``extend_episode_with_proposal`` which auto-assigns and immediately surfaces all underlying signals back to the proposer (the Fraudster never sees signals for *Investigator*-side / synthetic ads). We extract structured fields by regex from the same investigation text the agents already see — so the ledger and the free-form ``investigation_findings`` stay in lock-step (no extra info is leaked, only re-shaped). """ from __future__ import annotations import re from typing import Any, Dict, Iterable, Mapping, Optional # Regex extractors keyed by investigation target name. Patterns parse the # rendered investigation text produced by: # - counterfeint/data/ad_generator._generate_payment_investigation # - counterfeint/data/landing_pages.LandingPageData.to_investigation_text # - counterfeint/data/ad_generator._generate_targeting_investigation # Keep these in sync if the rendering changes. _LEDGER_EXTRACTORS: Dict[str, Dict[str, "re.Pattern[str]"]] = { "payment_method": { "payment_id": re.compile(r"Payment ID:\s*(\S+)"), "payment_type": re.compile(r"Method type:\s*(\S+)"), }, "landing_page": { "domain": re.compile(r"^Domain:\s*(\S+)", re.MULTILINE), "registrar": re.compile(r"Registrar:\s*([^\n]+)"), "domain_age_days": re.compile(r"Domain age:\s*(\d+)"), }, "targeting_overlap": { "targeting_fingerprint": re.compile(r"Targeting fingerprint:\s*(\S+)"), }, "advertiser_history": { # advertiser_id and verified_business are surfaced from the # AdvertiserProfile dataclass directly — see _build_entry below. }, } def build_evidence_ledger( *, episode: Any, registry: Optional[Any], ad_ids: Iterable[str], investigations: Mapping[str, Iterable[str]], ) -> Dict[str, Dict[str, Any]]: """Build a {ad_id: {field: value}} ledger over the given ad_ids. Parameters ---------- episode ``GeneratedEpisode`` providing ``ads``, ``advertiser_profiles`` and ``investigation_data``. registry Optional ``InvestigationToolRegistry`` — preferred source of already-rendered investigation text. Falls back to ``episode.investigation_data`` if not provided. ad_ids Which ads to include in the ledger. Caller decides scoping (Investigator: all ads it has touched; Fraudster: its own proposals). investigations ``{ad_id: [investigated_target, ...]}``. Determines which extractor sets to run per ad. """ if episode is None: return {} ads_by_id = {ad.ad_id: ad for ad in episode.ads} profiles = getattr(episode, "advertiser_profiles", {}) or {} inv_data = getattr(episode, "investigation_data", {}) or {} ledger: Dict[str, Dict[str, Any]] = {} for ad_id in ad_ids: entry = _build_entry( ad_id=ad_id, ad=ads_by_id.get(ad_id), profile=profiles.get(ad_id), investigated_targets=list(investigations.get(ad_id, []) or []), registry=registry, inv_data_for_ad=inv_data.get(ad_id, {}) or {}, ) if entry: ledger[ad_id] = entry return ledger def _build_entry( *, ad_id: str, ad: Any, profile: Any, investigated_targets: Iterable[str], registry: Optional[Any], inv_data_for_ad: Mapping[str, str], ) -> Dict[str, Any]: entry: Dict[str, Any] = {} if ad is not None: entry["category"] = ad.category if profile is not None: entry["country"] = profile.country entry["account_age_days"] = profile.account_age_days for target in investigated_targets: if target == "advertiser_history" and profile is not None: entry["advertiser_id"] = profile.advertiser_id entry["verified_business"] = bool(profile.verified_business) continue extractors = _LEDGER_EXTRACTORS.get(target) if not extractors: continue text = "" if registry is not None: text = registry.lookup(ad_id, target) or "" if not text: text = inv_data_for_ad.get(target, "") or "" for field_name, pattern in extractors.items(): m = pattern.search(text) if m: value: Any = m.group(1).strip() if field_name == "domain_age_days": try: value = int(value) except ValueError: pass entry[field_name] = value return entry __all__ = ["build_evidence_ledger"]