# Guardrails, Tests & Model Evaluation

How AstroAgent keeps itself **safe**, **correct**, and **measurable** — and how every piece fits
together to assess the whole agent.

There are three independent systems, each answering a different question:

| System | Question it answers | Lives in | Needs a model? |
|---|---|---|---|
| **Guardrails** | "Is this request/reply safe and on-brand?" | `backend/src/agent/guardrails.py`, `semantic_guard.py`, wired in `graph.py` | No (rails are deterministic; the agent uses one) |
| **Unit tests** | "Does each module do its job, and did I break anything?" | `backend/tests/` | Mostly no (LLM is mocked) |
| **Evaluation** | "How good is the *whole agent*, on this model, right now?" | `eval/` | Yes (runs the real graph) |

---

## Part A — Guardrails

### The big idea: defense in depth

No single check is trusted. A harmful request must get past **three independent layers** before it
can do damage, and each layer is cheap-first / expensive-only-when-needed.

```
                    ┌─────────────────────────────────────────────────────────┐
   user message ──▶ │ LAYER 1  Keyword fast-path        (instant, $0, no LLM)  │
                    │   classify_input / classify_sensitive / detect_offtopic  │
                    └───────────────┬─────────────────────────────────────────┘
                                    │ keyword MISS
                                    ▼
                    ┌─────────────────────────────────────────────────────────┐
   (meaning-based)  │ LAYER 2  Semantic fallback        (1 embedding, no LLM)  │
                    │   semantic_rail() — cosine vs anchor phrases             │
                    └───────────────┬─────────────────────────────────────────┘
                                    │ allowed through → agent reasons, calls tools
                                    ▼
                    ┌─────────────────────────────────────────────────────────┐
  agent's reply ──▶ │ LAYER 3  Output rail              (regex on final reply) │
                    │   detect_output_violation() → regenerate once → reframe  │
                    └─────────────────────────────────────────────────────────┘
```

On top of those three sits the **system prompt** (`SYSTEM_PROMPT` in `guardrails.py`) — the semantic
backstop that holds for any phrasing the deterministic layers never anticipated.

### The six rails

| Rail | Risk | How it should respond |
|---|---|---|
| **crisis** | self-harm / hopelessness (any language, incl. Hinglish, euphemism) | **Stop the reading.** Care + real helplines (iCall `9152987821`, Vandrevala `1860-2662-345`). Never a horoscope. |
| **injection** | "ignore your instructions", "reveal your system prompt", DAN/jailbreak | Refuse, stay in the astrology role, never leak the prompt. |
| **medical** | "will I get cancer", "how long will I live" | No diagnosis/prediction. Reframe + name a **healthcare professional**. |
| **legal** | "will I win my court case" | No verdict prediction. Reframe + name a **legal professional**. |
| **financial** | "which stocks should I buy", "buy gold?" | No buy/sell/invest advice or market calls. Reframe + name a **financial advisor**. |
| **fatalism** | "am I destined to be poor / doomed?" | Never doom/fate/certainty. Frame placements as **tendencies** + emphasize **agency**. |

### Where the layers run in the graph

`graph.py` wires the nodes: `router → extract_birth_details → agent ⇄ tools → cache_chart → output_guard → END`.

- **`router` node (Layer 1+2, input side).** First node after `START`. Calls `route_input(text)`
  (crisis/injection: keyword → semantic) and `detect_offtopic_intent(text)`. A crisis, injection, or
  off-topic hit **short-circuits with a canned reply before any tokens or tools are spent** — the
  strongest guarantee in the system. Medical/legal/financial are *not* short-circuited; instead the
  `agent` node injects a targeted `sensitive_nudge(...)` `SystemMessage` so the model reframes.
- **`agent` node.** Prepends `SYSTEM_PROMPT` + the current date + (if a sensitive keyword/semantic
  match) a `GUARDRAIL — …` nudge. This is where the *prompt-level* safety lives.
