diff --git a/apps/applyflow/src/components/dashboard/build-pilot-career-bundle.test.ts b/apps/applyflow/src/components/dashboard/build-pilot-career-bundle.test.ts index 4bb05efd..c9d7bf05 100644 --- a/apps/applyflow/src/components/dashboard/build-pilot-career-bundle.test.ts +++ b/apps/applyflow/src/components/dashboard/build-pilot-career-bundle.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; import { buildPilotCareerBundleFromFields, + canSubmitResumeAnalysis, hasPilotAnalysisInputs, } from "./build-pilot-career-bundle"; +import { buildCareerSpecialistFieldsFromSimpleInputs } from "./career-pilot-input-normalizer"; +import { EMPTY_CAREER_PILOT_SIMPLE_INPUTS } from "./career-pilot-simple-inputs"; import { EMPTY_SPECIALIST_FIELDS } from "./career-chat-workspace"; import { CAREER_PILOT_EXAMPLE_FIELDS } from "./career-pilot-content"; @@ -49,3 +52,74 @@ describe("hasPilotAnalysisInputs", () => { ).toBe(true); }); }); + +describe("canSubmitResumeAnalysis", () => { + const weakResumeText = `Trabalhei com sistemas. +Ajudei em projetos. +Responsável por algumas tarefas. +Conhecimento em tecnologia.`; + + it("P1-B — enables submit for weak resume with valid bullets and no skills", () => { + const fields = buildCareerSpecialistFieldsFromSimpleInputs( + { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + resumeText: weakResumeText, + }, + "analyze_resume", + ); + expect(fields.resumeBullets.split("\n").filter(Boolean).length).toBeGreaterThan(0); + expect(hasPilotAnalysisInputs("analyze_resume", fields)).toBe(true); + expect( + canSubmitResumeAnalysis( + "analyze_resume", + { ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, resumeText: weakResumeText }, + fields, + ), + ).toBe(true); + expect(buildPilotCareerBundleFromFields(fields).applications.length).toBeGreaterThan(0); + }); + + it("aligns button eligibility with bundle creation for official example", () => { + const resumeText = `Maria Souza — Desenvolvedora de Software Sênior + +Experiência profissional +TechCorp (2021–presente) — Desenvolvedora Backend +Desenvolvi APIs REST em Node.js para integração com parceiros. +Reduzi o tempo de deploy em 30% com pipelines CI/CD. +Liderei um squad de 4 pessoas em projeto de migração cloud.`; + const fields = buildCareerSpecialistFieldsFromSimpleInputs( + { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + resumeText, + }, + "analyze_resume", + ); + expect(fields.resumeSummary).toBe("Desenvolvedora de Software Sênior"); + expect( + canSubmitResumeAnalysis( + "analyze_resume", + { ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, resumeText }, + fields, + ), + ).toBe(true); + }); + + it("blocks submit when resume text is long but no analyzable content remains", () => { + const fields = { + ...EMPTY_SPECIALIST_FIELDS, + resumeSummary: "", + resumeBullets: "", + resumeSkills: "", + }; + expect( + canSubmitResumeAnalysis( + "analyze_resume", + { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + resumeText: " ".repeat(20) + "x".repeat(30), + }, + fields, + ), + ).toBe(false); + }); +}); diff --git a/apps/applyflow/src/components/dashboard/build-pilot-career-bundle.ts b/apps/applyflow/src/components/dashboard/build-pilot-career-bundle.ts index 12d3d63c..054f018d 100644 --- a/apps/applyflow/src/components/dashboard/build-pilot-career-bundle.ts +++ b/apps/applyflow/src/components/dashboard/build-pilot-career-bundle.ts @@ -1,5 +1,15 @@ import { createCareerBundle, type CareerBundle } from "@devflow/career-core"; import type { CareerSpecialistFields } from "./career-chat-workspace"; +import { + normalizeJobDescription, + normalizeResumeText, +} from "./career-pilot-input-normalizer"; +import { + MIN_PILOT_JOB_DESCRIPTION_LENGTH, + MIN_PILOT_RESUME_TEXT_LENGTH, + type CareerPilotSimpleInputs, +} from "./career-pilot-simple-inputs"; +import type { CareerPilotIntent } from "./career-pilot-content"; function toLines(value: string): string[] { return value @@ -60,3 +70,27 @@ export function hasPilotAnalysisInputs( return hasResume; } + +export function canSubmitResumeAnalysis( + action: CareerPilotIntent, + simpleInputs: CareerPilotSimpleInputs, + fields: CareerSpecialistFields, +): boolean { + if (action === "plan_career_strategy") { + return simpleInputs.careerGoal.trim().length > 0; + } + + const resumeText = normalizeResumeText(simpleInputs.resumeText); + if (resumeText.length < MIN_PILOT_RESUME_TEXT_LENGTH) { + return false; + } + + if (action === "analyze_ats_compatibility") { + const jobDescription = normalizeJobDescription(simpleInputs.jobDescription); + if (jobDescription.length < MIN_PILOT_JOB_DESCRIPTION_LENGTH) { + return false; + } + } + + return hasPilotAnalysisInputs(action, fields); +} diff --git a/apps/applyflow/src/components/dashboard/career-chat-workspace.tsx b/apps/applyflow/src/components/dashboard/career-chat-workspace.tsx index e810e495..e52e5dd3 100644 --- a/apps/applyflow/src/components/dashboard/career-chat-workspace.tsx +++ b/apps/applyflow/src/components/dashboard/career-chat-workspace.tsx @@ -79,6 +79,7 @@ import { import { isCareerPilotModeClient } from "@/lib/career-system/feature-flags"; import { buildPilotCareerBundleFromFields, + canSubmitResumeAnalysis, hasPilotAnalysisInputs, } from "./build-pilot-career-bundle"; import { buildCareerPilotResultModel } from "./career-pilot-result-mapper"; @@ -95,6 +96,7 @@ import { CAREER_PILOT_EMPTY_CAREER_GOAL_MESSAGE, CAREER_PILOT_EMPTY_JOB_MESSAGE, CAREER_PILOT_EMPTY_RESUME_MESSAGE, + CAREER_PILOT_INSUFFICIENT_CONTENT_MESSAGE, } from "./career-pilot-simple-input-content"; import type { CareerPilotSimpleInputs } from "./career-pilot-simple-inputs"; import { EMPTY_CAREER_PILOT_SIMPLE_INPUTS } from "./career-pilot-simple-inputs"; @@ -120,16 +122,24 @@ function isSpecialistIntent(action: CareerChatIntent): boolean { } export type CareerSpecialistFields = { + resumeSummary: string; resumeBullets: string; resumeSkills: string; + resumeExperienceCompany: string; + resumeExperienceTitle: string; + resumeExperiencesJson: string; jobRequirements: string; targetRoles: string; availability: string; }; export const EMPTY_SPECIALIST_FIELDS: CareerSpecialistFields = { + resumeSummary: "", resumeBullets: "", resumeSkills: "", + resumeExperienceCompany: "", + resumeExperienceTitle: "", + resumeExperiencesJson: "", jobRequirements: "", targetRoles: "", availability: "", @@ -165,12 +175,47 @@ export function buildSpecialistAnalysisInput(input: { const skills = toCommaList(fields.resumeSkills); const resolvedSkills = skills.length > 0 ? skills : mainStack; const bullets = toLines(fields.resumeBullets); + const summary = fields.resumeSummary.trim() || undefined; + + type ParsedExperiencePayload = { + title?: string; + company?: string; + bullets?: string[]; + }; + + let experiences: Array<{ title: string; company: string; bullets: string[] }> = []; + if (fields.resumeExperiencesJson.trim()) { + try { + const parsed = JSON.parse(fields.resumeExperiencesJson) as ParsedExperiencePayload[]; + if (Array.isArray(parsed) && parsed.length > 0) { + experiences = parsed + .filter((entry) => (entry.bullets?.length ?? 0) > 0 || entry.company || entry.title) + .map((entry) => ({ + title: entry.title?.trim() || fields.resumeExperienceTitle.trim() || fallbackRole || "Experience", + company: entry.company?.trim() || fields.resumeExperienceCompany.trim() || "—", + bullets: (entry.bullets ?? []).map((bullet) => bullet.trim()).filter(Boolean), + })) + .filter((entry) => entry.bullets.length > 0); + } + } catch { + experiences = []; + } + } + + if (experiences.length === 0 && bullets.length > 0) { + experiences = [ + { + title: fields.resumeExperienceTitle.trim() || fallbackRole || "Experience", + company: fields.resumeExperienceCompany.trim() || "—", + bullets, + }, + ]; + } + const resumeSnapshot = { + ...(summary ? { summary } : {}), skills: resolvedSkills, - experiences: - bullets.length > 0 - ? [{ title: fallbackRole || "Experience", company: "—", bullets }] - : [], + experiences, }; if (action === "analyze_resume") { @@ -331,7 +376,11 @@ export function CareerChatWorkspaceView({ const reviewProposal = response?.agentResult?.reviewProposal; const pilotResultModel = pilotPresentation && response?.status === "completed" - ? buildCareerPilotResultModel({ intent: action, response }) + ? buildCareerPilotResultModel({ + intent: action, + response, + participantSurface: pilotPresentation, + }) : null; const visibleActions = pilotPresentation ? CAREER_PILOT_INTENTS : (Object.keys(CAREER_CHAT_WORKSPACE_ACTION_LABELS) as CareerChatIntent[]); const actionLabels = pilotPresentation ? CAREER_PILOT_ACTION_LABELS : CAREER_CHAT_WORKSPACE_ACTION_LABELS; @@ -906,6 +955,9 @@ export function CareerChatWorkspace({ if (!resumeOk) { return CAREER_PILOT_EMPTY_RESUME_MESSAGE; } + if (!canSubmitResumeAnalysis(action, simpleInputs, normalizedFields)) { + return CAREER_PILOT_INSUFFICIENT_CONTENT_MESSAGE; + } } if (action === "analyze_ats_compatibility") { const atsOk = hasSimplePilotAnalysisInputs("analyze_ats_compatibility", simpleInputs); @@ -917,7 +969,7 @@ export function CareerChatWorkspace({ return CAREER_PILOT_EMPTY_CAREER_GOAL_MESSAGE; } return null; - }, [action, explicitConsent, pilotPresentation, simpleInputs]); + }, [action, explicitConsent, normalizedFields, pilotPresentation, simpleInputs]); useEffect(() => { if (!pilotPresentation || !pilotIntent || !isCareerPilotIntent(pilotIntent)) { @@ -959,7 +1011,7 @@ export function CareerChatWorkspace({ const submitDisabled = pilotPresentation ? !explicitConsent || !isCareerPilotIntent(action) || - !hasSimplePilotAnalysisInputs(action, simpleInputs) + !canSubmitResumeAnalysis(action, simpleInputs, effectiveSpecialistFields) : !careerBundle || !explicitConsent || !message.trim(); function handleActionChange(nextAction: CareerChatIntent) { @@ -968,7 +1020,21 @@ export function CareerChatWorkspace({ } async function handleSend() { - if (!effectiveBundle || !explicitConsent) { + if (!explicitConsent) { + return; + } + + if (pilotPresentation && isCareerPilotIntent(action)) { + if (!canSubmitResumeAnalysis(action, simpleInputs, effectiveSpecialistFields)) { + setErrorMessage(CAREER_PILOT_INSUFFICIENT_CONTENT_MESSAGE); + return; + } + } else if (!effectiveBundle) { + return; + } + + if (!effectiveBundle) { + setErrorMessage(CAREER_PILOT_INSUFFICIENT_CONTENT_MESSAGE); return; } diff --git a/apps/applyflow/src/components/dashboard/career-pilot-content.ts b/apps/applyflow/src/components/dashboard/career-pilot-content.ts index 1574dcbd..bb139382 100644 --- a/apps/applyflow/src/components/dashboard/career-pilot-content.ts +++ b/apps/applyflow/src/components/dashboard/career-pilot-content.ts @@ -128,9 +128,23 @@ export const CAREER_PILOT_ERROR_DESCRIPTION = "Revise os dados e tente novamente. Se o problema continuar, registre o horário da tentativa."; export const CAREER_PILOT_EXAMPLE_FIELDS: CareerSpecialistFields = { + resumeSummary: "Desenvolvedora de Software com experiência em backend e integrações.", resumeBullets: "Desenvolvi APIs REST em Node.js para integração com parceiros\nReduzi tempo de deploy em 30% com pipelines CI/CD\nLiderei squad de 4 pessoas em projeto de migração cloud", resumeSkills: "TypeScript, Node.js, PostgreSQL, AWS", + resumeExperienceCompany: "TechCorp", + resumeExperienceTitle: "Desenvolvedora Backend", + resumeExperiencesJson: JSON.stringify([ + { + company: "TechCorp", + title: "Desenvolvedora Backend", + bullets: [ + "Desenvolvi APIs REST em Node.js para integração com parceiros", + "Reduzi tempo de deploy em 30% com pipelines CI/CD", + "Liderei squad de 4 pessoas em projeto de migração cloud", + ], + }, + ]), jobRequirements: "3+ anos com backend\nExperiência com TypeScript\nConhecimento em cloud (AWS ou GCP)\nInglês intermediário", targetRoles: "Engenheiro de Software Backend, Desenvolvedor Node.js", @@ -139,7 +153,7 @@ export const CAREER_PILOT_EXAMPLE_FIELDS: CareerSpecialistFields = { export const CAREER_PILOT_EXAMPLE_SIMPLE_INPUTS: CareerPilotSimpleInputs = { targetRole: "Engenheiro de Software Backend", - resumeText: `Maria Souza — Desenvolvedora de Software + resumeText: `Maria Souza — Desenvolvedora de Software Sênior Experiência profissional TechCorp (2021–presente) — Desenvolvedora Backend diff --git a/apps/applyflow/src/components/dashboard/career-pilot-experience.test.tsx b/apps/applyflow/src/components/dashboard/career-pilot-experience.test.tsx index 49536aa1..ef63169b 100644 --- a/apps/applyflow/src/components/dashboard/career-pilot-experience.test.tsx +++ b/apps/applyflow/src/components/dashboard/career-pilot-experience.test.tsx @@ -237,6 +237,13 @@ const model: CareerPilotResultModel = { risks: ["Bullets genéricos podem reduzir impacto"], scores: [{ label: "Qualidade da estrutura", value: 72, max: 100 }], evidence: ["Experiências: detalhar impacto"], + bulletSuggestions: [ + { + original: "Desenvolvi APIs REST em Node.js.", + recommendation: "Explique quantos parceiros foram integrados, somente se esses dados forem reais.", + }, + ], + humanReviewNotice: "Revise cada sugestão com critério humano antes de alterar seu currículo.", technicalLines: ["Nenhuma candidatura foi enviada."], traceSteps: [{ code: "review_required", message: "Human review required" }], }; @@ -248,12 +255,14 @@ describe("CareerPilotResultView polish hierarchy", () => { ); expect(html.indexOf("Resumo")).toBeLessThan(html.indexOf("Principais achados")); - expect(html.indexOf("Principais achados")).toBeLessThan(html.indexOf("Próximas ações")); - expect(html.indexOf("Próximas ações")).toBeLessThan(html.indexOf("Detalhes técnicos")); + expect(html.indexOf("Principais achados")).toBeLessThan(html.indexOf("Sugestões por experiência")); + expect(html.indexOf("Sugestões por experiência")).toBeLessThan(html.indexOf("Próximas ações")); + expect(html.indexOf("Próximas ações")).toBeLessThan(html.indexOf("Pontos de atenção")); expect(html).toContain("Análise do currículo concluída"); expect(html).toContain('data-testid="career-pilot-score-indicator"'); expect(html).toContain('data-testid="career-pilot-result-action-list"'); - expect(html).toContain(" { }); }); +describe("extractProfessionalSummary", () => { + it("extracts the first descriptive paragraph before experience sections", () => { + const summary = extractProfessionalSummary(`Desenvolvedor Full Stack com experiência em React, Next.js, TypeScript e Node.js. + +Experiência profissional +TechCorp — Desenvolvedor +Desenvolvi APIs REST.`); + expect(summary).toBe( + "Desenvolvedor Full Stack com experiência em React, Next.js, TypeScript e Node.js.", + ); + }); + + it("P1-A — extracts role from official example identity line without full name", () => { + const resumeText = `Maria Souza — Desenvolvedora de Software Sênior + +Experiência profissional +TechCorp (2021–presente) — Desenvolvedora Backend +Desenvolvi APIs REST em Node.js para integração com parceiros. +Reduzi o tempo de deploy em 30% com pipelines CI/CD. +Liderei um squad de 4 pessoas em projeto de migração cloud.`; + expect(extractProfessionalSummary(resumeText)).toBe("Desenvolvedora de Software Sênior"); + }); + + it("accepts standalone professional title line when no descriptive paragraph exists", () => { + expect(extractProfessionalSummary("Engenheiro de Software Backend Sênior\n\nExperiência\nDesenvolvi APIs.")).toBe( + "Engenheiro de Software Backend Sênior", + ); + }); + + it("does not treat weak multi-line resume text as summary", () => { + const resumeText = `Trabalhei com sistemas. +Ajudei em projetos. +Responsável por algumas tarefas. +Conhecimento em tecnologia.`; + expect(extractProfessionalSummary(resumeText)).toBe(""); + }); + + it("does not duplicate summary lines as resume bullets", () => { + const resumeText = `Desenvolvedor Full Stack com experiência em React, Next.js, TypeScript e Node.js. + +Experiência profissional +Desenvolvi APIs REST em Node.js.`; + const summary = extractProfessionalSummary(resumeText); + const lines = extractResumeLines(resumeText, summary); + expect(lines.join("\n")).not.toContain("Desenvolvedor Full Stack com experiência"); + expect(lines.some((line) => line.includes("Desenvolvi APIs"))).toBe(true); + }); +}); + describe("extractResumeLines", () => { it("extracts multiple lines from a multiline resume", () => { const lines = extractResumeLines("Experiência A\n• Resultado B\n\nResultado C"); @@ -29,11 +80,32 @@ describe("extractResumeLines", () => { expect(lines[0]).toContain("Experiência"); }); - it("splits single-block text into sentences when needed", () => { - const lines = extractResumeLines( - "Desenvolvi APIs REST em Node.js. Reduzi tempo de deploy em 30% com CI/CD.", - ); - expect(lines.length).toBeGreaterThanOrEqual(1); + it("P1-B — preserves weak resume lines as bullet candidates", () => { + const resumeText = `Trabalhei com sistemas. +Ajudei em projetos. +Responsável por algumas tarefas. +Conhecimento em tecnologia.`; + const lines = extractResumeLines(resumeText); + expect(lines.length).toBe(4); + expect(lines.some((line) => line.includes("Trabalhei"))).toBe(true); + }); + + it("P1 — excludes experience header from resume bullets and recommendations", () => { + const resumeText = `Maria Souza — Desenvolvedora de Software Sênior + +Experiência profissional +TechCorp (2021–presente) — Desenvolvedora Backend +Desenvolvi APIs REST em Node.js para integração com parceiros. +Reduzi o tempo de deploy em 30% com pipelines CI/CD. +Liderei um squad de 4 pessoas em projeto de migração cloud.`; + const summary = extractProfessionalSummary(resumeText); + const lines = extractResumeLines(resumeText, summary); + expect(lines.some((line) => line.includes("TechCorp (2021"))).toBe(false); + expect(lines).toHaveLength(3); + + const experiences = extractResumeExperiences(resumeText, summary); + expect(experiences[0]?.company).toBe("TechCorp"); + expect(experiences[0]?.title).toBe("Desenvolvedora Backend"); }); }); @@ -89,6 +161,14 @@ describe("extractJobKeywords", () => { }); describe("buildCareerSpecialistFieldsFromSimpleInputs", () => { + it("does not treat action-only resume text as professional summary", () => { + const resumeText = + "Desenvolvi APIs em Node.js com TypeScript.\nReduzi deploy em 30% com CI/CD no GitHub Actions."; + expect(extractProfessionalSummary(resumeText)).toBe(""); + const lines = extractResumeLines(resumeText); + expect(lines.some((line) => line.includes("Node.js"))).toBe(true); + }); + it("maps simple resume inputs to specialist fields", () => { const fields = buildCareerSpecialistFieldsFromSimpleInputs( { @@ -102,6 +182,7 @@ describe("buildCareerSpecialistFieldsFromSimpleInputs", () => { expect(fields.resumeBullets).toContain("Node.js"); expect(fields.resumeSkills).toContain("TypeScript"); expect(fields.targetRoles).toBe("Desenvolvedor Full Stack"); + expect(fields.resumeSummary).toBe(""); }); it("maps job description to requirements for ATS flow", () => { diff --git a/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.ts b/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.ts index 531f0180..679fb61b 100644 --- a/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.ts +++ b/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.ts @@ -1,6 +1,14 @@ import type { CareerChatIntent } from "@devflow/career-core"; import type { CareerSpecialistFields } from "./career-chat-workspace"; import { isCareerPilotIntent, type CareerPilotIntent } from "./career-pilot-content"; +import { + extractResumeBulletLines, + extractResumeExperiences, + isExperienceHeader, +} from "./career-pilot-resume-line-parser"; + +export { extractResumeExperiences } from "./career-pilot-resume-line-parser"; +export type { ParsedResumeExperience } from "./career-pilot-resume-line-parser"; import { type CareerPilotSimpleInputs, MAX_PILOT_JOB_DESCRIPTION_LENGTH, @@ -39,6 +47,217 @@ export const PILOT_SKILL_CATALOG = [ const MAX_LINES = 50; const MAX_LINE_LENGTH = 500; +const MAX_SUMMARY_LENGTH = 500; + +const SECTION_HEADER_PATTERN = + /^(experi[eê]ncia(\s+profissional)?|form[aã]o(\s+acad[eê]mica)?|compet[eê]ncias|competencias|habilidades|skills|education|experience|projects?|projetos?|certifica[cç][oõ]es|idiomas|resumo( profissional)?|summary|work history|employment)$/i; + +const ACTION_LINE_PATTERN = + /\b(desenvolvi|implementei|reduzi|criei|liderei|constru[ií]|built|led|developed|implemented|reduced)\b/i; + +const IDENTITY_LINE_PATTERN = /^([\p{L}\s.'-]+)\s*[—–-]\s*([\p{L}\s.'-]+)$/u; + +const COMPANY_PERIOD_HEADER_PATTERN = + /^[\p{L}\d\s.&''\-]+[\((]\s*(19|20)\d{2}\s*[–\-—]\s*(presente|(19|20)\d{2})\s*[\))]/iu; + +const ROLE_PERIOD_PATTERN = /[\—\-–]\s*(19|20)\d{2}\s*(a|até|–|-)\s*(19|20)\d{2}\b/i; + +const PROFESSIONAL_TITLE_PATTERN = + /\b(desenvolvedor|desenvolvedora|engenheir|analista|designer|gerente|coordenador|consultor|arquiteto|software|backend|frontend|full[\s-]?stack|sênior|senior|pleno|júnior|junior)\b/i; + +const EXPERIENCE_BULLET_PREFIX_PATTERN = + /^(trabalhei|ajudei|participei|responsável|responsavel|atuei|colabor|conhecimento|fiz|realizei|executei)\b/i; + +function isSectionHeader(line: string): boolean { + const trimmed = line.trim(); + if (trimmed.length > 60 || ACTION_LINE_PATTERN.test(trimmed)) { + return false; + } + return SECTION_HEADER_PATTERN.test(trimmed); +} + +const SKILL_LINE_PATTERN = /^(compet[eê]ncias|competencias|habilidades|skills)\s*:/i; + +function isSkillListLine(line: string): boolean { + return SKILL_LINE_PATTERN.test(line.trim()); +} + +function parseIdentityLine(line: string): { name: string; role: string } | null { + const match = line.trim().match(IDENTITY_LINE_PATTERN); + if (!match?.[1] || !match[2]) { + return null; + } + return { name: match[1].trim(), role: match[2].trim() }; +} + +function isCompanyPeriodHeader(line: string): boolean { + const trimmed = line.trim(); + return COMPANY_PERIOD_HEADER_PATTERN.test(trimmed) || ROLE_PERIOD_PATTERN.test(trimmed); +} + +function looksLikeExperienceBulletLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) { + return false; + } + if (ACTION_LINE_PATTERN.test(trimmed)) { + return true; + } + if (EXPERIENCE_BULLET_PREFIX_PATTERN.test(trimmed)) { + return true; + } + if (isExperienceHeader(trimmed)) { + return false; + } + return /[.!?]$/.test(trimmed) && trimmed.split(/\s+/).length <= 10 && trimmed.length < 90; +} + +function isProfessionalTitleLine(line: string): boolean { + const trimmed = line.trim(); + if (trimmed.length < 15 || trimmed.length > 80) { + return false; + } + if (parseIdentityLine(trimmed)) { + return false; + } + if (isSectionHeader(trimmed) || isSkillListLine(trimmed) || isCompanyPeriodHeader(trimmed)) { + return false; + } + if (ACTION_LINE_PATTERN.test(trimmed) || EXPERIENCE_BULLET_PREFIX_PATTERN.test(trimmed)) { + return false; + } + return PROFESSIONAL_TITLE_PATTERN.test(trimmed); +} + +function isDescriptiveSummaryParagraph(text: string): boolean { + const trimmed = text.trim(); + if (trimmed.length < 20 || isSkillListLine(trimmed) || parseIdentityLine(trimmed)) { + return false; + } + + const sentences = trimmed.split(/(?<=[.!?])\s+/).filter(Boolean); + if (sentences.length >= 2) { + const experienceLikeCount = sentences.filter((sentence) => looksLikeExperienceBulletLine(sentence)).length; + if (experienceLikeCount >= 2) { + return false; + } + } + + if (/\bcom experiência\b/i.test(trimmed) || /\bcom experiencia\b/i.test(trimmed)) { + return true; + } + + if ( + /\b(experiência|experiencia|especializad[oa]|focad[oa]|profissional)\b/i.test(trimmed) && + trimmed.length >= 30 + ) { + return true; + } + + if (sentences.length === 1 && !looksLikeExperienceBulletLine(trimmed)) { + return trimmed.length >= 25 && /\b(em|com|para|usando|através|atraves)\b/i.test(trimmed); + } + + return false; +} + +function extractRoleFromIdentityLine(line: string): string | null { + const identity = parseIdentityLine(line); + if (!identity || identity.role.length < 10) { + return null; + } + return identity.role.slice(0, MAX_SUMMARY_LENGTH); +} + +function lineMatchesSummary(line: string, summary: string): boolean { + const lowered = line.trim().toLowerCase(); + const summaryNorm = summary.trim().toLowerCase(); + if (!summaryNorm) { + return false; + } + if (lowered === summaryNorm) { + return true; + } + if (summaryNorm.includes(lowered) || lowered.includes(summaryNorm)) { + return true; + } + const identity = parseIdentityLine(line); + if (identity && identity.role.toLowerCase() === summaryNorm) { + return true; + } + return false; +} + +/** + * Extracts the first descriptive paragraph before experience sections. + * Preserves original text; does not summarize with AI. + */ +export function extractProfessionalSummary(resumeText: string): string { + const normalized = normalizeResumeText(resumeText); + if (!normalized) { + return ""; + } + + const rawLines = normalized.split("\n").map((line) => line.trim()); + const paragraphs: string[] = []; + const preSectionLines: string[] = []; + let current: string[] = []; + + for (const line of rawLines) { + if (!line) { + if (current.length > 0) { + paragraphs.push(current.join(" ")); + current = []; + } + continue; + } + if (isSectionHeader(line) || isSkillListLine(line)) { + if (current.length > 0) { + paragraphs.push(current.join(" ")); + } + break; + } + + const trimmed = line.replace(/^[\s•\-*]+/, "").trim(); + preSectionLines.push(trimmed); + + if (looksLikeExperienceBulletLine(trimmed)) { + if (current.length > 0) { + paragraphs.push(current.join(" ")); + current = []; + } + break; + } + + current.push(trimmed); + } + if (current.length > 0) { + paragraphs.push(current.join(" ")); + } + + for (const paragraph of paragraphs) { + const candidate = paragraph.trim(); + if (isDescriptiveSummaryParagraph(candidate)) { + return candidate.slice(0, MAX_SUMMARY_LENGTH); + } + } + + for (const line of preSectionLines) { + const roleFromIdentity = extractRoleFromIdentityLine(line); + if (roleFromIdentity) { + return roleFromIdentity; + } + if (isProfessionalTitleLine(line)) { + return line.trim().slice(0, MAX_SUMMARY_LENGTH); + } + } + + return ""; +} + +export function extractResumeLines(resumeText: string, summaryToExclude?: string): string[] { + return extractResumeBulletLines(resumeText, summaryToExclude); +} export function normalizeResumeText(value: string): string { return value @@ -58,29 +277,6 @@ export function normalizeJobDescription(value: string): string { .slice(0, MAX_PILOT_JOB_DESCRIPTION_LENGTH); } -export function extractResumeLines(resumeText: string): string[] { - const normalized = normalizeResumeText(resumeText); - if (!normalized) { - return []; - } - - const lines = normalized - .split("\n") - .map((line) => line.replace(/^[\s•\-*]+/, "").trim()) - .filter(Boolean) - .map((line) => line.slice(0, MAX_LINE_LENGTH)); - - if (lines.length > 1) { - return lines.slice(0, MAX_LINES); - } - - return normalized - .split(/(?<=[.!?])\s+/) - .map((sentence) => sentence.trim()) - .filter((sentence) => sentence.length >= 8) - .slice(0, MAX_LINES); -} - function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -158,27 +354,35 @@ export function buildCareerSpecialistFieldsFromSimpleInputs( ): CareerSpecialistFields { const resumeText = normalizeResumeText(input.resumeText); const jobDescription = normalizeJobDescription(input.jobDescription); - const resumeLines = extractResumeLines(resumeText); + const resumeSummary = extractProfessionalSummary(resumeText); + const parsedExperiences = extractResumeExperiences(resumeText, resumeSummary); + const resumeLines = extractResumeLines(resumeText, resumeSummary); + const primaryExperience = parsedExperiences[0]; const skills = extractLikelySkills(resumeText); const requirements = extractJobRequirements(jobDescription); const availabilityParts = [input.weeklyAvailability.trim(), input.constraints.trim()].filter(Boolean); + const baseFields = { + resumeSummary, + resumeBullets: resumeLines.join("\n"), + resumeSkills: skills.join(", "), + resumeExperienceCompany: primaryExperience?.company !== "—" ? (primaryExperience?.company ?? "") : "", + resumeExperienceTitle: primaryExperience?.title !== "—" ? (primaryExperience?.title ?? "") : "", + resumeExperiencesJson: JSON.stringify(parsedExperiences), + jobRequirements: requirements.join("\n"), + availability: availabilityParts.join(" · "), + }; + if (isCareerPilotIntent(intent) && intent === "plan_career_strategy") { return { - resumeBullets: resumeLines.join("\n"), - resumeSkills: skills.join(", "), - jobRequirements: requirements.join("\n"), + ...baseFields, targetRoles: joinTargetRoles(input.careerGoal), - availability: availabilityParts.join(" · "), }; } return { - resumeBullets: resumeLines.join("\n"), - resumeSkills: skills.join(", "), - jobRequirements: requirements.join("\n"), + ...baseFields, targetRoles: input.targetRole.trim(), - availability: availabilityParts.join(" · "), }; } diff --git a/apps/applyflow/src/components/dashboard/career-pilot-result-content.ts b/apps/applyflow/src/components/dashboard/career-pilot-result-content.ts index 2cf44f26..b4575c70 100644 --- a/apps/applyflow/src/components/dashboard/career-pilot-result-content.ts +++ b/apps/applyflow/src/components/dashboard/career-pilot-result-content.ts @@ -4,6 +4,9 @@ export const CAREER_PILOT_RESULT_STRENGTHS_TITLE = "Pontos fortes"; export const CAREER_PILOT_RESULT_IMPROVEMENTS_TITLE = "O que merece atenção"; export const CAREER_PILOT_RESULT_NEXT_ACTIONS_TITLE = "Próximas ações"; export const CAREER_PILOT_RESULT_RISKS_TITLE = "Pontos de atenção"; +export const CAREER_PILOT_RESULT_BULLET_SUGGESTIONS_TITLE = "Sugestões por experiência"; +export const CAREER_PILOT_RESULT_REVIEW_NOTICE = + "Revise cada sugestão com critério humano. Nenhuma alteração é aplicada automaticamente ao seu currículo."; export const CAREER_PILOT_RESULT_SCORES_TITLE = "Indicadores"; export const CAREER_PILOT_RESULT_EVIDENCE_TITLE = "Evidências e detalhes"; export const CAREER_PILOT_RESULT_TECHNICAL_TITLE = "Detalhes técnicos"; diff --git a/apps/applyflow/src/components/dashboard/career-pilot-result-mapper.test.ts b/apps/applyflow/src/components/dashboard/career-pilot-result-mapper.test.ts index 17bedd59..457ea2cc 100644 --- a/apps/applyflow/src/components/dashboard/career-pilot-result-mapper.test.ts +++ b/apps/applyflow/src/components/dashboard/career-pilot-result-mapper.test.ts @@ -66,7 +66,58 @@ describe("buildCareerPilotResultModel", () => { expect(model?.summary.length).toBeGreaterThan(0); expect(model?.nextActions.length).toBeLessThanOrEqual(3); expect(model?.scores[0]?.label).toBe("Qualidade da estrutura"); + expect(model?.summary).toMatch(/Análise concluída/i); + expect(model?.technicalLines).toEqual([]); + expect(model?.traceSteps).toEqual([]); + }); + + it("exposes diagnostic metadata outside participant surface", () => { + const agentResult = orchestrateCareerAgents( + { + intent: "analyze_resume", + explicitConsent: true, + context: { + careerBundle: bundle, + selectedSignalIds: [], + analysisInput: { + resumeSnapshot: { + skills: ["TypeScript", "Node.js"], + experiences: [ + { + title: "Engineer", + company: "Acme", + bullets: ["Reduced API latency by 35%"], + }, + ], + }, + targetRole: "Backend Engineer", + }, + }, + }, + "2026-06-21T12:00:00.000Z", + ); + + const response = { + status: "completed" as const, + intent: "analyze_resume" as const, + reviewRequired: true, + safeForClient: true, + hasToken: false, + persisted: false, + executedExternally: false, + warnings: [], + toolProposals: [], + trace: { steps: [{ code: "review_required", message: "Human review", timestamp: "t" }] }, + agentResult, + }; + + const model = buildCareerPilotResultModel({ + intent: "analyze_resume", + response, + participantSurface: false, + }); expect(model?.technicalLines.some((line) => line.includes("Nenhuma candidatura"))).toBe(true); + expect(model?.traceSteps.length).toBeGreaterThan(0); }); it("maps ATS score label to compatibilidade estimada", () => { diff --git a/apps/applyflow/src/components/dashboard/career-pilot-result-mapper.ts b/apps/applyflow/src/components/dashboard/career-pilot-result-mapper.ts index 28472664..29f48c2a 100644 --- a/apps/applyflow/src/components/dashboard/career-pilot-result-mapper.ts +++ b/apps/applyflow/src/components/dashboard/career-pilot-result-mapper.ts @@ -13,6 +13,12 @@ export type CareerPilotScoreItem = { max?: number; }; +export type CareerPilotBulletSuggestion = { + original: string; + recommendation: string; + section?: string; +}; + export type CareerPilotResultModel = { flowTitle: string; summary: string; @@ -22,10 +28,23 @@ export type CareerPilotResultModel = { risks: string[]; scores: CareerPilotScoreItem[]; evidence: string[]; + bulletSuggestions: CareerPilotBulletSuggestion[]; + humanReviewNotice: string; technicalLines: string[]; traceSteps: { code: string; message: string }[]; }; +const INTERNAL_EVIDENCE_PATTERN = + /^(score:|pontuação|pontuacao|resume_score|pontuação_currículo|skills:|bullets:|quantified|mensuráveis|vagos:)/i; + +function isParticipantSafeEvidence(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return false; + if (INTERNAL_EVIDENCE_PATTERN.test(trimmed)) return false; + if (/^[\w_]+:\d+/i.test(trimmed)) return false; + return true; +} + export function takeTopUnique(items: string[], limit: number): string[] { const seen = new Set(); const result: string[] = []; @@ -43,20 +62,44 @@ export function takeTopUnique(items: string[], limit: number): string[] { return result; } +function mapBulletSuggestions(analysis: ResumeAnalysis): CareerPilotBulletSuggestion[] { + return (analysis.bulletRecommendations ?? []) + .filter((item) => !item.reason?.includes("forte")) + .slice(0, 5) + .map((item) => ({ + original: item.originalSummary, + recommendation: item.recommendation, + section: item.section, + })); +} + function mapResumeAnalysis( summary: string, analysis: ResumeAnalysis, agentResult: CareerAgentResult, + participantSurface: boolean, ): Omit { const strengths = analysis.strengths ?? []; const weaknesses = analysis.weaknesses ?? []; const missingEvidence = analysis.missingEvidence ?? []; - const bulletRecommendations = analysis.bulletRecommendations ?? []; const sectionRecommendations = analysis.sectionRecommendations ?? []; const risks = analysis.risks ?? []; const nextActions = analysis.nextActions ?? []; const evidence = agentResult.evidence ?? []; + const participantEvidence = participantSurface + ? takeTopUnique(sectionRecommendations, 3) + : takeTopUnique( + [ + ...(analysis.bulletRecommendations ?? []).map( + (item) => `${item.section}: ${item.recommendation}`, + ), + ...sectionRecommendations, + ...evidence.filter(isParticipantSafeEvidence), + ], + 8, + ); + return { summary, strengths: takeTopUnique(strengths, 3), @@ -64,14 +107,9 @@ function mapResumeAnalysis( nextActions: takeTopUnique([...nextActions, ...sectionRecommendations], 3), risks: takeTopUnique(risks, 5), scores: [{ label: "Qualidade da estrutura", value: analysis.score, max: 100 }], - evidence: takeTopUnique( - [ - ...bulletRecommendations.map((item) => `${item.section}: ${item.recommendation}`), - ...sectionRecommendations, - ...evidence, - ], - 8, - ), + evidence: participantEvidence, + bulletSuggestions: mapBulletSuggestions(analysis), + humanReviewNotice: "Revise cada sugestão com critério humano antes de alterar seu currículo.", }; } @@ -79,6 +117,7 @@ function mapAtsAnalysis( summary: string, analysis: AtsAnalysis, agentResult: CareerAgentResult, + participantSurface: boolean, ): Omit { const requiredRequirementCoverage = analysis.requiredRequirementCoverage ?? []; const matchedKeywords = analysis.matchedKeywords ?? []; @@ -117,13 +156,17 @@ function mapAtsAnalysis( scores: [ { label: "Compatibilidade estimada", value: analysis.compatibilityScore, max: 100 }, ], - evidence: takeTopUnique( - [ - ...requiredRequirementCoverage.map((item) => `${item.requirement}: ${item.status}`), - ...evidence, - ], - 8, - ), + evidence: participantSurface + ? takeTopUnique(recommendations, 3) + : takeTopUnique( + [ + ...requiredRequirementCoverage.map((item) => `${item.requirement}: ${item.status}`), + ...evidence.filter(isParticipantSafeEvidence), + ], + 8, + ), + bulletSuggestions: [], + humanReviewNotice: "Revise cada sugestão com critério humano antes de alterar seu currículo.", }; } @@ -131,6 +174,7 @@ function mapCareerStrategyPlan( summary: string, plan: CareerStrategyPlan, agentResult: CareerAgentResult, + participantSurface: boolean, ): Omit { const priorityRoles = plan.priorityRoles ?? []; const skillPriorities = plan.skillPriorities ?? []; @@ -155,22 +199,26 @@ function mapCareerStrategyPlan( nextActions: takeTopUnique([...thirtyDayPlan, ...applicationStrategy], 3), risks: takeTopUnique(risks, 5), scores: [], - evidence: takeTopUnique( - [ - ...portfolioPriorities, - ...sixtyDayPlan.map((item) => `60 dias: ${item}`), - ...ninetyDayPlan.map((item) => `90 dias: ${item}`), - ...evidence, - ], - 8, - ), + evidence: participantSurface + ? takeTopUnique([...thirtyDayPlan, ...applicationStrategy], 3) + : takeTopUnique( + [ + ...portfolioPriorities, + ...sixtyDayPlan.map((item) => `60 dias: ${item}`), + ...ninetyDayPlan.map((item) => `90 dias: ${item}`), + ...evidence.filter(isParticipantSafeEvidence), + ], + 8, + ), + bulletSuggestions: [], + humanReviewNotice: "Revise cada sugestão com critério humano antes de alterar seu currículo.", }; } -function fallbackFromAgentResult(agentResult: CareerAgentResult): Omit< - CareerPilotResultModel, - "flowTitle" | "technicalLines" | "traceSteps" -> { +function fallbackFromAgentResult( + agentResult: CareerAgentResult, + participantSurface: boolean, +): Omit { const findings = agentResult.findings ?? []; const recommendations = agentResult.recommendations ?? []; const warnings = agentResult.warnings ?? []; @@ -193,7 +241,11 @@ function fallbackFromAgentResult(agentResult: CareerAgentResult): Omit< nextActions: takeTopUnique(recommendations.map((item) => item.title), 3), risks: takeTopUnique(warnings.map((item) => item.message), 5), scores: [], - evidence: takeTopUnique(evidence, 8), + evidence: participantSurface + ? [] + : takeTopUnique(evidence.filter(isParticipantSafeEvidence), 8), + bulletSuggestions: [], + humanReviewNotice: "Revise cada sugestão com critério humano antes de alterar seu currículo.", }; } @@ -213,7 +265,9 @@ function flowTitleForIntent(intent: CareerChatIntent): string { export function buildCareerPilotResultModel(input: { intent: CareerChatIntent; response: CareerChatResponse; + participantSurface?: boolean; }): CareerPilotResultModel | null { + const participantSurface = input.participantSurface !== false; const agentResult = input.response.agentResult; if (!agentResult || input.response.status !== "completed") { return null; @@ -226,28 +280,30 @@ export function buildCareerPilotResultModel(input: { let core: Omit; if (agentResult.resumeAnalysis) { - core = mapResumeAnalysis(summary, agentResult.resumeAnalysis, agentResult); + core = mapResumeAnalysis(summary, agentResult.resumeAnalysis, agentResult, participantSurface); } else if (agentResult.atsAnalysis) { - core = mapAtsAnalysis(summary, agentResult.atsAnalysis, agentResult); + core = mapAtsAnalysis(summary, agentResult.atsAnalysis, agentResult, participantSurface); } else if (agentResult.careerStrategyPlan) { - core = mapCareerStrategyPlan(summary, agentResult.careerStrategyPlan, agentResult); + core = mapCareerStrategyPlan(summary, agentResult.careerStrategyPlan, agentResult, participantSurface); } else { - core = fallbackFromAgentResult(agentResult); + core = fallbackFromAgentResult(agentResult, participantSurface); } - const technicalLines = [ - "Nenhuma candidatura foi enviada.", - "Nenhuma alteração externa foi executada.", - input.response.persisted === false - ? "Seus dados não foram armazenados nesta sessão." - : "Registro limitado conforme consentimento.", - `Revisão humana necessária: ${input.response.reviewRequired ? "sim" : "não"}`, - ]; + const technicalLines = participantSurface + ? [] + : [ + "Nenhuma candidatura foi enviada.", + "Nenhuma alteração externa foi executada.", + input.response.persisted === false + ? "Seus dados não foram armazenados nesta sessão." + : "Registro limitado conforme consentimento.", + `Revisão humana necessária: ${input.response.reviewRequired ? "sim" : "não"}`, + ]; return { flowTitle: flowTitleForIntent(input.intent), ...core, technicalLines, - traceSteps: input.response.trace?.steps ?? [], + traceSteps: participantSurface ? [] : (input.response.trace?.steps ?? []), }; } diff --git a/apps/applyflow/src/components/dashboard/career-pilot-result-view.test.tsx b/apps/applyflow/src/components/dashboard/career-pilot-result-view.test.tsx index 33e1e5e4..b2d979d5 100644 --- a/apps/applyflow/src/components/dashboard/career-pilot-result-view.test.tsx +++ b/apps/applyflow/src/components/dashboard/career-pilot-result-view.test.tsx @@ -17,20 +17,37 @@ const model: CareerPilotResultModel = { risks: ["Bullets genéricos podem reduzir impacto"], scores: [{ label: "Qualidade da estrutura", value: 72, max: 100 }], evidence: ["Experiências: detalhar impacto"], + bulletSuggestions: [ + { + original: "Desenvolvi APIs REST em Node.js.", + recommendation: "Explique quantos parceiros foram integrados, somente se esses dados forem reais.", + }, + ], + humanReviewNotice: "Revise cada sugestão com critério humano antes de alterar seu currículo.", technicalLines: ["Nenhuma candidatura foi enviada."], traceSteps: [{ code: "review_required", message: "Human review required" }], }; describe("CareerPilotResultView", () => { - it("renders participant hierarchy with technical details collapsed by default", () => { + it("renders participant hierarchy with technical details hidden by default", () => { const html = renderToStaticMarkup(); expect(html.indexOf("Resumo")).toBeLessThan(html.indexOf("Principais achados")); - expect(html.indexOf("Principais achados")).toBeLessThan(html.indexOf("Próximas ações")); - expect(html.indexOf("Próximas ações")).toBeLessThan(html.indexOf("Detalhes técnicos")); - expect(html).toContain(" { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Detalhes técnicos"); + expect(html).toContain("review_required"); }); it("shows Portuguese flow titles for the three pilot flows", () => { diff --git a/apps/applyflow/src/components/dashboard/career-pilot-result-view.tsx b/apps/applyflow/src/components/dashboard/career-pilot-result-view.tsx index 7ed98c9b..07ebf2c3 100644 --- a/apps/applyflow/src/components/dashboard/career-pilot-result-view.tsx +++ b/apps/applyflow/src/components/dashboard/career-pilot-result-view.tsx @@ -4,8 +4,10 @@ import type { CareerChatIntent } from "@devflow/career-core"; import { ApplyFlowCard } from "@/components/ui/ApplyFlowCard"; import type { CareerPilotResultModel } from "./career-pilot-result-mapper"; import { + CAREER_PILOT_RESULT_BULLET_SUGGESTIONS_TITLE, CAREER_PILOT_RESULT_EVIDENCE_TITLE, CAREER_PILOT_RESULT_NEXT_ACTIONS_TITLE, + CAREER_PILOT_RESULT_REVIEW_NOTICE, CAREER_PILOT_RESULT_RISKS_TITLE, CAREER_PILOT_RESULT_SUMMARY_TITLE, CAREER_PILOT_RESULT_TECHNICAL_TITLE, @@ -141,6 +143,55 @@ export function CareerPilotEvidenceDetails({ evidence }: { evidence: string[] }) ); } +export function CareerPilotBulletSuggestions({ + suggestions = [], +}: { + suggestions?: CareerPilotResultModel["bulletSuggestions"]; +}) { + if (suggestions.length === 0) { + return null; + } + + return ( +
+

+ {CAREER_PILOT_RESULT_BULLET_SUGGESTIONS_TITLE} +

+
    + {suggestions.map((item) => ( +
  • +

    Original:

    +

    {item.original}

    +

    Sugestão:

    +

    {item.recommendation}

    +
  • + ))} +
+
+ ); +} + +export function CareerPilotHumanReviewNotice({ notice }: { notice: string }) { + if (!notice) { + return null; + } + + return ( +

+ {notice} +

+ ); +} + export function CareerPilotTechnicalDetails({ technicalLines, traceSteps, @@ -191,15 +242,18 @@ export function CareerPilotTechnicalDetails({ export function CareerPilotResultView({ model, intent, + participantSurface = true, }: { model: CareerPilotResultModel; intent?: CareerChatIntent; + participantSurface?: boolean; }) { const resolvedIntent = intent && isCareerPilotIntent(intent) ? intent : undefined; const primaryScore = model.scores[0]; const completionMessage = resolvedIntent ? careerPilotCompletionMessage(resolvedIntent, model.flowTitle) : `${model.flowTitle} concluída`; + const showDiagnostics = !participantSurface; return ( + - - + + {showDiagnostics ? ( + <> + + + + ) : null} ); } diff --git a/apps/applyflow/src/components/dashboard/career-pilot-resume-line-parser.test.ts b/apps/applyflow/src/components/dashboard/career-pilot-resume-line-parser.test.ts new file mode 100644 index 00000000..f0248b7b --- /dev/null +++ b/apps/applyflow/src/components/dashboard/career-pilot-resume-line-parser.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { __resumeAnalystTestUtils } from "../../../../../packages/career-core/src/career-agents/agents/resume-analyst"; +import { + classifyResumeLine, + extractResumeBulletLines, + extractResumeExperiences, + isExperienceHeader, + parseExperienceHeader, +} from "./career-pilot-resume-line-parser"; + +const MARIA_RESUME = `Maria Souza — Desenvolvedora de Software Sênior + +Experiência profissional +TechCorp (2021–presente) — Desenvolvedora Backend +Desenvolvi APIs REST em Node.js para integração com parceiros. +Reduzi o tempo de deploy em 30% com pipelines CI/CD. +Liderei um squad de 4 pessoas em projeto de migração cloud.`; + +describe("isExperienceHeader", () => { + it.each([ + "TechCorp (2021–presente) — Desenvolvedora Backend", + "TechCorp — Desenvolvedora Backend — 2021–2024", + "TechCorp | Desenvolvedora Backend | 2021–2024", + "Desenvolvedora Backend — TechCorp — jan/2021 a dez/2023", + "TechCorp (2021 - atual)", + "Empresa X — Engenheiro de Software", + ])("detects experience header: %s", (line) => { + expect(isExperienceHeader(line)).toBe(true); + }); + + it.each([ + "Reduzi o tempo de deploy em 30% entre 2021 e 2023.", + "Liderei 4 pessoas durante a migração de 2022.", + "Desenvolvi a plataforma usada por 10 mil usuários.", + "Desenvolvi APIs REST em Node.js para integração com parceiros.", + ])("does not treat action bullets as headers: %s", (line) => { + expect(isExperienceHeader(line)).toBe(false); + }); +}); + +describe("parseExperienceHeader", () => { + it("parses company, title and period from parenthetical format", () => { + expect(parseExperienceHeader("TechCorp (2021–presente) — Desenvolvedora Backend")).toEqual({ + company: "TechCorp", + title: "Desenvolvedora Backend", + period: "2021–presente", + }); + }); + + it("parses pipe-separated format", () => { + const parsed = parseExperienceHeader("TechCorp | Desenvolvedora Backend | 2021–2024"); + expect(parsed?.company).toBe("TechCorp"); + expect(parsed?.title).toBe("Desenvolvedora Backend"); + expect(parsed?.period).toBe("2021–2024"); + }); + + it("parses role-first format with month range", () => { + const parsed = parseExperienceHeader("Desenvolvedora Backend — TechCorp — jan/2021 a dez/2023"); + expect(parsed?.company).toBe("TechCorp"); + expect(parsed?.title).toBe("Desenvolvedora Backend"); + expect(parsed?.period).toMatch(/jan\/2021/i); + }); +}); + +describe("extractResumeExperiences", () => { + it("P1 — excludes experience headers from bullets (Maria fixture)", () => { + const summary = "Desenvolvedora de Software Sênior"; + const experiences = extractResumeExperiences(MARIA_RESUME, summary); + expect(experiences).toHaveLength(1); + expect(experiences[0]?.company).toBe("TechCorp"); + expect(experiences[0]?.title).toBe("Desenvolvedora Backend"); + expect(experiences[0]?.bullets).toHaveLength(3); + expect(experiences[0]?.bullets.some((bullet) => bullet.includes("TechCorp"))).toBe(false); + expect(experiences[0]?.bullets.some((bullet) => bullet.includes("2021"))).toBe(false); + }); + + it("keeps real bullets with years in the sentence", () => { + const experiences = extractResumeExperiences( + "TechCorp (2021–presente) — Backend\nReduzi custos em 20% entre 2021 e 2023.", + ); + expect(experiences[0]?.bullets).toEqual(["Reduzi custos em 20% entre 2021 e 2023."]); + expect(__resumeAnalystTestUtils.hasMeaningfulMetric(experiences[0]?.bullets[0] ?? "")).toBe(true); + }); + + it("classifies action lines as bullets before headers", () => { + expect(classifyResumeLine("Desenvolvi APIs REST em Node.js para integração com parceiros.")).toBe( + "bullet", + ); + expect(classifyResumeLine("Responsável por algumas tarefas.")).not.toBe("experience_header"); + }); +}); + +describe("extractResumeBulletLines", () => { + it("returns only bullet lines without headers", () => { + const lines = extractResumeBulletLines(MARIA_RESUME, "Desenvolvedora de Software Sênior"); + expect(lines).toHaveLength(3); + expect(lines.every((line) => !isExperienceHeader(line))).toBe(true); + }); +}); diff --git a/apps/applyflow/src/components/dashboard/career-pilot-resume-line-parser.ts b/apps/applyflow/src/components/dashboard/career-pilot-resume-line-parser.ts new file mode 100644 index 00000000..844fbad6 --- /dev/null +++ b/apps/applyflow/src/components/dashboard/career-pilot-resume-line-parser.ts @@ -0,0 +1,323 @@ +import { __resumeAnalystTestUtils } from "../../../../../packages/career-core/src/career-agents/agents/resume-analyst"; + +export type ResumeLineKind = + | "section_heading" + | "identity_role" + | "experience_header" + | "bullet" + | "skill_line" + | "summary" + | "unknown"; + +export type ParsedExperienceHeader = { + company?: string; + title?: string; + period?: string; +}; + +export type ParsedResumeExperience = { + title: string; + company: string; + period?: string; + bullets: string[]; +}; + +const SECTION_HEADER_PATTERN = + /^(experi[eê]ncia(\s+profissional)?|form[aã]o(\s+acad[eê]mica)?|compet[eê]ncias|competencias|habilidades|skills|education|experience|projects?|projetos?|certifica[cç][oõ]es|idiomas|resumo( profissional)?|summary|work history|employment)$/i; + +const SKILL_LINE_PATTERN = /^(compet[eê]ncias|competencias|habilidades|skills)\s*:/i; + +const IDENTITY_LINE_PATTERN = /^([\p{L}\s.'-]+)\s*[—–-]\s*([\p{L}\s.'-]+)$/u; + +const PROFESSIONAL_TITLE_PATTERN = + /\b(desenvolvedor|desenvolvedora|engenheir|analista|designer|gerente|coordenador|consultor|arquiteto|software|backend|frontend|full[\s-]?stack|sênior|senior|pleno|júnior|junior)\b/i; + +const PERIOD_IN_PARENS_PATTERN = + /[\((]\s*((?:19|20)\d{2}\s*[–\-—]\s*(?:presente|atual|até o momento|(19|20)\d{2}))\s*[\))]/iu; + +const YEAR_RANGE_PATTERN = + /\b(19|20)\d{2}\s*[–\-—]\s*(?:presente|atual|até o momento|(19|20)\d{2})\b/i; + +const MONTH_YEAR_RANGE_PATTERN = + /\b(jan|fev|mar|abr|mai|jun|jul|ago|set|out|nov|dez)[./]?\d{2,4}\s*(?:a|até|–|-)\s*(jan|fev|mar|abr|mai|jun|jul|ago|set|out|nov|dez)[./]?\d{2,4}\b/i; + +const HEADER_SEPARATOR_PATTERN = /\s*(?:[—–|]|\-)\s*/; + +const { startsWithActionVerb } = __resumeAnalystTestUtils; + +function cleanLine(line: string): string { + return line.replace(/^[\s•\-*]+/, "").trim(); +} + +function isSectionHeaderLine(line: string): boolean { + const trimmed = line.trim(); + if (trimmed.length > 60 || startsWithActionVerb(trimmed)) { + return false; + } + return SECTION_HEADER_PATTERN.test(trimmed); +} + +function isSkillListLine(line: string): boolean { + return SKILL_LINE_PATTERN.test(line.trim()); +} + +function looksLikePersonName(text: string): boolean { + const trimmed = text.trim(); + if ( + /\b(empresa|corp|corporation|ltd|inc|tech|s\.?a\.?|group|labs|consulting|solutions)\b/i.test( + trimmed, + ) + ) { + return false; + } + if (/\d|[\((]/.test(trimmed)) { + return false; + } + const words = trimmed.split(/\s+/).filter(Boolean); + if (words.length < 2 || words.length > 4) { + return false; + } + return words.every((word) => /^[\p{L}.'-]+$/u.test(word)); +} + +function parseIdentityLine(line: string): { name: string; role: string } | null { + const match = line.trim().match(IDENTITY_LINE_PATTERN); + if (!match?.[1] || !match[2]) { + return null; + } + const name = match[1].trim(); + const role = match[2].trim(); + if (!looksLikePersonName(name) || !looksLikeRole(role) || extractPeriod(line)) { + return null; + } + return { name, role }; +} + +function extractPeriod(text: string): string | undefined { + const paren = text.match(PERIOD_IN_PARENS_PATTERN); + if (paren?.[1]) { + return paren[1].trim(); + } + const range = text.match(YEAR_RANGE_PATTERN); + if (range?.[0]) { + return range[0].trim(); + } + const monthRange = text.match(MONTH_YEAR_RANGE_PATTERN); + if (monthRange?.[0]) { + return monthRange[0].trim(); + } + return undefined; +} + +function looksLikeRole(text: string): boolean { + const trimmed = text.trim(); + return PROFESSIONAL_TITLE_PATTERN.test(trimmed) || /\b(de|da|do)\s+\p{L}/iu.test(trimmed); +} + +function looksLikeCompany(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed || trimmed.length > 80) { + return false; + } + if (startsWithActionVerb(trimmed)) { + return false; + } + return !/[.!?]$/.test(trimmed); +} + +export function parseExperienceHeader(line: string): ParsedExperienceHeader | null { + const trimmed = cleanLine(line); + if (!trimmed || startsWithActionVerb(trimmed)) { + return null; + } + + const period = extractPeriod(trimmed); + const withoutPeriod = trimmed + .replace(PERIOD_IN_PARENS_PATTERN, "") + .replace(YEAR_RANGE_PATTERN, "") + .replace(MONTH_YEAR_RANGE_PATTERN, "") + .replace(/\s{2,}/g, " ") + .trim(); + + const parenRoleMatch = trimmed.match( + /^(.+?)\s*[\((]\s*((?:19|20)\d{2}\s*[–\-—]\s*(?:presente|atual|até o momento|(19|20)\d{2}))\s*[\))]\s*[—–-]\s*(.+)$/iu, + ); + if (parenRoleMatch?.[1] && parenRoleMatch[3]) { + return { + company: parenRoleMatch[1].trim(), + title: parenRoleMatch[3].trim(), + period: parenRoleMatch[2]?.trim() ?? period, + }; + } + + const parts = withoutPeriod + .split(HEADER_SEPARATOR_PATTERN) + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 2) { + const [left, right] = parts; + if (looksLikeRole(right) && looksLikeCompany(left)) { + return { company: left, title: right, period }; + } + if (looksLikeRole(left) && looksLikeCompany(right)) { + return { company: right, title: left, period }; + } + if (period) { + return { company: left, title: right, period }; + } + } + + if (parts.length === 3) { + const [first, second, third] = parts; + if (extractPeriod(third)) { + if (looksLikeRole(second)) { + return { company: first, title: second, period: third }; + } + return { company: second, title: first, period: third }; + } + if (looksLikeRole(second)) { + return { company: first, title: second, period: extractPeriod(third) ?? period }; + } + return { company: second, title: first, period: extractPeriod(third) ?? period }; + } + + if (period && parts.length === 1 && looksLikeCompany(parts[0]!)) { + return { company: parts[0], period }; + } + + return null; +} + +export function isExperienceHeader(line: string): boolean { + const trimmed = cleanLine(line); + if (!trimmed || startsWithActionVerb(trimmed)) { + return false; + } + if (isSectionHeaderLine(trimmed) || isSkillListLine(trimmed) || parseIdentityLine(trimmed)) { + return false; + } + if (/[.!?]$/.test(trimmed) && trimmed.split(/\s+/).length >= 6) { + return false; + } + + const parsed = parseExperienceHeader(trimmed); + if (parsed) { + return Boolean(parsed.company || parsed.title || parsed.period); + } + + return false; +} + +export function classifyResumeLine( + line: string, + options?: { summaryToExclude?: string }, +): ResumeLineKind { + const trimmed = cleanLine(line); + if (!trimmed) { + return "unknown"; + } + + const summary = options?.summaryToExclude?.trim(); + if (summary && (trimmed.toLowerCase() === summary.toLowerCase() || summary.includes(trimmed))) { + return "summary"; + } + + if (isSectionHeaderLine(trimmed)) { + return "section_heading"; + } + if (isSkillListLine(trimmed)) { + return "skill_line"; + } + if (parseIdentityLine(trimmed)) { + return "identity_role"; + } + if (startsWithActionVerb(trimmed)) { + return "bullet"; + } + if (isExperienceHeader(trimmed)) { + return "experience_header"; + } + if (/^(trabalhei|ajudei|participei|responsável|responsavel|atuei|conhecimento)\b/i.test(trimmed)) { + return "bullet"; + } + if (/[.!?]$/.test(trimmed)) { + return "bullet"; + } + + return "unknown"; +} + +export function extractResumeExperiences( + resumeText: string, + summaryToExclude?: string, +): ParsedResumeExperience[] { + const normalized = resumeText + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + if (!normalized) { + return []; + } + + const rawLines = normalized.split("\n").map(cleanLine).filter(Boolean); + const hasStructuredSections = rawLines.some((line) => isSectionHeaderLine(line)); + let reachedBody = !hasStructuredSections; + + const experiences: ParsedResumeExperience[] = []; + let current: ParsedResumeExperience | null = null; + + const pushCurrent = () => { + if (current && (current.bullets.length > 0 || current.company !== "—" || current.title !== "—")) { + experiences.push(current); + } + current = null; + }; + + for (const line of rawLines) { + if (summaryToExclude && line.toLowerCase() === summaryToExclude.trim().toLowerCase()) { + continue; + } + + const kind = classifyResumeLine(line, { summaryToExclude }); + + if (kind === "section_heading") { + reachedBody = true; + continue; + } + if (kind === "identity_role" || kind === "summary" || kind === "skill_line") { + continue; + } + if (!reachedBody && hasStructuredSections) { + continue; + } + + if (kind === "experience_header") { + pushCurrent(); + const parsed = parseExperienceHeader(line); + current = { + company: parsed?.company?.trim() || "—", + title: parsed?.title?.trim() || "—", + period: parsed?.period, + bullets: [], + }; + continue; + } + + if (kind === "bullet" || kind === "unknown") { + if (!current) { + current = { company: "—", title: "—", bullets: [] }; + } + current.bullets.push(line.slice(0, 500)); + } + } + + pushCurrent(); + return experiences.slice(0, 50); +} + +export function extractResumeBulletLines(resumeText: string, summaryToExclude?: string): string[] { + return extractResumeExperiences(resumeText, summaryToExclude).flatMap((experience) => experience.bullets); +} diff --git a/apps/applyflow/src/components/dashboard/career-pilot-simple-input-content.ts b/apps/applyflow/src/components/dashboard/career-pilot-simple-input-content.ts index 6abbb047..ccd16d94 100644 --- a/apps/applyflow/src/components/dashboard/career-pilot-simple-input-content.ts +++ b/apps/applyflow/src/components/dashboard/career-pilot-simple-input-content.ts @@ -41,3 +41,6 @@ export const CAREER_PILOT_EMPTY_JOB_MESSAGE = "Cole a descrição da vaga que de export const CAREER_PILOT_EMPTY_CAREER_GOAL_MESSAGE = "Informe seu objetivo profissional para montar o plano."; + +export const CAREER_PILOT_INSUFFICIENT_CONTENT_MESSAGE = + "Não foi possível identificar conteúdo suficiente para analisar. Adicione experiências, responsabilidades ou resultados do seu currículo."; diff --git a/apps/applyflow/src/components/dashboard/career-pilot-walkthrough-fixtures.test.ts b/apps/applyflow/src/components/dashboard/career-pilot-walkthrough-fixtures.test.ts new file mode 100644 index 00000000..0bd573c1 --- /dev/null +++ b/apps/applyflow/src/components/dashboard/career-pilot-walkthrough-fixtures.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + createCareerBundle, + orchestrateCareerAgents, + runResumeAnalyst, + type CareerAgentContext, + type CareerAnalysisInput, +} from "@devflow/career-core"; +import { __resumeAnalystTestUtils } from "../../../../../packages/career-core/src/career-agents/agents/resume-analyst"; +import { buildCareerSpecialistFieldsFromSimpleInputs } from "./career-pilot-input-normalizer"; +import { + buildPilotCareerBundleFromFields, + canSubmitResumeAnalysis, +} from "./build-pilot-career-bundle"; +import { buildSpecialistAnalysisInput } from "./career-chat-workspace"; +import { EMPTY_CAREER_PILOT_SIMPLE_INPUTS } from "./career-pilot-simple-inputs"; + +const FIXTURE_A_TEXT = `Maria Souza — Desenvolvedora de Software Sênior + +Experiência profissional +TechCorp (2021–presente) — Desenvolvedora Backend +Desenvolvi APIs REST em Node.js para integração com parceiros externos. +Reduzi o tempo de deploy em 30% com pipelines CI/CD no GitHub Actions. +Liderei um squad de 4 pessoas em projeto de migração para AWS. + +Competências: TypeScript, Node.js, PostgreSQL, AWS, Docker, Git`; + +const FIXTURE_C_TEXT = `Trabalhei com sistemas. +Ajudei em projetos. +Responsável por algumas tarefas. +Conhecimento em tecnologia.`; + +function sampleContext(input: CareerAnalysisInput): CareerAgentContext { + return { + intent: "analyze_resume", + analysisInput: input, + careerBundle: createCareerBundle( + [ + { + id: "fixture", + company: "Co", + role: "Dev", + source: "manual", + requiredSkills: ["—"], + status: "applied", + }, + ], + { mainStack: [], targetRole: "Dev" }, + ), + selectedSignalIds: [], + availableSignals: [], + explicitConsent: true, + }; +} + +function runPilotPipeline(resumeText: string, targetRole = "Engenheiro Backend") { + const fields = buildCareerSpecialistFieldsFromSimpleInputs( + { ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, targetRole, resumeText }, + "analyze_resume", + ); + const bundle = buildPilotCareerBundleFromFields(fields); + const canSubmit = canSubmitResumeAnalysis( + "analyze_resume", + { ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, targetRole, resumeText }, + fields, + ); + const analysisInput = buildSpecialistAnalysisInput({ + action: "analyze_resume", + fields, + mainStack: bundle.candidate?.mainStack ?? [], + fallbackRole: bundle.candidate?.targetRole ?? targetRole, + }); + const result = orchestrateCareerAgents( + { + intent: "analyze_resume", + explicitConsent: true, + context: { + careerBundle: bundle, + selectedSignalIds: [], + analysisInput, + }, + }, + "2026-06-22T12:00:00.000Z", + ); + const bullets = fields.resumeBullets.split("\n").filter(Boolean); + const quantified = bullets.filter((bullet) => + __resumeAnalystTestUtils.hasMeaningfulMetric(bullet), + ).length; + const vague = + result.resumeAnalysis?.bulletRecommendations.filter((item) => item.reason.includes("vago")) + .length ?? 0; + const headerRecommendations = + result.resumeAnalysis?.bulletRecommendations.filter( + (item) => + item.originalSummary.includes("TechCorp") || + (item.originalSummary.includes("2021") && item.originalSummary.includes("presente")), + ).length ?? 0; + const strongWithoutMetric = bullets.filter( + (bullet) => __resumeAnalystTestUtils.classifyBulletStrength(bullet) === "strong_without_metric", + ).length; + const experience = analysisInput?.resumeSnapshot?.experiences?.[0]; + + return { + fields, + canSubmit, + result, + quantified, + vague, + headerRecommendations, + strongWithoutMetric, + experience, + bullets, + }; +} + +describe("career pilot walkthrough fixtures", () => { + it("Fixture A — official simplified input end-to-end", () => { + const { + fields, + canSubmit, + result, + quantified, + vague, + headerRecommendations, + strongWithoutMetric, + experience, + bullets, + } = runPilotPipeline(FIXTURE_A_TEXT); + expect(fields.resumeSummary).toBe("Desenvolvedora de Software Sênior"); + expect(fields.resumeExperienceCompany).toBe("TechCorp"); + expect(fields.resumeExperienceTitle).toBe("Desenvolvedora Backend"); + expect(canSubmit).toBe(true); + expect(result.status).toBe("completed"); + expect(result.resumeAnalysis?.score).toBeGreaterThanOrEqual(70); + expect(result.resumeAnalysis?.score).toBeLessThanOrEqual(95); + expect(bullets).toHaveLength(3); + expect(experience?.company).toBe("TechCorp"); + expect(experience?.title).toBe("Desenvolvedora Backend"); + expect(experience?.bullets).toHaveLength(3); + expect(quantified).toBe(2); + expect(headerRecommendations).toBe(0); + expect(vague).toBe(0); + expect(strongWithoutMetric).toBeGreaterThanOrEqual(1); + expect(result.summary).toMatch(/Análise concluída/i); + expect(result.summary).not.toMatch(/mensurávelis/); + }); + + it("Fixture C — weak resume submits with low score", () => { + const { fields, canSubmit, result, vague } = runPilotPipeline(FIXTURE_C_TEXT); + expect(fields.resumeSummary).toBe(""); + expect(canSubmit).toBe(true); + expect(result.status).toBe("completed"); + expect(result.resumeAnalysis?.score).toBeGreaterThanOrEqual(0); + expect(result.resumeAnalysis?.score).toBeLessThanOrEqual(35); + expect(vague).toBeGreaterThan(0); + }); + + it("Fixture B/D/E — analyst snapshots remain stable", () => { + const fixtureB = runResumeAnalyst( + sampleContext({ + resumeSnapshot: { + skills: ["TypeScript", "React", "Node.js", "Express", "PostgreSQL"], + experiences: [ + { + title: "Desenvolvedor Full Stack", + company: "Startup", + bullets: [ + "Desenvolvi interfaces com React e Next.js para o produto principal.", + "Implementei autenticação JWT com cookies HttpOnly.", + "Criei APIs REST com Node.js, Express e Prisma.", + ], + }, + ], + }, + }), + ); + const fixtureD = runResumeAnalyst( + sampleContext({ + resumeSnapshot: { + summary: "Backend engineer focused on reliable services.", + skills: ["TypeScript", "Node.js", "PostgreSQL"], + experiences: [ + { + title: "Senior Engineer", + company: "Acme", + bullets: [ + "Reduced API latency by 35% by optimizing PostgreSQL queries.", + "Led migration of 12 services to TypeScript.", + ], + }, + ], + }, + }), + ); + const fixtureE = runResumeAnalyst( + sampleContext({ + resumeSnapshot: { + summary: "Profissional com experiência em integração e liderança técnica.", + skills: ["TypeScript"], + experiences: [ + { + title: "Engenheiro", + company: "Corp", + bullets: [ + "Construí integração com parceiros internacionais e documentação em inglês.", + "Coordenei entregas contínuas com foco em qualidade.", + ], + }, + ], + }, + }), + ); + + expect(fixtureB.resumeAnalysis.score).toBeGreaterThanOrEqual(30); + expect(fixtureD.resumeAnalysis.score).toBeGreaterThan(55); + expect(fixtureE.summary).toMatch(/Análise concluída/i); + expect(fixtureE.resumeAnalysis.strengths.join(" ")).toMatch(/liderança|integração|contínuas/i); + }); +}); diff --git a/docs/career-suite/PILOT-RUNBOOK.md b/docs/career-suite/PILOT-RUNBOOK.md index 5a8cefba..d646eaab 100644 --- a/docs/career-suite/PILOT-RUNBOOK.md +++ b/docs/career-suite/PILOT-RUNBOOK.md @@ -11,7 +11,7 @@ document complements — and does not replace — [`PILOT-VALIDATION.md`](./PILO ## Product & UX gate (before P01) -**P01 scheduling paused** during simplified-input implementation (2026-06-22). See [`SIMPLIFIED-INPUT-UX.md`](./SIMPLIFIED-INPUT-UX.md). +**P01 scheduling paused** during resume analysis quality fix (2026-06-22). See [`RESUME-ANALYSIS-QUALITY.md`](./RESUME-ANALYSIS-QUALITY.md) and issue #138. Simplified inputs merged in PR #137. | Gate | Source | |------|--------| @@ -21,9 +21,9 @@ document complements — and does not replace — [`PILOT-VALIDATION.md`](./PILO | No open P1 UX blockers | W-01/W-02 resolved; W-04 non-blocking P2 outside pilot surface | | Technical Preview smoke | [`PILOT-VALIDATION.md`](./PILOT-VALIDATION.md) + `main` smoke 2026-06-22 | -Current decision (2026-06-22): **`SIMPLIFIED INPUT UX IN PROGRESS`** — P01 scheduling paused until Preview visual validation of [`SIMPLIFIED-INPUT-UX.md`](./SIMPLIFIED-INPUT-UX.md) on the feature branch. +Current decision (2026-06-22): **`RESUME ANALYSIS QUALITY FIX IN PROGRESS`** — P01 scheduling paused until issue #138 is merged; simplified inputs on `main` @ `e5d5443`. -**Operational status:** `P01 SCHEDULING PAUSED — SIMPLIFIED INPUT UX IN PROGRESS` — use [`P01-OPERATIONAL-KIT.md`](./P01-OPERATIONAL-KIT.md) for moderator scripts; do not schedule P01 until simplified inputs are validated on Preview. +**Operational status:** `P01 SCHEDULING PAUSED — RESUME ANALYSIS QUALITY FIX IN PROGRESS` — use [`P01-OPERATIONAL-KIT.md`](./P01-OPERATIONAL-KIT.md) for moderator scripts; do not schedule P01 until Portuguese resume analysis is validated on Preview. --- diff --git a/docs/career-suite/RESUME-ANALYSIS-QUALITY.md b/docs/career-suite/RESUME-ANALYSIS-QUALITY.md new file mode 100644 index 00000000..3b12ff75 --- /dev/null +++ b/docs/career-suite/RESUME-ANALYSIS-QUALITY.md @@ -0,0 +1,158 @@ +# Career Suite — Resume Analysis Quality (Portuguese) + +Quality gate before **P01** closed pilot. Addresses [issue #138](https://github.com/devflow-modules/devflow/issues/138). + +**Operational status:** `P01 SCHEDULING PAUSED — RESUME ANALYSIS QUALITY FIX IN PROGRESS` + +--- + +## Problem found (pre-fix) + +Deterministic `resume_analyst` produced poor participant-facing output for Portuguese resumes: + +| Symptom | Cause | +|---------|--------| +| `Desenvolvi`, `Reduzi`, `Liderei` not recognized as action verbs | English-only verb catalog | +| Professional summary empty | Simplified input did not populate `resumeSnapshot.summary` | +| Artificially low score (~35) | Old rubric penalized missing projects/education sections | +| Participant content in English | Agent summary and labels not localized | +| Technical trace visible in pilot | UI exposed orchestration codes to participants | +| Generic recommendations | Vague bullets received one-size-fits-all guidance | + +**Example (Maria / TechCorp fixture) — before:** + +```text +score: ~35 +summary: absent +vague bullets flagged: 5 +participant language: English +trace: visible in pilot surface +``` + +**After fix (same fixture):** + +```text +score: 65–85 (normalized rubric) +summary: "Desenvolvedora de Software com experiência em backend e integrações." +measurable results: 2 (30%, 4 pessoas) +action verbs: Desenvolvi, Reduzi, Liderei recognized +participant language: Portuguese +trace: hidden on pilot participant surface +``` + +--- + +## Portuguese action verbs + +Catalog in `packages/career-core/src/career-agents/agents/resume-analyst.ts`: + +- **1ª pessoa:** desenvolvi, implementei, criei, liderei, reduzi, aumentei, automatizei, migrei, otimizei, entreguei, projetei, arquitetei, refatorei, gerenciei, coordenei, estruturei, integrei, construí, melhorei, participei +- **3ª pessoa:** desenvolveu, implementou, criou, liderou, reduziu, automatizou, otimizou, integrou, construiu, melhorou, … + +**Matching rules:** + +- Case-insensitive first token +- Strip leading/trailing punctuation (`Desenvolvi,` → `desenvolvi`) +- Preserve accents (`construí`) +- Exact token match only — no partial matches (`desenvolvimento` rejected) +- Nouns like `desenvolvimento`, `experiência`, `liderança` are never verbs + +--- + +## Professional summary extraction + +`extractProfessionalSummary()` in `career-pilot-input-normalizer.ts`: + +1. Reads the first descriptive paragraph **before** section headers (`Experiência profissional`, etc.) +2. Stops at action-verb lines (experience bullets) +3. Populates `resumeSnapshot.summary` via `buildSpecialistAnalysisInput()` +4. Excludes the same text from bullet extraction +5. Does **not** use LLM summarization — original text preserved (max 500 chars) + +--- + +## Scoring rubric (normalized) + +Only **applicable** dimensions contribute to the denominator: + +| Dimension | Max points | +|-----------|------------| +| Resumo profissional | 15 | +| Skills | 20 | +| Qualidade dos bullets | 30 | +| Resultados mensuráveis | 25 | +| Evidência de contexto/liderança | 10 | +| **Total** | **100** | + +```text +score = (pontos obtidos / pontos aplicáveis) × 100 +``` + +**Optional sections:** projects and education are **never penalized** when absent from simplified input. + +**Score bands (fixtures):** + +| Profile | Expected range | +|---------|----------------| +| Empty / very weak | 0–30 | +| Basic, no metrics | 30–55 | +| Coherent skills + verbs | 55–75 | +| Strong with verifiable metrics | 70–90 | + +--- + +## Participant localization + +All agent output for `analyze_resume` is Portuguese: + +- Summary, strengths, weaknesses, findings, recommendations, risks, nextActions +- Bullet recommendations cite the original text and explain what is strong or missing +- **Never invent metrics** — suggestions say “somente se esses dados forem reais” + +English resumes remain supported (regression fixture D). + +--- + +## Technical details visibility + +| Surface | Trace / codes / raw evidence | +|---------|------------------------------| +| Pilot participant (`participantSurface=true`) | **Hidden** | +| Developer diagnostic (`participantSurface=false`) | Available | +| `/dashboard/system-status` | Available | +| Backend orchestrator | Unchanged (observability preserved) | + +--- + +## Deterministic limits + +- No external LLM, providers, OAuth, database, or persistence +- Parser does not infer impact beyond explicit digits, `%`, or people counts +- Simplified input may omit projects/education — rubric adapts +- Human review always required (`reviewRequired: true`) + +--- + +## Tests + +| Package | File | +|---------|------| +| `@devflow/career-core` | `resume-analyst-portuguese.test.ts` (fixtures A–E) | +| `@devflow/career-core` | `specialist-agents.test.ts` | +| `applyflow` | `career-pilot-input-normalizer.test.ts` | +| `applyflow` | `career-pilot-result-mapper.test.ts` | +| `applyflow` | `career-pilot-result-view.test.tsx` | + +--- + +## Related docs + +- [`SIMPLIFIED-INPUT-UX.md`](./SIMPLIFIED-INPUT-UX.md) +- [`PILOT-RUNBOOK.md`](./PILOT-RUNBOOK.md) +- [`agents/RESUME-AGENT.md`](./agents/RESUME-AGENT.md) — update when agent contract changes + +--- + +## Infrastructure note (P2, out of scope) + +Vercel `turbo-ignore applyflow` may skip Preview deployments for ApplyFlow PRs. Tracked separately; does not block this quality fix. diff --git a/docs/career-suite/SIMPLIFIED-INPUT-UX.md b/docs/career-suite/SIMPLIFIED-INPUT-UX.md index 800907a5..55c38e08 100644 --- a/docs/career-suite/SIMPLIFIED-INPUT-UX.md +++ b/docs/career-suite/SIMPLIFIED-INPUT-UX.md @@ -33,7 +33,8 @@ Participants use `CareerPilotSimpleInputs`: `career-pilot-input-normalizer.ts` maps simple inputs to `CareerSpecialistFields`: - `normalizeResumeText` / `normalizeJobDescription` — line breaks, empty lines, length limits -- `extractResumeLines` — bullets from lines or sentences (no invented content) +- `extractProfessionalSummary` — first descriptive paragraph before sections/experience bullets; feeds `resumeSnapshot.summary` (see [`RESUME-ANALYSIS-QUALITY.md`](./RESUME-ANALYSIS-QUALITY.md)) +- `extractResumeLines` — bullets from lines or sentences (excludes summary paragraph; no invented content) - `extractLikelySkills` — small catalog, case-insensitive, deduplicated - `extractJobRequirements` — lines or sentences from job text - `extractJobKeywords` — Unicode-safe tokenization (`/[^\p{L}\p{N}+#.]+/u`) @@ -71,6 +72,6 @@ Data stays in React state for the open page and in the current `/career-chat/lib ## Pilot impact -**Operational status:** `P01 SCHEDULING PAUSED — SIMPLIFIED INPUT UX IN PROGRESS` +**Operational status:** `P01 SCHEDULING PAUSED — RESUME ANALYSIS QUALITY FIX IN PROGRESS` — see [`RESUME-ANALYSIS-QUALITY.md`](./RESUME-ANALYSIS-QUALITY.md). -P01 scheduling remains paused until Preview visual validation (desktop 1440×900, mobile 375×812) is completed on the feature branch PR. +P01 scheduling remains paused until Portuguese resume analysis quality (issue #138) is merged and validated on Preview. diff --git a/packages/career-core/src/career-agents/__tests__/resume-analyst-portuguese.test.ts b/packages/career-core/src/career-agents/__tests__/resume-analyst-portuguese.test.ts new file mode 100644 index 00000000..eae33e1c --- /dev/null +++ b/packages/career-core/src/career-agents/__tests__/resume-analyst-portuguese.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from "vitest"; +import { orchestrateCareerAgents } from "../orchestrator.js"; +import { runResumeAnalyst, __resumeAnalystTestUtils } from "../agents/resume-analyst.js"; +import type { CareerAgentContext, CareerAnalysisInput } from "../types.js"; +import { createSampleCareerBundle } from "./fixtures.js"; + +const REQUESTED_AT = "2026-06-22T12:00:00.000Z"; + +function ctx(analysisInput: CareerAnalysisInput): CareerAgentContext { + return { + intent: "analyze_resume", + analysisInput, + careerBundle: createSampleCareerBundle(), + selectedSignalIds: [], + availableSignals: [], + explicitConsent: true, + }; +} + +/** Fixture A — exemplo atual da Career Suite (Maria / backend PT). */ +const FIXTURE_A: CareerAnalysisInput = { + resumeSnapshot: { + summary: "Desenvolvedora de Software com experiência em backend e integrações.", + skills: ["TypeScript", "Node.js", "PostgreSQL", "AWS"], + experiences: [ + { + title: "Desenvolvedora Backend", + company: "TechCorp", + bullets: [ + "Desenvolvi APIs REST em Node.js para integração com parceiros externos.", + "Reduzi o tempo de deploy em 30% com pipelines CI/CD no GitHub Actions.", + "Liderei um squad de 4 pessoas em projeto de migração para AWS.", + ], + }, + ], + }, + targetRole: "Engenheiro de Software Backend", + targetStack: ["TypeScript", "Node.js", "PostgreSQL"], +}; + +/** Fixture B — currículo sem métricas. */ +const FIXTURE_B: CareerAnalysisInput = { + resumeSnapshot: { + skills: ["TypeScript", "React", "Node.js", "Express", "PostgreSQL"], + experiences: [ + { + title: "Desenvolvedor Full Stack", + company: "Startup", + bullets: [ + "Desenvolvi interfaces com React e Next.js para o produto principal.", + "Implementei autenticação JWT com cookies HttpOnly.", + "Criei APIs REST com Node.js, Express e Prisma.", + ], + }, + ], + }, +}; + +/** Fixture C — currículo fraco. */ +const FIXTURE_C: CareerAnalysisInput = { + resumeSnapshot: { + skills: ["HTML"], + experiences: [ + { + title: "Estagiário", + company: "Local", + bullets: ["Ajudei no time", "Fiz tarefas"], + }, + ], + }, +}; + +/** Fixture D — currículo em inglês (regressão EN). */ +const FIXTURE_D: CareerAnalysisInput = { + resumeSnapshot: { + summary: "Backend engineer focused on reliable services.", + skills: ["TypeScript", "Node.js", "PostgreSQL"], + experiences: [ + { + title: "Senior Engineer", + company: "Acme", + bullets: [ + "Reduced API latency by 35% by optimizing PostgreSQL queries.", + "Led migration of 12 services to TypeScript.", + ], + }, + ], + }, +}; + +/** Fixture E — acentos e Unicode. */ +const FIXTURE_E: CareerAnalysisInput = { + resumeSnapshot: { + summary: "Profissional com experiência em integração e liderança técnica.", + skills: ["TypeScript"], + experiences: [ + { + title: "Engenheiro", + company: "Corp", + bullets: [ + "Construí integração com parceiros internacionais e documentação em inglês.", + "Coordenei entregas contínuas com foco em qualidade.", + ], + }, + ], + }, +}; + +describe("resume_analyst Portuguese action verbs", () => { + const { + startsWithActionVerb, + hasMetric, + hasNumber, + hasMeaningfulMetric, + isVagueBullet, + isStrongBullet, + firstToken, + classifyBulletStrength, + } = __resumeAnalystTestUtils; + + it("recognizes Portuguese past-tense verbs on the first token", () => { + expect(startsWithActionVerb("Desenvolvi APIs REST em Node.js.")).toBe(true); + expect(startsWithActionVerb("Reduzi o tempo de deploy em 30%.")).toBe(true); + expect(startsWithActionVerb("Liderei um squad de 4 pessoas.")).toBe(true); + expect(startsWithActionVerb("Implementei autenticação JWT.")).toBe(true); + expect(startsWithActionVerb("Construí uma plataforma SaaS.")).toBe(true); + }); + + it("strips punctuation from the first token before matching", () => { + expect(firstToken("Desenvolvi,")).toBe("desenvolvi"); + expect(startsWithActionVerb("Desenvolvi, APIs REST.")).toBe(true); + }); + + it("preserves accents and rejects noun prefixes", () => { + expect(startsWithActionVerb("Desenvolvimento de APIs")).toBe(false); + expect(startsWithActionVerb("Experiência com React")).toBe(false); + expect(startsWithActionVerb("Construí integração com parceiros.")).toBe(true); + }); + + it("does not accept partial verb matches", () => { + expect(startsWithActionVerb("Desenvolvedor full stack")).toBe(false); + expect(startsWithActionVerb("Reduzindo custos")).toBe(false); + }); + + it("detects metrics including people counts", () => { + expect(hasMetric("Liderei um squad de 4 pessoas.")).toBe(true); + expect(hasMetric("Reduzi o tempo de deploy em 30%.")).toBe(true); + }); + + it("separates isolated years from meaningful metrics", () => { + expect(hasNumber("TechCorp (2021–presente)")).toBe(true); + expect(hasMeaningfulMetric("TechCorp (2021–presente)")).toBe(false); + expect(hasMeaningfulMetric("Desenvolvedor — 2020 a 2023")).toBe(false); + expect(hasMeaningfulMetric("Reduzi deploy em 30%")).toBe(true); + expect(hasMeaningfulMetric("Liderei 4 pessoas")).toBe(true); + expect(hasMeaningfulMetric("Integrei 12 parceiros")).toBe(true); + expect(hasMeaningfulMetric("Atendi 10 mil usuários")).toBe(true); + expect(hasMeaningfulMetric("Reduzi custos em 20% entre 2021 e 2023.")).toBe(true); + }); + + it("uses correct plural for mensuráveis in participant summary", () => { + const result = runResumeAnalyst(ctx(FIXTURE_A)); + expect(result.summary).toMatch(/mensuráveis/); + expect(result.summary).not.toMatch(/mensurávelis/); + }); + + it("classifies strong bullets without metrics separately from vague bullets", () => { + const { classifyBulletStrength } = __resumeAnalystTestUtils; + expect(classifyBulletStrength("Desenvolvi APIs REST em Node.js para integração com parceiros.")).toBe( + "strong_without_metric", + ); + expect(classifyBulletStrength("Responsável por algumas tarefas.")).toBe("vague"); + expect(isVagueBullet("Desenvolvi APIs REST em Node.js para integração com parceiros.")).toBe(false); + }); +}); + +describe("resume_analyst Portuguese fixtures", () => { + const { hasMeaningfulMetric } = __resumeAnalystTestUtils; + + it("Fixture A — exemplo atual: score coerente, PT, verbos e métricas", () => { + const result = runResumeAnalyst(ctx(FIXTURE_A)); + const analysis = result.resumeAnalysis; + const quantified = FIXTURE_A.resumeSnapshot!.experiences![0]!.bullets!.filter((bullet) => + hasMeaningfulMetric(bullet), + ); + + expect(result.summary).toMatch(/Análise concluída com pontuação estrutural/i); + expect(result.summary).not.toMatch(/Resume review completed/i); + expect(analysis.score).toBeGreaterThanOrEqual(65); + expect(analysis.score).toBeLessThanOrEqual(85); + expect(analysis.strengths.some((s) => /resumo profissional/i.test(s))).toBe(true); + expect(analysis.strengths.some((s) => /métricas verificáveis/i.test(s))).toBe(true); + expect(quantified).toHaveLength(2); + expect(analysis.strengths.some((s) => /liderança/i.test(s))).toBe(true); + expect(analysis.bulletRecommendations.filter((b) => b.reason.includes("vago")).length).toBe(0); + expect(analysis.weaknesses.every((w) => !/\bResume\b/i.test(w))).toBe(true); + }); + + it("Fixture B — sem métricas: score intermediário e recomendação sem inventar números", () => { + const result = runResumeAnalyst(ctx(FIXTURE_B)); + const analysis = result.resumeAnalysis; + + expect(analysis.score).toBeGreaterThanOrEqual(30); + expect(analysis.score).toBeLessThanOrEqual(75); + expect(analysis.weaknesses.some((w) => /mensurável/i.test(w))).toBe(true); + expect(analysis.bulletRecommendations.some((b) => /somente se essa informação for real/i.test(b.recommendation))).toBe( + true, + ); + expect(JSON.stringify(analysis)).not.toMatch(/\d{2,}%/); + }); + + it("Fixture C — currículo fraco: score baixo e orientações úteis", () => { + const result = runResumeAnalyst(ctx(FIXTURE_C)); + const analysis = result.resumeAnalysis; + + expect(analysis.score).toBeGreaterThanOrEqual(0); + expect(analysis.score).toBeLessThanOrEqual(30); + expect(analysis.weaknesses.length).toBeGreaterThan(0); + expect(analysis.nextActions.length).toBeGreaterThan(0); + }); + + it("Fixture D — inglês: regressão positiva para verbos EN", () => { + const result = runResumeAnalyst(ctx(FIXTURE_D)); + expect(result.resumeAnalysis.score).toBeGreaterThan(55); + expect(result.resumeAnalysis.bulletRecommendations.filter((b) => b.reason.includes("vago")).length).toBe(0); + }); + + it("Fixture E — acentos preservados no output", () => { + const result = runResumeAnalyst(ctx(FIXTURE_E)); + expect(result.summary).toMatch(/Análise concluída/i); + expect(result.resumeAnalysis.strengths.join(" ")).toMatch(/integração|liderança|contínuas/i); + }); +}); + +describe("resume_analyst orchestration regression", () => { + it("returns Portuguese participant summary via orchestrator", () => { + const result = orchestrateCareerAgents( + { + intent: "analyze_resume", + explicitConsent: true, + context: { + careerBundle: createSampleCareerBundle(), + selectedSignalIds: [], + analysisInput: FIXTURE_A, + }, + }, + REQUESTED_AT, + ); + + expect(result.status).toBe("completed"); + expect(result.summary).toMatch(/Análise concluída/i); + expect(result.evidence.some((e) => e.startsWith("pontuação_currículo:"))).toBe(true); + }); + + it("flags missing summary in Portuguese", () => { + const result = orchestrateCareerAgents( + { + intent: "analyze_resume", + explicitConsent: true, + context: { + careerBundle: createSampleCareerBundle(), + selectedSignalIds: [], + analysisInput: { + resumeSnapshot: { + skills: ["TypeScript"], + experiences: [ + { title: "Engenheiro", company: "Acme", bullets: ["Ajudei no time com tarefas diversas"] }, + ], + }, + }, + }, + }, + REQUESTED_AT, + ); + + expect(result.resumeAnalysis?.weaknesses.some((w) => /resumo profissional/i.test(w))).toBe(true); + }); + + it("flags exaggeration in Portuguese without inventing metrics", () => { + const result = orchestrateCareerAgents( + { + intent: "analyze_resume", + explicitConsent: true, + context: { + careerBundle: createSampleCareerBundle(), + selectedSignalIds: [], + analysisInput: { + resumeSnapshot: { + summary: "Especialista perfeito em engenharia.", + skills: ["TypeScript", "Node.js"], + experiences: [ + { + title: "Engenheiro", + company: "Acme", + bullets: ["Fui o melhor engenheiro que construiu o sistema perfeito para todos."], + }, + ], + }, + }, + }, + }, + REQUESTED_AT, + ); + expect(result.resumeAnalysis?.risks.some((r) => /exagero/i.test(r))).toBe(true); + }); +}); + +describe("resume_analyst contextual recommendations", () => { + it("suggests API-specific guidance for vague API bullets", () => { + const result = runResumeAnalyst( + ctx({ + resumeSnapshot: { + skills: ["Node.js"], + experiences: [ + { + title: "Dev", + company: "Co", + bullets: ["Desenvolvi APIs REST em Node.js para integração com parceiros."], + }, + ], + }, + }), + ); + const rec = result.resumeAnalysis.bulletRecommendations[0]; + expect(rec?.reason).toMatch(/sem resultado mensurável/i); + expect(rec?.recommendation).toMatch(/ação e contexto técnico/i); + expect(rec?.recommendation).not.toMatch(/30%|50%/); + }); +}); diff --git a/packages/career-core/src/career-agents/__tests__/specialist-agents.test.ts b/packages/career-core/src/career-agents/__tests__/specialist-agents.test.ts index a58cfc70..6c40a0b0 100644 --- a/packages/career-core/src/career-agents/__tests__/specialist-agents.test.ts +++ b/packages/career-core/src/career-agents/__tests__/specialist-agents.test.ts @@ -83,7 +83,7 @@ describe("resume_analyst", () => { expect(result.status).toBe("completed"); const analysis = result.resumeAnalysis; - expect(analysis?.weaknesses.some((w) => w.toLowerCase().includes("summary"))).toBe(true); + expect(analysis?.weaknesses.some((w) => /resumo/i.test(w))).toBe(true); expect(analysis?.bulletRecommendations.length).toBeGreaterThan(0); expect(analysis?.missingEvidence.some((e) => e.toLowerCase().includes("kubernetes"))).toBe(true); }); @@ -105,9 +105,7 @@ describe("resume_analyst", () => { }), REQUESTED_AT, ); - expect(result.resumeAnalysis?.risks.some((r) => r.toLowerCase().includes("overstatement"))).toBe( - true, - ); + expect(result.resumeAnalysis?.risks.some((r) => /exagero/i.test(r))).toBe(true); }); it("blocks when resume snapshot is missing", () => { diff --git a/packages/career-core/src/career-agents/agents/resume-analyst.ts b/packages/career-core/src/career-agents/agents/resume-analyst.ts index 8a82d3d2..0708141f 100644 --- a/packages/career-core/src/career-agents/agents/resume-analyst.ts +++ b/packages/career-core/src/career-agents/agents/resume-analyst.ts @@ -11,9 +11,21 @@ import type { * Deterministic resume analysis. No LLM is involved here: every output is derived * from documented heuristics over the sanitized resume snapshot. The agent never * invents metrics, skills, or experience and never rewrites the resume automatically. + * + * Rubric (normalized 0..100 — only applicable dimensions count): + * | Dimension | Max | + * |-----------------------------------|-----| + * | Resumo profissional | 15 | + * | Skills | 20 | + * | Qualidade dos bullets | 30 | + * | Resultados mensuráveis | 25 | + * | Evidência de contexto/liderança | 10 | + * | Total | 100 | + * + * Projects and education are optional inputs; they are never penalized when absent. */ -const ACTION_VERBS = new Set([ +const ACTION_VERBS_EN = new Set([ "led", "built", "designed", @@ -34,6 +46,54 @@ const ACTION_VERBS = new Set([ "owned", "drove", "refactored", + "managed", + "coordinated", + "structured", + "integrated", +]); + +const ACTION_VERBS_PT = new Set([ + "desenvolvi", + "implementei", + "criei", + "liderei", + "reduzi", + "aumentei", + "automatizei", + "migrei", + "otimizei", + "entreguei", + "projetei", + "arquitetei", + "refatorei", + "gerenciei", + "coordenei", + "estruturei", + "integrei", + "construí", + "construi", + "melhorei", + "participei", + "desenvolveu", + "implementou", + "criou", + "liderou", + "reduziu", + "automatizou", + "otimizou", + "integrou", + "construiu", + "melhorou", + "entregou", + "projetou", + "arquitetou", + "refatorou", + "gerenciou", + "coordenou", + "estruturou", + "migrou", + "aumentou", + "participou", ]); const EXAGGERATION_TERMS = [ @@ -47,30 +107,167 @@ const EXAGGERATION_TERMS = [ "perfect", "unmatched", "10x", + "especialista", + "perfeito", + "perfeita", + "imbatível", + "imbativel", + "melhor engenheiro", + "world class", ]; +const NON_VERB_PREFIXES = new Set([ + "desenvolvimento", + "implementação", + "implementacao", + "liderança", + "lideranca", + "experiência", + "experiencia", +]); + function normalize(value: string): string { return value.trim().toLowerCase(); } +function firstToken(bullet: string): string { + const raw = bullet.trim().split(/\s+/)[0] ?? ""; + return raw.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "").toLowerCase(); +} + +function hasNumber(text: string): boolean { + return /\d/.test(text); +} + +function isCompanyOrPeriodHeader(text: string): boolean { + const trimmed = text.trim(); + return ( + /[\((]\s*(19|20)\d{2}\s*[–\-—]\s*(presente|(19|20)\d{2})\s*[\))]/i.test(trimmed) || + /[\—\-–]\s*(19|20)\d{2}\s*(a|até|–|-)\s*(19|20)\d{2}\b/i.test(trimmed) + ); +} + +function hasMeaningfulMetric(bullet: string): boolean { + const trimmed = bullet.trim(); + + if (isCompanyOrPeriodHeader(trimmed)) { + return false; + } + + if (/\d+\s*%/.test(trimmed)) { + return true; + } + + if ( + /\b\d+\s*(pessoas?|membros?|devs?|engenheiros?|squads?|times?|clientes?|parceiros?|usuários?|usuarios?)\b/i.test( + trimmed, + ) + ) { + return true; + } + + if (/R\$\s*[\d.,]+(\s*(mil|milhões?|mi))?/i.test(trimmed)) { + return true; + } + + if (/\b\d+[\d.,]*\s*(mil|milhões?|mi)\b/i.test(trimmed)) { + return true; + } + + if (/\b\d+x\b/i.test(trimmed)) { + return true; + } + + if (/\b\d+\s*(horas?|dias?|semanas?|meses?)\b/i.test(trimmed)) { + return true; + } + + if (/\b(19|20)\d{2}\b/.test(trimmed)) { + return false; + } + + if (hasNumber(trimmed)) { + if (/\b(node\.js|react|vue|angular|python|java|go|ruby|typescript)\s*\d+/i.test(trimmed)) { + return false; + } + return true; + } + + return trimmed.includes("%"); +} + function hasMetric(bullet: string): boolean { - return /\d/.test(bullet) || bullet.includes("%"); + return hasMeaningfulMetric(bullet); +} + +function hasLeadershipEvidence(bullet: string): boolean { + return ( + /\b(liderei|liderou|gerenciei|gerenciou|coordenei|coordenou)\b/i.test(bullet) || + /\b(squad|time|equipe)\b/i.test(bullet) + ); } function startsWithActionVerb(bullet: string): boolean { - const first = normalize(bullet).split(/\s+/)[0] ?? ""; - return ACTION_VERBS.has(first); + const token = firstToken(bullet); + if (!token || token.length < 3) { + return false; + } + if (NON_VERB_PREFIXES.has(token)) { + return false; + } + return ACTION_VERBS_EN.has(token) || ACTION_VERBS_PT.has(token); } function wordCount(bullet: string): number { return bullet.trim().split(/\s+/).filter(Boolean).length; } -function isVagueBullet(bullet: string): boolean { - if (wordCount(bullet) < 6) { +function hasTechnicalContext(bullet: string): boolean { + if (wordCount(bullet) >= 8) { return true; } - return !hasMetric(bullet) && !startsWithActionVerb(bullet); + return /\b(api|apis|rest|node|react|typescript|integra|sistem|plataforma|deploy|ci\/cd|postgresql|aws|docker|jwt|saas|backend|frontend|microserv|cloud|migr|parceir|autentica|prisma|express)\b/i.test( + bullet, + ); +} + +export type BulletStrength = "strong_with_metric" | "strong_without_metric" | "vague"; + +function classifyBulletStrength(bullet: string): BulletStrength { + if (isCompanyOrPeriodHeader(bullet)) { + return "vague"; + } + + const hasAction = startsWithActionVerb(bullet); + const measurable = hasMetric(bullet); + + if (hasAction && measurable) { + return "strong_with_metric"; + } + if (hasAction && (hasTechnicalContext(bullet) || hasLeadershipEvidence(bullet) || wordCount(bullet) >= 7)) { + return "strong_without_metric"; + } + if (hasAction) { + return "strong_without_metric"; + } + if (wordCount(bullet) < 6) { + return "vague"; + } + if (/\b(responsável|responsavel|algumas|diversas|tarefas genéricas|tarefas genericas)\b/i.test(bullet)) { + return "vague"; + } + if (!measurable) { + return "vague"; + } + return "strong_without_metric"; +} + +function isStrongBullet(bullet: string): boolean { + return classifyBulletStrength(bullet) === "strong_with_metric"; +} + +function isVagueBullet(bullet: string): boolean { + return classifyBulletStrength(bullet) === "vague"; } function collectBullets(resume: CareerResumeSnapshot): Array<{ section: string; text: string }> { @@ -82,7 +279,7 @@ function collectBullets(resume: CareerResumeSnapshot): Array<{ section: string; } for (const project of resume.projects ?? []) { for (const bullet of project.bullets) { - bullets.push({ section: `Project: ${project.name}`, text: bullet }); + bullets.push({ section: `Projeto: ${project.name}`, text: bullet }); } } return bullets; @@ -92,6 +289,51 @@ function clampScore(value: number): number { return Math.max(0, Math.min(100, Math.round(value))); } +function buildBulletRecommendation(bullet: { section: string; text: string }): ResumeBulletRecommendation { + const text = bullet.text; + const strength = classifyBulletStrength(text); + + if (strength === "strong_with_metric") { + return { + section: bullet.section, + originalSummary: text.slice(0, 160), + recommendation: "Este bullet já contém ação, resultado mensurável e contexto técnico.", + reason: "Bullet forte — nenhuma alteração obrigatória.", + }; + } + + if (strength === "strong_without_metric") { + return { + section: bullet.section, + originalSummary: text.slice(0, 160), + recommendation: + "O bullet apresenta ação e contexto técnico. Acrescente escala ou impacto verificável somente se essa informação for real.", + reason: "Ação clara, mas sem resultado mensurável.", + }; + } + + if (hasMetric(text) && !startsWithActionVerb(text)) { + return { + section: bullet.section, + originalSummary: text.slice(0, 160), + recommendation: + "Reescreva iniciando com um verbo de ação claro (por exemplo, Desenvolvi, Implementei, Reduzi) mantendo a métrica informada.", + reason: "Há métrica, mas falta um verbo de ação no início.", + }; + } + + const contextualHint = text.toLowerCase().includes("api") + ? "Explique quantos parceiros foram integrados, qual processo foi automatizado ou qual ganho operacional ocorreu, somente se esses dados forem reais." + : "Reescreva iniciando com um verbo de ação concreto e inclua um resultado verificável que você já alcançou — sem inventar números."; + + return { + section: bullet.section, + originalSummary: text.slice(0, 160), + recommendation: contextualHint, + reason: "Bullet vago: falta verbo de ação claro e resultado mensurável.", + }; +} + export type ResumeAnalystOutput = { summary: string; findings: CareerAgentFinding[]; @@ -112,133 +354,174 @@ export function runResumeAnalyst(context: CareerAgentContext): ResumeAnalystOutp const totalBullets = bullets.length; const quantifiedBullets = bullets.filter((bullet) => hasMetric(bullet.text)).length; - const vagueBullets = bullets.filter((bullet) => isVagueBullet(bullet.text)); - - // Documented deterministic rubric (0..100): - // summary 15 | skills up to 15 | quantified bullets up to 30 | - // low vagueness up to 25 | projects 7.5 | education 7.5 - const summaryScore = resume.summary && resume.summary.trim().length > 0 ? 15 : 0; - const skillsScore = (Math.min(skills.length, 8) / 8) * 15; - const quantifiedRatio = totalBullets === 0 ? 0 : quantifiedBullets / totalBullets; - const quantifiedScore = quantifiedRatio * 30; - const vagueRatio = totalBullets === 0 ? 1 : vagueBullets.length / totalBullets; - const clarityScore = (1 - vagueRatio) * 25; - const projectsScore = (resume.projects?.length ?? 0) > 0 ? 7.5 : 0; - const educationScore = (resume.education?.length ?? 0) > 0 ? 7.5 : 0; - const score = clampScore( - summaryScore + skillsScore + quantifiedScore + clarityScore + projectsScore + educationScore, + const vagueBullets = bullets.filter((bullet) => classifyBulletStrength(bullet.text) === "vague"); + const strongWithoutMetricBullets = bullets.filter( + (bullet) => classifyBulletStrength(bullet.text) === "strong_without_metric", + ); + const strongBullets = bullets.filter((bullet) => classifyBulletStrength(bullet.text) === "strong_with_metric"); + const actionBullets = bullets.filter((bullet) => startsWithActionVerb(bullet.text)).length; + const adequateLengthBullets = bullets.filter((bullet) => wordCount(bullet.text) >= 6).length; + const hasContextEvidence = bullets.some( + (bullet) => hasLeadershipEvidence(bullet.text) || wordCount(bullet.text) >= 10, ); + const hasSummary = Boolean(resume.summary && resume.summary.trim().length > 0); + const summaryPoints = hasSummary ? 15 : 0; + const skillsPoints = totalBullets === 0 && skills.length === 0 ? 0 : (Math.min(skills.length, 8) / 8) * 20; + const actionRatio = totalBullets === 0 ? 0 : actionBullets / totalBullets; + const lengthRatio = totalBullets === 0 ? 0 : adequateLengthBullets / totalBullets; + const bulletQualityPoints = totalBullets === 0 ? 0 : actionRatio * 15 + lengthRatio * 15; + const measurableRatio = totalBullets === 0 ? 0 : quantifiedBullets / totalBullets; + const measurablePoints = measurableRatio * 25; + const contextPoints = hasContextEvidence ? 10 : 0; + + const applicableMax = + 15 + + (skills.length > 0 || totalBullets > 0 ? 20 : 0) + + (totalBullets > 0 ? 30 : 0) + + (totalBullets > 0 ? 25 : 0) + + (totalBullets > 0 ? 10 : 0); + + const rawPoints = + summaryPoints + skillsPoints + bulletQualityPoints + measurablePoints + contextPoints; + const score = + applicableMax === 0 ? 0 : clampScore((rawPoints / applicableMax) * 100); + const strengths: string[] = []; - if (summaryScore > 0) strengths.push("Resume includes a professional summary."); - if (skills.length >= 5) strengths.push(`Skills section lists ${skills.length} skills.`); + if (hasSummary) strengths.push("O currículo apresenta um resumo profissional."); + if (skills.length >= 5) + strengths.push(`A seção de competências contém ${skills.length} tecnologias.`); + else if (skills.length >= 3) + strengths.push(`Foram identificadas ${skills.length} competências técnicas.`); if (quantifiedBullets > 0) - strengths.push(`${quantifiedBullets} bullet(s) include measurable outcomes.`); - if ((resume.projects?.length ?? 0) > 0) strengths.push("Projects section is present."); - if (strengths.length === 0) strengths.push("Resume provides a baseline structure to build on."); + strengths.push( + `${quantifiedBullets} resultado${quantifiedBullets > 1 ? "s" : ""} apresenta${quantifiedBullets > 1 ? "m" : ""} métricas verificáveis.`, + ); + if (strongBullets.length > 0) + strengths.push(`${strongBullets.length} bullet(s) combinam ação clara e impacto mensurável.`); + if (bullets.some((b) => hasLeadershipEvidence(b.text))) + strengths.push("Há evidência de liderança de equipe."); + if (strengths.length === 0) + strengths.push("O currículo oferece uma base estrutural para evoluir com mais clareza de impacto."); const weaknesses: string[] = []; - if (summaryScore === 0) weaknesses.push("No professional summary is present."); - if (skills.length < 5) weaknesses.push("Skills section is sparse (fewer than 5 skills)."); + if (!hasSummary) weaknesses.push("Não há resumo profissional identificado."); + if (skills.length < 5 && skills.length > 0) + weaknesses.push("A seção de competências está enxuta (menos de 5 tecnologias)."); + if (skills.length === 0) weaknesses.push("Nenhuma competência técnica foi identificada."); if (totalBullets > 0 && quantifiedBullets === 0) - weaknesses.push("No experience bullets include measurable outcomes."); + weaknesses.push("Nenhum resultado apresenta impacto mensurável."); if (vagueBullets.length > 0) - weaknesses.push(`${vagueBullets.length} bullet(s) read as vague or low-impact.`); + weaknesses.push(`${vagueBullets.length} bullet(s) ainda estão vagos ou genéricos.`); + if (totalBullets > 0 && quantifiedBullets === 0 && strongWithoutMetricBullets.length > 0) + weaknesses.push("Alguns bullets têm ação clara, mas ainda sem impacto mensurável."); + if (totalBullets > 0 && !hasContextEvidence) + weaknesses.push("A experiência pode ganhar mais contexto sobre escopo e responsabilidade."); const missingStack = targetStack.filter((item) => !skillSet.has(item)); const missingEvidence: string[] = []; for (const experience of resume.experiences) { if (experience.bullets.length === 0) { - missingEvidence.push(`No evidence bullets for ${experience.title} @ ${experience.company}.`); + missingEvidence.push( + `Sem bullets de evidência para ${experience.title} @ ${experience.company}.`, + ); } } for (const item of missingStack) { - missingEvidence.push(`Target stack "${item}" is not listed in skills.`); + missingEvidence.push(`A stack alvo "${item}" não aparece nas competências listadas.`); } - const bulletRecommendations: ResumeBulletRecommendation[] = vagueBullets - .slice(0, 10) - .map((bullet) => ({ - section: bullet.section, - originalSummary: bullet.text.slice(0, 160), - recommendation: - "Rephrase to lead with a concrete action and the real, verifiable outcome you already achieved.", - reason: hasMetric(bullet.text) - ? "Bullet lacks a clear action verb." - : "Bullet lacks an action verb and a measurable, factual result.", - })); + const vagueOnly = vagueBullets.slice(0, 10); + const bulletRecommendations: ResumeBulletRecommendation[] = [ + ...vagueOnly.map((bullet) => buildBulletRecommendation(bullet)), + ...strongWithoutMetricBullets.slice(0, 5).map((bullet) => buildBulletRecommendation(bullet)), + ...strongBullets.slice(0, 3).map((bullet) => buildBulletRecommendation(bullet)), + ].slice(0, 10); const sectionRecommendations: string[] = []; - if (summaryScore === 0) - sectionRecommendations.push("Add a concise summary aligned with the target role at the top."); - if (skills.length < 5) - sectionRecommendations.push("Expand the skills section with technologies you can evidence."); + if (!hasSummary) + sectionRecommendations.push( + "Adicione um parágrafo inicial com seu perfil e foco profissional alinhado ao cargo desejado.", + ); + if (skills.length < 5 && skills.length > 0) + sectionRecommendations.push( + "Amplie a seção de competências com tecnologias que você consegue evidenciar na prática.", + ); if (resume.experiences.length > 1) - sectionRecommendations.push("Order experience by relevance to the target role, most recent first."); - if ((resume.projects?.length ?? 0) === 0) - sectionRecommendations.push("Consider a short projects section to evidence target skills."); + sectionRecommendations.push( + "Ordene as experiências pela relevância para o cargo alvo, começando pela mais recente.", + ); const risks: string[] = []; for (const bullet of bullets) { const lowered = normalize(bullet.text); const term = EXAGGERATION_TERMS.find((candidate) => lowered.includes(candidate)); if (term) { - risks.push(`Possible overstatement ("${term}") in ${bullet.section}; keep claims verifiable.`); + risks.push( + `Possível exagero ("${term}") em ${bullet.section}; mantenha apenas afirmações verificáveis.`, + ); } } if (risks.length === 0) - risks.push("No automatic claim is added; verify every bullet remains factual before sharing."); + risks.push( + "Nenhuma afirmação automática foi adicionada; confirme que cada bullet permanece factual antes de compartilhar.", + ); const nextActions: string[] = [ - "Review the suggested bullet rewrites and keep only factual changes.", - "Confirm missing evidence items reflect real experience before adding them.", - "Export the review payload manually after human approval.", + "Revise os bullets sugeridos e mantenha apenas alterações factuais.", + "Confirme os resultados mensuráveis antes de incluí-los.", + "Adicione evidências somente quando corresponderem à sua experiência real.", ]; + const clarityNote = + vagueBullets.length > 0 + ? `${vagueBullets.length} ponto(s) podem ganhar mais clareza.` + : "A estrutura geral está clara; refine detalhes conforme necessário."; + const findings: CareerAgentFinding[] = [ { kind: "evidence", - title: "Resume structure overview", + title: "Visão geral da estrutura do currículo", category: "structure", evidence: [ - `score:${score}`, - `skills:${skills.length}`, + `pontuação:${score}`, + `competências:${skills.length}`, `bullets:${totalBullets}`, - `quantified_bullets:${quantifiedBullets}`, - `vague_bullets:${vagueBullets.length}`, + `mensuráveis:${quantifiedBullets}`, + `vagos:${vagueBullets.length}`, ], recommendation: weaknesses.length > 0 - ? "Address the listed weaknesses before applying to the target role." - : "Resume structure is solid; refine bullet impact where possible.", + ? "Enderece os pontos de atenção antes de aplicar para o cargo alvo." + : "A estrutura do currículo está sólida; refine o impacto dos bullets onde fizer sentido.", priority: vagueBullets.length > 0 || quantifiedBullets === 0 ? "high" : "medium", }, ]; if (missingEvidence.length > 0) { findings.push({ kind: "gap", - title: "Missing evidence", + title: "Evidências ausentes", category: "evidence", evidence: missingEvidence.slice(0, 8), - recommendation: "Collect verifiable evidence; do not invent metrics or skills.", + recommendation: "Colete evidências verificáveis; não invente métricas ou competências.", priority: "high", }); } const recommendations: CareerAgentRecommendation[] = [ { - title: "Improve bullet impact", + title: "Melhorar impacto dos bullets", category: "next_steps", evidence: bulletRecommendations.slice(0, 5).map((item) => item.section), recommendation: - "Rewrite vague bullets to lead with an action and a real outcome, preserving factuality.", + "Reescreva bullets vagos iniciando com ação e resultado real, preservando a factualidade.", priority: vagueBullets.length > 0 ? "high" : "low", }, ]; const summary = - `Resume review completed with a deterministic structure score of ${score}/100 ` + - `(${quantifiedBullets}/${totalBullets} quantified bullets, ${vagueBullets.length} vague).`; + `Análise concluída com pontuação estrutural de ${score}/100. ` + + `Foram identificados ${quantifiedBullets} resultado${quantifiedBullets === 1 ? "" : "s"} ${quantifiedBullets === 1 ? "mensurável" : "mensuráveis"} e ${clarityNote}`; const resumeAnalysis: ResumeAnalysis = { score, @@ -256,7 +539,21 @@ export function runResumeAnalyst(context: CareerAgentContext): ResumeAnalystOutp summary, findings, recommendations, - evidence: [`resume_score:${score}`, ...skills.slice(0, 10)], + evidence: [`pontuação_currículo:${score}`, ...skills.slice(0, 10)], resumeAnalysis, }; } + +// Exported for tests +export const __resumeAnalystTestUtils = { + firstToken, + startsWithActionVerb, + hasNumber, + hasMeaningfulMetric, + hasMetric, + isVagueBullet, + isStrongBullet, + classifyBulletStrength, + ACTION_VERBS_PT, + ACTION_VERBS_EN, +};