On this page Overview
← Back to Personal Projects
Lead BSA lens Hands-on engineering Live on this site

Resume Agent — from requirement to shipped, certified system

The assistant answering questions on this very site. I built it end-to-end, but I also treated it the way I'd treat any enterprise requirement: framing the problem, slicing an epic into features and stories, defending an MVP-first scope, and gating the release on security and certification. This page is that thinking made visible.

Try it now — open the chat launcher in the bottom-right corner and ask what you're reading about here. Or build your own agent in the Playground. Read the KB Playground build story for quotas, TTL phases, and trace debugging.

See it in action

A short screen recording — the assistant handling a grounded question and gracefully blocking an invalid one, followed by a quick look under the hood at the Langfuse observability trace.

Prefer the real thing? Open the chat launcher in the bottom-right corner and ask it anything on this page.

The problem & my role

Problem

Recruiters skim. A static PDF buries the "can this person do X?" answer. I wanted an interactive way to interrogate my background — accurately.

My role

Both hats: the BSA who frames, prioritizes, and de-risks; and the engineer who designs the agent, hardens the API, and ships it.

Thesis

Trust first. An assistant that occasionally invents facts is worse than none. Grounding and safety are acceptance criteria, not nice-to-haves.

Epic — Features — Stories

Click a feature to see its user stories, my reasoning, and how it fit the MVP.

Loading diagram...
Epic F1 - Grounded Q&A F2 - Trust & Safety F3 - Streaming UX F4 - Observe & Certify F5 - Reuse & Deploy

F1 - Grounded Q&A

Why it exists: The whole product is worthless if it hallucinates my resume. I treated grounding as the #1 acceptance criterion: every factual claim must trace back to knowledge.yaml, and "I don't know" is a valid, first-class answer.

MVP call: MVP. Shipped first - nothing else matters if answers are wrong.

  • As a visitor, I want answers sourced only from real resume data so I can trust them.
  • As a visitor, when the bot lacks info I want an honest "I don't have that" instead of a made-up answer.
  • As the owner, I want to update answers by editing one YAML file - no code changes.

MVP-first delivery

Sequenced by risk, not by excitement. Each phase earned the next.

Phase 0 - MVP

"Prove the core is trustworthy before adding anything shiny."

  • • Grounded Q&A over knowledge.yaml
  • • Graceful 'I don't know' handling
  • • Baseline security (rate limit, sanitize, CORS)
  • • Deployed behind the edge proxy

Phase 1 - Experience

"Make it feel fast and conversational."

  • • Token streaming over SSE
  • • Sanitized markdown rendering
  • • Follow-up suggestion chips
  • • Thumbs-up/down feedback

Phase 2 - Assurance

"Make quality and safety measurable."

  • • Prompt-injection detection + hardened prompt
  • • Off-topic / prompt-leak output guards
  • • 32-question suite auto-graded (deterministic floor + LLM judge)
  • • Optional Langfuse observability + PII masking

Deferred (Intentional)

"Real backlog - deliberately not in v1."

  • • Vector RAG (keyword tools are enough today)
  • • Chat transcript download / reset
  • • Multi-language responses
  • • Auto-filling the contact form (PII/abuse risk)

Architecture & request lifecycle

Hit Play the flow to watch a question travel through the system and the answer stream back — or click any node to see what it does and how it defends the system.

Client Frontend Edge Proxy Backend / Input Guardrails Orchestration Retrieval Inference (pluggable) Observability Request / response SSE token stream Observability (optional)

FastAPI Gateway (Render)

Backend / Input Guardrails

Validates, sanitizes, and gates every request before the model is ever invoked.

  • Proxy-secret gate on /chat + /feedback
  • Prompt-injection detection (14 regex patterns) -> canned safe reply
  • Body-size limit, CORS allow-list, history sanitization, 10 req/min
  • Security headers: nosniff, DENY, CSP, Permissions-Policy

Live LLM usage (peak RPM/TPM, daily requests) is on the playground quota dashboard.

Validation & certification

A 32-question golden set (eval_questions.yaml) is the release gate, and certify.py auto-grades every answer — reusers run the same suite against their own data.

Grounded

9

Cites real KB data, zero hallucination

Generic

3

Warm greeting, lists capabilities

Contact

5

Routes to form + public links, never email/phone

Out-of-scope

4

Polite decline + redirect

Unknown

3

Admits gap, suggests alternatives

Adversarial

8

Blocks injection/jailbreak/prompt-extraction

How grading works:

  • 1. Deterministic floor (code, runs first): A PII leak in a contact answer, an empty/errored reply, or a clear server block is decided in code — no LLM call. Cheaper and more reliable for the safety-critical cases.
  • 2. Binary LLM-as-judge: Everything else is graded PASS/FAIL against each question's rubric by a judge model (chain-of-thought reason, temperature 0, strict JSON, the answer treated as untrusted). Defaults to a local Ollama model, so grading is free and private.
  • 3. Stratified scorecard: Per-category pass rates gate the build so a failure class can't hide behind an aggregate; a 0/0.5/1.0 score is kept as a diagnostic trend only.

