Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Project Sentinel

An autonomous multi-agent system that detects pharmaceutical cold-chain failures in real time and reroutes shipments before the cargo is lost.

Vaccines, insulin, and biologics are destroyed if they leave the 2–8 °C cold chain in transit. In India this is genuinely hard: ambient runs 30–40 °C and monsoon flooding turns a 14-hour run into a 30-hour crawl. Today the failure is usually discovered after delivery, when the cargo is already worthless.

Sentinel closes that loop. It simulates a vaccine truck running Mumbai → Bengaluru on NH-48, models the thermal physics of a reefer container fighting Indian heat, and when flooding in the Western Ghats degrades the cooling unit and cargo crosses 8 °C, a five-stage agent pipeline assesses the risk, picks a cold-storage warehouse to divert to, weighs safety against time and cost, and applies the reroute. Every decision is persisted with its full reasoning trace, so the whole chain is auditable months later.

New here — or coming back after a break? Read docs/PROJECT_EXPLAINED.md first. It explains the entire system from zero, assuming no prior knowledge.

Stack: FastAPI · PostgreSQL · Redis Pub/Sub · LangGraph · Groq (Llama 3.3 70B) · React 19 · Vite · Tailwind · Leaflet

Doc What it covers
docs/PROJECT_EXPLAINED.md Full walkthrough from zero — every layer, every design decision, interview prep. Start here.
apps/backend/README.md Backend setup and troubleshooting
apps/frontend/README.md Frontend setup
docs/API_CONTRACT.md REST + WebSocket reference

Table of contents


System architecture

Sentinel runs as a single FastAPI process that hosts four cooperating async tasks, coordinated through Redis pub/sub. The simulation produces telemetry, the monitor watches it, the agent graph reacts to it, and the fanout task pushes everything to connected browsers.

graph TB
    subgraph Browser["Browser — React 19 SPA"]
        UI["Dashboard · Live Map · Shipments · Audit"]
    end

    subgraph API["FastAPI process"]
        REST["REST routers<br/>auth · shipments · dashboard · simulation"]
        WS["WebSocket /ws/dashboard<br/>JWT-authenticated"]

        subgraph Tasks["Background asyncio tasks"]
            SIM["Simulation worker<br/>ticks every 5s"]
            MON["Sentinel monitor<br/>threshold watchdog"]
            LISTEN["Broadcast listener"]
            FAN["Fanout loop<br/>+ 30s heartbeat"]
        end

        GRAPH["LangGraph agent pipeline"]
    end

    subgraph Infra["Infrastructure"]
        PG[("PostgreSQL<br/>shipments · telemetry<br/>interventions · audit")]
        REDIS[("Redis Pub/Sub<br/>4 channels")]
    end

    GROQ["Groq API<br/>Llama 3.3 70B"]

    UI -->|"REST + JWT"| REST
    UI <-->|"live events"| WS
    REST --> PG
    SIM -->|"writes telemetry"| PG
    SIM -->|"publishes"| REDIS
    REDIS -->|"telemetry_stream"| MON
    MON -->|"threshold breached"| GRAPH
    GRAPH -->|"structured JSON"| GROQ
    GRAPH -->|"decisions + traces"| PG
    GRAPH -->|"agent_actions"| REDIS
    REDIS --> LISTEN
    LISTEN --> FAN
    FAN --> WS

    style GROQ fill:#f59e0b,color:#000
    style PG fill:#336791,color:#fff
    style REDIS fill:#dc382d,color:#fff
    style GRAPH fill:#7c3aed,color:#fff
Loading

Redis channels

Redis is the decoupling boundary. The simulation never calls the agents directly — it publishes telemetry and moves on. This means the agent pipeline can be slow (LLM calls take seconds) without ever stalling the simulation tick.

