A personal assistant that answers questions about one person's background, grounded in a markdown knowledge base they write themselves.
Built with FastAPI + LangChain / LangGraph. Deployable to Railway (or any Docker host). Talked to over HTTPS by a website chat widget.
The browser never talks to OpenAI or Anthropic. Keys stay on this service.
A static portfolio is a one-way pitch. This lets a visitor ask about experience, projects, and skills and get an answer from facts you wrote down — not something the model invented.
Browser chat widget
→ POST /api/v1/chat
→ FastAPI / ChatService
→ LangGraph agent + search_knowledge_base
→ knowledge_base/*.md, *.yaml
Full request lifecycle: ARCHITECTURE.md.
There is no vector database. The knowledge base is small enough to load in memory. Retrieval is keyword match with a safe fallback to the full corpus.
app/ API, config, graph, tools, services, observability
knowledge_base/ Markdown/YAML facts — edit these, not Python
tests/ unit, api, integration, evaluation
scripts/ local run + retrieval eval
- Incoming message is validated, rate-limited, and optionally short-circuited by regex moderation.
- If it proceeds, a LangGraph tool-calling agent runs with one tool:
search_knowledge_base. - The system prompt requires the tool to be called before any factual claim, forbids invented history, and keeps replies as plain text.
- Conversation history (last 12 turns) is stored in SQLite and passed into the next turn.
When ZIZKADB_HOST or ZIZKADB_API_KEY is set, each chat turn is also traced in ZizkaDB: user message, routing decision, LangGraph/LLM/tool steps, and the final reply, all under the same sessionId. Tracing is optional and never fails the chat.
Files in knowledge_base/:
| File | Contents |
|---|---|
profile.md |
Name, title, contact, links, summary |
career_timeline.yaml |
Roles (dates marked provisional where they overlap) |
education.md |
Degree |
skills.md |
Technical skills |
projects.md |
Shipped work and links |
hobbies_and_misc.md |
Intentionally empty — the agent must say it does not know |
To update facts: edit a file and redeploy. No code change.
To add a document: drop another .md or .yaml into knowledge_base/. The loader picks it up automatically.
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env # set OPENAI_API_KEY or ANTHROPIC_API_KEY
uvicorn app.main:app --reloadOr bash scripts/run_local.sh.
curl http://localhost:8000/healthz
curl http://localhost:8000/readyz
curl -X POST http://localhost:8000/api/v1/chat \
-H "Content-Type: application/json" \
-d '{"sessionId":"11111111-1111-1111-1111-111111111111","message":"What is Saad'\''s experience?"}'OpenAPI: http://localhost:8000/docs
pytest
ruff check .
mypy app
python scripts/eval.pyEvaluation cases in tests/evaluation/ check that retrieval returns the right facts (profile, experience, React Native, projects, education, links, unknown hobbies). CI does not call a live LLM.
| Endpoint | Purpose |
|---|---|
GET /healthz |
Liveness |
GET /readyz |
Knowledge loaded + LLM key present |
POST /api/v1/chat |
{ sessionId, message } → { reply, sessionId, messageId, createdAt, simulated } |
GET /api/v1/chat/history?sessionId= |
Recent messages |
Errors are always {"error": "..."}.
docker compose up --buildRailway: connect this repo, mount a volume at /app/data, set env vars from ENVIRONMENT.md. Health check is /healthz (railway.json). Full steps, CORS, and the website widget URL: DEPLOYMENT.md.
Minimum production vars: an LLM key, CORS_ALLOWED_ORIGINS (your site, including www if needed), ENVIRONMENT=production.
Point the chat widget at this service:
VITE_CHAT_API_BASE=http://localhost:8000/api/v1/chat
or the Railway URL in production. The widget does not import LangChain.
- Write a function in
app/tools/. - Register it next to
search_knowledge_baseinapp/graph/agent.py. - Add a unit test.
The API contract does not change.
See SECURITY.md. Summary: moderation before the LLM, rate limits, daily budget, size/timeouts, locked CORS, redacted logs, no stack traces to the client.
| Symptom | Likely cause |
|---|---|
/readyz is degraded |
No LLM key, or knowledge_base/ empty |
| CORS error in the browser | Origin missing from CORS_ALLOWED_ORIGINS |
| History gone after redeploy | No volume at /app/data |
| Canned fallback reply | Daily budget exhausted or graph timeout |