This document is the binding contract for all implementation agents. It maps 1:1 to
technical_interviewer_software_specification.pdf. Where the spec says "recommended", this
contract makes the concrete choice. Do not deviate without recording the deviation.
- Python: 3.10+ in a virtualenv (see README for setup; CI runs 3.10). Every Python process must set env
USE_TF=0before importing transformers/sentence_transformers (guards against machines with a broken TensorFlow install). Set it defensively at the top of entrypoints viaos.environ.setdefault("USE_TF", "0")before any ML import. - Node v20+ and npm available on PATH.
- Local wiki (optional): a directory of markdown notes at
wiki/in the repo root, or wherever envTI_WIKI_DIRpoints. Development used a personal AI/ML/DS knowledge base (concepts/*.md~204 files,sources/*.md,index.md): markdown with a**Summary**:line, Obsidian[[wiki-links]],## In the sources,## Related pagessections. The app must boot and run fully without it (retriever reports unloaded; wiki grounding is skipped). - Claude Code CLI v2.1.195 on PATH as
claude(headless:claude -p "<prompt>" --output-format json). - No ANTHROPIC_API_KEY / OPENAI_API_KEY currently set. No
codexCLI. The spec's "Codex agents" are implemented as agent roles running on the LLM provider chain (below). - Installed py packages: fastapi, uvicorn, websockets(12), sqlalchemy, aiosqlite, pydantic v2, sentence-transformers, faiss-cpu, torch(cuda), pypdf, anthropic, cryptography, bs4, httpx, pytest, pytest-asyncio, jinja2, python-multipart.
- Backend port: 8011. Vite dev port: 5173 (proxy
/apiand/ws→ 8011).
Technical_Interviewer/
DESIGN.md (this file)
README.md [owner: integrator]
scripts/
index_wiki.py [B] build FAISS index from wiki
run_qa.py [B] run Codex QA Agent, print spec-format QA report
start.ps1 [integrator] one-command run (backend serves built frontend)
dev.ps1 [integrator]
backend/
requirements.txt [pinned already]
pytest.ini [D]
app/
__init__.py [A]
config.py [pinned already — do not rewrite, extend only if essential]
schemas.py [pinned already — do not rewrite; single source of truth]
main.py [A] FastAPI app: REST routers + WS route + static serving of ../frontend/dist if exists
database.py [A] SQLAlchemy engine/session (sqlite: backend/data/app.db)
models.py [A] ORM models
security/crypto.py [A] Fernet encrypt/decrypt; key auto-generated at backend/data/secret.key
api/
__init__.py routes_users.py routes_sessions.py routes_reports.py
routes_privacy.py routes_misc.py [A]
ws/interview_ws.py [A] WebSocket endpoint /ws/interview/{session_id}
core/
orchestrator.py [A] interview state machine
question_selector.py [A] bank filtering, no-dup selection
hints.py [A] hint policy + 5 levels + penalty
scoring.py [A] weighted formula + role weights (calls llm/scorer for raw metrics)
report_generator.py [A] final report (all spec §8 fields), recoverable
transcript.py [A] autosaving transcript store (encrypted at rest)
parsing.py [A] resume (pdf via pypdf + txt) & job description parsing → topics/skills
llm/
provider.py [B] provider chain: AnthropicAPI → ClaudeCLI → Offline
interviewer.py [B] greeting/question phrasing/followup/checkin/closing generation
scorer.py [B] evaluate_answer() → MetricScores (LLM w/ JSON schema, heuristic fallback)
rag/
indexer.py [B] chunking + embedding + FAISS build
retriever.py [B] WikiRetriever.search()
agents/
research_agent.py [B] internet research (untrusted-content hardened), source logging, bank update
planning_agent.py [B] interview plan builder (spec §6.2 output shape)
qa_agent.py [B] runs tests/latency/scoring-consistency/security checks → spec §12.3 report
data/
question_bank.json [B] seed bank (≥120 questions, schema below)
wiki_index/ (generated)
tests/
unit/ ai_logic/ integration/ [D]
conftest.py fixtures/ [D]
frontend/ [C] Vite + React 18 + TS + Tailwind + Zustand
src/pages: SetupPage InterviewPage ReportPage SessionsPage
src/components: Avatar TranscriptPanel Timer SectionIndicator ScoreDashboard HintButton
VideoPreview Controls SetupForm
src/lib: api.ts ws.ts speech.ts store.ts types.ts
src/__tests__/ (Vitest + @testing-library/react, jsdom)
Owners: A = backend-core agent, B = ai-rag-agents agent, C = frontend agent, D = test agent (runs after A+B). Never write outside your owned files.
- roles:
"Data Scientist" | "Algorithm Researcher" | "AI Engineer" - modes:
"Quick Practice" | "Standard" | "Deep Research"(10–20 / 45–60 / 60–90 min). These are the wire/DB values; the UI displays "Deep Research" as "Deep Dive" viaMODE_LABELS/modeLabel(frontend/src/lib/types.ts). Do not rename the enum value. - difficulty:
"Junior" | "Mid-level" | "Senior" | "Research-level" | "Staff/Lead-level" - hint_policy:
"none" | "on_request" | "adaptive" - interviewer_style:
"Friendly" | "Strict" | "Research professor" | "Startup CTO" | "Big-tech interviewer" - session status:
"created" | "planning" | "ready" | "active" | "paused" | "completed" | "cancelled" - question source:
"local_wiki" | "internet" | "generated" | "seed" - languages:
"en" | "he"(+ free string). UI offers English and Hebrew (LANGUAGESin frontend/src/lib/types.ts). Hebrew runs the interview room right-to-left, useshe-ILWeb-Speech recognition, and synthesizes via the browser's Hebrew voice (the Kokoro sidecar is English-only, so non-enlines skip it). Interviewer/scorer text is produced in Hebrew by the LLM (_lang_directive); fully-offline Hebrew uses Hebrew fallback templates for the persona lines, but bank question bodies stay English offline (translation needs the LLM).
Track topics (spec §5) — use these exact topic lists for bank/plan/filtering:
- Data Scientist: Python, SQL, Statistics, Probability, A/B testing, Experiment design, Feature engineering, Supervised learning, Unsupervised learning, Model evaluation, Business case reasoning, Data cleaning, Communication of insights
- Algorithm Researcher: Algorithms, Data structures, Complexity analysis, Probability, Optimization, Graph algorithms, Dynamic programming, Mathematical proofs, Research paper understanding, Experimental design, Benchmarking
- AI Engineer: Deep learning, Transformers, LLMs, RAG, Embeddings, Fine-tuning, Evaluation, Agents, MLOps, Model serving, Latency optimization, Distributed training, GPU memory, Safety and monitoring
GET /api/health→{status:"ok", version, llm_provider, wiki_index_loaded}POST /api/users{name, target_roles[]}→ UserOutGET /api/users/{user_id}→ UserOutGET /api/users/{user_id}/sessions→ SessionOut[]POST /api/sessionsSessionCreate (schemas.py) → SessionOut (status becomesreadyafter synchronous planning; planning = research agent (if allow_internet) + planning agent + RAG. Must complete < 30s; internet research has an 8s overall cap and falls back gracefully.)GET /api/sessions/{id}→ SessionOut (includes plan)GET /api/sessions/{id}/transcript→ TranscriptOutGET /api/sessions/{id}/report→ ReportOut (404 w/ detail "not ready" if absent;POST /api/sessions/{id}/report/regeneraterecovers a failed generation)GET /api/sessions/{id}/sources→ SourceCitationOut[]DELETE /api/sessions/{id}(full delete) ;DELETE /api/sessions/{id}/transcript;DELETE /api/sessions/{id}/recordingGET /api/question-bank?role=&difficulty=&topic=→ QuestionBankItem[]POST /api/rag/search{query, k=5}→{results:[{text, source, score}]}POST /api/qa/run→{report: str, passed: bool}(runs qa_agent; may take ~min)
All messages JSON {type, ...}. Server drives the interview; client renders + speaks.
Client → Server:
{"type":"start"}— begin/resume delivery{"type":"answer","text":str,"duration_seconds":float,"input_mode":"voice"|"text"}{"type":"partial_transcript","text":str}— live partial (persist last partial as safety){"type":"hint_request"}{"type":"silence","seconds":float}— client-detected silence (send at ≥12s stuck){"type":"interrupt"}— user began speaking over TTS{"type":"pause"}/{"type":"resume"}/{"type":"skip"}/{"type":"end"}{"type":"more_time_response","wants_more_time":bool}
Server → Client:
{"type":"interviewer","kind":"greeting"|"question"|"followup"|"checkin"|"ack"|"closing", "text":str,"section":str,"question_id":str|null,"question_index":int,"total_questions":int}{"type":"hint","level":1-5,"text":str,"question_id":str,"hints_used":int}{"type":"score","question_id":str,"scores":{correctness,depth,clarity,structure,practicality, mathematical_rigor,tradeoff_awareness,communication},"overall":float,"feedback":str}{"type":"section_change","section":str,"section_index":int,"total_sections":int}{"type":"state","status":"active"|"paused"|"completed","elapsed_seconds":float, "remaining_seconds":float}{"type":"report_ready","session_id":str}{"type":"error","message":str}
Flow (spec §6.3): greeting → background question → per plan section: technical questions →
answer → (optional followup, max 1 per question, decided by llm/interviewer.decide_followup) →
score each major answer (background/behavioral answers get scored too but flagged
is_behavioral) → adaptive hint offer if silence and policy allows → closing → report
generated in background task → report_ready.
User(id uuid str pk, name, target_roles json, created_at)InterviewSession(id, user_id fk, role, mode, difficulty, duration_minutes, language, hint_policy, interviewer_style, use_resume, use_job_description, use_wiki, allow_internet, record_session, disable_cloud_ai, resume_text_enc, job_description_enc, plan json, status, overall_score float|null, current_section_idx, current_question_idx, elapsed_seconds float, created_at, completed_at|null)Question(id, session_id fk, topic, difficulty, question_text, source, expected_points json, section, order_idx, is_behavioral bool, asked_at|null)Answer(id, question_id fk, transcript_enc, duration_seconds, hints_used int, created_at)Score(id, answer_id fk, correctness, depth, clarity, structure, practicality, mathematical_rigor, tradeoff_awareness, communication # all int 1-5 , overall float, feedback text)TranscriptEntry(id, session_id fk, ts, speaker "interviewer"|"candidate"|"system", text_enc)SourceCitation(id, session_id fk|null, url, title, quality "high"|"medium"|"rejected", fetched_at, notes)Report(id, session_id fk unique, content_enc json-str, created_at, generation_failed bool)
*_enc columns hold Fernet-encrypted text (security/crypto.py). Transcript autosaves on every
message. Report content JSON = ReportOut in schemas.py.
8 metrics, each int 1–5. Weighted overall (0–5, round 2dp). Weights by role (sum = 1.0):
| metric | base/DS-adj | Data Scientist | Algorithm Researcher | AI Engineer |
|---|---|---|---|---|
| correctness | 0.30 | 0.30 | 0.25 | 0.25 |
| depth | 0.20 | 0.15 | 0.20 | 0.15 |
| clarity | 0.15 | 0.10 | 0.10 | 0.10 |
| structure | 0.10 | 0.10 | 0.10 | 0.10 |
| practicality | 0.10 | 0.15 | 0.05 | 0.15 |
| mathematical_rigor | 0.00 | 0.10 | 0.15 | 0.00 |
| tradeoff_awareness | 0.10 | 0.05 | 0.10 | 0.20 |
| communication | 0.05 | 0.05 | 0.05 | 0.05 |
(The "base" column is the spec's suggested formula and is used for unknown roles.)
Hint penalty: overall = max(1.0, overall - 0.15 * hints_used) applied by core/scoring.py.
core.scoring.compute_overall(metrics: MetricScores, role: str, hints_used: int) -> float is pure
and unit-tested. Raw metrics come from llm/scorer.evaluate_answer(...).
# app/rag/retriever.py [B]
class WikiRetriever:
def __init__(self, index_dir: str = settings.wiki_index_dir): ...
@property
def loaded(self) -> bool: ...
def search(self, query: str, k: int = 5) -> list[RagResult] # schemas.RagResult
def get_retriever() -> WikiRetriever # cached singleton; returns unloaded-but-safe object if no index
# app/llm/provider.py [B]
class LLMProvider: # chain facade
name: str
def complete_text(self, system: str, prompt: str, max_tokens: int = 800, timeout: float = 20.0) -> str
def complete_json(self, system: str, prompt: str, schema_model: type[BaseModel],
timeout: float = 30.0) -> BaseModel # validated; falls down chain on failure
def get_provider(disable_cloud_ai: bool = False, fast_only: bool = False) -> LLMProvider
# Chain: AnthropicAPI (if key & not disabled) → ClaudeCLI (local, allowed even when cloud disabled;
# it is the configured local agent runtime) → Offline (always succeeds, deterministic).
# NOTE: ClaudeCLI is skipped when env TI_DISABLE_CLAUDE_CLI=1 (tests set this for speed/determinism).
# DECIDED DEVIATION: fast_only=True drops ClaudeCLI from the chain (10-20s per headless call).
# The live WS loop and synchronous session planning use fast_only=True to meet their 2.5s/30s
# budgets; only background report generation uses the full chain including the CLI.
# app/llm/scorer.py [B]
def evaluate_answer(question: QuestionBankItem | QuestionOut, transcript: str, role: str,
context_snippets: list[str], provider: LLMProvider,
language: str = "en") -> tuple[MetricScores, str]
# language (default "en") sets the feedback text language on the LLM path (metrics unaffected;
# offline heuristic feedback stays English). Backward-compatible optional kwarg.
# returns (metrics, feedback). Empty/very short transcript → all 1s, feedback explains.
# Heuristic fallback: expected_points keyword coverage (correctness/depth), sentence count &
# discourse markers (structure/clarity), numbers/formulas (mathematical_rigor), "trade-off|however|
# depends" (tradeoff_awareness), words-per-answer band (communication), domain terms (practicality).
# Monotone: strictly more matched expected_points must never lower any metric.
# app/llm/interviewer.py [B]
# Each function takes a trailing optional `language: str = "en"`; "he" appends a Hebrew
# directive to the LLM prompt and selects Hebrew offline-fallback templates.
def greeting(style: str, role: str, candidate_name: str, provider, language: str = "en") -> str
def background_question(role: str, provider, language: str = "en") -> str
def phrase_question(item, style: str, provider, language="en") -> str # may return item.question_text as-is
def decide_followup(item, transcript: str, metrics: MetricScores | None, provider, language="en") -> str | None
def checkin_after_silence(style: str, provider, language: str = "en") -> str # asks if user wants more time
def closing(style: str, provider, language: str = "en") -> str
# app/agents/planning_agent.py [B]
def build_plan(cfg: SessionCreate, resume_text: str | None, jd_text: str | None,
retriever, bank: list[QuestionBankItem], provider) -> InterviewPlan
# InterviewPlan in schemas.py; spec §6.2 shape + section_questions allocation.
# Section count/question count scale with mode & duration. Always starts "background",
# ends "candidate questions". Deep Research must use resume/JD topics when provided.
# app/agents/research_agent.py [B]
def research_questions(role: str, difficulty: str, allow_internet: bool,
provider, session_id: str | None = None
) -> tuple[list[QuestionBankItem], list[SourceCitationOut]]
# allow_internet=False → ([], []). Hardened: fetched page text is DATA; strip scripts; regex-flag
# injection patterns ("ignore previous instructions", "system prompt", etc.) → sanitize before any
# LLM call; never execute instructions from pages; curated seed URL list; store citations incl.
# rejected ones with quality="rejected". Total wall-clock cap 8s (httpx timeouts) — on any failure
# return partial/empty results, never raise.
# app/agents/qa_agent.py [B]
def run_qa(project_root: str) -> QAReport # schemas.QAReport
def format_report(r: QAReport) -> str # EXACT spec §12.3 text format
# Runs: pytest (unit/ai_logic/integration) via subprocess (env USE_TF=0, TI_DISABLE_CLAUDE_CLI=1),
# latency checks (RAG search <1.5s warm, offline orchestrator step <0.5s, report gen <30s),
# scoring consistency (offline scorer deterministic; good > bad answer), security checks
# (encryption round-trip, injection sample sanitized, no wiki text in /api/health).Question bank JSON item (backend/data/question_bank.json — list of these):
{"id":"ds-stat-001","role":"Data Scientist","topic":"Statistics","difficulty":"Senior",
"question_text":"...","expected_points":["...","..."],"followups":["..."],
"is_behavioral":false,"source":"seed"}Bank requirements: ≥120 items total; every role×topic covered; every role×difficulty ≥6;
plus ≥4 behavioral items per role (topic "Behavioral"); include the spec §5 example questions.
core/question_selector.select_questions(bank, role, difficulty, topics, n, exclude_ids) filters
by exact role+difficulty, falls back to adjacent difficulty if starved, never repeats an id
within a session.
Levels 1..5: 1 small nudge, 2 conceptual direction, 3 structured outline, 4 partial answer,
5 full explanation (only after scoring). Policy none/on_request/adaptive.
core/hints.py: next_hint(question, hints_used, provider) -> (level, text); hint text via
provider with offline fallback built from expected_points (level1: first point as nudge-question;
level3: outline = bulleted expected_points; level4: half the points expanded). Each hint
increments Answer.hints_used → scoring penalty (§6). Adaptive: orchestrator offers hint after
silence message or after a scored answer with correctness ≤ 2.
overall_score (0-100 int = mean overall of scored answers × 20), role_readiness (0-100 with difficulty modifier: Junior ×1.0 … Staff ×0.9 documented in code), topic_scores{topic: 0-5}, best_answers[{question, score, why}], weakest_answers[...], missing_concepts[] (expected_points not covered + RAG-suggested wiki concepts), communication_feedback str, technical_feedback str, suggested_study_plan[≥3 items], recommended_next_interview{role, mode, difficulty, focus_topics}, questions_asked[], transcript_summary str, hints_used_total int, time_per_question[{question_id, seconds}]. Text sections via provider (offline fallback composes from scores/topics). Generation must be recoverable: on failure Report.generation_failed=True and regenerate endpoint.
- SetupPage: full form — role, mode, difficulty (duration is derived from the mode; the manual duration slider was removed as confusing), language, hint policy, interviewer style, toggles (resume upload .pdf/.txt + JD textarea, use wiki, allow internet research, record session, disable cloud AI), name. Validation with clear errors (required role/mode/difficulty; duration in mode range). POST /api/users + /api/sessions → navigate /interview/:id.
- InterviewPage: video-call layout. Left: avatar panel (large) + candidate
getUserMediapreview (bottom-right thumbnail, mirrored). Right: live transcript panel (auto-scroll, speaker-labeled, partial line italic). Top bar: timer (mm:ss elapsed / remaining), current section indicator, status. Bottom controls: mic toggle, Hint button (hidden when policy none; shows hints_used), Skip, Pause/Resume, End Interview (confirm dialog), text-input fallback box (always available). Camera/mic permission denied → visible fallback states (camera: initials tile; mic: text mode banner). Score toast/badge after each scored answer. Silence detection: no final result & no typing for 12s while a question is open → sendsilence. - speech.ts: webkitSpeechRecognition wrapper (continuous, interimResults →
partial_transcript, final → answer submit on stop-of-speech OR explicit Send). speechSynthesis for interviewer lines;onboundary/word events drive avatar mouth; starting recognition while speaking sendsinterrupt+speechSynthesis.cancel(). - Avatar.tsx: pure SVG synthetic character (obviously non-photoreal but professional): head,
shoulders, eyes (blink every 3–6s random), animated mouth (4 viseme shapes cycling while
speaking), subtle idle sway; 5 style variants change palette/accessory (Friendly warm,
Strict navy+glasses, Research professor beard+blazer, Startup CTO hoodie, Big-tech badge).
Props:
{style, speaking: boolean, name}. - ReportPage: renders every ReportOut field; radar/bar of topic_scores (pure SVG, no chart lib); study plan checklist; "Start recommended interview" button prefills setup.
- SessionsPage: session list w/ status/score, links to report, privacy actions (delete session / transcript / recording) with confirmation.
- store.ts (Zustand): session config, ws state, transcript entries, scores, timer, hints.
- Tests (Vitest+RTL, jsdom): setup validation, role/mode selection, timer render, transcript display, score dashboard, report rendering, camera-denied, mic-denied, pause/resume, end flow. Mock WebSocket/speech/media APIs in test setup.
- Latency: no LLM call may block > 20s (provider timeout → fallback down chain). Offline path responds instantly. UI shows "thinking" indicator during waits. Partial transcripts are client-side (<500ms by construction). Report < 30s (offline fallback guarantees). DECIDED DEVIATION from spec §7.3: the 2.5s interviewer-response and 1.5s final-transcript targets are met by construction on the fast chain (offline instant; final transcript is client-side) but are not hard-enforced when the Anthropic API is slow — the 20s provider timeout with a visible thinking indicator is the enforced bound.
- Security: Fernet encryption at rest for resume/JD/transcript/report; wiki never exposed except via explicit /api/rag/search; recordings only when record_session (MVP: recording = storing transcript audio flag; no actual A/V storage — document this); full deletion endpoints; all internet sources logged as SourceCitation; synthetic avatar only, no real-person likeness.
- Reliability: WS reconnect (client retries 3× with backoff, server resumes from session state); transcript autosave per message; report regenerate endpoint.
- Privacy toggles honored server-side: allow_internet=False → research agent never fetches; disable_cloud_ai=True → Anthropic API skipped (CLI+offline only).
Backend pytest layout: tests/unit/test_users.py test_sessions.py test_question_selection.py test_difficulty_filter.py test_hints.py test_scoring.py test_report.py test_transcript.py test_parsing.py test_rag.py test_citations.py ; tests/ai_logic/test_no_duplicates.py test_followup_relevance.py test_score_schema.py test_empty_answer.py test_score_quality.py test_hint_penalty.py test_role_rubric.py test_injection.py ; tests/integration/test_full_flow.py (TestClient: create user+session offline (use_wiki fixture index, allow_internet False, TI_DISABLE_CLAUDE_CLI=1) → WS: start→greeting→background→answer→question→answer→followup→score→ end→report ready with all fields). RAG tests use a tiny fixture index built from 3 markdown files in tests/fixtures/mini_wiki (build in conftest with the real indexer, k=2 — keep runtime sane by scoping sentence-transformers model load to session fixture). All tests must pass with no network and no API key.
- TTS: HeadTTS sidecar (
voice/headtts, pinned c08f4ca, MIT) running Kokoro-82M-v1.0-ONNX-timestamped (Apache-2.0) on webgpu/fp32 — full-precision audio with no quantization artifacts, measured steady-state0.7–0.9s per sentence (RTF≈0.1) on this GPU. (Verified 2026-07-03: fp16/q4f16 emit silent audio on this GPU, q8 crashes onnxruntime's webgpu DequantizeLinear, CPU fp32 is unusable (RTF4); q4 works but sounds audibly robotic — fp32/webgpu is the only full-quality working combination.) Port 8012, REST+WS, provisioned byscripts/setup_voice.ps1, launched+warmed by start/dev scripts. Same-origin proxyPOST /api/voice/ttsmirrors/v1/synthesize:{input, voice, language, speed, audioEncoding}→{audio: b64 wav 24kHz, words/wtimes/wdurations, visemes/vtimes/vdurations (Oculus), phonemes}./api/health.voice_engine=headtts|unavailable(0.5s probe, 10s cache). - Frontend
src/lib/voice.tsVoiceEngine: sentence-chunked pipeline (short first chunk, 1-ahead prefetch, gapless AudioContext scheduling, utterance FIFO), rAF word/viseme timeline,interrupt()aborts fetches+sources, health-gated transparent fallback to speechSynthesis,STYLE_VOICESstyle→(voice,speed) map, sink for the 3D head. - Avatar — the interview always renders the real-time 3D talking head
(
src/components/TalkingHeadAvatar.tsx:@met4citizen/talkinghead(MIT) + three, lazy-loaded; raw HeadTTS payloads viaspeakAudio; two photo-realistic GLBs — Avaturn + Avatar SDK, gender-matched to voices;frontend/public/avatars/LICENSES.md). Built-in idle blink/sway/mood; mouth animation is viseme-driven from the TTS word/viseme timeline (standard 3D morph-target animation). Fallback: the SVGAvatar.tsxwhen WebGL is unavailable or the 3D stack fails to load. The interview begins behind a "Start interview" gesture (InterviewPage) that unlocks audio autoplay and requests the microphone (on by default) before connecting the WS. A photorealistic still character portrait (frontend/public/interviewers/, one fixed face per interviewer style vialib/characters.ts, gender always matching the style's voice) is shown on the start gate and loading screens. - Character generation:
scripts/tmp Imagen-4 pipeline (Gemini key from Secret Managergemini-api-key); 12 characters (3 roles × 2F/2M), ~60KB JPEG each. - Measured: warm idle first-audio ~450ms; ~1.8–2.7s under concurrent LLM CPU load; mouth-animation offset ≈ 0ms by construction (viseme timeline ships with the audio).
GET /api/users/{user_id}/progress (ProgressOut in schemas.py): completed sessions
chronological, readiness_trend (0-100 per session from reports), topic_trends
(topic → per-session 0-5 from Score.overall via Answer→Question), current_weak_topics
(last-2-session avg < 3.0) / current_strong_topics (≥ 4.0), and curriculum — weak topics
(session mean < 3.5) + report missing_concepts, recency-weighted 3/2/1, case-insensitive
dedupe, priority by rank tertiles (1=Now/2=Next/3=Later), capped 15, wiki_refs from the RAG
retriever when loaded. Deterministic, no LLM calls (core/progress.py). Frontend:
ProgressPage at /progress (SVG readiness line chart, topic delta rows, weak/strong chips,
curriculum checklist persisted in localStorage ti_curriculum_done, session history table).
QA Status: PASS
Critical Issues:
- ...
Missing Tests:
- ...
Latency Problems:
- ...
Security Concerns:
- ...
Recommended Fixes:
- ...
(“- none” under a heading when empty.)