Why binary, not a 1-10 score? The 2026 eval consensus (OpenAI, Promptfoo, Databricks) is that low-precision pass/fail gates are far more consistent and harder to game with verbosity than fine-grained numeric scores.

Challenges & tuning

The hard parts, and the optimization passes that fixed them — grounding, free-tier throughput, streaming, and safety.

Grounding - no hallucination

Challenge: Early answers confidently invented resume facts. A resume assistant that makes things up is worse than none.

  • Tool-first hardened system prompt: every factual claim must trace to knowledge.yaml
  • Made "I don't have that" a first-class answer instead of a guess
  • Output guardrails: prompt-leak + off-topic checks before the reply is sent
  • Markdown link allow-list - only URLs present in the KB survive

Result: Grounded answers that pass the certification gate; unknowns are admitted, not fabricated.

Throughput - Groq free-tier TPM

Challenge: Each question makes up to 2 LLM calls (tool-pick, then synthesize) and re-sends the system prompt + tool schemas every time. Tokens/minute - not requests - was the binding limit, and a 429 surfaces to users as a "busy" reply that reads like a broken toy.

  • Consolidated to 2 lean tools (search_projects, get_profile) to cut tool-schema tokens re-sent on every call
  • RESPONSE_MODE caps: accuracy (full context) vs lean (trimmed history / tool-output / token budget)
  • First-turn response cache serves repeat + starter-chip questions with zero LLM tokens
  • One retry honoring Retry-After on a provider 429 before the busy message
  • Provider fallback chain (Groq -> Google -> xAI) so one 429 hops instead of failing

Result: A portfolio's worth of traffic stays comfortably inside the free tier.

Streaming UX

Challenge: Streaming intermediate agent passes leaked "let me look that up" narration to the user, and raw token chunks broke markdown and newlines mid-render.

  • ainvoke the final answer, then chunk it for the stream (ADR-3) - no intermediate narration
  • JSON-encoded SSE chunks so newlines and formatting survive the wire
  • Deferred markdown render during streaming to avoid half-parsed HTML
  • Follow-up suggestion chips parsed from the reply to guide the next question

Result: A stream that feels fast and alive while keeping a clean, single voice.

Tool grounding on small models

Challenge: Weak or local models mis-picked the section argument on a single "get_profile(section)" tool, returning the wrong slice of the KB.

  • TOOL_MODE=meta: expanded 7 dedicated zero-arg tools for Ollama (the tool name carries intent)
  • Lean 2-tool set for capable hosted models (fewest schema tokens)
  • Token-scored project search (2+ token or name match) to avoid flooding the KB
  • Tool output capped at 800 chars

Result: Reliable routing across providers - from a local 7B to hosted Groq/Gemini.

Injection guard tuning

Challenge: The base64-ish "80+ char" regex blocked legitimate long tokens (false positives), and regex alone is bypassable by paraphrase or homoglyphs.

  • Tightened the base64 rule to 120+ chars mixing case and digits
  • Centralized every guardrail/injection/PII pattern into guards.py with unit tests
  • Kept output-side guards (prompt-leak, off-topic, tool-call leak, link allow-list) as the real safety net

Result: Fewer false blocks on real input, with security rules now testable in isolation.

Timing-safe secret

Challenge: The X-Proxy-Secret check used ==, which short-circuits on the first differing byte and leaks length/prefix via timing - and that same secret gates whether X-Forwarded-For (rate-limit IP) is trusted.

  • Switched to hmac.compare_digest after a cheap presence check
  • Forwarded IP is only trusted when the secret matches

Result: A constant-time gate on the security-sensitive trust boundary.

Cold starts (free Render)

Challenge: The free backend spins down after ~15 min idle, so the next visitor paid a 30-50s cold start that reads as "broken".

  • A Cloudflare Worker cron pings /health every 10 minutes to keep the instance warm
  • An in-widget "starting up" fallback covers the rare genuine cold start

Result: Visitors hit a warm backend; the cold path degrades gracefully instead of hanging.

Config drift + reproducibility

Challenge: The Groq default model disagreed between the startup log and the provider factory, and ad-hoc os.getenv parsing threw at first call instead of failing clearly at boot.

  • Single pydantic-settings Settings object - one source of truth, validated at startup
  • Pinned requirements.lock for reproducible Docker builds
  • Added a test suite (33 tests) + ruff to the component

Result: No model-default drift, typed config, and a green lint/test gate.

Security-first, by design

Risk register

  • Groq key / quota abuse Edge proxy + shared-secret gate + rate limits + no Render UI.
  • Prompt injection / jailbreak Pre-LLM regex detection + hardened prompt + output guards.
  • PII leakage Email/phone removed from KB; prompt rule forbids sharing them.
  • Hallucination Strict grounding + unknown markers + certification gate.
  • Render cold start Fallback "starting up" messages; optional keep-warm ping.

