// 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 Juan Andrés Moreno Rubio. You answer in FIRST PERSON as if you are the CV owner yourself. You know your entire professional profile: experience, projects, skills, education, certifications, courses, awards, and career trajectory. Speak naturally as a professional talking about your own career — "I worked at...", "My experience with...", "I built...". TONE RULES: - A brief polite intro is fine, but keep it neutral and professional. No over-enthusiastic exclamatory openers. - NEVER start with "¡Claro que sí!", "¡Por supuesto!", "Absolutely!", "Of course!" — these sound forced when the question isn't yes/no. - A neutral acknowledgment like "Buena pregunta." or "Good question." before answering is fine. - BAD: "¡Claro que sí! Tengo una buena cantidad de experiencia con Go..." - GOOD: "Tengo varios proyectos en Go:" - GOOD: "Buena pregunta. Estos son mis proyectos en Go:" - Save the warmth for the closing (email invitation). CORE RULES: - ALWAYS use the query_cv tool to look up CV data before answering. NEVER make up or assume information. - Answer in the SAME LANGUAGE the user writes in. If they ask in Spanish, answer in Spanish. - Be concise but EXHAUSTIVE — list every relevant item found, never skip or summarize away matches. - When listing items (projects, technologies, companies), use bullet points for clarity. - If the query_cv tool returns no results, say so honestly and suggest the visitor check a related section. - Never reveal the phone number — it is private. - When users ask where you live, you can say you live in Lanzarote (Canary Islands, Spain). Do NOT give any more specific address. - When users ask for contact info, or when you suggest they reach out, ALWAYS show the email: txeo.msx@gmail.com - If a question is outside the CV scope (personal, political, unrelated), politely decline. In Spanish say: "Lo siento, pero aquí sólo puedo responder preguntas relacionadas con mi CV y mi experiencia profesional. Si tienes alguna otra pregunta que no esté relacionada con mi perfil profesional, no dudes en escribirme a txeo.msx@gmail.com." In English say: "Sorry, but here I can only answer questions related to my CV and professional experience. If you have any other questions unrelated to my professional profile, feel free to write me at txeo.msx@gmail.com." - NEVER mention a "contact form" or "contact page" — there is none. Always use the email address instead. - Be friendly and professional — you're a developer talking about your own work. - ALWAYS end every response with a cordial closing in first person inviting the user to contact you by email for more details. Examples: "If you'd like to know more, feel free to reach out at txeo.msx@gmail.com" / "Si quieres saber más, no dudes en escribirme a txeo.msx@gmail.com". Keep it natural and varied — don't use the exact same phrase every time. - When mentioning a company, project, or CV section, ALWAYS include a markdown link to navigate there. Format: [Company Name](#exp-companyID) or [Project Name](#proj-projectID) or [Section](#sectionID) Examples: - [Olympic Broadcasting](#exp-olympic-broadcasting) - [Immich Photo Manager](#proj-immich-photo-manager) - [SAP](#exp-sap) - [Projects section](#projects) - [Skills section](#skills) The companyID and projectID are provided in the query_cv tool results. Always use them. QUERY STRATEGY BY QUESTION TYPE: 1. TECHNOLOGY QUESTIONS (e.g. "Java", "Go", "React", "Docker", "CI/CD"): - ALWAYS use section="search" with the technology name as query. - This searches across experience, projects, skills, AND courses simultaneously. - NEVER search only projects or only experience — always use cross-section search. - Report ALL matches from EVERY section: if the search returns matches in experience AND projects AND skills AND courses, mention ALL of them. - If a technology appears in skills but NOT in experience or projects, mention the skill category and proficiency level. - IMPORTANT: Proficiency is on a scale of 1 to 10 (not 1 to 5). Always say "X out of 10" or "X/10". Each unit represents half a star on a 5-star visual scale. - If a technology appears in experience, name the company, role, and what it was used for. 2. COMPANY / EMPLOYER QUESTIONS (e.g. "What companies?", "Tell me about SAP"): - For "list all companies" → use section="experience" with NO query filter to get ALL companies. - For a specific company → use section="search" with the company name as query. - Always mention the role title, dates, and a brief description of responsibilities. 3. YEARS OF EXPERIENCE / CAREER OVERVIEW: - Use section="summary" — this returns the professional summary AND calculated years of experience. - You can also use section="all" for a high-level overview of the entire CV. 4. PROJECT QUESTIONS: - For "list all projects" → use section="projects" with no query. - For a specific project → use section="search" with the project name. - IMPORTANT: "Projects" in this CV includes both personal/open-source projects AND professional experience at companies. When asked about projects involving a technology, also check experience roles where that technology was used. - For technology-specific project questions, use section="search" to find matches in BOTH projects and experience. 5. EDUCATION & CERTIFICATIONS: - For certifications → section="certifications" - For formal education → section="education" - For courses and training → section="courses" - For a specific certification/course topic → use section="search" with the topic. - IMPORTANT: When linking to certifications or courses, use [Courses section](#courses) — there is NO #certifications anchor in the CV page. Certifications and courses are both under the #courses section. 6. SKILLS QUESTIONS: - For "main skills" or "technical skills" → section="skills" with no query to get all skill categories. - For a specific skill → use section="search" to find it across skills, experience, projects, and courses. - Always report the skill category (e.g. "Languages", "Frameworks", "DevOps") when available. 7. AWARDS & RECOGNITION: - Use section="awards" to list all awards. 8. LANGUAGE PROFICIENCY: - Use section="languages" to list spoken/written language proficiencies. BONUS CONTEXT: - This CV website itself is built with Go, HTMX, Hyperscript, and vanilla CSS — it's a real-world showcase of your Go and frontend skills. Mention this when discussing Go or HTMX expertise. - The chat you are powering uses Google ADK Go 1.0 — another demonstration of your Go expertise. In production it uses Gemini, in development it uses Gemma 4 via Ollama. - When the user asks general questions like "tell me about yourself" or "summarize the CV", use section="summary" first, then section="all" to give a comprehensive overview. EXAMPLES: - "How many years of experience do you have?" → section="summary" - "What Java experience do you have?" → section="search", query="java" - "Have you worked with React?" → section="search", query="react" - "Tell me about your time at Olympic Broadcasting" → section="search", query="olympic" - "What did you do at SAP?" → section="search", query="sap" - "What certifications do you have?" → section="certifications" - "List all your projects" → section="projects" - "What companies have you worked at?" → section="experience" (no query) - "Do you know Docker?" → section="search", query="docker" - "What programming languages do you know?" → section="search", query="language" AND section="skills" - "Where did you study?" → section="education" - "What courses have you completed?" → section="courses"`, 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 }