Skip to content

AI Portfolio Generator

A full-stack portfolio generator that extracts a resume's content and runs it through a locally-hosted Qwen3 model via Ollama, then renders the result into one of five real themes; live in the browser before anything is exported as a self-contained HTML file or a zipped static site. Nothing about the pipeline is a thin wrapper around an API call: extraction, analysis, rendering, and export are each their own stage, with the model running on infrastructure I control rather than someone else's endpoint.

Type
Web App
Role
Full-Stack Developer
Built
2026
Updated
2026
Live Demo
Source
Tech Stack
FastAPIReactTypeScriptPythonOllama (Qwen3)ViteDocker
01

Why I Built This

Most "AI resume tools" send your resume to someone else's API. I wanted the opposite: a portfolio generator where the model (Qwen3, via Ollama) runs on infrastructure I control, so a resume never has to leave the machine to get analyzed.

The other goal was removing the excuse to never publish a portfolio: upload a PDF, pick from five real themes, and see a working static site with no deploy step required just to check the result.

Resumes are messy documents (inconsistent formatting, mixed date styles, bullet points that run together), so this also became a genuine test of how far structured extraction can be pushed before it needs a human to clean up after it.

02

Resume Analysis Pipeline

The generation flow is a straight pipeline: Resume PDF → Extract → AI Analysis → Portfolio JSON → Theme Renderer → Static Website → Export. Extraction and AI analysis are the two expensive stages, so everything downstream (theme rendering, live preview, export) runs against the resulting Portfolio JSON instead of re-touching the model.

03

A Frozen Engine, Not a Tangled Monolith

website-generator/ holds the extraction, AI analysis, and rendering logic, and is treated as a black box the rest of the app isn't allowed to reach into directly. generator_service.py is the single, deliberate crossing point between the FastAPI backend and that engine, a small constraint that pays off immediately, since the engine has its own 779-test suite and a bug fix or model swap inside it can't accidentally break auth, dashboards, or export just by sharing a repo.

04

Five Themes, One Data Contract

Minimal, Executive, Developer, Creative, and Modern SaaS aren't palette variants of one template; each is a genuinely different layout, information density, and visual language, built to suit different fields and personalities rather than just recoloring the same page.

That only works because the Portfolio JSON produced by the analysis stage is generic enough to describe all five structures. The renderer picks a theme and interprets the same data differently, so adding a sixth theme later means writing a new renderer against an existing schema, not touching extraction or analysis at all.

05

Export System

Export has two distinct outputs: a single self-contained HTML file with styles and assets inlined for something as simple as attaching to an email, and a zipped static site with assets split out properly for anyone who wants to actually deploy it. Both are generated from the same rendered output; the difference is entirely in how that output gets packaged, not in a second rendering pass.

Getting the self-contained file genuinely self-contained took more care than expected: fonts, images, and any theme-specific assets all have to inline cleanly or the "just open this file" promise breaks the moment it's opened somewhere without network access.

06

Technical Challenges

Running Qwen3 locally via Ollama instead of calling a hosted API removes the privacy risk but adds a real availability problem; a slow or unreachable local model has to fail into an honest, visible empty state rather than hang the request or silently return nothing.

Getting five themes to feel like genuinely different layouts, not the same layout with new CSS variables, meant the Portfolio JSON schema had to be generic enough to describe five different structures, pushing a lot of the design work into the data contract rather than the renderer.

07

What's Next

The extraction pipeline currently does best with conventional single-column resumes, multi-column layouts and heavily designed PDFs still trip it up more than plain text ones, so improving extraction robustness against messier real-world formatting is the next real target rather than adding a sixth theme.

A longer-term idea is letting someone nudge the AI's interpretation directly (re-emphasize a project, downplay an old role) without re-uploading the resume, since right now correcting a misread detail means starting the pipeline over from the PDF.

08

Key Decisions

The AI/rendering engine (website-generator/) was frozen behind one file, generator_service.py, the only file allowed to import it. That boundary means the engine's extraction, analysis, and rendering logic can keep evolving without the SaaS layer around it noticing.

Theme switching only re-renders the page rather than re-running extraction and AI analysis, since those are the expensive steps and layout is the only thing that actually changes between themes.

AI output is schema-validated before it's trusted anywhere in the app, rather than treated as raw text, a model response that doesn't match the expected shape gets rejected instead of silently rendered.

Export was built as two separate paths (a single self-contained HTML file with everything inlined, and a zipped static site with assets split out) rather than one format, since "paste this into an email" and "deploy this to Vercel" are genuinely different use cases with different requirements.

09

What I Learned

Keeping one isolation boundary around the generation engine made it possible to build a 779-test suite for that engine independently of the SaaS layer, and to trust that a change in one never silently breaks the other.

Running the model locally instead of through a hosted API meant thinking hard about failure modes up front: timeouts, partial output, and Ollama not being reachable all needed to fail into an honest empty state instead of a crash.