Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

35 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

🧠 ragent

A guardrail-first policy assistant that knows when to give up and call a human πŸ™‹β€β™‚οΈ

Multi-turn LangGraph agent Β· hybrid RAG Β· frustration-aware escalation Β· async ingestion pipeline

Python FastAPI LangGraph Claude Chroma Celery License


πŸ‘‹ What even is this thing?

ragent answers "what does the policy say about X?" questions over your own PDFs/DOCX policy docs β€” but it's built around two questions most weekend-project RAG bots don't bother asking:

  1. 🧯 "Is the model actually making this up?" β†’ input/output guardrails + a lexical grounding check + an LLM judge + RAGAS, so hallucinations get caught instead of shipped.
  2. 😀 "Is the user about to lose their mind?" β†’ a cumulative, regex-scored frustration meter that auto-escalates to a human the moment things cross a threshold β€” no vibes-based LLM judgment call required.

It's exposed three ways off the same agent graph:

Surface Use it for
🌐 FastAPI (/api/v1/ask, /ask/stream, /ingest) Talking to it over HTTP, streaming tokens via SSE
πŸ”Œ MCP server (src/mcp_server.py) Plugging policy search into Claude Desktop or any MCP client
🐍 Direct Python (ask(), ask_with_session()) Embedding the agent straight into another service

Built as a personal deep-dive into LangGraph state machines, hybrid retrieval, and "what does production-grade actually require" β€” so expect a few 🚧 rough edges alongside the parts that are genuinely solid.


πŸ—ΊοΈ Table of Contents


πŸ—οΈ Architecture

flowchart TB
    subgraph Clients["πŸšͺ Entry Points"]
        A1["FastAPI\n/ask, /ask/stream"]
        A2["MCP Server (stdio)\nsearch_policies / list_policy_documents"]
        A3["Direct Python API\nask() / ask_with_session()"]
    end

    subgraph MW["πŸ›‘οΈ HTTP Middleware Stack"]
        RID["RequestID"] --> RL["RateLimit\n(sliding window / IP)"] --> CORS["CORS"] --> LOG["Structured JSON logging"]
    end

    subgraph Core["🧠 Master Agent β€” LangGraph StateGraph"]
        direction TB
        IG["input_guard\nPII + prompt-injection filter"]
        FC["frustration_check\nregex signal scan + score update"]
        RT["router\nLLM structured-output classifier"]
        PA["policy_agent (sub-graph)"]
        OOS["out_of_scope_handler"]
        OG["output_guard\nPII redaction + grounding check"]
        HE["human_escalation πŸ†˜\nticket stub"]
    end

    subgraph SubGraph["πŸ“š policy_agent sub-graph"]
        direction TB
        TQ["transform_query"] --> RD["retrieve"] --> GN["generate\n(ChatAnthropic)"]
    end

    subgraph RAGLayer["πŸ” RAG Layer"]
        VS[("Chroma\npersistent + singleton")]
        EMB["Embeddings\nHF BGE (local) or OpenAI"]
        BM["BM25Retriever\nkeyword search"]
        ENS["EnsembleRetriever\n0.6 semantic / 0.4 BM25"]
    end

    subgraph AsyncPipe["βš™οΈ Async Ingestion Pipeline"]
        REDIS[("Redis\nbroker + result backend")]
        WORKER["Celery worker\nload β†’ chunk β†’ embed β†’ store"]
    end

    A1 --> MW --> IG
    A3 --> IG
    A2 -.direct retriever call, skips guardrails.-> ENS

    IG -->|blocked| END1(["END"])
    IG --> FC
    FC -->|score β‰₯ 0.80| HE
    FC -->|below threshold| RT
    RT -->|intent=policy| PA
    RT -->|intent=out_of_scope| OOS
    PA --> OG --> END2(["END"])
    OOS --> END2
    HE --> END2

    PA -.contains.-> SubGraph
    RD --> ENS --> VS
    ENS --> BM
    VS --> EMB

    A1 -."POST /ingest".-> REDIS --> WORKER --> VS

    CKPT[("SqliteSaver\n(fallback: MemorySaver)")]
    Core -. persists frustration_score,\nescalation_requested, per session_id .- CKPT
Loading

πŸ” Design Zoo

A handful of extra diagrams purely so future-me (or anyone poking around this repo) can see how each moving part behaves, not just that it exists.