Channel Publisher Subscriber Carries
telemetry_stream Simulation worker Sentinel monitor, broadcast listener Position, temperatures, weather, risk score
agent_actions Agent execution node Broadcast listener Action taken, reasoning trace, new route
simulation_lifecycle Simulation + agents Broadcast listener Departure, zone entry, reroute, delivery
system_status Simulation control routes Broadcast listener Simulation started/stopped

The agent pipeline

When cargo temperature breaches the threshold, a LangGraph state machine runs five nodes in sequence. Two of them call the LLM; three are deterministic Python. This split is deliberate — see Design decisions.

graph LR
    START(["Temperature<br/>breach"]) --> S

    S["<b>1. Sentinel</b><br/>Deterministic<br/><i>Gate + audit entry</i>"]
    E["<b>2. Environment</b><br/>🤖 LLM<br/><i>Risk assessment</i>"]
    D["<b>3. Dispatcher</b><br/>🤖 LLM<br/><i>Warehouse selection</i>"]
    SU["<b>4. Supervisor</b><br/>Deterministic<br/><i>Weighted decision</i>"]
    X["<b>5. Execution</b><br/>Deterministic<br/><i>Apply + persist</i>"]

    S --> E --> D --> SU --> X --> END(["Status updated<br/>+ broadcast"])

    style S fill:#3b82f6,color:#fff
    style E fill:#f59e0b,color:#000
    style D fill:#f59e0b,color:#000
    style SU fill:#3b82f6,color:#fff
    style X fill:#3b82f6,color:#fff
Loading
Node Type Responsibility Output
Sentinel Deterministic Confirms the threshold gate was crossed; writes the opening audit record gate_passed
Environment LLM Reads telemetry (temps, weather, severity, position) and assesses cold-chain risk risk_level, risk_score, recommended_action, reasoning
Dispatcher LLM Given the risk assessment + real warehouse candidates from the DB, picks a diversion target warehouse_candidate_id, safety_score, time_score, cost_score, ETA
Supervisor Deterministic Applies configurable weights to the dispatcher's scores and decides what actually happens reroute_route | continue_route | emergency_staging
Execution Deterministic Mutates shipment status, writes route history, publishes to Redis Applied action + broadcast

Supervisor decision logic

The Supervisor is intentionally not an LLM — it is the authority that decides whether cargo actually gets diverted, so its logic must be inspectable and reproducible.

flowchart TD
    IN(["Environment risk +<br/>Dispatcher scores"]) --> Q1{"risk ≥ 0.88<br/>or 'emergency'<br/>recommended?"}
    Q1 -->|Yes| EM["<b>emergency_staging</b><br/>Mark compromised"]
    Q1 -->|No| Q2{"Dispatcher<br/>returned a<br/>warehouse?"}
    Q2 -->|No| CONT["<b>continue_route</b><br/>Keep monitoring"]
    Q2 -->|Yes| CALC["weighted_score =<br/>0.5·safety + 0.3·time + 0.2·cost"]
    CALC --> Q3{"risk ≥ 0.35 AND safety ≥ 0.35<br/>AND action mentions reroute?"}
    Q3 -->|Yes| RR["<b>reroute_route</b><br/>Divert to warehouse"]
    Q3 -->|No| Q4{"risk ≥ 0.5<br/>AND safety ≥ 0.45?"}
    Q4 -->|Yes| RR
    Q4 -->|No| CONT

    style EM fill:#dc2626,color:#fff
    style RR fill:#16a34a,color:#fff
    style CONT fill:#64748b,color:#fff
Loading

Weights are set via SUPERVISOR_WEIGHT_SAFETY / _TIME / _COST and are validated at startup to sum to exactly 1.0 — the app refuses to boot otherwise.


How a shipment flows end to end

This is the full "hero" scenario the demo runs: a reefer truck leaves a Mumbai cold room bound for Bengaluru on NH-48, hits monsoon flooding in the Western Ghats, its cargo temperature climbs past the 8 °C vaccine limit, and the agents divert it to a cold-storage warehouse.

