# 🤖 CareerSIM Agent

A standalone Python LangGraph agent for career-simulation conversations. Ships
with a Gradio developer console **and** a stateless FastAPI production server.

## ✨ Features

- **LangGraph conversation flow** with persona-driven AI responses,
  configurable via `data/personas.json` + `data/simulations.json`.
- **LLM-based evaluation** for user/AI sentiment + emotion and per-goal
  progress tracking, using a separate (cheaper) eval model configurable via
  `OPENAI_EVAL_MODEL`.
- **Retrieval-Augmented Generation (RAG)** using Chroma + OpenAI embeddings,
  indexing per-simulation and per-persona Markdown documents from
  `data/documents/`.
- **Proactive messaging** — start, inactivity, and follow-up bursts are
  modelled as explicit graph branches.
- **Two run modes from one binary:**
  - Gradio developer console (default) — state inspector, node tracing, goal
    dashboard, manual proactive triggers.
  - FastAPI production server (`--serve api`) — stateless JSON API with both
    batch and Server-Sent Events streaming endpoints.

## 🚀 Quick Start

```bash
# From agent/

# Create venv and install with uv (recommended)
uv venv
source .venv/bin/activate
uv sync

# Or plain pip
python3 -m venv .venv
source .venv/bin/activate
pip install -e .

# Configure
cp .env.example .env
# Edit .env and set at least OPENAI_API_KEY

# Gradio dev console (default, port 7860)
python -m careersim_agent.main

# FastAPI production server (port 8000)
python -m careersim_agent.main --serve api
```

Useful flags:

| Flag | Default | Notes |
| --- | --- | --- |
| `--serve gradio\|api` | `gradio` | Selects which server to run |
| `--host` | `0.0.0.0` | API-mode bind host |
| `--port` | `7860` (gradio) / `8000` (api) | Server port |

Interactive API docs (Swagger UI) are available at `http://<host>:<port>/docs`
when running in API mode.

## 🚦 Production API (`--serve api`)

The server is fully **stateless** — the backend sends the full
`ConversationState` + a command on every request, the graph runs, and the
updated state is returned. No sessions are held in memory on the agent.

### 🛣️ Endpoints

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/health` | Liveness probe (`{"status":"ok"}`). |
| GET | `/simulations` | List available simulations (slug, title, persona). |
| POST | `/conversation/init` | Create initial state; optionally run the persona's opening proactive message. |
| POST | `/conversation/turn` | Run one user-message turn (batch). |
| POST | `/conversation/proactive` | Trigger a proactive message: `start` / `inactivity` / `followup` (batch). |
| POST | `/conversation/init/stream` | SSE variant of `init`. |
| POST | `/conversation/turn/stream` | SSE variant of `turn`. |
| POST | `/conversation/proactive/stream` | SSE variant of `proactive`. |

Batch responses share a common shape:

```json
{
  "state":          { "...full ConversationState (wire format)..." },
  "messages":       [{"role": "human|ai", "content": "..."}],
  "goal_progress":  [{ "goalNumber": 1, "status": "in_progress", "...": "..." }],
  "analysis":       { "user_sentiment": "...", "user_emotion": "...",
                      "ai_sentiment": "...",   "ai_emotion": "..." }
}
```

SSE endpoints emit one `event: message` per generated AI message (with a
persona-aware `typing_delay_sec` for realistic replay on the frontend) and a
final `event: done` carrying the complete updated state.

### 🔐 Authentication

Every route except `/health` is gated on a shared secret. Callers must
send the configured `AGENT_INTERNAL_KEY` in an `X-Internal-Key` header;
missing or mismatched values return **401**. `/health` is intentionally
exempt so Docker / load-balancer liveness probes can poll it without
holding the key.

When `AGENT_INTERNAL_KEY` is empty the agent logs a single
`AGENT_INTERNAL_KEY is not set — accepting all requests` warning and
treats every caller as trusted. This is the default for local dev,
the Gradio console, and the pytest suite; production deployments must
set it (and must set the matching value on the Fastify API side — see
`api/.env.example`).

```bash
curl -H "X-Internal-Key: $AGENT_INTERNAL_KEY" \
     http://localhost:8001/simulations