1. Request lifecycle β€” sync /ask

sequenceDiagram
    participant C as Client
    participant MW as Middleware
    participant API as FastAPI route
    participant SEM as asyncio.Semaphore
    participant G as master_agent (LangGraph)
    participant IG as input_guard
    participant FR as frustration_check
    participant RT as router (LLM)
    participant PA as policy_agent
    participant VS as Chroma
    participant LLM as ChatAnthropic
    participant OG as output_guard

    C->>MW: POST /ask {query, session_id?}
    MW->>MW: assign request_id, check rate limit
    MW->>API: forward request
    API->>SEM: acquire (max_concurrent_llm_calls)
    API->>G: ainvoke(turn_input, thread_id=session_id)\nwrapped in asyncio.wait_for(timeout)
    G->>IG: check_input(state)
    alt PII or injection detected
        IG-->>G: error + canned final_response
        G-->>API: return blocked response
    else clean
        IG-->>G: sanitized_query
        G->>FR: detect_frustration_signals + update score
        alt score β‰₯ 0.80
            FR-->>G: escalation_requested
            G->>G: human_escalation_node πŸ†˜
            G-->>API: ticket-reference message
        else
            FR-->>G: continue
            G->>RT: classify(sanitized_query)
            RT-->>G: intent (policy | out_of_scope)
            alt intent == policy
                G->>PA: run sub-graph
                PA->>VS: retriever.invoke(query)
                VS-->>PA: top-k Document chunks
                PA->>LLM: generate(context + question)
                LLM-->>PA: generated_response
                PA-->>G: state update
                G->>OG: check_output(state)
                OG-->>G: final_response + guardrail_flags
            else out_of_scope
                G->>G: out_of_scope_handler_node
            end
            G-->>API: AskResponse
        end
    end
    API->>SEM: release
    API-->>C: {response, guardrail_flags, sources?, session_id}
Loading

The streaming twin (/ask/stream) runs the identical graph via astream_events(..., version="v2"), filters for on_chat_model_stream events scoped to the generate node only (so router/judge tokens never leak to the client), and emits SSE with a final done: true frame.

2. Async ingestion via Celery + Redis

Swapped BackgroundTasks for a real queue. πŸŽ‰

sequenceDiagram
    participant U as You
    participant API as POST /ingest
    participant R as Redis (broker)
    participant W as Celery worker
    participant VS as Chroma

    U->>API: {"directory": "data/policies", "clear": false}
    API->>R: ingest_documents.delay(dir, clear)
    R-->>API: task_id
    API-->>U: 202 Accepted {task_id, status: "queued"}

    Note over W,R: worker polls Redis, picks up the task

    R->>W: dequeue ingest_documents
    W->>W: update_state(step="clearing", pct=0)
    opt clear=true
        W->>VS: delete_collection()
    end
    W->>W: update_state(step="loading", pct=20)
    W->>W: load_directory() β†’ chunk with RecursiveCharacterTextSplitter
    W->>W: update_state(step="embedding", pct=50)
    W->>VS: add_documents(chunks)
    W->>W: update_state(step="done", pct=100)
    W-->>R: result {chunks_written, cleared}

    U->>API: GET /ingest/status/{task_id}
    API->>R: AsyncResult(task_id).state / .info
    R-->>API: {state, pct, step, chunks_written}
    API-->>U: IngestStatusResponse
Loading

Why this over BackgroundTasks: crash-safe redelivery (task_acks_late=True), automatic retry with exponential backoff on transient I/O errors, real progress percentages instead of "check the logs," and the ability to add more worker processes without touching the API process at all.

3. Frustration score state machine

The escalation logic, drawn as what it actually is β€” a one-way ratchet with a trapdoor. πŸ˜…

stateDiagram-v2
    [*] --> Calm: session starts (score = 0.0)
    Calm --> Mildly_Annoyed: score += weight\n(e.g. "that's wrong" = +0.15)
    Mildly_Annoyed --> Frustrated: score += weight\n(e.g. "this is ridiculous" = +0.30)
    Frustrated --> Escalated: score β‰₯ 0.80 πŸ†˜
    Mildly_Annoyed --> Calm: no new signals\n(score never decreases,\nbut also never auto-cools)
    Calm --> Escalated: single strong signal\n(e.g. "connect me to a human" = +0.40,\ncan combine with prior turns)
    Escalated --> [*]: human_escalation_node\nticket issued, graph ends turn

    note right of Escalated
        Score is cumulative across the
        whole session (persisted via
        SqliteSaver, keyed by session_id).
        It only ever goes up within a turn β€”
        there's no decay mechanism today.
    end note
