This portfolio and its chatbot
The only project in my profile that whoever evaluates me can audit in full: the repository is public and every architecture decision is justified in writing, including the decision not to use RAG.
A portfolio with a chatbot that answers questions about my professional profile. I built it in public, with an open repository, because in an applied-AI profile verifiable code is worth more than the description of a project nobody can open.
- Next.js
- TypeScript
- Tailwind CSS
- Zod
- Vercel
- Question
- Validation (Zod)
- Per-IP rate limit
- Prompt + full corpus
- Streaming answer
Provider failure: notice and retry
The problem
A résumé makes claims and you have to take its word for it. I wanted at least one project in my profile to be fully auditable by whoever reads it.
The chatbot adds a risk a static site doesn't have: a model endpoint exposed to the internet is, without limits, an open bill for anyone who finds the URL.
The content has to stay consistent across the site, the bot and my résumé. Three places stating the same thing will drift apart; it's a matter of time, not discipline.
Constraints
- Zero infrastructure cost.
- Public repository: any bad decision is out in the open, and no sensitive data can make it into the Git history.
- The site has to be fully usable with the chatbot turned off.
- A single source of truth for facts: if a fact isn't in the dossier, it doesn't exist for the site or the bot.
Decisions
No RAG, on purpose
The full corpus of my profile fits comfortably in the model's context window. The rule is that retrieval is justified when the corpus does NOT fit; that's not the case here. It's the decision I'm proudest of in this project, because the easy answer was to add a vector store and say I do RAG.
Trade-off: Every call sends the whole corpus, so the cost per request is higher and grows if the corpus grows. At this scale it's irrelevant, and the day it stops being so, the condition for revisiting the decision is already written down.
The LLM provider behind an interface
The provider is the dependency most likely to change — for price, availability or quality — and the easiest to isolate. An environment variable selects the implementation and the rest of the code doesn't know which one is active. The abstraction was put to the test sooner than expected: switching providers meant adding one file and one case in the factory, without touching the endpoint, the widget or the corpus.
Trade-off: A layer of indirection up front, which I normally avoid. I accept it here because the axis of change is identified, not assumed.
Content in typed JSON, no CMS
The content changes a few times a year and only one person edits it. The type schema is the barrier: a project marked as archived can't be rendered as a link, and a recommendation can't exist without declaring the author's relationship to me. The compiler enforces those rules, not memory.
Trade-off: Editing content requires a commit. That's acceptable, even desirable: every change to a public claim stays in the history.
Environment validated with Zod, failing at build time
Two requirements that contradict each other: the repo has to build right after cloning without credentials, and a malformed value can't reach production. The solution is that nothing is required but everything validates its shape when present. Absent means a planned degradation; present and invalid means a broken build.
Trade-off: It's more code to read than using `process.env` directly, and it forces you to think about what happens visually when each variable is missing.
Rate limiting and a spending cap as a startup condition
The chat is only enabled if the kill switch is on and both the provider key and the rate-limit backend are present. If any of the three is missing, the chat stays off and the full site is still served.
Trade-off: More configuration before the endpoint's first deploy. I'd rather have a portfolio without a chat than a chat usable as free compute.
Architecture
Next.js with the App Router and TypeScript. The site is prerendered as static pages and uses Server Components by default: client-side JavaScript is limited to what really needs interaction — the theme toggle, the recommendations carousel, the certificate viewer and the chat — and with the chat turned off, not even the widget's code is shipped. The content lives in typed JSON validated against the schema at build time, so data that breaks the contract breaks the build instead of the page. The chatbot is a Route Handler that injects the full corpus as context, with the provider behind a swappable interface, hard rules in the prompt, per-IP rate limiting and a kill switch. User input always travels in its own turn, never concatenated into the system prompt.
Outcome
- A public, auditable repository, with a README that works as a decision log.
- Zero-cost infrastructure.
- The site works in full with the chatbot turned off, which was the main design requirement.
- A content guardrail runs before every build and breaks the compilation if a fact I decided not to publish shows up: the rule enforces itself instead of depending on me remembering it.
- A set of reference questions checks the bot's behavior against the real endpoint — validation and limits included — before the system prompt is touched.
What I'd do differently today
- The static site shipped first and the chatbot only after it. It's a sequence I set for myself on purpose, because the temptation to show off the bot before the rest is solid is strong.
- The set of reference questions exists and runs against the real endpoint, but I still trigger it by hand. It should run in continuous integration on every system prompt change: as long as it depends on me remembering, it isn't a guarantee.
- The rate limiter fails open on purpose: if the counter doesn't respond, requests go through. That's only defensible because the provider's free tier enforces the spending ceiling outside the code. The day the account has billing enabled, the decision has to be revisited — and it's the kind of assumption that ages silently.