doc/29-AI-CHAT-SHOWCASE.md: comprehensive technical showcase covering the AI-powered CV navigation feature for potential clients/employers. 9 technical decisions explained with code: 1. ADK Go 1.0 as agent framework 2. Single agent, single tool design 3. Cross-section search across all CV data 4. CV navigation links (GPS for the CV) 5. Dual-provider with auto-fallback (Gemini→Ollama) 6. Model warmup on chat open 7. HTMX + plain JS (no SPA framework) 8. Rate limiting (30 req/hour/IP) 9. Graceful degradation Linked from README.md and doc index.
11 KiB
29. AI-Powered CV Navigation — Technical Showcase
What This Is
This CV site includes an AI assistant that lets visitors navigate and query the CV through natural language conversation. Instead of scanning a dense document, visitors ask questions like "What Go projects has he built?" or "Has he worked with React?" and get instant, cross-referenced answers with clickable links that scroll directly to the relevant section.
Live at: juan.andres.morenorub.io
The Problem
A CV is information-dense. Recruiters and hiring managers have specific questions but must scan every section to find answers. Technologies span multiple sections (a language appears in experience, projects, AND skills). Cross-referencing is manual and slow.
The Solution
An AI agent embedded in the CV page that:
- Understands the entire CV — searches across all sections simultaneously
- Answers in natural language — bilingual (English/Spanish), concise, with bullet points
- Navigates the document — every company, project, and section name in the response is a clickable link that closes the chat, scrolls to the target, and highlights it with a green pulse
- Degrades gracefully — no API key? No chat icon. API down? Automatic fallback to local AI.
Technical Architecture
┌─────────────────────────────────────────────────────────────┐
│ CV Site (Go) │
│ │
│ Visitor clicks mascot → chat opens → types question │
│ │ │
│ ▼ │
│ HTMX POST /api/chat ──→ Go Handler │
│ │ │
│ ┌─────────┴──────────┐ │
│ ▼ ▼ │
│ Try Gemini Try Ollama │
│ (primary) (auto-fallback) │
│ │ │ │
│ └────────┬───────────┘ │
│ ▼ │
│ ADK Go Agent │
│ "cv_assistant" │
│ │ │
│ ▼ │
│ query_cv tool │
│ (cross-section search) │
│ │ │
│ ▼ │
│ Cached CV JSON │
│ (same data that renders the page) │
│ │ │
│ ▼ │
│ Response with navigation links │
│ [Olympic Broadcasting](#exp-olympic) │
│ │ │
│ ▼ │
│ HTMX swaps into chat panel │
│ Links scroll + highlight on click │
└─────────────────────────────────────────────────────────────┘
Key Technical Decisions
1. Google ADK Go 1.0 as the Agent Framework
We chose ADK Go (v1.0, released March 2026) for the agent layer. ADK Go provides:
llmagent.New— declarative agent definition with instruction and toolsfunctiontool.New— type-safe Go function → agent tool bridge with auto-generated JSON schemarunner.Runner— manages agent execution, sessions, and tool calling loopssession.InMemoryService— lightweight session management for conversation context
Why not a simpler approach (raw API calls)? ADK Go handles the tool-calling protocol automatically — the agent decides which tool to call, the framework executes it, feeds results back, and the agent synthesizes. With raw API calls, we'd need to implement this loop ourselves.
2. Single Agent, Single Tool
The CV data is bounded and structured. We use one agent (cv_assistant) with one tool (query_cv). Multi-agent orchestration would be over-engineering here. The intelligence comes from:
- A comprehensive instruction prompt covering 8 question types
- A
searchmode that queries across experience, projects, skills, and courses simultaneously - Instruction to always include navigation links using CV anchor IDs
3. Cross-Section Search
When a visitor asks about a technology (e.g., "Go"), the tool searches all sections at once:
case "search":
crossResult := make(map[string]any)
if exp := filterExperience(cv.Experience, q); len(exp) > 0 {
crossResult["experience"] = exp
}
if proj := filterProjects(cv.Projects, q); len(proj) > 0 {
crossResult["projects"] = proj
}
if skills := filterSkills(cv.Skills, q); len(skills) > 0 {
crossResult["skills"] = skills
}
if courses := filterCourses(cv.Courses, q); len(courses) > 0 {
crossResult["courses"] = courses
}
This prevents the classic problem of "I searched projects but the answer was in experience."
4. CV Navigation Links (GPS for the CV)
The agent includes markdown links in its responses:
[Olympic Broadcasting](#exp-olympic-broadcasting) — SAP CDC solutions...
[Immich Photo Manager](#proj-immich-photo-manager) — MCP server for...
See the [Skills section](#skills) for full proficiency details.
The formatResponse function converts these to clickable HTML links. When clicked, JavaScript:
- Closes the chat panel
- Smooth-scrolls to the target element
- Pulses a green highlight for 2 seconds
This turns the chat into a navigation tool — like Google Maps for a document.
5. Dual-Provider with Automatic Fallback
// Handler has primary + fallback runners
type Handler struct {
primary *chatRunner // Gemini (fast, cloud)
fallback *chatRunner // Ollama (local, unlimited)
}
// Try primary, fall back on any error
response, sessionID, err := h.runAgent(h.primary, message)
if err != nil && h.fallback != nil {
log.Printf("Primary failed, falling back to %s", h.fallback.label)
response, sessionID, err = h.runAgent(h.fallback, message)
}
- Primary: Gemini 2.5 Flash — fast (
2s), pay-as-you-go ($0.0003/question) - Fallback: Ollama with Mistral Small 3.2 on local Mac Mini via Tailscale — free, unlimited
- Switching: Automatic and transparent. If Gemini returns 429/503, Ollama handles the request.
- No manual intervention — visitors never see the provider switch.
6. Model Warmup on Chat Open
Ollama loads models on demand (~10-15s cold start). To hide this latency:
function toggleChatPanel() {
// ... open panel ...
if (!chatWarmedUp) {
chatWarmedUp = true;
fetch('/api/chat/warmup', { method: 'POST' }); // background
}
}
When the visitor opens the chat, a silent warmup request fires. By the time they type a question, the model is loaded and ready.
7. HTMX + Plain JavaScript
The chat widget uses HTMX for server communication and plain JavaScript for interactions:
<!-- Form submits via HTMX -->
<form id="chat-form" hx-post="/api/chat"
hx-target="#chat-messages"
hx-swap="beforeend scroll:#chat-messages:bottom"
hx-indicator="#chat-typing">
<!-- Chips trigger via JavaScript -->
<button onclick="sendChatQuestion('What Go projects has he built?')">
Go projects?
</button>
Responses are HTML fragments — the server renders the chat bubbles, HTMX swaps them in. No client-side state management, no JSON parsing, no virtual DOM.
8. Rate Limiting
chatRateLimiter := middleware.NewRateLimiter(30, 1*time.Hour)
mux.Handle("/api/chat", chatRateLimiter.Middleware(...))
30 requests per hour per IP — generous for genuine visitors, prevents abuse.
9. Graceful Degradation
func NewHandler(dataCache *cache.DataCache) *Handler {
// Try Gemini → Try Ollama → Disable chat
// If neither provider works, chat icon doesn't appear at all
}
{{if .ChatEnabled}}
<!-- entire chat widget -->
{{end}}
No API key? No Ollama? The chat icon simply doesn't render. Zero JavaScript errors, zero broken UI, zero console noise.
Technology Stack
| Component | Technology | Purpose |
|---|---|---|
| Agent Framework | Google ADK Go 1.0 | Agent definition, tool calling, session management |
| Primary LLM | Gemini 2.5 Flash | Cloud inference, fast responses |
| Fallback LLM | Mistral Small 3.2 via Ollama | Local inference on Apple Silicon |
| Server Communication | HTMX 2.0 | Form submission, response swapping, indicators |
| Interactions | Plain JavaScript | Panel toggle, chip clicks, navigation scroll |
| Backend | Go 1.25+ stdlib net/http |
HTTP handler, markdown→HTML, rate limiting |
| Styling | CSS with CV design tokens | Green theme, dark mode, responsive |
Files
internal/chat/
├── agent.go # LLM agent + query_cv tool + cross-section search
├── handler.go # Dual-provider handler, warmup, fallback, response rendering
└── ollama.go # Ollama model.LLM adapter (OpenAI-compatible API)
templates/partials/
├── widgets/chat-widget.html # HTMX chat panel + JS functions
└── modals/chat-help-modal.html # Accordion help modal with clickable questions
static/css/04-interactive/_chat.css # Full styling (tokens, dark theme, responsive, nav links)
tests/mjs/83-chat-mascot.test.mjs # 46 Playwright test assertions
What This Demonstrates
- AI agent integration in production Go applications — not a prototype, a deployed feature
- ADK Go 1.0 in a real-world use case — function calling, session management, multi-provider
- Multi-provider LLM architecture — cloud primary with local fallback, transparent switching
- Hypermedia-driven AI UI — HTMX server-rendered responses, no SPA framework needed
- Document navigation via AI — chat responses that link to and highlight document sections
- Graceful engineering — degrades cleanly, rate-limited, bilingual, theme-aware