Loading

4. Middleware pipeline

Every request runs this gauntlet before it ever touches the agent graph:

flowchart LR
    Req(["Incoming request"]) --> RID["🏷️ RequestIDMiddleware\nassigns/echoes X-Request-ID"]
    RID --> RLM["🚦 RateLimitMiddleware\nsliding window per client IP"]
    RLM -->|"limit exceeded"| R429(["429 + Retry-After"])
    RLM -->|"under limit"| CORS["🌍 CORSMiddleware"]
    CORS --> TIME["⏱️ Request timing log\nmethod, path, status, duration_ms"]
    TIME --> ROUTE["Route handler\n(/ask, /ingest, /health...)"]
    ROUTE --> JSONLOG["πŸ“‹ JSONLogFormatter\nstructured stdout for log aggregators"]
Loading

5. Concurrency control β€” the semaphore dance

Per-worker throttling so one uvicorn process doesn't fire 200 concurrent LLM calls at Anthropic:

flowchart TB
    subgraph Worker["Single uvicorn worker process"]
        SEM["asyncio.Semaphore(max_concurrent_llm_calls)"]
        R1["Request A"] -->|acquire| SEM
        R2["Request B"] -->|acquire| SEM
        R3["Request C"] -.waits.-> SEM
        SEM --> LLMCall["ChatAnthropic.ainvoke()"]
    end
    Note["Total concurrency across the fleet =\nmax_concurrent_llm_calls Γ— num_workers\n(the semaphore is per-process, not global)"]
Loading

🧰 Tech Stack & Why

Layer Choice Why πŸ€”
Orchestration LangGraph StateGraph Explicit, inspectable state machine instead of an implicit agent loop β€” routing/escalation logic is testable as plain functions independent of any LLM call.
LLM Claude via langchain-anthropic One provider client reused for generation, routing, scope detection, and judging β€” swappable per-node by model name.
Web framework FastAPI + Uvicorn Native async matches LangGraph's async invocation path; free OpenAPI docs at /docs; first-class SSE for token streaming.
Vector store Chroma, singleton + threading lock Zero-ops embedded vector DB; singleton avoids N embedding-model loads and SQLite "database is locked" errors under concurrent requests.
Embeddings HuggingFace BGE (all-MiniLM-L6-v2) locally, OpenAI opt-in Local by default = no second paid API + no extra network hop on every retrieval.
Keyword retrieval rank-bm25 + custom BM25Retriever Lexical fallback for exact-term queries dense embeddings under-rank.
Hybrid fusion EnsembleRetriever, weights [0.6, 0.4] RRF-style fusion of semantic + keyword without hand-rolling it.
Doc parsing unstructured[pdf,docx] Handles messy real-world PDFs/DOCX (tables, headers) better than naive text extraction.
Chunking RecursiveCharacterTextSplitter Splits on paragraph β†’ line β†’ sentence β†’ word, preserving semantic units.
Structured LLM output Pydantic + .with_structured_output() Router intent, scope decision, judge scores are typed models β€” no hand-parsed JSON.
Multi-turn state SqliteSaver checkpointer (fallback MemorySaver), keyed by thread_id=session_id Frustration score + escalation flag survive a server restart now.
Async task queue Celery + Redis Crash-safe, retryable, horizontally-scalable ingestion β€” see the ingestion diagram.
CLI Typer + Rich ingest --file/-f or --dir/-d, with --dry-run and a pretty summary table β€” nicer than raw argparse prints.
Frustration detection Hand-written weighted regex bank, no LLM call Deterministic, sub-millisecond, zero-cost per turn β€” this signal gates on every single message, so an LLM call here would be wasteful.
Evaluation Custom LLM-judge + RAGAS Two independent eval paths for faithfulness/relevance/completeness/hallucination and standardized retrieval metrics.
Observability LangSmith (opt-in) + structured JSON logs + request IDs Tracing when you want it, greppable/aggregatable logs always.
Rate limiting In-memory sliding-window middleware Simple per-process throttle; documented TODO to move to Redis INCR so multi-worker deployments share one limit.
Tool exposure MCP server (stdio) Any MCP client (Claude Desktop, etc.) can call search_policies/list_policy_documents directly β€” deliberately narrower than the guarded HTTP path.
Packaging pyproject.toml optional-dependency groups (api, mcp, eval, openai, dev) Install only what you need.
Linting/typing ruff + mypy --strict Fast lint/import-sort + strict typing on a TypedDict-heavy state model.

