"""The agent's tools -written as plain functions so they're unit-testable without the agent. `src/agent.py` wraps these as Pydantic AI tools (injecting the campaign via RunContext for the two that read campaign data, so the model can't redirect them to a different target). Design note: `scan_risk_signals` is deliberately **deterministic** -it surfaces signals, it does NOT decide. It even flags "interest mentioned" without judging it, because the agent must read PROH-3 to tell a permitted debt-principal case from a prohibited interest-bearing one. Tools investigate; the agent adjudicates; a human decides. """ from __future__ import annotations import json import re from pathlib import Path from .config import CONFIG from .schemas import Campaign, RiskSignal from .store import search # --------------------------------------------------------------------- RAG tools def policy_search(query: str, top_k: int = 4) -> list[dict]: """Retrieve the most relevant policy rules for a query. Returns rule_id + text so the agent can cite by stable ID.""" hits = search(CONFIG.policy_collection, query, top_k=top_k) return [ { "rule_id": h.metadata.get("rule_id", ""), "section": h.metadata.get("section", ""), "text": h.text, "score": round(h.score, 3), } for h in hits ] def similar_cases(query: str, top_k: int = 3) -> list[dict]: """Retrieve past adjudicated campaigns similar to the query, for precedent.""" hits = search(CONFIG.cases_collection, query, top_k=top_k) return [ { "case_id": h.metadata.get("case_id", ""), "outcome": h.metadata.get("outcome", ""), "text": h.text, "score": round(h.score, 3), } for h in hits ] # ------------------------------------------------------------------ sanctions (mock) def _load_sanctions() -> dict: try: return json.loads(Path(CONFIG.sanctions_path).read_text(encoding="utf-8")) except Exception: return {"embargoed_countries": [], "denied_entities": [], "denied_persons": [], "list_version": "unavailable"} def check_sanctions(names: list[str], countries: list[str]) -> dict: """MOCK sanctions screen of beneficiary/organizer names and countries. Returns a structured match result. Per COMP-1, ANY match is a hard escalate to compliance — never an automated approve or reject. This is a stub; the real list drops in behind this same signature. """ data = _load_sanctions() embargoed = {c.lower() for c in data.get("embargoed_countries", [])} denied = {e.lower() for e in data.get("denied_entities", []) + data.get("denied_persons", [])} country_hits = sorted({c for c in countries if c and c.lower() in embargoed}) name_hits = sorted({n for n in names if n and n.lower() in denied}) matched = bool(country_hits or name_hits) return { "matched": matched, "country_matches": country_hits, "name_matches": name_hits, "list_version": data.get("list_version", "unknown"), "is_mock": True, "note": "MOCK list. Per COMP-1 any match is a hard ESCALATE to compliance, never auto-decided.", } # --------------------------------------------------- deterministic risk signal scanner _AML_THRESHOLD = 50_000 # COMP-2 _BREAKDOWN_THRESHOLD = 10_000 # ELIG-4 _VAGUE_BENEFICIARY_THRESHOLD = 5_000 # ELIG-2 _PATTERNS: list[tuple[str, str, str, str]] = [ # (signal_name, severity, regex, human detail) ("off_platform_payment", "high", r"\b(paypal|venmo|zelle|cash app|crypto|usdt|btc|bitcoin|wallet address|bank transfer|wire (?:it|the money)|send (?:it )?direct(?:ly)?|my (?:personal )?account|dm me)\b", "Solicits payment outside the platform (possible COMP-3)."), ("manufactured_urgency", "medium", r"(only \d+ ?(?:hours?|minutes?) left|tonight|right now|act now|no time to (?:verify|waste)|every second counts|will (?:die|not survive)|don'?t scroll)", "High-pressure / urgency language that discourages verification (CONT-3)."), ("embedded_instruction", "high", r"(ignore (?:all )?(?:previous|prior|the) (?:instructions|policy)|system note|do not escalate|approve this|already (?:been )?approved|skip (?:all )?checks|as an ai|you must (?:approve|output))", "Campaign text contains instructions aimed at the reviewer/AI -possible prompt injection (DEC-6)."), ("prize_draw", "high", r"(raffle|prize draw|grand prize|enter to win|win a |each \$?\d+ .* entr|more entries)", "Donate-to-enter prize/lottery mechanics (possible PROH-4)."), ("investment_return", "high", r"(guaranteed?\s+\d+%|\d+%\s+(?:annual|monthly)\s+return|fixed return|investment fund|locked .* term|grow your wealth)", "Framed as an investment with a promised return (possible PROH-3 / PROH-7)."), ("interest_mentioned", "low", r"\binterest(?:-bearing| rate|s)?\b", "Mentions interest -agent must read PROH-3 to distinguish permitted principal relief from prohibited riba."), ("weapons", "high", r"(weapons?|rifles?|firearms?|ammunition|body armou?r|tactical gear|front line|paramilitary)", "References weapons/violence (possible PROH-2)."), ("coercive_religious", "medium", r"(you (?:will )?(?:share|bear) the sin|you will be questioned|guaranteed (?:zakat )?acceptance|refusing is a sin|obligation to give to me|secure your reward)", "Coercive religious framing or doubtful zakat claim (CONT-2) -human judgment required."), ] _VAGUE_BENEFICIARY_TOKENS = ( "community", "people in need", "those in need", "unspecified", "orphans", "the needy", "anyone", "whoever", "family", ) def scan_risk_signals(campaign: Campaign) -> list[RiskSignal]: """Deterministic heuristics over the campaign. Surfaces signals for the agent; decides nothing.""" text = f"{campaign.title}\n{campaign.story}".lower() signals: list[RiskSignal] = [] for name, severity, pattern, detail in _PATTERNS: if re.search(pattern, text, flags=re.IGNORECASE): signals.append(RiskSignal(name=name, detail=detail, severity=severity)) # type: ignore[arg-type] # Monetary thresholds (deterministic, from the goal amount). if campaign.goal_amount >= _AML_THRESHOLD: signals.append(RiskSignal( name="high_value_goal", severity="high", detail=f"Goal {campaign.goal_amount:.0f} {campaign.currency} >= {_AML_THRESHOLD} AML threshold -COMP-2 requires compliance review.")) elif campaign.goal_amount > _BREAKDOWN_THRESHOLD: signals.append(RiskSignal( name="large_goal_no_breakdown_check", severity="medium", detail=f"Goal {campaign.goal_amount:.0f} {campaign.currency} > {_BREAKDOWN_THRESHOLD}; ELIG-4 requires a fund-use breakdown -verify one is present.")) # Vague beneficiary on a non-trivial goal (ELIG-2). bname = campaign.beneficiary.name.lower() if any(tok in bname for tok in _VAGUE_BENEFICIARY_TOKENS) and campaign.goal_amount > _VAGUE_BENEFICIARY_THRESHOLD: signals.append(RiskSignal( name="vague_beneficiary", severity="medium", detail=f"Beneficiary '{campaign.beneficiary.name}' is non-specific on a {campaign.goal_amount:.0f} {campaign.currency} goal (ELIG-2).")) # Unverified organizer (ELIG-1) -a soft signal, escalatory only in combination. if not campaign.organizer.verified: signals.append(RiskSignal( name="unverified_organizer", severity="low", detail="Organizer identity is not verified (ELIG-1) -escalatory in combination with other signals.")) return signals