An AI interview-preparation coach for FDM consultants heading into technology placements at investment banks.
It turns handover material written by returning consultants — PowerPoint decks, PDFs, Word write-ups — into a searchable knowledge base, then uses it for two things:
- Ask — grounded Q&A about financial concepts (trade lifecycle, order flow, settlement), what each technology role actually involves, and what previous consultants experienced on placement. Every answer cites the deck and slide it came from, and the model answers only from the uploaded material — see Why answers stop where the material stops.
- Practise — role-specific mock interviews. One question at a time, an honest score out of 10, a model answer, and a follow-up that drills into whatever you left out. Ask for a coding topic (Java, Python, SQL, algorithms) and it alternates language-concept questions with LeetCode-style exercises, reviewed for correctness, complexity and edge cases.
The app ships with a starter knowledge pack, so it is useful before anyone uploads anything.
Windows (PowerShell)
cd C:\Users\Asus\FDM_APP
.\run.ps1 -Setup # creates .venv, installs dependencies, creates .env
# add your free CF_ACCOUNT_ID and CF_API_TOKEN to .env, then:
.\run.ps1macOS / Linux
./run.sh # first run sets everything up and creates .env
# add your free CF_ACCOUNT_ID and CF_API_TOKEN to .env, then:
./run.shManual, if you prefer
python -m venv .venv
.venv/Scripts/python -m pip install -r requirements.txt # Windows
# .venv/bin/python -m pip install -r requirements.txt # macOS/Linux
cp .env.example .env # then edit it
.venv/Scripts/python -m uvicorn app.main:app --reloadOpen http://127.0.0.1:8000. Interactive API docs are at /docs.
You need one thing to get started: free Cloudflare Workers AI credentials. Create an API token with the Workers AI permission at https://dash.cloudflare.com/profile/api-tokens, and take the account id from your dashboard URL. Put them in .env as CF_API_TOKEN and CF_ACCOUNT_ID. Everything else has a working default.
The free allowance is 10,000 Neurons a day, roughly 75 grounded answers. LLM_PROVIDER=groq and LLM_PROVIDER=gemini are also free and need only a single key each; both are drop-in alternatives.
Prefer Claude? Set LLM_PROVIDER=anthropic and ANTHROPIC_API_KEY instead. All four providers sit behind one interface, so the switch touches only app/llm.py.
The app runs in two configurations from the same codebase, chosen by whether DATABASE_URL is set.
| Local (default) | Hosted | |
|---|---|---|
| Database | SQLite file, data/consultant_experience.db |
Postgres (Supabase, Singapore) |
| Original decks | data/uploads/ |
Private S3 bucket (Supabase Storage) |
| Setup needed | none | see DEPLOY.md |
Local needs no accounts and nothing leaves the machine except the questions and retrieved excerpts sent to the model provider. Back it up by copying one file.
Hosted is Supabase + Render, both free tiers, region Singapore — the closest either offers to Hong Kong (~30–40ms). Neither has a Hong Kong region, and no free managed Postgres does; if residency is ever mandated, DEPLOY.md covers the alternatives. Full walkthrough, free-tier limits and troubleshooting are in DEPLOY.md.
Free app hosts (Render, Fly, Railway) give you an ephemeral filesystem. Deploy the naive way and the app appears to work, then silently loses the entire knowledge base and every uploaded PowerPoint on the next deploy, restart or idle-timeout. That is why the hosted configuration puts the database in Postgres and the files in object storage, and why the app refuses to start if STORAGE_BACKEND=s3 is misconfigured rather than quietly falling back to a disk that gets wiped.
As shipped, anyone with the URL can read and search the knowledge base; uploading, deleting and downloading original files require ADMIN_TOKEN.
That is fine while the app holds only the starter pack. It stops being fine the moment a real client handover deck goes in, because a Render URL is guessable and not secret. Setting one environment variable closes it:
APP_PASSWORD=<something long>
The whole site then sits behind a browser login prompt — pages, API and static files alike, with /healthz left open for the platform's health probe. No code change.
Worth saying plainly once: decks describing a named bank's systems, incidents and processes are commercially sensitive and quite possibly covered by the client contract. Free tiers are appropriate for the starter pack and generic material. For real HSBC handover decks, set APP_PASSWORD at minimum, and consider hosting somewhere FDM controls with SSO in front.
python -m scripts.migrate_to_postgres --dry-run
python -m scripts.migrate_to_postgres --with-originals --with-historySafe to re-run — documents already in the target are matched on content hash and skipped.
Upload (.pptx/.pdf/.docx/.md/.txt)
│
▼
Extract text ───────── python-pptx keeps slide numbers AND speaker notes
│ pypdf keeps page numbers
▼
Chunk ──────────────── ~1100 chars, small slides packed together,
│ long sections split with overlap
▼
Embed ──────────────── Voyage / fastembed / built-in hashing
│
▼
Store ──────────────── SQLite locally / Postgres hosted
│ (originals to disk or an S3 bucket)
│
▼
Retrieve ───────────── BM25 keyword + vector similarity
│ fused with Reciprocal Rank Fusion
▼
LLM (see below) ────── streamed answer with inline [1][2] citations
Why hybrid retrieval. BM25 alone nails the jargon (T+2, FIX 35=D, PnL break) but misses paraphrases. Vectors alone catch "what happens after a trade is agreed" but drift on exact identifiers. Fusing the two rankings handles both, and it means the app still retrieves sensibly when no embedding model is installed.
Citations are structural, not decorative. Retrieved chunks are numbered in the prompt and carry their source deck, slide number, client, department and role. Every factual claim about a placement must carry the citation of the excerpt it came from.
An early version told the model its knowledge came from two places — the decks and its own background — and asked it to flag the difference. It did not hold. Asked what tech stack a business analyst placement used, it produced a tidy table headed "Tools (from the decks)" listing Jira, Confluence, Visio and Figma, then went on to suggest React, Angular, Spring Boot, Camunda and Pega. The deck named none of them. Everything in it was plausible, which is exactly the problem: a consultant cannot tell invented detail from lived experience, and would repeat it in an interview.
Ask now treats the excerpts as the only source, under three rules:
- No technology, product, vendor or tool may be named unless it appears in an excerpt. Not Jira, not React, however certain the model is that a bank of this kind uses one.
- No invented structure. No "a typical day" broken into morning and afternoon, no schedule of meetings, unless an excerpt describes one. A plausible timetable assembled from nothing is a fabrication even when every activity in it sounds reasonable.
- No labelling its own knowledge as the decks'. A heading or table column that says "from the decks" may contain only cited material.
When the excerpts do not cover the question, the answer says so and stops. General background is allowed at most two sentences, at the very end, under a line beginning Beyond the material: — never woven into the body, never in a table, never cited.
Be clear about what this does and does not guarantee. The rules live in the system prompt, and tests pin them there so they cannot be quietly dropped or reworded away — but they constrain the model rather than the output. A model can still disobey a prompt. If you see a fabricated tool name, that is a bug worth reporting, not the design working as intended.
app/
main.py FastAPI app, startup, /api/status
config.py settings from .env
db.py schema + connections; speaks SQLite or Postgres
storage.py original files: local disk, S3-compatible, or none
llm.py Claude client: streaming + schema-validated JSON
prompts.py system prompts, role briefs, output schemas
security.py admin-token gate for writes
ingest/
extract.py pptx / pdf / docx / md / txt -> text with locators
chunk.py segments -> retrieval-sized chunks
pipeline.py extract -> chunk -> embed -> store
seed.py loads the starter pack on first run
retrieval/
embeddings.py Voyage / fastembed / hashing, auto-selected
search.py BM25 + vector + RRF
routers/
documents.py upload, list, search, delete, re-index
chat.py SSE streaming chat
interview.py mock interview: start / answer / next / review
web/ the UI - plain HTML, CSS and ES modules, no build step
seed/ starter knowledge pack (markdown)
scripts/
bulk_ingest.py index a whole folder at once
migrate_to_postgres.py move a local knowledge base to the hosted one
tests/test_smoke.py runs without an API key, against SQLite or Postgres
Dockerfile production image
render.yaml Render blueprint - creates the service from this repo
DEPLOY.md step-by-step Supabase + Render setup
Through the UI: the Knowledge base tab. Pick a file, tag it with the client, department, role and placement period, enter the admin token, upload. Tagging matters — it is what lets someone filter to "Production Support at HSBC" or "Corporate Lending" specifically, and what makes mock-interview questions role-appropriate.
Roles. developer, production_support, business_analyst_tech, business_analyst_non_tech, general. The two business-analyst values matter: a technical BA gets asked to read SQL and API payloads, a non-technical one is asked about requirements, stakeholders and process instead. Plain business_analyst still exists for material tagged before the split and remains searchable.
In bulk:
.venv/Scripts/python -m scripts.bulk_ingest "C:\handovers\HSBC 2025" --client HSBC --department "Corporate Lending" --role developer
# or let the folder structure supply the metadata:
# <root>/HSBC/production_support/2025H1/deck.pptx
.venv/Scripts/python -m scripts.bulk_ingest "C:\handovers" --infer-from-path
.venv/Scripts/python -m scripts.bulk_ingest "C:\handovers" --dry-run # preview firstSupported: .pptx, .pdf, .docx, .md, .txt, up to 60 MB each.
Notes on extraction quality. PowerPoint speaker notes are indexed along with the slide body — they are often where a consultant explains what a slide actually means, and they make the knowledge base noticeably better. Scanned PDFs and image-only slides contain no extractable text and will be rejected; they need OCR first. Legacy .ppt files must be re-saved as .pptx.
Anonymising. The consultant name field is free text and optional. Initials or "2025 cohort" work fine, and the app never requires a real name.
Everything lives in .env (see .env.example).
| Setting | Default | Notes |
|---|---|---|
LLM_PROVIDER |
cloudflare |
cloudflare (free), groq (free), gemini (free, tight quota) or anthropic (paid) |
CF_ACCOUNT_ID / CF_API_TOKEN |
— | Required for the default provider; free 10,000 Neurons/day |
CF_MODEL |
@cf/openai/gpt-oss-120b |
Open-weight reasoning model on Workers AI |
GROQ_API_KEY |
— | Only when LLM_PROVIDER=groq; free key from console.groq.com |
GROQ_MODEL |
openai/gpt-oss-120b |
Open-weight reasoning model on Groq |
GOOGLE_API_KEY |
— | Only when LLM_PROVIDER=gemini |
GEMINI_MODEL |
gemini-flash-lite-latest |
Covered by the free tier |
ANTHROPIC_API_KEY |
— | Only when LLM_PROVIDER=anthropic |
ANTHROPIC_MODEL |
claude-opus-5 |
claude-sonnet-5 is cheaper and still strong here |
ANTHROPIC_EFFORT |
high |
medium or low cuts latency and cost noticeably |
ANTHROPIC_THINKING_DISPLAY |
summarized |
Shows a reasoning summary in the UI; omitted hides it |
ANTHROPIC_SERVER_FALLBACKS |
true |
Server-side refusal fallback. Set false on Bedrock/Vertex/Foundry |
EMBEDDING_PROVIDER |
auto |
voyage, fastembed, hashing, or auto |
ADMIN_TOKEN |
change-me-… |
Required to upload or delete. Change it. |
APP_PASSWORD |
(empty) | Set it to put the whole site behind a login prompt |
DATABASE_URL |
(empty) | Empty = SQLite. A postgresql:// URL = hosted Postgres |
STORAGE_BACKEND |
local |
local, s3 or none. Must be s3 when hosted |
RETRIEVAL_TOP_K |
8 |
Excerpts passed to the model per question |
CHUNK_TARGET_CHARS |
1100 |
Bigger = more context per excerpt, fewer excerpts |
DATA_DIR |
./data |
Where the database and uploads live |
Generate an admin token with:
python -c "import secrets; print(secrets.token_urlsafe(32))"The app runs out of the box with hashing — deterministic feature hashing in pure numpy, no install, no network. It is weaker on synonyms than a real model, which is exactly why retrieval always fuses it with BM25. Fine for a pilot.
For better retrieval, pick one:
# Local — nothing leaves the machine, no API key, ~90 MB model downloaded once
pip install fastembed
# Hosted — best quality, generous free tier
pip install voyageai # then set VOYAGE_API_KEY in .envThen set EMBEDDING_PROVIDER and re-index — the Re-index button on the Knowledge base tab, or POST /api/documents/reindex. Vectors from a different model are ignored by search until they are regenerated, so skipping this step silently degrades retrieval to keyword-only.
On the default setup: nothing. Cloudflare Workers AI's free allowance covers the model calls, Supabase's free tier covers the database and file storage, and Render's free tier covers hosting. The embedding step runs locally and needs no key at all.
The free tier has rate limits rather than charges — a burst of simultaneous users can hit "rate limit, wait a minute" rather than a bill. Verify the current limits when you create the key.
If you switch to LLM_PROVIDER=anthropic, claude-opus-5 costs $5/$25 per million input/output tokens — roughly 5–6 US cents per question, so a 20-question practice session is about $1–2. Better at nuanced interview scoring; the trade-off is real but so is the cost.
.venv/Scripts/python -m pip install pytest httpx
.venv/Scripts/python -m pytest -qThey cover chunking, ingestion, retrieval, role filtering, storage and the HTTP API, and run against a throwaway database with the hashing embedder — no API key needed.
To run them against Postgres instead of SQLite, point DATABASE_URL at any Postgres and re-run:
docker run -d --name pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:16-alpine
DATABASE_URL=postgresql://postgres:dev@127.0.0.1:5432/postgres .venv/Scripts/python -m pytest -qFull interactive docs at /docs when the app is running.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/status |
Counts, embedding provider, whether the key is set |
GET |
/api/documents |
List indexed material (filter by role, client, department) |
POST |
/api/documents |
Upload and index (needs X-Admin-Token) |
DELETE |
/api/documents/{id} |
Remove a document and its chunks |
GET |
/api/documents/{id}/chunks |
Inspect exactly what was indexed |
GET |
/api/documents/{id}/original |
Download the deck as uploaded (admin only) |
GET |
/api/documents/search/query?q= |
Hybrid search, no LLM involved |
POST |
/api/documents/reindex |
Re-embed everything with the current model |
POST |
/api/chat/stream |
Streaming grounded chat (SSE) |
GET |
/api/chat/sessions |
Conversation history |
POST |
/api/interview/start |
Begin a mock interview |
POST |
/api/interview/answer |
Submit an answer, get a scored assessment |
POST |
/api/interview/next |
Next question, or the follow-up |
GET |
/api/interview/sessions/{id} |
Full transcript and average score |
GET |
/healthz |
Liveness probe; touches the database |
Worth knowing before anyone asks for a demo:
- No user accounts.
APP_PASSWORDgives you one shared site password andADMIN_TOKENprotects writes, but there is no per-person identity, no audit of who read what, and no way to revoke one individual. Supabase Auth is the natural next step and is not built. - No OCR. Scanned PDFs and image-only slides are rejected rather than silently indexed as empty.
- No automatic PII redaction. Whoever uploads is responsible for what is in the deck. A reasonable next step is a review queue between upload and indexing.
- No usage analytics. Nothing tracks which consultants practised or how they scored beyond what is stored locally in their own session history.
- Retrieval is in-memory. Both halves of search load into the process at boot. Fine to roughly 50k chunks — a few hundred decks, comfortably inside the Supabase free tier. Past that, move the vector half into
pgvector; DEPLOY.md says exactly what to change. - No automated backups. Neither free tier backs anything up.
pg_dumpon a schedule once the knowledge base is worth keeping.