πŸ’‘ Core Design Decisions

πŸ›‘οΈ Guardrails live in the graph, not in HTTP middleware

input_guard and output_guard are LangGraph nodes, so the same checks apply whether you hit the agent over HTTP, embed it directly in Python, or (partially) via MCP. Trade-off: a blocked request still costs one node execution β€” there's no free "reject before graph starts" path.

😀 Frustration is cumulative and one-directional

update_frustration_score() never subtracts β€” once you're annoyed, the score only climbs within the session (see the state machine). Crossing ESCALATION_THRESHOLD = 0.80 routes to human_escalation_node regardless of what the router would've decided β€” a deterministic, regex-computed circuit breaker sitting on top of an otherwise LLM-driven pipeline.

🧭 Structured routing that fails open

router_node classifies intent via .with_structured_output(RouterDecision). If the router call throws, the code defaults to "policy" rather than failing the whole request β€” availability over strict scope enforcement, by explicit design.

πŸ₯ˆ Hybrid retrieval is opt-in

build_retriever() only builds the BM25 + semantic ensemble when use_bm25=True and a corpus is supplied. It's there and ready, but wiring a corpus into retrieve_node at call time is what flips it on.

🧡 Output grounding via cheap lexical overlap, not embeddings

_is_grounded() tokenizes retrieved context + generated response (words >4 chars), and flags low grounding if a >20-word response shares <3 words with the retrieved vocabulary. Deliberately lightweight β€” a full embedding-similarity check would cost an extra model call on every response.

πŸ“¦ Celery + Redis over fire-and-forget background tasks

Documented directly in ingest.py's docstring β€” the switch bought crash recovery, real status polling, automatic retry with backoff, horizontal scaling, and process isolation (the old approach shared the API's event loop; the new one runs in a totally separate worker process).

πŸ”’ Chroma as a locked singleton

get_vectorstore() uses a double-checked lock so 20 concurrent requests share one Chroma client instead of spinning up 20 (20x the embedding-model RAM, 20 SQLite file handles, guaranteed "database is locked" errors).

🚦 Per-process semaphore + timeout around every LLM call

/ask acquires an asyncio.Semaphore(max_concurrent_llm_calls) before invoking the graph, wrapped in asyncio.wait_for(agent_timeout_seconds). The semaphore is explicitly per-worker-process (documented in dependencies.py) β€” total fleet concurrency is max_concurrent_llm_calls Γ— num_workers, so the per-worker value should be tuned against your actual Anthropic rate limit.

🩺 Two-tier health checking

/health is a pure liveness probe for pod-restart decisions. /status actually opens the Chroma collection and reports degraded (not a 5xx) if it can't β€” because the service can still limp along answering from parametric knowledge even with retrieval down.

πŸ”Œ MCP server as a narrower surface

mcp_server.py calls the retriever directly, skipping guardrails and frustration tracking entirely β€” MCP tools are meant to hand raw context to an external LLM's own reasoning loop, not re-expose the fully-guarded conversational agent.


πŸ“ Module Map

