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
Expand Up @@ -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([
Expand Down Expand Up @@ -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())
Expand All @@ -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"));
Expand All @@ -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"));

Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
155 changes: 126 additions & 29 deletions apps/applyflow/src/components/dashboard/career-chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(" ")),
},
};
}
Expand All @@ -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;
};
Expand Down Expand Up @@ -273,6 +284,9 @@ export function CareerChatWorkspaceView({
onSubmitPilotFeedback,
submitDisabled = false,
onDismissError,
simpleInputs,
onSimpleInputsChange,
validationMessage = null,
}: CareerChatWorkspaceProps & {
action: CareerChatIntent;
message: string;
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -458,21 +476,32 @@ export function CareerChatWorkspaceView({
)}
</div>

{showSpecialist ? (
{showSpecialist && pilotPresentation && simpleInputs && onSimpleInputsChange ? (
<div
className={cn(
"space-y-3",
pilotPresentation
? "rounded-[var(--af-radius-sm)] border border-[color:var(--af-border)] bg-[color:var(--af-surface-muted)] p-4"
: "space-y-2 rounded-[var(--af-radius-sm)] border border-violet-500/25 p-2",
)}
className="space-y-4 rounded-[var(--af-radius-sm)] border border-[color:var(--af-border)] bg-[color:var(--af-surface-muted)] p-4"
data-testid="career-chat-specialist-inputs"
>
{!pilotPresentation ? (
<p className="font-medium text-[color:var(--af-text)]">
{CAREER_CHAT_WORKSPACE_SPECIALIST_LABEL}
</p>
) : null}
<CareerPilotSimpleInputsForm
intent={action}
value={simpleInputs}
onChange={onSimpleInputsChange}
/>
<CareerPilotInputReview
intent={action}
fields={specialistFields}
onFieldChange={(field, value) => onSpecialistFieldChange?.(field, value)}
/>
</div>
) : null}

{showSpecialist && !pilotPresentation ? (
<div
className="space-y-2 rounded-[var(--af-radius-sm)] border border-violet-500/25 p-2"
data-testid="career-chat-specialist-inputs"
>
<p className="font-medium text-[color:var(--af-text)]">
{CAREER_CHAT_WORKSPACE_SPECIALIST_LABEL}
</p>
{action !== "plan_career_strategy" ? (
<>
<label htmlFor="career-chat-resume-bullets" className={careerPolishLabel}>
Expand Down Expand Up @@ -811,6 +840,8 @@ export function CareerChatWorkspace({
pilotPresentation = false,
pilotIntent,
initialSpecialistFields,
simpleInputs: simpleInputsProp,
onSimpleInputsChange,
onPilotActionChange,
onPilotAnalysisComplete,
}: CareerChatWorkspaceProps) {
Expand All @@ -829,9 +860,65 @@ export function CareerChatWorkspace({
const [specialistFields, setSpecialistFields] = useState<CareerSpecialistFields>(
initialSpecialistFields ?? EMPTY_SPECIALIST_FIELDS,
);
const [localSimpleInputs, setLocalSimpleInputs] = useState<CareerPilotSimpleInputs>(
simpleInputsProp ?? EMPTY_CAREER_PILOT_SIMPLE_INPUTS,
);
const [reviewOverrides, setReviewOverrides] = useState<Partial<CareerSpecialistFields> | null>(
null,
);
const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
const pilotMode = pilotPresentation || isCareerPilotModeClient();

const simpleInputs = simpleInputsProp ?? localSimpleInputs;

function handleSimpleInputsChange(next: CareerPilotSimpleInputs) {
if (onSimpleInputsChange) {
onSimpleInputsChange(next);
} else {
setLocalSimpleInputs(next);
}
setReviewOverrides(null);
}

const normalizedFields = useMemo(() => {
if (!pilotPresentation || !isCareerPilotIntent(action)) {
return specialistFields;
}
return buildCareerSpecialistFieldsFromSimpleInputs(simpleInputs, action);
}, [action, pilotPresentation, simpleInputs, specialistFields]);

const effectiveSpecialistFields = useMemo(() => {
if (!pilotPresentation) {
return specialistFields;
}
if (!reviewOverrides) {
return normalizedFields;
}
return { ...normalizedFields, ...reviewOverrides };
}, [normalizedFields, pilotPresentation, reviewOverrides, specialistFields]);

const pilotValidationMessage = useMemo(() => {
if (!pilotPresentation || !isCareerPilotIntent(action) || explicitConsent) {
return null;
}
if (action === "analyze_resume" || action === "analyze_ats_compatibility") {
const resumeOk = hasSimplePilotAnalysisInputs("analyze_resume", simpleInputs);
if (!resumeOk) {
return CAREER_PILOT_EMPTY_RESUME_MESSAGE;
}
}
if (action === "analyze_ats_compatibility") {
const atsOk = hasSimplePilotAnalysisInputs("analyze_ats_compatibility", simpleInputs);
if (!atsOk) {
return CAREER_PILOT_EMPTY_JOB_MESSAGE;
}
}
if (action === "plan_career_strategy" && !simpleInputs.careerGoal.trim()) {
return CAREER_PILOT_EMPTY_CAREER_GOAL_MESSAGE;
}
return null;
}, [action, explicitConsent, pilotPresentation, simpleInputs]);

useEffect(() => {
if (!pilotPresentation || !pilotIntent || !isCareerPilotIntent(pilotIntent)) {
return;
Expand All @@ -846,11 +933,11 @@ export function CareerChatWorkspace({
if (!pilotPresentation || !isCareerPilotIntent(action)) {
return null;
}
if (!hasPilotAnalysisInputs(action, specialistFields)) {
if (!hasPilotAnalysisInputs(action, effectiveSpecialistFields)) {
return null;
}
return buildPilotCareerBundleFromFields(specialistFields);
}, [action, careerBundle, pilotPresentation, specialistFields]);
return buildPilotCareerBundleFromFields(effectiveSpecialistFields);
}, [action, careerBundle, effectiveSpecialistFields, pilotPresentation]);

const hasBundle = pilotPresentation
? effectiveBundle != null
Expand All @@ -872,7 +959,7 @@ export function CareerChatWorkspace({
const submitDisabled = pilotPresentation
? !explicitConsent ||
!isCareerPilotIntent(action) ||
!hasPilotAnalysisInputs(action, specialistFields)
!hasSimplePilotAnalysisInputs(action, simpleInputs)
: !careerBundle || !explicitConsent || !message.trim();

function handleActionChange(nextAction: CareerChatIntent) {
Expand Down Expand Up @@ -900,7 +987,7 @@ export function CareerChatWorkspace({
try {
const analysisInput = buildSpecialistAnalysisInput({
action,
fields: specialistFields,
fields: effectiveSpecialistFields,
mainStack: effectiveBundle.candidate?.mainStack ?? [],
fallbackRole:
effectiveBundle.candidate?.targetRole ?? effectiveBundle.applications[0]?.role ?? "",
Expand Down Expand Up @@ -990,10 +1077,20 @@ export function CareerChatWorkspace({
}
}}
isSending={isSending}
specialistFields={specialistFields}
onSpecialistFieldChange={(field, value) =>
setSpecialistFields((current) => ({ ...current, [field]: value }))
}
specialistFields={effectiveSpecialistFields}
onSpecialistFieldChange={(field, value) => {
if (pilotPresentation) {
setReviewOverrides((current) => ({
...(current ?? normalizedFields),
[field]: value,
}));
return;
}
setSpecialistFields((current) => ({ ...current, [field]: value }));
}}
simpleInputs={pilotPresentation ? simpleInputs : undefined}
onSimpleInputsChange={pilotPresentation ? handleSimpleInputsChange : undefined}
validationMessage={pilotValidationMessage}
pilotMode={pilotMode}
pilotPresentation={pilotPresentation}
submitDisabled={submitDisabled}
Expand Down
32 changes: 29 additions & 3 deletions apps/applyflow/src/components/dashboard/career-pilot-content.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CareerChatIntent } from "@devflow/career-core";
import type { CareerSpecialistFields } from "./career-chat-workspace";
import type { CareerPilotSimpleInputs } from "./career-pilot-simple-inputs";

export const CAREER_PILOT_EYEBROW = "Piloto Career Suite";

Expand Down Expand Up @@ -113,13 +114,13 @@ export const CAREER_PILOT_MESSAGE_LABEL = "Contexto adicional (opcional)";
export const CAREER_PILOT_DEFAULT_MESSAGE = "Quero melhorar meu posicionamento profissional.";

export const CAREER_PILOT_EMPTY_RESUME_HINT =
"Informe ao menos uma experiência ou habilidade do seu currículo para iniciar a análise.";
"Cole um trecho suficiente do currículo para realizar a análise.";

export const CAREER_PILOT_EMPTY_ATS_HINT =
"Informe experiências ou habilidades do currículo e os requisitos da vaga para comparar.";
"Cole o currículo e a descrição da vaga que deseja comparar.";

export const CAREER_PILOT_EMPTY_STRATEGY_HINT =
"Informe os cargos-alvo que deseja perseguir para montar o plano.";
"Informe seu objetivo profissional para montar o plano.";

export const CAREER_PILOT_ERROR_TITLE = "Não foi possível concluir a análise.";

Expand All @@ -136,6 +137,31 @@ export const CAREER_PILOT_EXAMPLE_FIELDS: CareerSpecialistFields = {
availability: "10h/semana",
};

export const CAREER_PILOT_EXAMPLE_SIMPLE_INPUTS: CareerPilotSimpleInputs = {
targetRole: "Engenheiro de Software Backend",
resumeText: `Maria Souza — Desenvolvedora de Software

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`,
jobDescription: `Engenheiro de Software Backend — Empresa SaaS

Requisitos:
- 3+ anos de experiência com backend
- Experiência com TypeScript
- Conhecimento em cloud (AWS ou GCP)
- Inglês intermediário
- Experiência com APIs REST`,
careerGoal:
"Conseguir uma vaga como Engenheiro de Software Backend Sênior nos próximos 90 dias.",
weeklyAvailability: "10 horas por semana",
constraints: "Trabalho remoto, empresas SaaS e contratação CLT",
};

export const CAREER_PILOT_INTENTS = [
"analyze_resume",
"analyze_ats_compatibility",
Expand Down
Loading
Loading