```

### 🧰 Minimal client example

```python
import os
import httpx

BASE = "http://localhost:8000"

# Internal-API key (see `Authentication` above). Leave headers empty
# in dev mode when AGENT_INTERNAL_KEY is unset.
HEADERS = {"X-Internal-Key": os.environ["AGENT_INTERNAL_KEY"]}

# Start
init = httpx.post(f"{BASE}/conversation/init", headers=HEADERS, json={
    "simulation_slug": "behavioral-interview-brenda",
    "session_id": "sess-123",  # backend-owned
}).json()
state = init["state"]

# Turn
turn = httpx.post(f"{BASE}/conversation/turn", headers=HEADERS, json={
    "state": state,
    "user_message": "Hi Brenda, thanks for having me.",
}).json()
state = turn["state"]
print(turn["messages"][-1])
print(turn["goal_progress"])
```

## 🎛️ Gradio Dev Console (`--serve gradio`)

Browser UI at http://localhost:7860 with:

- Simulation picker and live chat
- Per-turn goal-progress dashboard
- Full state / trace / analysis inspectors
- Manual buttons for `inactivity` and `followup` proactive triggers

Intended for developers iterating on personas, prompts, and graph behaviour —
not as the backend's runtime target.

## 🐳 Docker

The `Dockerfile` builds a self-contained image that runs the agent. By
default it starts the Gradio console on `7860`; override the command to run
the API server instead.

```bash
# Build
docker build -t careersim-agent ./agent

# Gradio dev console
docker run --rm -p 7860:7860 --env-file agent/.env careersim-agent

# FastAPI production server
docker run --rm -p 8000:8000 --env-file agent/.env careersim-agent \
  python -m careersim_agent.main --serve api --port 8000
```

> The top-level `docker-compose.local.yml` currently has the `agent` service
> commented out; run it standalone via `docker run` (above) until it's wired
> back into the compose stack.

## ⚙️ Configuration

Configuration is loaded from environment variables (or `.env`). See
`.env.example` for the authoritative list; the most important knobs are:

| Variable | Default | Purpose |
| --- | --- | --- |
| `OPENAI_API_KEY` | _(required)_ | Key for chat + embeddings + eval calls. |
| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Swap for OpenRouter, etc. |
| `OPENAI_MODEL` | `gpt-4o-mini` | Main conversation model. |
| `OPENAI_EVAL_MODEL` | _(falls back to main)_ | Cheaper model for sentiment / emotion / goal eval. |
| `RAG_ENABLED` | `true` | Toggle Chroma retrieval. |
| `RAG_EMBEDDING_MODEL` | `text-embedding-3-small` | Embedding model. |
| `RAG_CHROMA_PERSIST_DIR` | `.chroma_db` | Persistent vector store path (relative to `agent/`). |
| `GRADIO_SERVER_PORT` | `7860` | Dev console port. |
| `LOG_LEVEL` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR`. |

Simulations and personas are pure data — edit `data/simulations.json`,
`data/personas.json`, and the Markdown files under `data/documents/` to add or
tune scenarios. No code changes required.

## 📁 Project Structure

```
agent/
├── data/
│   ├── personas.json             # Persona definitions
│   ├── simulations.json          # Simulations + goals
│   └── documents/                # RAG corpus
│       ├── shared/               #   Cross-simulation knowledge
│       ├── personas/<slug>/      #   Per-persona background
│       └── simulations/<slug>/   #   Per-simulation context
├── src/careersim_agent/
│   ├── main.py                   # CLI entry (--serve gradio|api)
│   ├── config.py                 # pydantic-settings
│   ├── api/app.py                # FastAPI app factory
│   ├── graph/
│   │   ├── builder.py            # StateGraph wiring
│   │   ├── state.py              # ConversationState + goal progress
│   │   └── nodes/                # conversation, analysis, evaluation,
│   │                             # proactive, retrieval
│   ├── prompts/templates.py      # Prompt templates
│   ├── services/
│   │   ├── conversation_service.py  # Stateless orchestration layer
│   │   ├── data_loader.py           # Simulation + persona loader
│   │   ├── eval_service.py          # LLM-based evaluation
│   │   └── retrieval_service.py     # Chroma / RAG
│   └── ui/gradio_app.py          # Dev console
├── tests/
│   ├── test_api.py                   # FastAPI + statelessness contract
│   ├── test_conversation_service.py  # Service + serialisation + typing delay
│   ├── test_graph.py                 # Graph construction
│   └── test_data_consistency.py      # Persona/simulation data integrity
├── Dockerfile
├── pyproject.toml
└── .env.example
```