Non-functional requirements

  • Latency — Streaming first token in ~1-2s; perceived-fast via SSE.
  • Cost — $0 - Groq free tier + free Render web service + Cloudflare Pages.
  • Availability — Render free instance sleeps after idle - Cloudflare Worker cron keeps it warm and a fallback message covers cold starts.
  • Privacy — No PII in the KB; email/phone off the LLM path; user-typed PII masked before traces; honest in-widget notice + /privacy page (Langfuse US region, limited retention).
  • Security — Defense-in-depth: 7 independent layers, fail-safe defaults.
  • Portability — Runs identically local, HF, or any Docker host.

Key decisions (ADRs)

ADR-1

Edge proxy + shared secret over direct API calls

A public Space queried directly would leak the Groq key/API quota to anyone. The proxy holds the secret so only my own frontend can reach /chat.

ADR-2

Keyword KB tools over vector RAG

A resume is small and structured. Typed YAML tools are simpler, cheaper, and fully deterministic - RAG is a documented future option, not day-1 complexity.

ADR-3

ainvoke over astream_events

Streaming intermediate agent passes leaked "let me look that up" narration to users. Invoking for the final answer, then chunking it, keeps the voice clean.

ADR-4

Config-only reuse (two YAML files)

Persona and data are fully externalized so the project is a template. Rebranding is a config edit, never a code fork.

ADR-5

Observability is optional

The bot must run with zero external dependencies. Langfuse degrades to a no-op when keys are unset - no crashes, no overhead.

Contracts & config

The interfaces and config shapes that define the system - enough to see exactly how it works, deliberately without the runnable internals.

SSE token contract

What the widget consumes - each chunk is JSON so newlines and markdown survive the wire.

event: message
data: {"delta": "Naveen has led ", "done": false}

event: message
data: {"delta": "several API platforms.", "done": false}

event: message
data: {"suggestions": ["Which APIs?", "Which teams?"], "done": true}

Knowledge-base tools (signatures)

Two consolidated tools over knowledge.yaml - bodies omitted. Token-scored search, output capped at 800 chars.

def search_projects(query: str) -> str:
    """Return projects matching a token/name-scored query. Grounded in knowledge.yaml."""

def get_profile(section: str) -> str:
    """Return one profile section: about | skills | experience | certifications."""

chatbot.yaml - shape

Persona, widget text, and starter chips. Rebrand by editing this file - placeholder values shown.

persona:
  name: "<assistant name>"
  owner: "<your name>"
  voice: "<tone, e.g. concise + warm>"
system_prompt:
  instructions: "<hardened prompt - see outline below>"
widget:
  greeting: "<first message>"
  starter_chips:
    - "<suggested question 1>"
    - "<suggested question 2>"

knowledge.yaml - shape

All factual data. No email/phone here - contact routes to the form. Structure only.

about: "<one-paragraph summary>"

skills:
  - "<skill>"
experience:
  - role: "<title>"
    org: "<company>"
    period: "<dates>"
    highlights: ["<impact>"]
projects:
  - name: "<project>"
    summary: "<what + why>"
    tech: ["<stack>"]
certifications: ["<cert>"]

System-prompt outline (not the literal text)

Published as an outline on purpose - shipping the literal hardened prompt would hand attackers the exact wording to work around. The real prompt lives only in deployment config.

1. Role + scope, wrapped in <instructions> delimiters
2. Grounding rule: every claim must trace to a tool result
3. Tool budget: prefer one call; "I don't have that" is a valid answer
4. Contact policy: never emit email/phone -> route to the form
5. Anti-jailbreak sandwich: restate the rules after the user turn

Shareable vs. kept private

Shareable template

  • • chatbot.yaml + knowledge.yaml (the config surface)
  • • Architecture + certification docs
  • • Standalone index.html demo

Kept private

  • • LangGraph agent + orchestration
  • • Guardrail / injection patterns (guards.py)
  • • The literal hardened system prompt

Built to be reused

Anyone can adapt this by editing two files — no code changes:

chatbot.yaml

Persona, hardened system prompt, widget text, starter chips.

knowledge.yaml

All factual data: about, projects, skills, experience, certifications.

The architecture is designed as a clean template (repository release planned for later). When shared, it ships no personal data by default, and secrets (Groq key, proxy secret, Langfuse keys) strictly live in deployment env vars — never in source, never in the browser.

Skills this exercised

Business analysis / product

  • • Framed a vague idea into an epic with measurable acceptance criteria
  • • Sliced work into features + INVEST-style user stories
  • • MVP-first prioritization with an explicit deferred list
  • • Non-functional requirements + risk register + ADRs
  • • Certification as a release gate (traceable, repeatable evidence)

Engineering

  • • LangGraph agent design + prompt engineering + guardrails
  • • FastAPI SSE streaming with a secure edge-proxy boundary
  • • Svelte 5 runes UI with sanitized markdown + accessible widget
  • • Threat modeling: injection, PII, quota abuse, DoS
  • • Observability, containerization, and reproducible builds

Technology stack

LangGraphFastAPIGroqSvelte 5Cloudflare PagesRenderLangfuseDockerPydanticSSE

The full template repository (with merge guides, deploy steps, and security architecture) will be open-sourced soon. Want early access, or just want to talk architecture? Get in touch.