amana / src /tools.py
Misbahuddin's picture
Pivot YouTube RAG scaffold into Amana — T&S triage copilot (Phases 1–3.6)
250666a
Raw
History Blame Contribute Delete
7.71 kB
"""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