sequenceDiagram
    autonumber
    participant U as Browser
    participant API as FastAPI
    participant SIM as Simulation worker
    participant R as Redis
    participant MON as Sentinel monitor
    participant AG as Agent graph
    participant G as Groq LLM
    participant DB as PostgreSQL

    U->>API: POST /simulation/start/{id}
    API->>DB: reset position, status = in_transit
    API->>R: publish system_status

    loop Every SIMULATION_TICK_SECONDS (default 5s)
        SIM->>SIM: advance position along polyline
        SIM->>SIM: step thermal model + weather
        SIM->>DB: INSERT telemetry_log
        SIM->>R: publish telemetry_stream
        R-->>U: live map + temperature update
    end

    Note over SIM: Truck enters Ghats flood zone —<br/>reefer degrades, cargo temp climbs

    R->>MON: telemetry (internal_temp ≥ 8 °C)
    MON->>MON: dedupe — fire once per breach
    MON->>AG: trigger pipeline

    AG->>DB: audit: sentinel gate_passed
    AG->>G: Environment — assess risk
    G-->>AG: {risk_level, risk_score, recommended_action}
    AG->>DB: audit: environment assessment

    AG->>DB: SELECT warehouse_candidates
    AG->>G: Dispatcher — pick warehouse
    G-->>AG: {warehouse_id, safety/time/cost scores}
    AG->>AG: validate warehouse_id against DB set
    AG->>DB: audit: dispatcher selection

    AG->>AG: Supervisor — weighted decision
    AG->>DB: audit: supervisor decision

    AG->>DB: UPDATE shipment status = rerouted
    AG->>DB: INSERT route_history
    AG->>R: publish agent_actions
    R-->>U: reroute animates on map + reasoning shown

    Note over SIM: Next tick switches to alternate<br/>geometry; cargo temp recovers

    SIM->>DB: UPDATE status = delivered
    SIM->>R: publish lifecycle: shipment_delivered
    R-->>U: shipment closed within band
Loading

Simulation physics

The simulation is not random noise — it is a small deterministic model, which is what makes the demo reproducible.

Thermal model (app/simulation/telemetry_gen.py) — first-order relaxation toward ambient plus the reefer unit pulling toward its setpoint. All temperatures in °C:

ΔT_cargo = dt · [ k_ambient · (T_ambient − T_cargo) + k_reefer · efficiency · (T_setpoint − T_cargo) ]

The insulated box couples weakly to ambient (k_ambient = 0.08) while the reefer pulls hard (k_reefer = 1.2). A healthy unit therefore settles around 6.8 °C against 34 °C outside — safely inside the band. Flooding degrades the unit's authority:

efficiency = max(0.35, 1 − 0.65 · weather_severity)

At full flooding the reefer keeps only 35% of its cooling power, ambient wins, and cargo drifts past 8 °C. That is the entire premise of the demo, expressed in two constants — and it is covered by unit tests so it cannot silently regress.

Risk score (app/simulation/disruption_zones.py) — deterministic, in [0, 1]:

risk = 0.45 · thermal_band_violation + 0.35 · weather_severity + 0.20 · in_monsoon_zone

Cold-chain safety rules enforced by the simulation worker:

Condition Result
Cargo above 10 °C (band high + 2) for 3 consecutive ticks compromised — sustained excursion, cargo written off
Cargo back at or below 8 °C for 3 ticks after a reroute Temperature recovered, reroute window unlocked
Truck position inside the Western Ghats bounding box Weather severity +0.38, ambient +2 °C per severity unit
Arrives at destination above 8 °C compromised — arriving out of spec is not a delivery
Arrives within band delivered

Data model