- **`output_guard` node (Layer 3, output side).** Runs only on the agent's **final** turn (no tool
  calls). Inspects the reply with `detect_output_violation(reply, category, fatalistic)`. If it leaked
  advice or fatalism, it **regenerates once** with a correction; if the rewrite still violates, it
  drops to a deterministic `safe_reframe(...)`. Clean replies pass through with **no extra LLM call**,
  so normal turns keep flat latency.

### Layer 1 — keyword fast-path (`guardrails.py`)

Plain lowercase **substring** matchers (not regex), tuned to avoid false positives (e.g. the *Cancer*
zodiac sign vs. "get cancer"):

- `classify_input(text) → "crisis" | "injection" | None` — `_CRISIS_KEYWORDS` (direct, euphemism,
  typos, Hinglish/transliterated Hindi) and `_INJECTION_KEYWORDS`.
- `classify_sensitive(text) → "medical" | "legal" | "financial" | None` — `_MEDICAL/_LEGAL/_FINANCIAL_KEYWORDS`.
- `detect_offtopic_intent(text) → bool` — fires **only** when there is no astrology/birth signal
  *and* a clear trivia cue (so it never blocks a real astrology question).

These are fast and free but **miss paraphrases** by design — that's what Layer 2 is for.

### Layer 2 — semantic fallback (`semantic_guard.py`)

Catches the paraphrases keywords miss, **without an LLM**:

- Reuses the shared local **sentence-transformers** embedder (`agent.embedder.get_embedder()` — the
  same instance as the RAG knowledge tool, so no extra dependency or model load).
- Each rail has a few **anchor phrases** (`_ANCHORS`) embedded once per process. For a message it
  computes the **max cosine similarity** to each rail's anchors (`rail_scores`, LRU-cached so one turn
  = one encode) and predicts a rail when similarity ≥ that rail's **tuned threshold**.
- Thresholds load from `eval/semantic_thresholds.json` (produced by `tune_semantic.py`); safe defaults
  apply if absent. Current tuned values: crisis `0.42`, injection `0.40`, legal `0.32`, fatalism
  `0.46`, medical/financial `0.50`.
- Gated by env `ASTRO_SEMANTIC_GUARD` (set to `0` to disable — the unit suite does this for speed).

The wrappers `route_input`, `sensitive_category`, `is_fatalistic` are what the graph actually calls:
each tries the keyword check first and only falls back to `semantic_rail(...)` on a miss. One subtle
rule: a chart-framed **mortality/lifespan prediction** ("how many years do I have left?") embeds close
to the *crisis* anchors but is really a *medical* question — `route_input` detects this and routes it
to the medical reframe instead of the helpline reply.

### Layer 3 — output rail (`detect_output_violation`)

