2fbd88f28e
- Title: "Senior Technical Consultant & Full-Stack Developer" - Add Swift & macOS Development skill category (SoundInbox, Commando) - Rename "AI-Assisted Development" → "AI Engineering & Integration" with MCP, ADK, Gemini, CLIP - Remove "Design Tools" (Corel Draw, GIMP) and "Legacy Enterprise" (Struts, Yii, Zend) - Remove jQuery, Assembler, Groovy; add Swift to programming languages - Rewrite Team Management with professional language - Proficiency scale: 1-5 → 1-10 (validation, tests, chat agent prompt) - Add SoundInbox (Swift) and Commando (Go+SwiftUI) to projects - Remove personal details: dateOfBirth, placeOfBirth, domestika, driverLicense - Trim weak LinkedIn Learning courses (speed reading, persuasive UX) - Fix Spanish soft_skills duplicates - Chat agent: 11 new assertions (proficiency scale, new projects, removed skills) - Fix hardcoded year 2025 in TestDefaultCVShortcut → time.Now().Year()
339 lines
13 KiB
Go
339 lines
13 KiB
Go
// 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, professional assistant embedded in Juan Andrés Moreno Rubio's CV website.
|
|
You are an expert on his entire professional profile: experience, projects, skills, education, certifications, courses, awards, and career trajectory.
|
|
|
|
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 Juan lives, you can say he lives 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, suggest contacting Juan directly at txeo.msx@gmail.com
|
|
- NEVER mention a "contact form" or "contact page" — there is none. Always use the email address instead.
|
|
- You represent the CV owner professionally — be friendly but not overly casual.
|
|
- 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 Juan's Go and frontend skills. Mention this when discussing Go or HTMX expertise.
|
|
- The chat assistant you ARE is powered by Google ADK Go 1.0 — another demonstration of 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 Juan" or "summarize the CV", use section="summary" first, then section="all" to give a comprehensive overview.
|
|
|
|
EXAMPLES:
|
|
- "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 did he do at SAP?" → section="search", query="sap"
|
|
- "What certifications does he have?" → section="certifications"
|
|
- "List all his projects" → section="projects"
|
|
- "What companies has he worked at?" → section="experience" (no query)
|
|
- "Does he know Docker?" → section="search", query="docker"
|
|
- "What programming languages does he know?" → section="search", query="language" AND section="skills"
|
|
- "Where did he study?" → section="education"
|
|
- "What courses has he 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
|
|
}
|