Spaces:
Sleeping
Sleeping
| """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 | |