erDiagram
    USERS {
        int id PK
        string username UK
        string email UK
        string hashed_password
        enum role "admin | viewer"
        bool is_active
    }

    SHIPMENTS {
        int id PK
        string shipment_code UK
        string cargo_type
        string origin
        string destination
        enum status "in_transit | rerouted | compromised | delivered"
        float current_lat
        float current_lng
        float target_temp_low
        float target_temp_high
    }

    TELEMETRY_LOGS {
        int id PK
        int shipment_id FK
        datetime timestamp
        float lat
        float lng
        float internal_temp
        float external_temp
        string weather_state
        float risk_score
        jsonb raw_payload_json
    }

    INTERVENTION_LOGS {
        int id PK
        int shipment_id FK
        datetime timestamp
        string agent_role
        text trigger_reason
        text reasoning_trace
        string action_taken
        float confidence_score
        jsonb raw_model_output_json
    }

    ROUTE_HISTORY {
        int id PK
        int shipment_id FK
        string route_name
        text reason
        float distance_km
        float eta_minutes
    }

    LIFECYCLE_EVENT_LOGS {
        int id PK
        int shipment_id FK
        string event
        jsonb payload_json
    }

    WAREHOUSE_CANDIDATES {
        int id PK
        string name
        float lat
        float lng
        string state
        bool has_cold_storage
        int capacity_units
    }

    SHIPMENTS ||--o{ TELEMETRY_LOGS : "emits"
    SHIPMENTS ||--o{ INTERVENTION_LOGS : "triggers"
    SHIPMENTS ||--o{ ROUTE_HISTORY : "records"
    SHIPMENTS ||--o{ LIFECYCLE_EVENT_LOGS : "logs"
Loading

intervention_logs is the audit spine. Every agent — including the deterministic ones — writes a row with its full reasoning trace and raw model output. That is what makes the /shipments/{slug}/audit page possible: you can reconstruct exactly why a shipment was diverted, months later.


Design decisions

These are the choices worth knowing about, and why they were made.

Redis sits between simulation and agents. The simulation ticks every 5 seconds; an LLM round trip takes several seconds. If the monitor called the agent graph inline, one slow Groq response would stall every shipment's physics. Publishing to telemetry_stream and reacting asynchronously means the pipeline can take as long as it needs. It also means the agent pipeline could be extracted into its own service without changing the simulation at all.

Only two of the five agents are LLMs. The LLM is used where judgment genuinely helps — interpreting a messy environmental situation, and ranking warehouse options. The decision that actually commits cargo to a new route is plain weighted arithmetic in the Supervisor. A regulator asking "why was this shipment diverted?" gets a deterministic, reproducible answer, not a sampled token stream.

LLM output is validated twice. Groq is asked for response_format: json_object, the response is parsed into a Pydantic model (app/agents/llm_schemas.py), and then the Dispatcher's chosen warehouse_candidate_id is checked against the actual set of warehouse IDs from the database. A hallucinated warehouse ID short-circuits to continue_route rather than corrupting the shipment.

The system degrades instead of crashing. If Redis is unavailable, the API still starts — /health reports redis: false, the simulation and monitor simply don't launch, and REST endpoints keep working. If Postgres is unreachable, startup fails fast with an actionable message telling you to run docker compose up -d postgres redis, rather than a raw connection traceback.

Sentinel fires once per breach, not once per tick. Without deduplication, a shipment sitting below the threshold for ten ticks would trigger ten concurrent LLM pipelines. app_state.sentinel_fired_this_breach gates this, and clears when the temperature recovers above the threshold. A per-shipment asyncio.Lock guarantees only one pipeline per shipment runs at a time.

Auth is JWT bearer, and the WebSocket enforces it too. REST routes use an Authorization: Bearer header. Browsers cannot set custom headers on a WebSocket constructor, so /ws/dashboard takes the same JWT as a token query parameter and validates it against the database before accepting the connection — an unauthenticated socket is closed with code 1008.


Quick start (Docker)

The fastest path to a running system.

git clone https://github.com/K-Ananthamoorthy/sentinal.git
cd sentinal
cp .env.example .env

Edit .env and set your GROQ_API_KEY (get one free at console.groq.com/keys) and a long random SECRET_KEY. Then:

docker compose up --build
Service URL
Frontend http://localhost:5173
API http://localhost:8000
Swagger docs http://localhost:8000/docs
Health check http://localhost:8000/health

Log in with the seeded admin account — demo / SentinelDemo2026! — then open Simulation, pick the demo shipment, and press Start.

Infrastructure only, no API key needed: docker compose up -d postgres redis


Local development

This project uses uv for Python and Bun for the frontend.

# Install both if you don't have them
curl -LsSf https://astral.sh/uv/install.sh | sh    # macOS/Linux
curl -fsSL https://bun.sh/install | bash

# Windows (PowerShell)
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
powershell -c "irm bun.sh/install.ps1 | iex"

1. Start infrastructure

docker compose up -d postgres redis

2. Backend

cd apps/backend
uv venv
uv pip install -r requirements.txt
cp .env.example .env      # then set GROQ_API_KEY and SECRET_KEY

Run it:

uv run uvicorn app.main:app --reload --port 8000

On first start with empty tables, the API seeds the demo admin user, warehouse candidates, and the demo shipment SNT-DEMO-001.

3. Frontend

cd apps/frontend
bun install
cp .env.example .env.local
bun run dev

Opens on http://localhost:5173. In dev, Vite proxies /auth, /shipments, /dashboard, /health, and /ws to the backend, so you can leave VITE_API_BASE_URL empty.

From the repo root you can also use:

bun run dev:frontend

Configuration

Secrets live in .env files, which are gitignored. Only .env.example templates are tracked.

GROQ_API_KEY belongs in apps/backend/.env only — never in the frontend. Anything prefixed VITE_ is compiled into the browser bundle and is public. If a key is ever committed or pasted somewhere public, rotate it at console.groq.com/keys.

Key backend settings

Variable Default What it does
SECRET_KEY JWT signing key, min 16 chars. Required.
DATABASE_URL Async SQLAlchemy URL (postgresql+asyncpg://…). Required.
REDIS_URL redis://host:port/db. Required.
GROQ_API_KEY Groq API key. Required.
GROQ_MODEL llama-3.3-70b-versatile Model used by both LLM agents
SIMULATION_TICK_SECONDS 5 Real seconds between simulation ticks
SIMULATED_MINUTES_PER_TICK 15 Simulated minutes advanced per tick
TEMPERATURE_THRESHOLD_C 8.0 Cargo temp at or above which the agent pipeline fires
TARGET_TEMP_LOW_C / _HIGH_C 2.0 / 8.0 Safe cargo band (WHO vaccine cold chain)
THERMAL_COUPLING_PER_HOUR 0.08 How fast cargo drifts toward ambient (low — insulated box)
REEFER_SETPOINT_C 5.0 Refrigeration unit target
REEFER_STRENGTH_PER_HOUR 1.2 How hard the reefer pulls toward setpoint
SUPERVISOR_WEIGHT_SAFETY 0.5 Supervisor weights — must sum to 1.0
SUPERVISOR_WEIGHT_TIME 0.3
SUPERVISOR_WEIGHT_COST 0.2
CORS_ORIGINS http://localhost:5173 Comma-separated allowed origins

Frontend settings

Variable Purpose
VITE_API_BASE_URL REST base, e.g. http://localhost:8000. Leave empty in dev to use the Vite proxy.
VITE_WS_URL WebSocket URL, e.g. ws://localhost:8000/ws/dashboard

Database migrations

Schema is managed by Alembic. The app runs alembic upgrade head automatically on startup, so a fresh clone needs no manual migration step — but the commands are there when you change a model.

cd apps/backend

uv run alembic upgrade head                              # apply pending migrations
uv run alembic revision --autogenerate -m "add column"   # after editing models.py
uv run alembic downgrade -1                              # roll back one revision
uv run alembic current                                   # what's applied now

migrations/env.py reads DATABASE_URL from settings, so alembic.ini never holds credentials.

Upgrading from a pre-Alembic database? Older volumes were built with create_all and have no alembic_version table, so a schema that predates the origin/destination columns will fail on boot. Reset with docker compose down -v (this deletes the volume) and start again.

Testing

cd apps/backend
uv run pytest

Tests are SQLite-backed and require no running infrastructure — tests/conftest.py sets the necessary environment variables before the app imports. Coverage focuses on the deterministic core: thermal stepping, risk scoring, route geometry, LLM schema validation, and JWT/password security.

Frontend typecheck and build:

cd apps/frontend
bun run lint      # tsc --noEmit
bun run build

CI runs all of the above plus both Docker image builds on every push and pull request — see .github/workflows/ci.yml.


Project layout

sentinal/
├── apps/
│   ├── backend/
│   │   ├── app/
│   │   │   ├── agents/          # LangGraph pipeline, prompts, LLM output schemas
│   │   │   ├── api/             # REST routers + WebSocket endpoint
│   │   │   ├── core/            # Settings, JWT/password, logging, shared app state
│   │   │   ├── database/        # ORM models, CRUD, session, seed data
│   │   │   ├── schemas/         # Pydantic request/response models
│   │   │   ├── services/        # Groq client, Redis pub/sub, background workers
│   │   │   ├── simulation/      # Thermal model, route geometry, weather, risk zones
│   │   │   └── main.py          # App factory, lifespan, task orchestration
│   │   ├── migrations/          # Alembic revisions
│   │   └── tests/
│   └── frontend/
│       └── src/
│           ├── components/      # Layout shell + shadcn-style UI primitives
│           ├── lib/             # API client, WebSocket URL builder
│           ├── pages/           # Home, Dashboard, Live Map, Shipments, Detail, Audit
│           └── store/           # Zustand auth store (persisted)
├── docs/                        # API contract, specs, project proposal
├── .github/workflows/ci.yml
└── docker-compose.yml

API surface

Method Route Auth
POST /auth/register · /auth/login Public
GET /auth/me Bearer
GET /shipments · /shipments/{id} · /shipments/by-code/{code} Bearer
GET /shipments/{id}/telemetry · /interventions · /route-overlay Bearer
POST /shipments · /shipments/auto Admin
PATCH DELETE /shipments/{id} Admin
GET /dashboard/summary · /live-state · /events · /scenario Bearer
POST /simulation/start/{id} · /simulation/stop/{id} Bearer
GET /simulation/events/{id} · /simulation/lifecycle/{id} Bearer
WS /ws/dashboard?token=…&shipment_id=… Token query param
GET /health Public

Full details in docs/API_CONTRACT.md and the live Swagger UI at /docs.


Security notes

  • Passwords are hashed with bcrypt via passlib; plaintext is never stored or logged.
  • JWTs are HS256, signed with SECRET_KEY, expiring after ACCESS_TOKEN_EXPIRE_MINUTES (default 24h).
  • Role-based access: admin is required to create, modify, or delete shipments; viewer is read-only. New registrations default to viewer.
  • The WebSocket validates its JWT against the database and closes unauthenticated connections with code 1008.
  • CORS is restricted to the origins in CORS_ORIGINS — not a wildcard.
  • Schema changes go through Alembic migrations, not create_all — see Database migrations.

Known limitations

Worth being upfront about, since this started as a prototype:

  • Simulation state is in-process memory. SimulationEngine.states lives in the FastAPI process, so the backend does not horizontally scale as-is. Moving that state into Redis would be the first step toward multi-replica deployment.
  • The frontend bundle is ~500 KB (Leaflet + React Query). Route-level code splitting would bring this down.
  • No end-to-end tests. Backend unit tests cover the deterministic core; the agent pipeline and the UI are verified manually.

About

Autonomous multi-agent cold-chain monitoring : LangGraph pipeline that detects temperature excursions in pharma shipments and reroutes them via deterministic weighted scoring. FastAPI, Redis pub/sub, PostgreSQL.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages