Files
cv-site/internal/chat/agent.go
T

275 lines
9.2 KiB
Go
Raw Normal View History

// 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.
- For technology questions (e.g. "Java", "Go", "React"), ALWAYS use section="search" — this searches across experience, projects, courses, and skills simultaneously. Do NOT search only projects or only experience. Always report ALL matches from every section.
- When reporting results, be EXHAUSTIVE. If the search returns matches in experience AND projects AND skills, mention ALL of them. Never truncate or summarize away matches.
- Answer in the SAME LANGUAGE the user writes in. If they ask in Spanish, answer in Spanish.
- Be concise but complete — list every relevant item found, don't skip any.
- When listing items (projects, technologies, companies), use bullet points.
- If the query_cv tool returns no results for a question, say so honestly and suggest the visitor check a related section.
- IMPORTANT: This CV website itself is built with Go + HTMX — you can mention this as context when discussing Go expertise if relevant.
- You may reference sections of the CV 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?" → section="summary"
- "What Java experience does he have?" → section="search", query="java"
- "Has he worked with React?" → section="search", query="react"
- "Tell me about his time at Olympic Broadcasting" → section="search", query="olympic"
- "What certifications does he have?" → section="certifications"
- "List all his projects" → section="projects"`,
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: 'search' (cross-section keyword search — recommended for technology queries), '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.
For technology or keyword queries (e.g. "Java", "Go", "React", "Olympic"), use section="search" to search across experience, projects, skills, and courses simultaneously. This avoids missing results that appear in multiple sections.
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 "search":
// Cross-section search: search across experience, projects, skills, and courses simultaneously.
crossResult := make(map[string]any)
total := 0
if expMatches := filterExperience(cv.Experience, q); len(expMatches) > 0 {
crossResult["experience"] = expMatches
total += len(expMatches)
}
if projMatches := filterProjects(cv.Projects, q); len(projMatches) > 0 {
crossResult["projects"] = projMatches
total += len(projMatches)
}
if skillMatches := filterSkills(cv.Skills, q); len(skillMatches) > 0 {
crossResult["skills"] = skillMatches
total += len(skillMatches)
}
if courseMatches := filterCourses(cv.Courses, q); len(courseMatches) > 0 {
crossResult["courses"] = courseMatches
total += len(courseMatches)
}
result.TotalFound = total
result.Data = mustJSON(crossResult)
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
}