Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
82 changes: 74 additions & 8 deletions apps/applyflow/src/components/dashboard/career-chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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: "",
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
16 changes: 15 additions & 1 deletion apps/applyflow/src/components/dashboard/career-pilot-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
};
Expand All @@ -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("<details");
expect(html).toContain("career-pilot-result-review-notice");
expect(html).not.toContain("Detalhes técnicos");
expect(html).not.toContain("Agent response");
expect(html).not.toContain("reviewRequired");
});
Expand Down
Loading
Loading