ragent/
β”œβ”€β”€ main.py                          # uvicorn entrypoint
β”œβ”€β”€ scripts/
β”‚   └── ingest_data.py                 # πŸ–₯️  Typer CLI: --file/--dir, --clear, --dry-run, Rich summary table
└── src/
    β”œβ”€β”€ agents/
    β”‚   β”œβ”€β”€ state.py                   # AgentState TypedDict β€” shared schema across every graph node
    β”‚   β”œβ”€β”€ master_agent.py            # top-level StateGraph: guardrails, frustration, routing, escalation
    β”‚   β”œβ”€β”€ policy_agent.py            # sub-graph: transform_query β†’ retrieve β†’ generate
    β”‚   └── __init__.py
    β”œβ”€β”€ api/
    β”‚   β”œβ”€β”€ app.py                      # FastAPI factory (lifespan, middleware wiring, routers)
    β”‚   β”œβ”€β”€ middleware.py               # πŸ†• RequestID, RateLimit (sliding window), JSON log formatter
    β”‚   β”œβ”€β”€ dependencies.py             # verify_api_key, get_llm_semaphore, get_request_id
    β”‚   β”œβ”€β”€ schema.py                   # Pydantic request/response models
    β”‚   └── routes/
    β”‚       β”œβ”€β”€ query.py                # POST /ask, POST /ask/stream (SSE) β€” semaphore + timeout wrapped
    β”‚       β”œβ”€β”€ ingest.py                # POST /ingest (Celery), GET /ingest/status/{task_id}
    β”‚       β”œβ”€β”€ ingest_bgtask.py         # πŸ—„οΈ archived: the old BackgroundTasks version, kept for reference
    β”‚       └── health.py                # GET /health (liveness), GET /status (readiness)
    β”œβ”€β”€ worker/
    β”‚   β”œβ”€β”€ celery_app.py                # πŸ†• Celery config: JSON serialization, late acks, single-prefetch
    β”‚   └── tasks.py                     # πŸ†• ingest_documents task β€” retry w/ backoff, progress reporting
    β”œβ”€β”€ guardrails/
    β”‚   β”œβ”€β”€ input_guard.py                # PII (CC/SSN) + prompt-injection regex filter
    β”‚   β”œβ”€β”€ output_guard.py               # PII redaction + lexical grounding heuristic
    β”‚   β”œβ”€β”€ frustration_detector.py       # weighted regex signal bank + cumulative score
    β”‚   └── scope_detector.py             # standalone LLM in/out-of-scope classifier
    β”œβ”€β”€ rag/
    β”‚   β”œβ”€β”€ document_processor.py         # UnstructuredFileLoader + RecursiveCharacterTextSplitter
    β”‚   β”œβ”€β”€ embeddings.py                  # local (HF BGE) or OpenAI embedding provider, lru_cached
    β”‚   β”œβ”€β”€ vectorstore.py                 # πŸ†• Chroma singleton (thread-locked) + retriever factory
    β”‚   └── retriver.py                    # BM25Retriever + EnsembleRetriever hybrid builder
    β”œβ”€β”€ evaluation/
    β”‚   β”œβ”€β”€ judge_llm.py                   # structured-output LLM judge: 4-dimension scoring
    β”‚   └── ragas_eval.py                   # RAGAS harness (faithfulness, answer relevance, context precision)
    β”œβ”€β”€ observability/
    β”‚   └── tracing.py                      # LangSmith env-var setup, run URL builder
    β”œβ”€β”€ mcp_server.py                        # stdio MCP server: search_policies, list_policy_documents
    └── config/
        └── settings.py                      # pydantic-settings, .env-backed

βš™οΈ Configuration Reference

All settings load via pydantic-settings from environment variables / a .env file (case-insensitive).

Setting Default Notes
anthropic_api_key "" Required for any LLM call to succeed
llm_model claude-opus-4-8 Shared across generation, routing, scope detection, judging
llm_max_tokens / llm_temperature 4096 / 0.1 Low temperature favors grounded answers over creativity
embedding_provider / embedding_model local / all-MiniLM-L6-v2 local (HF BGE, CPU) or openai
chroma_presist_dir / chroma_collection_name ./data/chroma-db / policies Persistent vector store location
retriver_top_k 6 Top-k for retrieval
use_bm25 False Enables hybrid retrieval once a corpus is wired in
chunk_size / chunk_overlap 100 / 200 Splitter config
langsmith_api_key / langsmith_project / langsmith_tracing β€” / ragent / False langsmith_enabled gates on both key + flag
redis_url redis://localhost:6317 πŸ†• Celery broker + result backend
max_concurrent_llm_calls 10 πŸ†• Per-worker semaphore size
agent_timeout_seconds 60 πŸ†• Hard timeout around master_agent.ainvoke()
rate_limit_per_minute 60 πŸ†• Sliding-window limit per client IP
cors_origin ["*"] πŸ†• CORS allow-list
api_secret_key "" πŸ†• Bearer token for verify_api_key β€” unset = auth disabled

Built as a hands-on exploration of guardrail-first agent design β€” issues, PRs, and "hey this regex is wrong" comments welcome. ✨

About

Guardrail first policy assistant with frustration aware escalation. Multi-turn agent with LLM based routing and escalation

Topics

Resources

Stars

Watchers

Forks

Used by

Contributors

Languages