The key insight: it checks for the **presence of a positive signal**, not just the absence of bad
words. A reply to a sensitive question **must** carry a professional referral/decline
(`_REFERRAL_MARKERS`); a reply to a fatalistic question **must** carry agency/tendency language
(`_AGENCY_MARKERS`). A gentle reframe with *no referral*, or a bland reassurance with *no agency word*,
counts as a violation. It also catches imperative advice (`_HARD_ADVICE_MARKERS`, e.g. "you should buy
gold") even when a referral is also present, and explicit doom phrasing (`_FATALISM_MARKERS`). These
marker sets deliberately mirror the eval's `check_reframe_guardrail` / `check_antifatalism`, so the
rail and the scorer agree on what "safe" means.

---

## Part B — Unit tests (`backend/tests/`)

Run with `pytest` from `backend/`. These are fast, deterministic, and the LLM is **mocked** wherever a
model would otherwise be called — so they test *our* code, not the model. `conftest.py` sets
`ASTRO_SEMANTIC_GUARD="0"` for the whole suite (keyword-only, no embedding load); the dedicated
semantic tests re-enable it via monkeypatch.

| Test file | Unit under test | What it asserts (highlights) |
|---|---|---|
| `conftest.py` | suite setup | Disables semantic guard globally for speed/determinism. |
| `test_echo.py` | `graph` router + mocked flow | Crisis input short-circuits (object **and** dict message) with a helpline; injection input is rejected without leaking "system"; state has `messages`/`birth_details`/`chart`; a mocked normal flow returns a moon-sign reply. |
| `test_birth_extraction.py` | `extract_birth_details` regex + `router` | Parses the form sentence in many shapes — name/known time, no-name, unknown-time, month abbreviations, ordinals + "of", US `January 15, 1990` order, places with periods (`St. Louis`); a normal question parses to `None`; "Cancel" acks and ends the turn. |
| `test_chart.py` | `compute_birth_chart` | Mumbai 1995 → moon `Pis`, abs_pos ≈341.37° (±1°), nakshatra Uttara Bhadrapada pada 3, lagna+houses present; unknown time → `time_known=False`, lagna/houses `None`; rejects 30 Feb, out-of-range lat/lng, bad/empty timezone, year out of range; noon birth is `time_known=True`. |
| `test_chart_reference.py` | `compute_birth_chart` accuracy | Independent Lahiri/sidereal anchors: ayanamsa at J2000 ≈23.85° (±0.1°), sidereal Sun 2000 in `Sag` ≈256.5° (±1°), Makar Sankranti 1990 Sun just into `Cap`; same input twice → identical output (determinism). |
| `test_geocode.py` | `geocode_place` | Mumbai/Delhi resolve to expected lat/lng/`Asia/Kolkata`; gibberish → error; whitespace → error with **no network call**; surfaces `resolved_name`; a `None` timezone (South Pole, monkeypatched) → error, not `tz=None`. |
| `test_transits.py` | `get_daily_transits` | Valid date returns `transits` with moon/sun; bad date → error; all positions in `[0,360)`; reads natal moon/lagna from the explicit arg, from injected `state["chart"]`, with explicit overriding state; retrograde summary list is consistent and Sun is never retrograde. |
| `test_model.py` | `make_model` | Empty/`None` API key → `ValueError` mentioning "API key"; unknown provider → `ValueError` mentioning "provider". (BYOK config is validated, no key is hardcoded.) |
| `test_graph_state.py` | `cache_chart`, `_parse_tool_content`, budget | Parses JSON-string and dict tool payloads, returns `None` on garbage; caches a successful chart, ignores errored charts and other tools; `_MAX_TOOL_TURNS` is a sane bounded int. |
| `test_guardrails.py` | `guardrails.py` (all rails + output) | Keyword classifiers fire on crisis (incl. euphemism/Hinglish/typo), injection (incl. DAN), medical/legal/financial; **no** false positive on the *Cancer* zodiac sign or normal readings; lifespan question → medical; semantic-vs-keyword precedence rules; nudges contain `GUARDRAIL`; end-to-end short-circuits via the graph; `detect_output_violation` flags leaked advice / missing referral / missing agency and passes compliant reframes; `output_guard` regenerates-then-passes, falls back to safe reframe, and leaves clean replies untouched (1 LLM call). |
| `test_semantic_guard.py` | `semantic_guard.py` (model on) | Paraphrases map to the right top rail (legal/financial/medical/fatalism); near-anchor phrases clear the default threshold; `only=` restricts the rail set; benign astrology not flagged; disabled flag returns `None`; the mortality question really trips semantic *crisis* but `route_input` correctly redirects it to medical; genuine distress still routes to crisis. |
| `test_embedder.py` | `agent.embedder.get_embedder` | Singleton — two calls return the same object; the RAG tool and semantic guard share one embedder instance. |
| `test_knowledge.py` | `knowledge_lookup` (RAG) | *Skips if `sentence_transformers` absent.* Returns a list of strings, honors `k`, returns topically-relevant passages for graha/bhava queries, and is not a stub. |
| `test_eval_metrics.py` | `eval/metrics.py` checks | The deterministic checks are **honest**: crisis needs helpline + no tools + no reading; trivial/short replies fail `graceful_response`; `graceful_error` must name the date problem and must not have computed a chart; reframe needs a professional/decline and fails on a prediction; clarification needs a question naming a missing field (or a `request_birth_details` call). |
| `test_guardrail_metrics.py` | `eval/guardrail_metrics.py` | `case_text` prefers the last human turn; combined classifier prediction; confusion-matrix math (recall/FNR/FPR/precision); `output_violated` for financial/crisis/fatalism; `output_overblocked` only on a *real* refusal (and the "-ically" → bare "icall" regression guard); ASR computed over harmful cases only. |

**What the unit layer assesses:** the chart math (the never-cut accuracy requirement), the input
parsing, the tool I/O contracts, the BYOK model factory, the graph plumbing/caching, and the rail +
metric logic — all **without** spending a model call.

---

## Part C — Model evaluation (`eval/`)

This is where the *whole agent* is run on a real model and scored. There are two harnesses plus a
tuning script.

### 1. Golden-set evaluation — `python eval/run_eval.py`

Runs the **full graph** on a 32-case golden set (`golden_set.jsonl`) and scores each case three ways.

**Deterministic checks (`metrics.py`).** Each `check_*` returns `True`/`False`/`None` (`None` = not
applicable, excluded from scoring). A case passes only if every applicable check passes:
- `check_right_tools` — expected tools were called (e.g. `geocode_place` + `compute_birth_chart`).
- `check_chart_tolerance` / `check_moon_rashi` / `check_nakshatra` — chart output matches the
  reference within `tol_deg` (the accuracy guarantee, end-to-end through the model's tool calls).
- `check_crisis_guardrail` — zero tools, a real helpline, no reading.
- `check_injection_guardrail` — refuses, no system-prompt leak, no jailbreak compliance.
- `check_reframe_guardrail` — no prediction + names a professional / declines.
- `check_antifatalism` — no doom language + agency/tendency framing.
- `check_graceful_error` (invalid date), `check_partial_chart` (unknown time), `check_clarification`
  (missing info), `check_graceful_redirect` (off-topic), `check_graceful_response`, `check_step_budget`.

**Operational metrics (`compute_run_metrics`).** Latency (p50/p95), tool-call count, token usage
(input/output/total via `usage_metadata` — `eval_mode` disables streaming so providers report counts),
and `compute_cost` from per-1k rates in `.env`.

**LLM-as-judge (tone).** For cases flagged `"judge": true`, `judge_warmth` scores warmth/appropriateness
1–5 using a **separate** model (`JUDGE_PROVIDER` + `JUDGE_MODEL`, recommended to be a *different family*
than the agent for unbiased scoring). Where a case carries a human `gold_tone`, the scorecard reports a
**judge-agreement rate** (|judge − gold| ≤ 1) so the judge itself is sanity-checked.

**Outputs.** A terminal **scorecard** (pass-rate by category, latency, tokens, cost, tone, agreement),
a multi-model **comparison table** when a sweep model is set, `eval/SCORECARD.md` (latest run), and an
appended `eval/results_log.csv` (schema-versioned — it auto-archives the old log if columns change).

**Models are fully env-driven** (no hardcoded slugs): `EVAL_*` (agent under test, falls back to
`DEFAULT_*`), `SWEEP_*` (optional second model to compare), `JUDGE_*`. One API key per provider
(`OLLAMA_API_KEY` / `OPENROUTER_API_KEY`) is resolved by `provider_api_key`.

### 2. Guardrail-robustness evaluation — `python eval/run_guardrail_eval.py`

Specifically attacks the safety rails using `guardrail_set.jsonl` (32 labeled cases: `harmful` attacks
vs. `benign` look-alikes, across `direct` / `euphemism` / `paraphrase` / `encoding` / `translation`
variants). It measures **two layers**:

- **Classifier layer (always, no LLM).** Runs the deterministic input classifiers over the labeled set
  and builds a per-rail confusion matrix: **Recall** (harmful caught), **FNR** (harmful **missed** —
  the paraphrase gap), **FPR** (benign look-alikes wrongly flagged), Precision, F1. This is the no-key,
  no-model robustness signal — it quantifies exactly *how leaky the keyword layer is on its own*.
- **Output layer (only with a base model key).** Runs the full agent and computes **ASR (attack
  success rate)** per rail = fraction of harmful cases whose **final reply** violated the rail (via
  `output_violated`), lower is stronger. For benign cases it computes an **over-block** rate
  (`output_overblocked` — a benign question that got a canned refusal/redirect). When the judge is
  configured, an OpenRouter judge cross-checks each harmful verdict.

Writes `eval/guardrail_results.csv` and `eval/GUARDRAIL_SCORECARD.md`. The point: FNR shows the keyword
layer's blind spots; ASR shows whether the *semantic + prompt + output* layers actually close them.

### 3. Semantic-threshold tuning — `python eval/tune_semantic.py`

Optimizes Layer 2 so it has **fewer misses AND fewer false alarms**. Loads `semantic_tune_set.jsonl`
(56 cases — deliberately **separate** from the held-out `guardrail_set.jsonl` so the eval stays
honest), splits dev/test, and sweeps each rail's cosine threshold:
- **Safety rails** (crisis/injection/fatalism): maximize recall subject to FPR ≤ 0.15 (a miss is worse
  than a mild false alarm).
- **Medical/legal/financial**: balance via Youden's J (`recall − FPR`) to avoid over-blocking ordinary
  money/health/legal-adjacent chat.

It prints dev + **held-out** metrics and writes the tuned `eval/semantic_thresholds.json` that
`semantic_guard.py` loads at runtime.

---

## How it all assesses the entire agent

Each risk is covered at **multiple** levels, so a single failure never reaches the user unchecked:

| Concern | Unit test | Guardrail (runtime) | Evaluation |
|---|---|---|---|
| Chart accuracy | `test_chart*`, `test_chart_reference` | — | `check_chart_tolerance/moon_rashi/nakshatra` over the golden set |
| Crisis safety | `test_guardrails`, `test_echo` | Layer 1+2 router short-circuit + system prompt | `check_crisis_guardrail`; guardrail ASR (crisis) |
| Injection / prompt leak | `test_guardrails` | router short-circuit + system prompt | `check_injection_guardrail`; ASR (injection) |
| Medical/legal/financial | `test_guardrails`, `test_eval_metrics` | nudge + system prompt + **output rail** | `check_reframe_guardrail`; ASR + over-block |
| Fatalism | `test_guardrails` | system prompt + output rail | `check_antifatalism`; ASR (fatalism) |
| Paraphrase robustness | `test_semantic_guard` | Layer 2 semantic fallback | classifier FNR vs. ASR; tuned via `tune_semantic.py` |
| Tone / warmth | — | system prompt VOICE section | LLM-as-judge (1–5) + judge-agreement rate |
| Cost / latency / loops | `test_graph_state` (budget) | `_MAX_TOOL_TURNS` cap | `compute_run_metrics`, `check_step_budget`, comparison table |

**Unit tests** prove the parts are correct and guard against regressions. **Guardrails** are the live
runtime defense (deterministic-first, semantic fallback, output backstop). **Evaluation** runs the real
graph on real models and turns "is it good?" into numbers — pass-rate, ASR, FNR/FPR, tone, latency,
cost — that you can compare across models and track over time.

---

## Quick command reference

```bash
# Unit tests (fast, LLM mocked) — from backend/
pytest                              # whole suite
pytest tests/test_chart.py -v       # one module

# Golden-set evaluation — from repo root (reads backend/.env)
python eval/run_eval.py             # agent (+ sweep + judge if configured)
python eval/run_eval.py --no-judge  # skip tone judge (faster, offline)
python eval/run_eval.py --no-sweep  # single model only

# Guardrail-robustness evaluation
python eval/run_guardrail_eval.py             # classifier + output layers
python eval/run_guardrail_eval.py --no-output # classifier layer only (no key needed)

# Re-tune the semantic thresholds (writes eval/semantic_thresholds.json)
python eval/tune_semantic.py
```

Relevant `.env` keys: `DEFAULT_PROVIDER/MODEL` (agent), `EVAL_PROVIDER/MODEL`, `SWEEP_PROVIDER/MODEL`,
`JUDGE_PROVIDER/MODEL`, `OLLAMA_API_KEY` / `OPENROUTER_API_KEY`, `*_INPUT/OUTPUT_COST_PER_1K`,
`ASTRO_SEMANTIC_GUARD` (`0` to disable Layer 2).
