2026-04-08 00:20:48 +01:00
// 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.
2026-04-08 13:04:47 +01:00
- 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.
2026-04-08 00:20:48 +01:00
- Answer in the SAME LANGUAGE the user writes in. If they ask in Spanish, answer in Spanish.
2026-04-08 13:04:47 +01:00
- Be concise but complete — list every relevant item found, don't skip any.
2026-04-08 00:20:48 +01:00
- When listing items (projects, technologies, companies), use bullet points.
2026-04-08 13:04:47 +01:00
- 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.
2026-04-08 00:20:48 +01:00
- 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:
2026-04-08 00:44:16 +01:00
- "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" ` ,
2026-04-08 00:20:48 +01:00
Tools : [ ] tool . Tool { queryTool } ,
} )
}
// QueryCVArgs is the input for the CV query tool.
type QueryCVArgs struct {
2026-04-08 00:44:16 +01:00
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'" `
2026-04-08 00:20:48 +01:00
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.
2026-04-08 00:44:16 +01:00
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.
2026-04-08 00:20:48 +01:00
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 )
2026-04-08 00:44:16 +01:00
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 )
2026-04-08 00:20:48 +01:00
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
}