diff --git a/apps/applyflow/src/components/dashboard/career-chat-workspace-recovery.test.tsx b/apps/applyflow/src/components/dashboard/career-chat-workspace-recovery.test.tsx index 9f48fbe9..0f4c672f 100644 --- a/apps/applyflow/src/components/dashboard/career-chat-workspace-recovery.test.tsx +++ b/apps/applyflow/src/components/dashboard/career-chat-workspace-recovery.test.tsx @@ -12,6 +12,7 @@ import { CareerChatWorkspaceView, EMPTY_SPECIALIST_FIELDS, } from "./career-chat-workspace"; +import { EMPTY_CAREER_PILOT_SIMPLE_INPUTS } from "./career-pilot-simple-inputs"; import * as careerChatClient from "./career-chat-workspace-client"; const bundle = createCareerBundle([ @@ -156,6 +157,8 @@ describe("CareerChatWorkspace retry flow", () => { it("preserves form data, shows the error panel, and completes after retry", async () => { const user = userEvent.setup(); + const resumeText = + "Desenvolvi APIs REST em Node.js com TypeScript. Reduzi deploy em 30% com pipelines CI/CD."; const runSpy = vi .spyOn(careerChatClient, "runCareerChatLibrechat") .mockRejectedValueOnce(new careerChatClient.CareerChatRequestError()) @@ -168,17 +171,13 @@ describe("CareerChatWorkspace retry flow", () => { availableSignals={[]} pilotPresentation pilotIntent="analyze_resume" - initialSpecialistFields={{ - ...EMPTY_SPECIALIST_FIELDS, - resumeBullets: "Built APIs", - resumeSkills: "TypeScript", - }} + simpleInputs={{ ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, resumeText }} + onSimpleInputsChange={() => undefined} />, ); const panel = within(view.getByTestId("career-chat-workspace-panel")); - const bullets = panel.getByTestId("career-chat-resume-bullets") as HTMLTextAreaElement; - const skills = panel.getByTestId("career-chat-resume-skills") as HTMLInputElement; + const resume = panel.getByTestId("career-pilot-resume-text") as HTMLTextAreaElement; await user.click(panel.getByTestId("career-chat-consent-checkbox")); await user.click(panel.getByTestId("career-chat-send-button")); @@ -187,8 +186,7 @@ describe("CareerChatWorkspace retry flow", () => { expect(panel.getByTestId("career-pilot-analysis-error")).toBeTruthy(); }); expect(panel.queryByTestId("career-pilot-result-view")).toBeNull(); - expect(bullets.value).toBe("Built APIs"); - expect(skills.value).toBe("TypeScript"); + expect(resume.value).toBe(resumeText); await user.click(panel.getByTestId("career-pilot-analysis-retry")); @@ -197,8 +195,7 @@ describe("CareerChatWorkspace retry flow", () => { }); expect(runSpy).toHaveBeenCalledTimes(2); - expect(bullets.value).toBe("Built APIs"); - expect(skills.value).toBe("TypeScript"); + expect(resume.value).toBe(resumeText); expect(panel.queryByTestId("career-pilot-analysis-error")).toBeNull(); }); }); diff --git a/apps/applyflow/src/components/dashboard/career-chat-workspace.test.tsx b/apps/applyflow/src/components/dashboard/career-chat-workspace.test.tsx index 5da81579..607d378e 100644 --- a/apps/applyflow/src/components/dashboard/career-chat-workspace.test.tsx +++ b/apps/applyflow/src/components/dashboard/career-chat-workspace.test.tsx @@ -432,6 +432,22 @@ describe("buildSpecialistAnalysisInput", () => { ]); }); + it("extracts Unicode keywords for Portuguese job requirements", () => { + const input = buildSpecialistAnalysisInput({ + action: "analyze_ats_compatibility", + fields: { + ...EMPTY_SPECIALIST_FIELDS, + jobRequirements: "Experiência com backend\nInglês intermediário\nConhecimento em cloud", + }, + mainStack: ["TypeScript"], + fallbackRole: "Backend Engineer", + }); + expect(input?.jobSnapshot?.keywords).toContain("experiência"); + expect(input?.jobSnapshot?.keywords).toContain("inglês"); + expect(input?.jobSnapshot?.keywords).toContain("intermediário"); + expect(input?.jobSnapshot?.keywords).toContain("conhecimento"); + }); + it("builds target roles for plan_career_strategy", () => { const input = buildSpecialistAnalysisInput({ action: "plan_career_strategy", diff --git a/apps/applyflow/src/components/dashboard/career-chat-workspace.tsx b/apps/applyflow/src/components/dashboard/career-chat-workspace.tsx index db4ebb0f..e810e495 100644 --- a/apps/applyflow/src/components/dashboard/career-chat-workspace.tsx +++ b/apps/applyflow/src/components/dashboard/career-chat-workspace.tsx @@ -84,6 +84,20 @@ import { import { buildCareerPilotResultModel } from "./career-pilot-result-mapper"; import { CareerPilotResultView } from "./career-pilot-result-view"; import { CareerPilotFeedback } from "./career-pilot-feedback"; +import { CareerPilotInputReview } from "./career-pilot-input-review"; +import { CareerPilotSimpleInputsForm } from "./career-pilot-simple-inputs-form"; +import { + buildCareerSpecialistFieldsFromSimpleInputs, + extractJobKeywords, + hasSimplePilotAnalysisInputs, +} from "./career-pilot-input-normalizer"; +import { + CAREER_PILOT_EMPTY_CAREER_GOAL_MESSAGE, + CAREER_PILOT_EMPTY_JOB_MESSAGE, + CAREER_PILOT_EMPTY_RESUME_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"; import { CareerAnalysisLoading } from "./career-analysis-loading"; import { CareerAnalysisError } from "./career-analysis-error"; import { @@ -174,12 +188,7 @@ export function buildSpecialistAnalysisInput(input: { jobSnapshot: { title: fallbackRole || "Target role", requiredRequirements: requirements, - keywords: requirements.flatMap((req) => - req - .toLowerCase() - .split(/[^a-z0-9+#.]+/) - .filter((token) => token.length >= 3), - ), + keywords: extractJobKeywords(requirements.join(" ")), }, }; } @@ -197,6 +206,8 @@ export type CareerChatWorkspaceProps = { pilotPresentation?: boolean; pilotIntent?: CareerChatIntent; initialSpecialistFields?: CareerSpecialistFields; + simpleInputs?: CareerPilotSimpleInputs; + onSimpleInputsChange?: (next: CareerPilotSimpleInputs) => void; onPilotActionChange?: (action: CareerChatIntent) => void; onPilotAnalysisComplete?: (intent: CareerPilotIntent) => void; }; @@ -273,6 +284,9 @@ export function CareerChatWorkspaceView({ onSubmitPilotFeedback, submitDisabled = false, onDismissError, + simpleInputs, + onSimpleInputsChange, + validationMessage = null, }: CareerChatWorkspaceProps & { action: CareerChatIntent; message: string; @@ -303,6 +317,9 @@ export function CareerChatWorkspaceView({ }) => Promise<{ ok: boolean }>; submitDisabled?: boolean; onDismissError?: () => void; + simpleInputs?: CareerPilotSimpleInputs; + onSimpleInputsChange?: (next: CareerPilotSimpleInputs) => void; + validationMessage?: string | null; }) { const messageLength = message.length; const showSpecialist = pilotPresentation || isSpecialistIntent(action); @@ -319,8 +336,9 @@ export function CareerChatWorkspaceView({ 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; - const pilotEmptyHint = - action === "plan_career_strategy" + const pilotEmptyHint = validationMessage + ? validationMessage + : action === "plan_career_strategy" ? CAREER_PILOT_EMPTY_STRATEGY_HINT : action === "analyze_ats_compatibility" ? CAREER_PILOT_EMPTY_ATS_HINT @@ -458,21 +476,32 @@ export function CareerChatWorkspaceView({ )} - {showSpecialist ? ( + {showSpecialist && pilotPresentation && simpleInputs && onSimpleInputsChange ? (
- {!pilotPresentation ? ( -

- {CAREER_CHAT_WORKSPACE_SPECIALIST_LABEL} -

- ) : null} + + onSpecialistFieldChange?.(field, value)} + /> +
+ ) : null} + + {showSpecialist && !pilotPresentation ? ( +
+

+ {CAREER_CHAT_WORKSPACE_SPECIALIST_LABEL} +

{action !== "plan_career_strategy" ? ( <>
diff --git a/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.test.ts b/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.test.ts new file mode 100644 index 00000000..d7909348 --- /dev/null +++ b/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { + buildCareerSpecialistFieldsFromSimpleInputs, + extractJobKeywords, + extractJobRequirements, + extractLikelySkills, + extractResumeLines, + hasSimplePilotAnalysisInputs, + normalizeJobDescription, + normalizeResumeText, + PILOT_SKILL_CATALOG, +} from "./career-pilot-input-normalizer"; +import { EMPTY_CAREER_PILOT_SIMPLE_INPUTS } from "./career-pilot-simple-inputs"; + +describe("normalizeResumeText", () => { + it("normalizes line breaks and removes repeated empty lines", () => { + expect(normalizeResumeText("Linha 1\r\n\r\n\r\nLinha 2")).toBe("Linha 1\n\nLinha 2"); + }); + + it("returns empty string for blank input", () => { + expect(normalizeResumeText(" \n ")).toBe(""); + }); +}); + +describe("extractResumeLines", () => { + it("extracts multiple lines from a multiline resume", () => { + const lines = extractResumeLines("Experiência A\n• Resultado B\n\nResultado C"); + expect(lines.length).toBeGreaterThanOrEqual(2); + 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); + }); +}); + +describe("extractLikelySkills", () => { + it("identifies skills present in the text", () => { + const skills = extractLikelySkills("Experiência com TypeScript, React e Node.js em produção."); + expect(skills).toContain("TypeScript"); + expect(skills).toContain("React"); + expect(skills).toContain("Node.js"); + }); + + it("does not invent skills absent from the text", () => { + const skills = extractLikelySkills("Experiência com planilhas e atendimento ao cliente."); + for (const skill of skills) { + expect(PILOT_SKILL_CATALOG).toContain(skill); + } + expect(skills).not.toContain("Kubernetes"); + }); + + it("removes duplicate skills", () => { + const skills = extractLikelySkills("TypeScript TypeScript typescript e React."); + const lowered = skills.map((s) => s.toLowerCase()); + expect(new Set(lowered).size).toBe(lowered.length); + }); +}); + +describe("extractJobRequirements", () => { + it("extracts requirements from multiline job descriptions", () => { + const requirements = extractJobRequirements( + "Requisitos:\nExperiência com TypeScript\nInglês intermediário", + ); + expect(requirements.some((line) => line.includes("TypeScript"))).toBe(true); + expect(requirements.some((line) => line.includes("intermediário"))).toBe(true); + }); +}); + +describe("extractJobKeywords", () => { + it("preserves accented Portuguese tokens", () => { + const keywords = extractJobKeywords( + "experiência com backend, inglês intermediário e conhecimento em cloud", + ); + expect(keywords).toContain("experiência"); + expect(keywords).toContain("inglês"); + expect(keywords).toContain("intermediário"); + expect(keywords).toContain("conhecimento"); + expect(keywords).not.toContain("experi"); + expect(keywords).not.toContain("ingl"); + }); + + it("returns empty array for blank text", () => { + expect(extractJobKeywords("")).toEqual([]); + }); +}); + +describe("buildCareerSpecialistFieldsFromSimpleInputs", () => { + it("maps simple resume inputs to specialist fields", () => { + const fields = buildCareerSpecialistFieldsFromSimpleInputs( + { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + targetRole: "Desenvolvedor Full Stack", + resumeText: + "Desenvolvi APIs em Node.js com TypeScript.\nReduzi deploy em 30% com CI/CD no GitHub Actions.", + }, + "analyze_resume", + ); + expect(fields.resumeBullets).toContain("Node.js"); + expect(fields.resumeSkills).toContain("TypeScript"); + expect(fields.targetRoles).toBe("Desenvolvedor Full Stack"); + }); + + it("maps job description to requirements for ATS flow", () => { + const fields = buildCareerSpecialistFieldsFromSimpleInputs( + { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + resumeText: "Experiência com TypeScript e Node.js em APIs REST para SaaS.", + jobDescription: "Requisitos:\nExperiência com TypeScript\nInglês intermediário", + }, + "analyze_ats_compatibility", + ); + expect(fields.jobRequirements).toContain("TypeScript"); + expect(fields.jobRequirements).toContain("intermediário"); + }); + + it("maps career plan inputs to target roles and availability", () => { + const fields = buildCareerSpecialistFieldsFromSimpleInputs( + { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + careerGoal: "Vaga Full Stack Sênior em 90 dias", + weeklyAvailability: "10 horas por semana", + constraints: "Remoto, CLT", + }, + "plan_career_strategy", + ); + expect(fields.targetRoles).toContain("Full Stack"); + expect(fields.availability).toContain("10 horas"); + expect(fields.availability).toContain("Remoto"); + }); +}); + +describe("hasSimplePilotAnalysisInputs", () => { + it("requires sufficient resume text for analyze_resume", () => { + expect( + hasSimplePilotAnalysisInputs("analyze_resume", { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + resumeText: "curto", + }), + ).toBe(false); + expect( + hasSimplePilotAnalysisInputs("analyze_resume", { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + resumeText: + "Desenvolvi APIs REST em Node.js com TypeScript para integração com parceiros.", + }), + ).toBe(true); + }); + + it("requires job description for ATS comparison", () => { + expect( + hasSimplePilotAnalysisInputs("analyze_ats_compatibility", { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + resumeText: + "Desenvolvi APIs REST em Node.js com TypeScript para integração com parceiros.", + jobDescription: "curta", + }), + ).toBe(false); + }); + + it("requires career goal for strategy plan", () => { + expect( + hasSimplePilotAnalysisInputs("plan_career_strategy", EMPTY_CAREER_PILOT_SIMPLE_INPUTS), + ).toBe(false); + expect( + hasSimplePilotAnalysisInputs("plan_career_strategy", { + ...EMPTY_CAREER_PILOT_SIMPLE_INPUTS, + careerGoal: "Conseguir vaga sênior", + }), + ).toBe(true); + }); +}); + +describe("normalizeJobDescription", () => { + it("respects maximum length", () => { + const long = "a".repeat(20_000); + expect(normalizeJobDescription(long).length).toBeLessThanOrEqual(12_000); + }); +}); diff --git a/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.ts b/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.ts new file mode 100644 index 00000000..531f0180 --- /dev/null +++ b/apps/applyflow/src/components/dashboard/career-pilot-input-normalizer.ts @@ -0,0 +1,202 @@ +import type { CareerChatIntent } from "@devflow/career-core"; +import type { CareerSpecialistFields } from "./career-chat-workspace"; +import { isCareerPilotIntent, type CareerPilotIntent } from "./career-pilot-content"; +import { + type CareerPilotSimpleInputs, + MAX_PILOT_JOB_DESCRIPTION_LENGTH, + MAX_PILOT_RESUME_TEXT_LENGTH, + MIN_PILOT_JOB_DESCRIPTION_LENGTH, + MIN_PILOT_RESUME_TEXT_LENGTH, +} from "./career-pilot-simple-inputs"; + +export const PILOT_SKILL_CATALOG = [ + "JavaScript", + "TypeScript", + "React", + "Next.js", + "Node.js", + "Express", + "NestJS", + "Prisma", + "PostgreSQL", + "MySQL", + "MongoDB", + "Redis", + "AWS", + "Azure", + "GCP", + "Docker", + "Kubernetes", + "Jest", + "Vitest", + "Playwright", + "Cypress", + "Git", + "GitHub Actions", + "REST", + "GraphQL", +] as const; + +const MAX_LINES = 50; +const MAX_LINE_LENGTH = 500; + +export function normalizeResumeText(value: string): string { + return value + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim() + .slice(0, MAX_PILOT_RESUME_TEXT_LENGTH); +} + +export function normalizeJobDescription(value: string): string { + return value + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim() + .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, "\\$&"); +} + +export function extractLikelySkills(text: string): string[] { + const normalized = normalizeResumeText(text); + if (!normalized) { + return []; + } + + const haystack = normalized.toLowerCase(); + const found: string[] = []; + const seen = new Set(); + + for (const skill of PILOT_SKILL_CATALOG) { + const pattern = new RegExp(`\\b${escapeRegExp(skill.toLowerCase())}\\b`, "i"); + if (pattern.test(haystack) && !seen.has(skill.toLowerCase())) { + seen.add(skill.toLowerCase()); + found.push(skill); + } + } + + return found.slice(0, MAX_LINES); +} + +export function extractJobRequirements(jobDescription: string): string[] { + const normalized = normalizeJobDescription(jobDescription); + if (!normalized) { + return []; + } + + const lineCandidates = normalized + .split("\n") + .map((line) => line.replace(/^[\s•\-*\d.)]+/, "").trim()) + .filter((line) => line.length >= 3); + + if (lineCandidates.length >= 2) { + return lineCandidates.slice(0, MAX_LINES); + } + + return normalized + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter((sentence) => sentence.length >= 8) + .slice(0, MAX_LINES); +} + +export function extractJobKeywords(text: string): string[] { + const seen = new Set(); + const keywords: string[] = []; + + for (const token of text.toLowerCase().split(/[^\p{L}\p{N}+#.]+/u)) { + const trimmed = token.trim(); + if (trimmed.length < 3 || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + keywords.push(trimmed); + } + + return keywords.slice(0, 100); +} + +function joinTargetRoles(goal: string): string { + const parts = goal + .split(/[\n,;]+/) + .map((entry) => entry.trim()) + .filter(Boolean); + return parts.length > 0 ? parts.join(", ") : goal.trim(); +} + +export function buildCareerSpecialistFieldsFromSimpleInputs( + input: CareerPilotSimpleInputs, + intent: CareerChatIntent, +): CareerSpecialistFields { + const resumeText = normalizeResumeText(input.resumeText); + const jobDescription = normalizeJobDescription(input.jobDescription); + const resumeLines = extractResumeLines(resumeText); + const skills = extractLikelySkills(resumeText); + const requirements = extractJobRequirements(jobDescription); + const availabilityParts = [input.weeklyAvailability.trim(), input.constraints.trim()].filter(Boolean); + + if (isCareerPilotIntent(intent) && intent === "plan_career_strategy") { + return { + resumeBullets: resumeLines.join("\n"), + resumeSkills: skills.join(", "), + jobRequirements: requirements.join("\n"), + targetRoles: joinTargetRoles(input.careerGoal), + availability: availabilityParts.join(" · "), + }; + } + + return { + resumeBullets: resumeLines.join("\n"), + resumeSkills: skills.join(", "), + jobRequirements: requirements.join("\n"), + targetRoles: input.targetRole.trim(), + availability: availabilityParts.join(" · "), + }; +} + +export function hasSimplePilotAnalysisInputs( + action: CareerPilotIntent, + input: CareerPilotSimpleInputs, +): boolean { + const resumeText = normalizeResumeText(input.resumeText); + const jobDescription = normalizeJobDescription(input.jobDescription); + const resumeOk = resumeText.length >= MIN_PILOT_RESUME_TEXT_LENGTH; + + if (action === "plan_career_strategy") { + return input.careerGoal.trim().length > 0; + } + + if (action === "analyze_ats_compatibility") { + return resumeOk && jobDescription.length >= MIN_PILOT_JOB_DESCRIPTION_LENGTH; + } + + return resumeOk; +} diff --git a/apps/applyflow/src/components/dashboard/career-pilot-input-review.tsx b/apps/applyflow/src/components/dashboard/career-pilot-input-review.tsx new file mode 100644 index 00000000..a342632c --- /dev/null +++ b/apps/applyflow/src/components/dashboard/career-pilot-input-review.tsx @@ -0,0 +1,132 @@ +"use client"; + +import type { CareerChatIntent } from "@devflow/career-core"; +import type { CareerSpecialistFields } from "./career-chat-workspace"; +import { isCareerPilotIntent } from "./career-pilot-content"; +import { + CAREER_PILOT_REVIEW_AVAILABILITY_LABEL, + CAREER_PILOT_REVIEW_DISCLOSURE_HINT, + CAREER_PILOT_REVIEW_DISCLOSURE_TITLE, + CAREER_PILOT_REVIEW_EXPERIENCES_LABEL, + CAREER_PILOT_REVIEW_REQUIREMENTS_LABEL, + CAREER_PILOT_REVIEW_SKILLS_LABEL, + CAREER_PILOT_REVIEW_TARGET_ROLE_LABEL, +} from "./career-pilot-simple-input-content"; +import { careerPolishInput, careerPolishLabel, careerPolishTextarea } from "./career-polish-classes"; + +export function CareerPilotInputReview({ + intent, + fields, + onFieldChange, +}: { + intent: CareerChatIntent; + fields: CareerSpecialistFields; + onFieldChange: (field: keyof CareerSpecialistFields, value: string) => void; +}) { + if (!isCareerPilotIntent(intent)) { + return null; + } + + const showResume = intent !== "plan_career_strategy"; + const showJob = intent === "analyze_ats_compatibility"; + const showPlan = intent === "plan_career_strategy"; + + return ( +
+ + + {CAREER_PILOT_REVIEW_DISCLOSURE_TITLE} + + ▾ + + + +
+

{CAREER_PILOT_REVIEW_DISCLOSURE_HINT}

+ + {showResume ? ( + <> + +