feat: add AI chat widget powered by ADK Go 1.0
Visitors can ask questions about the CV via a floating chat panel. The agent uses Gemini to answer questions about experience, projects, skills, and education by querying the cached CV JSON data. - internal/chat/agent.go: LLM agent with query_cv tool that searches CV data by section (experience, projects, skills, etc.) with keyword filtering - internal/chat/handler.go: POST /api/chat endpoint with session management, graceful degradation when GOOGLE_API_KEY is not set - chat-widget.html: HTMX-powered floating chat panel with Hyperscript toggle - _chat.css: Responsive chat UI with dark theme support - Wired into existing architecture via dependency injection (CVHandler, routes, main.go) — zero breaking changes, all existing tests pass
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
// Package chat provides an ADK Go agent that answers questions about CV data.
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/cache"
|
||||
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
|
||||
|
||||
"google.golang.org/adk/agent"
|
||||
"google.golang.org/adk/agent/llmagent"
|
||||
"google.golang.org/adk/model"
|
||||
"google.golang.org/adk/tool"
|
||||
"google.golang.org/adk/tool/functiontool"
|
||||
)
|
||||
|
||||
// NewAgent creates the CV chat agent with a query tool that reads from the data cache.
|
||||
func NewAgent(llm model.LLM, dataCache *cache.DataCache) (agent.Agent, error) {
|
||||
queryTool, err := newQueryCVTool(dataCache)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query_cv tool: %w", err)
|
||||
}
|
||||
|
||||
return llmagent.New(llmagent.Config{
|
||||
Name: "cv_assistant",
|
||||
Model: llm,
|
||||
Description: "Answers questions about Juan Andrés Moreno Rubio's CV and professional experience.",
|
||||
Instruction: `You are a helpful assistant embedded in a professional CV website.
|
||||
You answer questions about the CV owner's experience, projects, skills, education, and career.
|
||||
|
||||
RULES:
|
||||
- Use the query_cv tool to look up CV data before answering. Never make up information.
|
||||
- Answer in the SAME LANGUAGE the user writes in. If they ask in Spanish, answer in Spanish.
|
||||
- Be concise and direct — visitors want quick answers, not essays.
|
||||
- When listing items (projects, technologies, companies), use bullet points.
|
||||
- If the query_cv tool returns no results for a question, say so honestly.
|
||||
- You may reference sections of the CV (e.g., "See the Projects section") to guide the visitor.
|
||||
- Never reveal personal contact details (email, phone) — just point them to the contact form.
|
||||
- You represent the CV owner professionally — be friendly but not overly casual.
|
||||
|
||||
EXAMPLES of questions you might receive:
|
||||
- "How many years of experience does Juan have?"
|
||||
- "What Go projects has he built?"
|
||||
- "Has he worked with React?"
|
||||
- "Tell me about his time at Olympic Broadcasting"
|
||||
- "What certifications does he have?"`,
|
||||
Tools: []tool.Tool{queryTool},
|
||||
})
|
||||
}
|
||||
|
||||
// QueryCVArgs is the input for the CV query tool.
|
||||
type QueryCVArgs struct {
|
||||
Section string `json:"section" jsonschema:"CV section to query: 'experience', 'projects', 'skills', 'education', 'languages', 'certifications', 'courses', 'awards', 'summary', 'all'"`
|
||||
Query string `json:"query" jsonschema:"Search term to filter results (e.g. 'Go', 'React', '2019', 'Olympic'). Empty returns all items in the section."`
|
||||
Language string `json:"language" jsonschema:"Language for CV data: 'en' or 'es'. Default: 'en'."`
|
||||
}
|
||||
|
||||
// QueryCVResult contains the query results.
|
||||
type QueryCVResult struct {
|
||||
Section string `json:"section"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TotalFound int `json:"total_found"`
|
||||
Data string `json:"data"` // JSON-encoded results
|
||||
}
|
||||
|
||||
func newQueryCVTool(dataCache *cache.DataCache) (tool.Tool, error) {
|
||||
return functiontool.New(functiontool.Config{
|
||||
Name: "query_cv",
|
||||
Description: `Query the CV data to answer questions about experience, projects, skills, education, certifications, and more.
|
||||
Use the 'section' parameter to target a specific area, and 'query' to filter by keyword.
|
||||
Always call this tool before answering CV-related questions.`,
|
||||
}, func(ctx tool.Context, args QueryCVArgs) (QueryCVResult, error) {
|
||||
lang := args.Language
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
|
||||
cv := dataCache.GetCV(lang)
|
||||
if cv == nil {
|
||||
return QueryCVResult{Section: args.Section, TotalFound: 0, Data: "[]"}, nil
|
||||
}
|
||||
|
||||
q := strings.ToLower(args.Query)
|
||||
result := QueryCVResult{Section: args.Section, Query: args.Query}
|
||||
|
||||
switch args.Section {
|
||||
case "summary":
|
||||
result.Data = fmt.Sprintf(`{"summary": %q, "years_of_experience": %d}`,
|
||||
cv.Summary, calculateYears())
|
||||
result.TotalFound = 1
|
||||
|
||||
case "experience":
|
||||
matches := filterExperience(cv.Experience, q)
|
||||
result.TotalFound = len(matches)
|
||||
result.Data = mustJSON(matches)
|
||||
|
||||
case "projects":
|
||||
matches := filterProjects(cv.Projects, q)
|
||||
result.TotalFound = len(matches)
|
||||
result.Data = mustJSON(matches)
|
||||
|
||||
case "skills":
|
||||
matches := filterSkills(cv.Skills, q)
|
||||
result.TotalFound = len(matches)
|
||||
result.Data = mustJSON(matches)
|
||||
|
||||
case "education":
|
||||
result.TotalFound = len(cv.Education)
|
||||
result.Data = mustJSON(cv.Education)
|
||||
|
||||
case "languages":
|
||||
result.TotalFound = len(cv.Languages)
|
||||
result.Data = mustJSON(cv.Languages)
|
||||
|
||||
case "certifications":
|
||||
result.TotalFound = len(cv.Certifications)
|
||||
result.Data = mustJSON(cv.Certifications)
|
||||
|
||||
case "courses":
|
||||
matches := filterCourses(cv.Courses, q)
|
||||
result.TotalFound = len(matches)
|
||||
result.Data = mustJSON(matches)
|
||||
|
||||
case "awards":
|
||||
result.TotalFound = len(cv.Awards)
|
||||
result.Data = mustJSON(cv.Awards)
|
||||
|
||||
case "all":
|
||||
// Return a high-level overview
|
||||
overview := map[string]int{
|
||||
"experience_count": len(cv.Experience),
|
||||
"project_count": len(cv.Projects),
|
||||
"skill_categories": len(cv.Skills.Technical),
|
||||
"language_count": len(cv.Languages),
|
||||
"certification_count": len(cv.Certifications),
|
||||
"course_count": len(cv.Courses),
|
||||
"award_count": len(cv.Awards),
|
||||
}
|
||||
result.TotalFound = 1
|
||||
result.Data = mustJSON(overview)
|
||||
|
||||
default:
|
||||
result.Data = `{"error": "unknown section"}`
|
||||
}
|
||||
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Filter helpers — match by keyword across relevant fields
|
||||
|
||||
func filterExperience(items []cvmodel.Experience, q string) []cvmodel.Experience {
|
||||
if q == "" {
|
||||
return items
|
||||
}
|
||||
var out []cvmodel.Experience
|
||||
for _, e := range items {
|
||||
if matchesAny(q, e.Company, e.Position, e.Location, e.StartDate, e.EndDate, e.ShortDescription) ||
|
||||
matchesSlice(q, e.Technologies) || matchesSlice(q, e.Responsibilities) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterProjects(items []cvmodel.Project, q string) []cvmodel.Project {
|
||||
if q == "" {
|
||||
return items
|
||||
}
|
||||
var out []cvmodel.Project
|
||||
for _, p := range items {
|
||||
if matchesAny(q, p.Title, p.ShortDescription, p.Location) ||
|
||||
matchesSlice(q, p.Technologies) || matchesSlice(q, p.Responsibilities) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterSkills(skills cvmodel.Skills, q string) []cvmodel.SkillCategory {
|
||||
if q == "" {
|
||||
return skills.Technical
|
||||
}
|
||||
var out []cvmodel.SkillCategory
|
||||
for _, cat := range skills.Technical {
|
||||
if matchesAny(q, cat.Category) || matchesSlice(q, cat.Items) {
|
||||
out = append(out, cat)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterCourses(items []cvmodel.Course, q string) []cvmodel.Course {
|
||||
if q == "" {
|
||||
return items
|
||||
}
|
||||
var out []cvmodel.Course
|
||||
for _, c := range items {
|
||||
if matchesAny(q, c.Title, c.Institution, c.Description) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchesAny(q string, fields ...string) bool {
|
||||
for _, f := range fields {
|
||||
if strings.Contains(strings.ToLower(f), q) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesSlice(q string, items []string) bool {
|
||||
for _, item := range items {
|
||||
if strings.Contains(strings.ToLower(item), q) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func calculateYears() int {
|
||||
firstDay := time.Date(2005, time.April, 1, 0, 0, 0, 0, time.UTC)
|
||||
now := time.Now()
|
||||
years := now.Year() - firstDay.Year()
|
||||
if now.Month() < firstDay.Month() ||
|
||||
(now.Month() == firstDay.Month() && now.Day() < firstDay.Day()) {
|
||||
years--
|
||||
}
|
||||
return years
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/cache"
|
||||
|
||||
"google.golang.org/adk/agent"
|
||||
"google.golang.org/adk/model/gemini"
|
||||
"google.golang.org/adk/runner"
|
||||
"google.golang.org/adk/session"
|
||||
"google.golang.org/genai"
|
||||
)
|
||||
|
||||
// Handler serves the chat API endpoint.
|
||||
type Handler struct {
|
||||
runner *runner.Runner
|
||||
sessionService session.Service
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewHandler creates a chat handler. Returns a disabled handler if GOOGLE_API_KEY is not set.
|
||||
func NewHandler(dataCache *cache.DataCache) *Handler {
|
||||
apiKey := os.Getenv("GOOGLE_API_KEY")
|
||||
if apiKey == "" {
|
||||
log.Println("⚠️ GOOGLE_API_KEY not set — chat feature disabled")
|
||||
return &Handler{enabled: false}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
modelName := os.Getenv("MODEL_NAME")
|
||||
if modelName == "" {
|
||||
modelName = "gemini-2.5-flash"
|
||||
}
|
||||
|
||||
llm, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{
|
||||
APIKey: apiKey,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to initialize Gemini model: %v — chat disabled", err)
|
||||
return &Handler{enabled: false}
|
||||
}
|
||||
|
||||
cvAgent, err := NewAgent(llm, dataCache)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to create CV agent: %v — chat disabled", err)
|
||||
return &Handler{enabled: false}
|
||||
}
|
||||
|
||||
sessionSvc := session.InMemoryService()
|
||||
|
||||
r, err := runner.New(runner.Config{
|
||||
AppName: "cv-chat",
|
||||
Agent: cvAgent,
|
||||
SessionService: sessionSvc,
|
||||
AutoCreateSession: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to create runner: %v — chat disabled", err)
|
||||
return &Handler{enabled: false}
|
||||
}
|
||||
|
||||
log.Printf("💬 Chat agent enabled (model: %s)", modelName)
|
||||
|
||||
return &Handler{
|
||||
runner: r,
|
||||
sessionService: sessionSvc,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled returns whether the chat feature is available.
|
||||
func (h *Handler) Enabled() bool {
|
||||
return h.enabled
|
||||
}
|
||||
|
||||
// HandleChat processes POST /api/chat requests.
|
||||
// Expects form field "message" and optional "session_id".
|
||||
// Returns an HTML fragment for HTMX to swap into the chat panel.
|
||||
func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.enabled {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprint(w, `<div class="chat-message chat-error">Chat is not available at the moment.</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(r.FormValue("message"))
|
||||
if message == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprint(w, `<div class="chat-message chat-error">Please enter a message.</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := r.FormValue("session_id")
|
||||
if sessionID == "" {
|
||||
sessionID = "default"
|
||||
}
|
||||
|
||||
// Ensure session exists
|
||||
ctx := r.Context()
|
||||
_, err := h.sessionService.Get(ctx, &session.GetRequest{
|
||||
AppName: "cv-chat",
|
||||
UserID: "visitor",
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
// Create new session
|
||||
created, createErr := h.sessionService.Create(ctx, &session.CreateRequest{
|
||||
AppName: "cv-chat",
|
||||
UserID: "visitor",
|
||||
})
|
||||
if createErr != nil {
|
||||
log.Printf("Chat session create error: %v", createErr)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprint(w, `<div class="chat-message chat-error">Failed to start chat session.</div>`)
|
||||
return
|
||||
}
|
||||
sessionID = created.Session.ID()
|
||||
}
|
||||
|
||||
// Run the agent
|
||||
userMsg := genai.NewContentFromText(message, genai.RoleUser)
|
||||
|
||||
var response strings.Builder
|
||||
for event, err := range h.runner.Run(ctx, "visitor", sessionID, userMsg, agent.RunConfig{}) {
|
||||
if err != nil {
|
||||
log.Printf("Chat agent error: %v", err)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprint(w, `<div class="chat-message chat-error">Something went wrong. Please try again.</div>`)
|
||||
return
|
||||
}
|
||||
if event.IsFinalResponse() {
|
||||
if event.Content != nil {
|
||||
for _, part := range event.Content.Parts {
|
||||
if part.Text != "" {
|
||||
response.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render the response as HTML
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
// User message bubble
|
||||
_, _ = fmt.Fprintf(w, `<div class="chat-message chat-user">%s</div>`, html.EscapeString(message))
|
||||
|
||||
// Agent response bubble
|
||||
agentText := response.String()
|
||||
if agentText == "" {
|
||||
agentText = "I couldn't find an answer to that. Try asking about experience, projects, skills, or education."
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `<div class="chat-message chat-agent">%s</div>`, formatResponse(agentText))
|
||||
|
||||
// Hidden input to preserve session ID for next request
|
||||
_, _ = fmt.Fprintf(w, `<input type="hidden" name="session_id" value="%s" form="chat-form"/>`, sessionID)
|
||||
}
|
||||
|
||||
// formatResponse converts basic markdown to HTML for the chat bubble.
|
||||
func formatResponse(text string) string {
|
||||
// Escape HTML first
|
||||
text = html.EscapeString(text)
|
||||
|
||||
// Bold: **text** → <strong>text</strong>
|
||||
for strings.Contains(text, "**") {
|
||||
text = strings.Replace(text, "**", "<strong>", 1)
|
||||
text = strings.Replace(text, "**", "</strong>", 1)
|
||||
}
|
||||
|
||||
// Bullet points: lines starting with "- " → <li>
|
||||
lines := strings.Split(text, "\n")
|
||||
var result []string
|
||||
inList := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "• ") {
|
||||
if !inList {
|
||||
result = append(result, "<ul>")
|
||||
inList = true
|
||||
}
|
||||
result = append(result, "<li>"+strings.TrimPrefix(strings.TrimPrefix(trimmed, "- "), "• ")+"</li>")
|
||||
} else {
|
||||
if inList {
|
||||
result = append(result, "</ul>")
|
||||
inList = false
|
||||
}
|
||||
if trimmed != "" {
|
||||
result = append(result, "<p>"+trimmed+"</p>")
|
||||
}
|
||||
}
|
||||
}
|
||||
if inList {
|
||||
result = append(result, "</ul>")
|
||||
}
|
||||
|
||||
return strings.Join(result, "")
|
||||
}
|
||||
@@ -21,15 +21,17 @@ type CVHandler struct {
|
||||
emailService *email.Service
|
||||
serverAddr string
|
||||
dataCache *cache.DataCache
|
||||
chatEnabled bool
|
||||
}
|
||||
|
||||
// NewCVHandler creates a new CV handler
|
||||
func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache) *CVHandler {
|
||||
func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache, chatEnabled bool) *CVHandler {
|
||||
return &CVHandler{
|
||||
templates: tmpl,
|
||||
pdfGenerator: pdf.NewGenerator(c.TimeoutPDFGeneration),
|
||||
emailService: emailService,
|
||||
serverAddr: serverAddr,
|
||||
dataCache: dataCache,
|
||||
chatEnabled: chatEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +365,7 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er
|
||||
"CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang),
|
||||
"AlternateEN": "https://juan.andres.morenorub.io/?lang=en",
|
||||
"AlternateES": "https://juan.andres.morenorub.io/?lang=es",
|
||||
"ChatEnabled": h.chatEnabled,
|
||||
}
|
||||
|
||||
return data, nil
|
||||
|
||||
@@ -95,5 +95,5 @@ func newTestCVHandler(t testing.TB, serverAddr string, emailService *email.Servi
|
||||
|
||||
dataCache := getTestCache(t)
|
||||
|
||||
return NewCVHandler(tmplManager, serverAddr, emailService, dataCache)
|
||||
return NewCVHandler(tmplManager, serverAddr, emailService, dataCache, false)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ package routes
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/chat"
|
||||
c "github.com/juanatsap/cv-site/internal/constants"
|
||||
"github.com/juanatsap/cv-site/internal/handlers"
|
||||
"github.com/juanatsap/cv-site/internal/middleware"
|
||||
)
|
||||
|
||||
// Setup configures all application routes and middleware
|
||||
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
|
||||
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler, chatHandler *chat.Handler) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Shortcut routes for default CV (year-aware) - MUST be before "/" route
|
||||
@@ -18,6 +19,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
|
||||
|
||||
// API routes (must be before "/" to avoid catch-all)
|
||||
mux.HandleFunc("/api/cmd-k", cvHandler.CmdKData) // CMD+K command palette data
|
||||
mux.HandleFunc("/api/chat", chatHandler.HandleChat) // AI chat endpoint
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("/", cvHandler.Home)
|
||||
|
||||
Reference in New Issue
Block a user