## 🧪 Testing

### ✅ Unit / contract tests

```bash
uv run pytest                 # Full suite
uv run pytest tests/test_api.py -v
uv run pytest tests/test_api.py::TestStatelessness -v
```

The tests mock the LangGraph runnable where appropriate, so the suite runs
without an OpenAI API key.

### 🎬 End-to-end simulation runs (`test_simulation.py`)

`test_simulation.py` is a CLI harness that drives a complete simulation
against the **running Gradio dev console** (`--serve gradio`, port 7860). It's
the fastest way to sanity-check a new persona, a tweaked goal, or an
evaluation-threshold change without clicking through the UI turn-by-turn —
and it can log the full transcript + goal progression to disk for review.

It runs in two modes:

- **Interactive** — you type the user's side, the agent replies as the
  persona, and per-turn goal progress is printed inline.
- **Auto** (`--auto`) — an OpenAI-driven "candidate" plays the user side using
  a per-simulation strategy prompt baked into the script. Useful for
  end-to-end smoke tests and for tuning new simulations.

Prerequisites:

- The Gradio dev console must be running locally
  (`python -m careersim_agent.main` or `uv run python -m careersim_agent.main`).
- `--auto` additionally needs `OPENAI_API_KEY` (from `agent/.env`).

```bash
# From agent/

# List every simulation defined in data/ (always in sync with the JSON)
uv run python test_simulation.py --list

# Interactive run — you play the user
uv run python test_simulation.py --sim behavioral-interview-brenda

# Auto-run, log full transcript + goal snapshots, also export as JSON
uv run python test_simulation.py \
  --sim recruiter-coldreach-vikram --auto --log --json
```

Useful flags:

| Flag | Default | Notes |
| --- | --- | --- |
| `--sim <slug>` | `behavioral-interview-brenda` | Simulation slug from `data/simulations.json` |
| `--auto` | _(off)_ | Use an OpenAI-driven candidate for the user side |
| `--turns <n>` | `15` | Max turns before the run is force-ended |
| `--list` | _(off)_ | Print all sims + personas and exit |
| `--quiet` | _(off)_ | Reduce per-turn verbosity |
| `--url <url>` | `http://localhost:7860` | Gradio server URL |
| `--log` | _(off)_ | Stream a full text log to `agent/logs/<slug>_<timestamp>.log` |
| `--log-dir <dir>` | `logs` | Override log directory |
| `--json` | _(off)_ | Also export the conversation as structured JSON next to the log |

The auto-runner is intentionally opinionated: each simulation has a tailored
candidate-side prompt so the user side actually pushes through persona
resistance instead of giving up at the first deflection. When you add a new
simulation, also add a matching entry to `SIMULATION_PROMPTS` in
`test_simulation.py` so `--auto` can drive it sensibly.

## 📝 Development Notes

- Both run modes share the same `ConversationService`, which is the single
  source of truth for graph invocation, (de)serialisation, and typing-delay
  computation.
- The API is a pure function of its inputs — see `TestStatelessness` in
  `tests/test_api.py` for the contract. The backend owns all persistence.
- Graph-level nodes live in `src/careersim_agent/graph/nodes/` and are
  composed in `builder.py`; the flow is intentionally linear-with-branches so
  it's easy to trace in the dev console.

---

## 📜 License

This project is licensed under the MIT License -- see the [LICENSE.md](../LICENSE.md) file for details.

## 👤 Author

Pavel Vdovenko ([reactivecake@gmail.com](mailto:reactivecake@gmail.com))
