feat(cv-import): add source-grounded review and explicit corrections
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { api } from './api';
|
||||
import { emptyStructuredCv } from './profileCv';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import CareerProfilePage from './views/CareerProfilePage';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
getApiErrorMessage: jest.requireActual('./api').getApiErrorMessage,
|
||||
api: { get: jest.fn(), post: jest.fn(), put: jest.fn() },
|
||||
}));
|
||||
jest.mock('./components/CropImageDialog', () => () => null);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const run = { id: 23, trigger: 'upload', status: 'pending_review', artifactFileName: 'career.pdf',
|
||||
startedAtUtc: '2026-10-26T10:00:00Z', parserVersion: 'docling-v1', normalizerVersion: 'document-v1', llmPromptVersion: 'none' };
|
||||
const review = { runId: 23, status: 'pending_review', revision: 7, fileName: 'career.pdf',
|
||||
sourceUrl: '/api/profile-cv/runs/23/source', sourceText: 'Source name\nDocker', warnings: ['Check ambiguous dates.'],
|
||||
groups: [{ id: 'Contact', category: 'Contact', label: 'Contact', matchDescription: null, fields: [
|
||||
{ id: 'Contact.fullName', label: 'Full name', currentValue: 'Saved name', extractedValue: 'Source name', sourceSnippet: 'Source name', sourcePage: 1, method: 'docling', reviewState: 'suggested' },
|
||||
{ id: 'Contact.headline', label: 'Headline', currentValue: 'Saved headline', extractedValue: 'Engineer', sourceSnippet: null, sourcePage: null, method: 'deterministic', reviewState: 'suggested' },
|
||||
] }] };
|
||||
let currentReview = review;
|
||||
let runs = [run];
|
||||
|
||||
function renderImport() {
|
||||
window.history.replaceState({}, '', '/career?section=import');
|
||||
return render(<ToastProvider><I18nProvider><CareerProfilePage /></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
currentReview = review;
|
||||
runs = [run];
|
||||
URL.createObjectURL = jest.fn(() => 'blob:owned-source');
|
||||
URL.revokeObjectURL = jest.fn();
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/career/profile') return Promise.resolve({ data: { profile: emptyStructuredCv(), revision: 7, completeness: { percent: 0, missing: [], sections: [] } } } as any);
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { provider: 'local', userName: 'Importer' } } as any);
|
||||
if (url === '/career/profile/versions') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/profile-cv/runs') return Promise.resolve({ data: runs } as any);
|
||||
if (url === '/profile-cv/runs/23/review') return Promise.resolve({ data: currentReview } as any);
|
||||
if (url === '/profile-cv/runs/23/diff') return Promise.resolve({ data: { revision: 7, diff: { categories: [], hasChanges: false, totalAdded: 0, totalUpdated: 0, totalLowConfidence: 0 } } } as any);
|
||||
if (url === '/profile-cv/runs/23/source') return Promise.resolve({ data: new Blob(['%PDF-1.4'], { type: 'application/pdf' }) } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.post.mockResolvedValue({ data: { runId: 23, status: 'applied', revision: 8, structuredCv: emptyStructuredCv() } } as any);
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test('Docling review requires explicit decisions and sends only reviewed corrections before showing durable completion', async () => {
|
||||
renderImport();
|
||||
const choice = await screen.findByRole('combobox', { name: 'Decision: Full name' });
|
||||
expect(choice).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled();
|
||||
expect(screen.getByText('Current: Saved name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Imported: Source name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Page 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Check ambiguous dates.')).toBeInTheDocument();
|
||||
fireEvent.change(choice, { target: { value: 'correct' } });
|
||||
fireEvent.change(screen.getByLabelText('Correction: Full name'), { target: { value: 'Reviewed name' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Professional headline' }), { target: { value: 'keep' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/23/apply-reviewed', {
|
||||
expectedVersion: 7, decisions: [{ id: 'Contact.fullName', action: 'correct', value: 'Reviewed name' }, { id: 'Contact.headline', action: 'keep' }],
|
||||
}));
|
||||
expect(await screen.findByText('Reviewed changes saved to your Career Profile.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Open Career Profile' })).toHaveAttribute('href', '/career?section=profile');
|
||||
expect(screen.getByRole('link', { name: 'Open CV editor' })).toHaveAttribute('href', '/career/builder');
|
||||
});
|
||||
|
||||
test('stale apply retains corrections and refreshes current values but requires every choice to be confirmed again', async () => {
|
||||
mockedApi.post.mockRejectedValueOnce({ response: { status: 409 } });
|
||||
renderImport();
|
||||
fireEvent.change(await screen.findByRole('combobox', { name: 'Decision: Full name' }), { target: { value: 'correct' } });
|
||||
fireEvent.change(screen.getByLabelText('Correction: Full name'), { target: { value: 'Keep this correction' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Professional headline' }), { target: { value: 'use' } });
|
||||
currentReview = { ...review, revision: 9, groups: [{ ...review.groups[0], fields: [{ ...review.groups[0].fields[0], currentValue: 'Changed elsewhere' }, review.groups[0].fields[1]] }] };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
|
||||
expect(await screen.findByText('Current: Changed elsewhere')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Correction: Full name')).toHaveValue('Keep this correction');
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled();
|
||||
expect(screen.getByRole('combobox', { name: 'Decision: Full name' })).toHaveValue('');
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Full name' }), { target: { value: 'correct' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled();
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Professional headline' }), { target: { value: 'keep' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/profile-cv/runs/23/apply-reviewed', {
|
||||
expectedVersion: 9, decisions: [{ id: 'Contact.fullName', action: 'correct', value: 'Keep this correction' }, { id: 'Contact.headline', action: 'keep' }],
|
||||
}));
|
||||
});
|
||||
|
||||
test('original PDF uses the authenticated API blob and revokes its URL when leaving the page', async () => {
|
||||
const { unmount } = renderImport();
|
||||
const preview = await screen.findByTitle('Original CV document');
|
||||
expect(preview).toHaveAttribute('src', 'blob:owned-source');
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/profile-cv/runs/23/source', expect.objectContaining({ responseType: 'blob' }));
|
||||
expect(screen.getByRole('link', { name: 'Download original' })).toHaveAttribute('download', 'career.pdf');
|
||||
unmount();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owned-source');
|
||||
});
|
||||
|
||||
test.each([['career.pdf', 'application/pdf', '/profile-cv/import'], ['career.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '/profile-cv/import'], ['career.txt', 'text/plain', '/profile-cv/upload']])('upload %s uses its compatible pipeline', async (fileName, type, route) => {
|
||||
runs = [];
|
||||
const { container } = renderImport();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Upload CV' })).toBeEnabled());
|
||||
fireEvent.change(container.querySelector('input[type="file"][accept*=".pdf"]')!, { target: { files: [new File(['content'], fileName, { type })] } });
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(route, expect.any(FormData), { headers: { 'Content-Type': 'multipart/form-data' } }));
|
||||
});
|
||||
|
||||
test('oversized document is explained before upload and a later valid file remains selectable', async () => {
|
||||
runs = [];
|
||||
const { container } = renderImport();
|
||||
await screen.findByRole('button', { name: 'Upload CV' });
|
||||
const input = container.querySelector('input[type="file"][accept*=".pdf"]')!;
|
||||
const file = new File(['small'], 'career.pdf', { type: 'application/pdf' });
|
||||
Object.defineProperty(file, 'size', { value: 5 * 1024 * 1024 + 1 });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
expect(await screen.findByText('Choose a file no larger than 5 MiB.')).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
fireEvent.change(input, { target: { files: [new File(['pdf'], 'career.pdf', { type: 'application/pdf' })] } });
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('group approval is deliberate and a dirty profile disables application without losing review corrections', async () => {
|
||||
renderImport();
|
||||
const group = await screen.findByRole('group', { name: 'Contact information' });
|
||||
fireEvent.click(within(group).getByRole('button', { name: 'Use all extracted values in this group' }));
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Career profile' }));
|
||||
fireEvent.change(await screen.findByLabelText('Full name'), { target: { value: 'Unsaved draft' } });
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Import review' }));
|
||||
expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Career profile' }));
|
||||
expect(screen.getByLabelText('Full name')).toHaveValue('Unsaved draft');
|
||||
});
|
||||
@@ -2,6 +2,58 @@ export type UiLanguage = "en" | "nb";
|
||||
|
||||
export const translations = {
|
||||
en: {
|
||||
cvImportReviewHelp: "Review every field. Nothing changes in your Career Profile until you apply your choices.",
|
||||
cvImportPanels: "Import source and review",
|
||||
cvImportSourcePanel: "Source document",
|
||||
cvImportReviewPanel: "Review extracted information",
|
||||
cvImportDownload: "Download original",
|
||||
cvImportSourceTitle: "Original CV document",
|
||||
cvImportSourceLoading: "Loading original document",
|
||||
cvImportSourceFailed: "Could not load the original document. Source excerpts remain available for review.",
|
||||
cvImportSourceEmpty: "No source text is available. Download the original document to check it.",
|
||||
cvImportEvidence: "Source excerpt",
|
||||
cvImportPage: "Page {page}",
|
||||
cvImportNoEvidence: "No source excerpt available. Check the original before approving this value.",
|
||||
cvImportMethodDocument: "Read from document",
|
||||
cvImportMethodDeterministic: "Suggested from document structure",
|
||||
cvImportReviewedCount: "{reviewed} of {total} fields reviewed",
|
||||
cvImportDecision: "Decision: {field}",
|
||||
cvImportChoose: "Choose a decision",
|
||||
cvImportKeep: "Keep current value / skip addition",
|
||||
cvImportUse: "Use extracted value",
|
||||
cvImportCorrect: "Correct extracted value",
|
||||
cvImportCorrection: "Correction: {field}",
|
||||
cvImportUseGroup: "Use all extracted values in this group",
|
||||
cvImportApplyReviewed: "Apply reviewed changes",
|
||||
cvImportStaleReview: "Your Career Profile changed elsewhere. Your corrections are retained. Compare the refreshed current values and confirm every choice again.",
|
||||
cvImportNoFields: "No supported fields were extracted. Your Career Profile has not changed.",
|
||||
cvImportSaved: "Reviewed changes saved to your Career Profile.",
|
||||
cvImportOpenProfile: "Open Career Profile",
|
||||
cvImportOpenEditor: "Open CV editor",
|
||||
cvImportSizeError: "Choose a file no larger than 5 MiB.",
|
||||
cvImportFormatError: "Choose a PDF, DOCX, text, Markdown, PNG, JPEG or WebP file.",
|
||||
cvImportUploadHelp: "Drop a PDF or DOCX here, or choose a file. Maximum 5 MiB. Text, Markdown and images use the existing importer.",
|
||||
cvImportStageExtracting: "Reading document structure",
|
||||
cvImportStageValidating: "Validating your document",
|
||||
cvImportFieldName: "Name",
|
||||
cvImportFieldRole: "Role",
|
||||
cvImportMatchedRecord: "Matches an existing record — review the affected fields",
|
||||
cvImportNewRecord: "New record — keep only the information you want to add",
|
||||
cvImportExpired: "Source expired — upload the document again",
|
||||
cvImportStageSections: "Identifying document sections",
|
||||
cvImportStageAnalysing: "Analysing career information",
|
||||
cvImportStageDetails: "Validating extracted details",
|
||||
cvImportStagePreparing: "Preparing your review",
|
||||
cvImportWarningNative: "Complex PDF columns and table relationships may need corrections. Check the original document.",
|
||||
cvImportWarningPages: "DOCX page numbers cannot be established reliably. Source excerpts are provided instead.",
|
||||
cvImportWarningOcr: "OCR is disabled. Scanned or image-only content cannot be extracted.",
|
||||
cvImportStageInterpreting: "Preparing extracted information for review",
|
||||
cvImportStageReady: "Ready for your review",
|
||||
cvImportFieldTitle: "Title",
|
||||
cvImportFieldCompany: "Company",
|
||||
cvImportFieldStart: "Start date",
|
||||
cvImportFieldEnd: "End date",
|
||||
cvImportFieldCurrent: "Current position",
|
||||
appTitle: "Jobbjakt",
|
||||
appTagline: "Track your hunt",
|
||||
applicationLanguage: "Application language",
|
||||
@@ -2927,6 +2979,58 @@ export const translations = {
|
||||
coverAiReject: "Reject",
|
||||
},
|
||||
nb: {
|
||||
cvImportReviewHelp: "Gå gjennom hvert felt. Karriereprofilen endres først når du bruker valgene dine.",
|
||||
cvImportPanels: "Kilde og gjennomgang av import",
|
||||
cvImportSourcePanel: "Kildedokument",
|
||||
cvImportReviewPanel: "Se gjennom hentet informasjon",
|
||||
cvImportDownload: "Last ned originalen",
|
||||
cvImportSourceTitle: "Opprinnelig CV-dokument",
|
||||
cvImportSourceLoading: "Laster originaldokumentet",
|
||||
cvImportSourceFailed: "Kunne ikke laste originaldokumentet. Kildeutdragene er fortsatt tilgjengelige for gjennomgang.",
|
||||
cvImportSourceEmpty: "Ingen kildetekst er tilgjengelig. Last ned originaldokumentet for å kontrollere det.",
|
||||
cvImportEvidence: "Kildeutdrag",
|
||||
cvImportPage: "Side {page}",
|
||||
cvImportNoEvidence: "Ingen kildeutdrag tilgjengelig. Kontroller originalen før du godkjenner verdien.",
|
||||
cvImportMethodDocument: "Lest fra dokumentet",
|
||||
cvImportMethodDeterministic: "Foreslått ut fra dokumentstrukturen",
|
||||
cvImportReviewedCount: "{reviewed} av {total} felt gjennomgått",
|
||||
cvImportDecision: "Valg: {field}",
|
||||
cvImportChoose: "Velg en handling",
|
||||
cvImportKeep: "Behold nåværende verdi / hopp over tillegg",
|
||||
cvImportUse: "Bruk hentet verdi",
|
||||
cvImportCorrect: "Korriger hentet verdi",
|
||||
cvImportCorrection: "Korrigering: {field}",
|
||||
cvImportUseGroup: "Bruk alle hentede verdier i denne gruppen",
|
||||
cvImportApplyReviewed: "Bruk gjennomgåtte endringer",
|
||||
cvImportStaleReview: "Karriereprofilen ble endret et annet sted. Korrigeringene dine er beholdt. Sammenlign de oppdaterte verdiene og bekreft hvert valg på nytt.",
|
||||
cvImportNoFields: "Ingen støttede felt ble hentet. Karriereprofilen er ikke endret.",
|
||||
cvImportSaved: "Gjennomgåtte endringer er lagret i karriereprofilen.",
|
||||
cvImportOpenProfile: "Åpne karriereprofilen",
|
||||
cvImportOpenEditor: "Åpne CV-editoren",
|
||||
cvImportSizeError: "Velg en fil som ikke er større enn 5 MiB.",
|
||||
cvImportFormatError: "Velg en PDF-, DOCX-, tekst-, Markdown-, PNG-, JPEG- eller WebP-fil.",
|
||||
cvImportUploadHelp: "Slipp en PDF eller DOCX her, eller velg en fil. Maksimalt 5 MiB. Tekst, Markdown og bilder bruker den eksisterende importen.",
|
||||
cvImportStageExtracting: "Leser dokumentstrukturen",
|
||||
cvImportStageValidating: "Kontrollerer dokumentet ditt",
|
||||
cvImportFieldName: "Navn",
|
||||
cvImportFieldRole: "Rolle",
|
||||
cvImportMatchedRecord: "Samsvarer med en eksisterende oppføring — gjennomgå feltene som berøres",
|
||||
cvImportNewRecord: "Ny oppføring — behold bare informasjonen du vil legge til",
|
||||
cvImportExpired: "Kilden er utløpt — last opp dokumentet på nytt",
|
||||
cvImportStageSections: "Identifiserer dokumentdelene",
|
||||
cvImportStageAnalysing: "Analyserer karriereinformasjon",
|
||||
cvImportStageDetails: "Kontrollerer de hentede opplysningene",
|
||||
cvImportStagePreparing: "Klargjør gjennomgangen din",
|
||||
cvImportWarningNative: "Komplekse PDF-kolonner og tabeller kan kreve rettelser. Kontroller originaldokumentet.",
|
||||
cvImportWarningPages: "Sidetall i DOCX kan ikke fastslås pålitelig. Kildeutdrag vises i stedet.",
|
||||
cvImportWarningOcr: "OCR er deaktivert. Skannet innhold eller innhold som bare består av bilder, kan ikke hentes ut.",
|
||||
cvImportStageInterpreting: "Klargjør hentet informasjon for gjennomgang",
|
||||
cvImportStageReady: "Klar for gjennomgang",
|
||||
cvImportFieldTitle: "Stilling",
|
||||
cvImportFieldCompany: "Arbeidsgiver",
|
||||
cvImportFieldStart: "Startdato",
|
||||
cvImportFieldEnd: "Sluttdato",
|
||||
cvImportFieldCurrent: "Nåværende stilling",
|
||||
appTitle: "Jobbjakt",
|
||||
appTagline: "Hold oversikt over jobbsøkingen",
|
||||
applicationLanguage: "Språk i appen",
|
||||
|
||||
@@ -582,14 +582,14 @@ test('/career profile exposes extraction progress', async () => {
|
||||
expect(fileInput).not.toBeNull();
|
||||
});
|
||||
|
||||
test('/career profile can queue a CV upload without visiting import review first', async () => {
|
||||
test('/career profile can queue a legacy text CV upload without visiting import review first', async () => {
|
||||
extractionRunsResponse = [];
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { queued: true, extractionRunId: 22, status: 'queued', created: true } } as any);
|
||||
const { container } = renderPage('profile');
|
||||
|
||||
expect(await screen.findByRole('button', { name: /upload cv/i })).toBeEnabled();
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"][accept*=".pdf"]')!;
|
||||
fireEvent.change(fileInput, { target: { files: [new File(['resume'], 'resume.pdf', { type: 'application/pdf' })] } });
|
||||
fireEvent.change(fileInput, { target: { files: [new File(['resume'], 'resume.txt', { type: 'text/plain' })] } });
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/upload', expect.any(FormData), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
|
||||
@@ -28,6 +28,11 @@ test("legacy structured items without IDs remain usable and acquire no client-ge
|
||||
}
|
||||
});
|
||||
|
||||
test("reviewed field provenance survives client normalization and a later save", () => {
|
||||
const source = { metadata: { fields: { "skills.0": { sourceBlockId: "b0001", sourceArtifactId: 21, sourceExtractionRunId: 7, sourceFileSha256: "abc", method: "deterministic-source" } } }, skills: ["C#"] };
|
||||
expect(normalizeStructuredCv(JSON.parse(JSON.stringify(source))).metadata.fields["skills.0"]).toMatchObject(source.metadata.fields["skills.0"]);
|
||||
});
|
||||
|
||||
test("legacy parsed sections keep their facts without inventing structured item IDs", () => {
|
||||
const profile = parseStructuredCvJson(JSON.stringify([
|
||||
{ name: "Professional Summary", content: "Recorded delivery\nRecorded support", wordCount: 4 },
|
||||
|
||||
@@ -10,6 +10,9 @@ export type StructuredCvFieldMetadata = {
|
||||
sourceSnippet?: string;
|
||||
sourcePage?: number;
|
||||
sourceBlockId?: string;
|
||||
sourceArtifactId?: number;
|
||||
sourceExtractionRunId?: number;
|
||||
sourceFileSha256?: string;
|
||||
reviewState?: string;
|
||||
lastUpdatedAtUtc?: string;
|
||||
};
|
||||
@@ -225,6 +228,9 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
||||
sourceSnippet: normalizeString(value?.sourceSnippet),
|
||||
sourcePage: value?.sourcePage != null && Number.isFinite(Number(value.sourcePage)) ? Number(value.sourcePage) : undefined,
|
||||
sourceBlockId: normalizeString(value?.sourceBlockId),
|
||||
sourceArtifactId: value?.sourceArtifactId != null && Number.isInteger(Number(value.sourceArtifactId)) && Number(value.sourceArtifactId) > 0 ? Number(value.sourceArtifactId) : undefined,
|
||||
sourceExtractionRunId: value?.sourceExtractionRunId != null && Number.isInteger(Number(value.sourceExtractionRunId)) && Number(value.sourceExtractionRunId) > 0 ? Number(value.sourceExtractionRunId) : undefined,
|
||||
sourceFileSha256: normalizeString(value?.sourceFileSha256),
|
||||
reviewState: normalizeString(value?.reviewState),
|
||||
lastUpdatedAtUtc: normalizeString(value?.lastUpdatedAtUtc),
|
||||
}]))
|
||||
|
||||
@@ -12,6 +12,7 @@ import CropImageDialog from "../components/CropImageDialog";
|
||||
import ProfileCompleteness from "./career/ProfileCompleteness";
|
||||
import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview";
|
||||
import CareerEvidenceBank from "./career/CareerEvidenceBank";
|
||||
import CvImportReview from "./career/CvImportReview";
|
||||
import {
|
||||
EducationSection,
|
||||
InterestsSection,
|
||||
@@ -106,7 +107,7 @@ const importLabelKeys: Record<string, TranslationKey> = {
|
||||
"waiting to retry": "careerImportWaitingRetry", "waiting for approved fallback": "careerImportWaitingFallback",
|
||||
"cancellation requested": "operationsCancellationRequested", pending_review: "careerImportPendingReview", applied: "careerImportApplied",
|
||||
discarded: "careerImportDiscarded", failed: "careerImportFailed", cancelled: "careerImportCancelled",
|
||||
upload: "careerImportUpload", rebuild: "careerImportRebuild", improve: "careerImportImprove", reprocess: "careerImportReprocess",
|
||||
upload: "careerImportUpload", document_import: "careerImportUpload", expired: "cvImportExpired", rebuild: "careerImportRebuild", improve: "careerImportImprove", reprocess: "careerImportReprocess",
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
@@ -178,6 +179,7 @@ export default function CareerProfilePage() {
|
||||
const [profileRevision, setProfileRevision] = useState<number | null>(null);
|
||||
const profileReadId = useRef(0);
|
||||
const [uploadingCv, setUploadingCv] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [improvingCv, setImprovingCv] = useState(false);
|
||||
const [rebuildingCv, setRebuildingCv] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
@@ -323,19 +325,32 @@ export default function CareerProfilePage() {
|
||||
}, []);
|
||||
|
||||
const queueCvUpload = useCallback(async (file: File) => {
|
||||
if (uploadingCv || !canUseAi || me?.provider !== "local") return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError(t("cvImportSizeError"));
|
||||
return;
|
||||
}
|
||||
if (!/\.(pdf|docx|txt|md|png|jpe?g|webp)$/i.test(file.name)) {
|
||||
setUploadError(t("cvImportFormatError"));
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
setUploadError(null);
|
||||
setUploadingCv(true);
|
||||
try {
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
const route = /\.(pdf|docx)$/i.test(file.name) ? "/profile-cv/import" : "/profile-cv/upload";
|
||||
const res = await api.post<QueuedCvRunResponse>(route, formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
await loadExtractionRuns();
|
||||
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("profileCvUploadFailed")), "error");
|
||||
const message = getApiErrorMessage(error, t("profileCvUploadFailed"));
|
||||
setUploadError(message);
|
||||
toast(message, "error");
|
||||
} finally {
|
||||
setUploadingCv(false);
|
||||
}
|
||||
}, [loadExtractionRuns, t, toast]);
|
||||
}, [canUseAi, loadExtractionRuns, me?.provider, t, toast, uploadingCv]);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
const readId = ++profileReadId.current;
|
||||
@@ -389,7 +404,7 @@ export default function CareerProfilePage() {
|
||||
|
||||
useEffect(() => {
|
||||
for (const run of extractionRuns) {
|
||||
if (run.status === "pending_review" && !runDiffs[run.id] && !runDiffLoading[run.id] && runDiffErrors[run.id] === undefined) void loadRunDiff(run.id);
|
||||
if (run.parserVersion !== "docling-v1" && run.status === "pending_review" && !runDiffs[run.id] && !runDiffLoading[run.id] && runDiffErrors[run.id] === undefined) void loadRunDiff(run.id);
|
||||
}
|
||||
}, [extractionRuns, loadRunDiff, runDiffErrors, runDiffLoading, runDiffs]);
|
||||
|
||||
@@ -486,6 +501,8 @@ export default function CareerProfilePage() {
|
||||
: run.status === "queued" || run.status === "running");
|
||||
const pendingCvRun = extractionRuns.find((run) => run.status === "pending_review");
|
||||
const extractionProgress = activeCvRun?.operation?.progressPercent;
|
||||
const stageKeys: Record<string, TranslationKey> = { cv_validating_file: "cvImportStageValidating", cv_extracting: "cvImportStageExtracting", cv_identifying_sections: "cvImportStageSections", cv_analysing: "cvImportStageAnalysing", cv_validating_details: "cvImportStageDetails", cv_preparing_review: "cvImportStagePreparing", cv_interpreting: "cvImportStageInterpreting", cv_review_ready: "cvImportStageReady" };
|
||||
const importStage = (stage?: string | null) => stage && Object.hasOwn(stageKeys, stage) ? t(stageKeys[stage]) : t("careerProfileImportProcessing");
|
||||
const importLabel = (value: string) => Object.hasOwn(importLabelKeys, value) ? t(importLabelKeys[value]) : value;
|
||||
const importChangeLabel = (change: CvImportChange, category: string) => category === "Professional summary" ? importLabel(category)
|
||||
: change.id === "Contact" && change.confidence === "Low" && change.label === "Contact details" ? t("careerImportContactDetails") : change.label;
|
||||
@@ -620,7 +637,7 @@ export default function CareerProfilePage() {
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.75 }}>
|
||||
{typeof extractionProgress === "number"
|
||||
? t("careerProfileImportPercent", { percent: extractionProgress })
|
||||
: t("careerProfileImportProcessing")}
|
||||
: importStage(activeCvRun?.operation?.progressStage)}
|
||||
</Typography>
|
||||
</Box> : null}
|
||||
{pendingCvRun ? <Alert severity="success" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => navigateWorkspace("import")}>{t("careerProfileImportReview")}</Button>}>
|
||||
@@ -695,7 +712,12 @@ export default function CareerProfilePage() {
|
||||
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", minWidth: 0, display: careerOnly ? "block" : "none" }}>
|
||||
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
|
||||
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }} onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
if (editorDisabled || uploadingCv || activeCvRun || !canUseAi) return;
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) void queueCvUpload(file);
|
||||
}}>
|
||||
{!canUseAi && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>
|
||||
@@ -772,6 +794,8 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
</Box>
|
||||
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("cvImportUploadHelp")}</Typography>
|
||||
{uploadError ? <Alert severity="error" sx={{ mb: 1.5 }}>{uploadError}</Alert> : null}
|
||||
<Alert severity="info" sx={{ mb: 2, borderRadius: 2.5 }}>
|
||||
{t("profileCvStructuredDefaultHint")}
|
||||
</Alert>
|
||||
@@ -819,6 +843,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{run.artifactFileName || t("profileCvNoStoredArtifact")}</Typography>
|
||||
{run.operation?.progressStage && activeOperationStatuses.has(run.operation.status) ? <Typography variant="body2" role="status" aria-live="polite" sx={{ mt: 1 }}>{importStage(run.operation.progressStage)}</Typography> : null}
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.75 }}>
|
||||
{run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")}
|
||||
</Typography>
|
||||
@@ -837,7 +862,21 @@ export default function CareerProfilePage() {
|
||||
<Typography variant="body2" component="p" sx={{ fontWeight: 600 }}>{t(runActionErrors[run.id].key)}</Typography>
|
||||
{runActionErrors[run.id].message ? <Typography variant="body2" component="p">{runActionErrors[run.id].message}</Typography> : null}
|
||||
</Alert> : null}
|
||||
{run.status === "pending_review" ? (
|
||||
{run.parserVersion === "docling-v1" && (run.status === "pending_review" || run.status === "applied") ? <CvImportReview
|
||||
runId={run.id}
|
||||
disabled={editorDisabled || profileDirty || reviewingRunId !== null}
|
||||
onApplyingChange={(applying) => {
|
||||
profileMutationPending.current = applying;
|
||||
setReviewingRunId(applying ? run.id : null);
|
||||
setReviewAction(applying ? "accept" : null);
|
||||
}}
|
||||
onApplied={async () => {
|
||||
setExtractionRuns((current) => current.map((item) => item.id === run.id ? { ...item, status: "applied" } : item));
|
||||
invalidateImportComparisons();
|
||||
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
|
||||
}}
|
||||
onDiscard={() => void reviewRun(run.id, "discard")}
|
||||
/> : run.status === "pending_review" ? (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
{runDiffs[run.id] ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Box, Button, FormControl, InputLabel, LinearProgress, NativeSelect, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||
import { api, getApiErrorMessage } from "../../api";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
import type { TranslationKey } from "../../i18n/translations";
|
||||
|
||||
type ReviewField = {
|
||||
id: string; label: string; currentValue: string | null; extractedValue: string;
|
||||
sourceSnippet: string | null; sourcePage: number | null; method: string; reviewState: string;
|
||||
};
|
||||
type ReviewGroup = { id: string; category: string; label: string; matchDescription: string | null; fields: ReviewField[] };
|
||||
type ImportReview = {
|
||||
runId: number; status: string; revision: number; fileName: string; sourceText: string;
|
||||
warnings: string[]; groups: ReviewGroup[];
|
||||
};
|
||||
type Choice = { action: "keep" | "use" | "correct" | "add"; value?: string; confirmed: boolean };
|
||||
|
||||
const labelKeys: Record<string, TranslationKey> = {
|
||||
contact: "careerProfileContactSection", fullName: "profileCvContactFullName", headline: "profileCvContactHeadline",
|
||||
email: "profileCvContactEmail", phone: "profileCvContactPhone", location: "profileCvContactLocation",
|
||||
website: "profileCvContactWebsite", linkedIn: "profileCvContactLinkedIn", summary: "profileCvStructuredSummary",
|
||||
jobs: "cvSectionExperience", education: "cvSectionEducation", skills: "cvSectionSkills", languages: "cvSectionLanguages",
|
||||
interests: "cvSectionInterests", projects: "cvSectionProjects", certifications: "cvSectionCertifications",
|
||||
title: "cvImportFieldTitle", company: "cvImportFieldCompany", start: "cvImportFieldStart", end: "cvImportFieldEnd",
|
||||
isCurrent: "cvImportFieldCurrent", bullets: "careerImportDetails", details: "careerImportDetails",
|
||||
awards: "cvSectionAwards", publications: "cvSectionPublications", organisations: "cvSectionOrganisations", references: "cvSectionReferences",
|
||||
qualification: "profileCvEducationQualification", institution: "profileCvEducationInstitution", level: "profileCvLanguageLevel", notes: "profileCvLanguageNotes",
|
||||
name: "cvImportFieldName", issuer: "careerImportIssuer", date: "careerImportDates", role: "cvImportFieldRole", otherSections: "cvEditorCustom",
|
||||
Contact: "careerProfileContactSection", "Full name": "profileCvContactFullName", Headline: "profileCvContactHeadline",
|
||||
Email: "profileCvContactEmail", Phone: "profileCvContactPhone", Location: "profileCvContactLocation",
|
||||
Website: "profileCvContactWebsite", LinkedIn: "profileCvContactLinkedIn", Summary: "profileCvStructuredSummary",
|
||||
Experience: "cvSectionExperience", Education: "cvSectionEducation", Skills: "cvSectionSkills",
|
||||
Languages: "cvSectionLanguages", Interests: "cvSectionInterests", Projects: "cvSectionProjects",
|
||||
Certifications: "cvSectionCertifications", Level: "profileCvLanguageLevel", Details: "careerImportDetails",
|
||||
Bullets: "careerImportDetails", Title: "cvImportFieldTitle", Company: "cvImportFieldCompany",
|
||||
Start: "cvImportFieldStart", End: "cvImportFieldEnd", "Is current": "cvImportFieldCurrent",
|
||||
};
|
||||
|
||||
export default function CvImportReview({ runId, disabled, onApplyingChange, onApplied, onDiscard }: {
|
||||
runId: number; disabled: boolean; onApplyingChange: (applying: boolean) => void;
|
||||
onApplied: () => Promise<void>; onDiscard: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const translate = useRef(t);
|
||||
translate.current = t;
|
||||
const [review, setReview] = useState<ImportReview | null>(null);
|
||||
const [choices, setChoices] = useState<Record<string, Choice>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comparisonCurrent, setComparisonCurrent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const applyPending = useRef(false);
|
||||
const [applied, setApplied] = useState(false);
|
||||
const [mobilePanel, setMobilePanel] = useState("review");
|
||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
||||
const [sourceIsPdf, setSourceIsPdf] = useState(false);
|
||||
const [sourceError, setSourceError] = useState(false);
|
||||
const [sourceAttempt, setSourceAttempt] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const reviewStatus = review?.status;
|
||||
|
||||
const loadReview = useCallback(async () => {
|
||||
const requestGeneration = ++generation.current;
|
||||
setLoading(true);
|
||||
setComparisonCurrent(false);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get<ImportReview>(`/profile-cv/runs/${runId}/review`);
|
||||
if (requestGeneration !== generation.current) return;
|
||||
setReview(response.data);
|
||||
setApplied(response.data.status === "applied");
|
||||
setComparisonCurrent(true);
|
||||
} catch (failure) {
|
||||
if (requestGeneration === generation.current) setError(getApiErrorMessage(failure, translate.current("careerImportComparisonFailed")));
|
||||
} finally {
|
||||
if (requestGeneration === generation.current) setLoading(false);
|
||||
}
|
||||
}, [runId]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestState = generation;
|
||||
setChoices({});
|
||||
setStale(false);
|
||||
void loadReview();
|
||||
return () => { ++requestState.current; };
|
||||
}, [loadReview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reviewStatus !== "pending_review" || applied) return;
|
||||
const controller = new AbortController();
|
||||
let disposed = false;
|
||||
let ownedUrl: string | null = null;
|
||||
setSourceUrl(null);
|
||||
setSourceError(false);
|
||||
void api.get<Blob>(`/profile-cv/runs/${runId}/source`, { responseType: "blob", signal: controller.signal }).then((response) => {
|
||||
if (disposed) return;
|
||||
ownedUrl = URL.createObjectURL(response.data);
|
||||
setSourceIsPdf(response.data.type === "application/pdf");
|
||||
setSourceUrl(ownedUrl);
|
||||
}).catch(() => { if (!disposed) setSourceError(true); });
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
if (ownedUrl) URL.revokeObjectURL(ownedUrl);
|
||||
};
|
||||
}, [runId, sourceAttempt, reviewStatus, applied]);
|
||||
|
||||
const label = (value: string) => Object.hasOwn(labelKeys, value) ? t(labelKeys[value]) : value;
|
||||
const warningKeys: Record<string, TranslationKey> = { pdf_native_order_no_layout_or_tables: "cvImportWarningNative", docx_page_numbers_unavailable: "cvImportWarningPages", ocr_disabled: "cvImportWarningOcr" };
|
||||
const fields = review?.groups.flatMap((group) => group.fields) ?? [];
|
||||
const reviewedCount = fields.filter((field) => choices[field.id]?.confirmed).length;
|
||||
const allReviewed = fields.length > 0 && reviewedCount === fields.length;
|
||||
|
||||
async function apply() {
|
||||
if (disabled || applyPending.current || !comparisonCurrent || !review || !allReviewed) return;
|
||||
applyPending.current = true;
|
||||
setApplying(true);
|
||||
onApplyingChange(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post(`/profile-cv/runs/${runId}/apply-reviewed`, {
|
||||
expectedVersion: review.revision,
|
||||
decisions: fields.map((field) => {
|
||||
const choice = choices[field.id];
|
||||
return { id: field.id, action: choice.action, ...(choice.action === "correct" ? { value: choice.value ?? field.extractedValue } : {}) };
|
||||
}),
|
||||
});
|
||||
setApplied(true);
|
||||
await onApplied();
|
||||
} catch (failure) {
|
||||
if ((failure as { response?: { status?: number } }).response?.status === 409) {
|
||||
setStale(true);
|
||||
setChoices((current) => Object.fromEntries(Object.entries(current).map(([id, choice]) => [id, { ...choice, confirmed: false }])));
|
||||
await loadReview();
|
||||
} else setError(getApiErrorMessage(failure, translate.current("profileCvApplyChangesFailed")));
|
||||
} finally {
|
||||
applyPending.current = false;
|
||||
setApplying(false);
|
||||
onApplyingChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (applied) return <Alert severity="success" sx={{ mt: 1.5 }}>
|
||||
<Typography component="p">{t("cvImportSaved")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
||||
<Button component="a" href="/career?section=profile">{t("cvImportOpenProfile")}</Button>
|
||||
<Button component="a" href="/career/builder">{t("cvImportOpenEditor")}</Button>
|
||||
</Box>
|
||||
</Alert>;
|
||||
|
||||
return <Box sx={{ mt: 1.5, minWidth: 0 }} aria-busy={loading || applying}>
|
||||
{loading ? <LinearProgress aria-label={t("careerImportComparisonLoading")} /> : null}
|
||||
{error ? <Alert severity="error" sx={{ mb: 1 }} action={!comparisonCurrent ? <Button color="inherit" onClick={() => void loadReview()} disabled={loading}>{t("retry")}</Button> : undefined}>{error}</Alert> : null}
|
||||
{stale ? <Alert severity="warning" sx={{ mb: 1 }}>{t("cvImportStaleReview")}</Alert> : null}
|
||||
{!review ? null : <>
|
||||
<Typography component="p" variant="body2" sx={{ mb: 1 }}>{t("cvImportReviewHelp")}</Typography>
|
||||
{review.warnings.map((warning, index) => <Alert key={index} severity="warning" sx={{ mb: 1 }}>{Object.hasOwn(warningKeys, warning) ? t(warningKeys[warning]) : warning}</Alert>)}
|
||||
<Tabs value={mobilePanel} onChange={(_, value: string) => setMobilePanel(value)} aria-label={t("cvImportPanels")} sx={{ display: { xs: "flex", md: "none" }, mb: 1 }}>
|
||||
<Tab value="review" label={t("cvImportReviewPanel")} id={`cv-import-${runId}-review-tab`} aria-controls={`cv-import-${runId}-review-panel`} />
|
||||
<Tab value="source" label={t("cvImportSourcePanel")} id={`cv-import-${runId}-source-tab`} aria-controls={`cv-import-${runId}-source-panel`} />
|
||||
</Tabs>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1.2fr)" }, gap: 2, minWidth: 0 }}>
|
||||
<Paper component="section" variant="outlined" id={`cv-import-${runId}-source-panel`} aria-label={t("cvImportSourcePanel")} sx={{ p: 1.5, minWidth: 0, alignSelf: "start", display: { xs: mobilePanel === "source" ? "block" : "none", md: "block" } }}>
|
||||
<Typography component="h4" variant="subtitle1">{t("cvImportSourcePanel")}</Typography>
|
||||
<Typography variant="body2" sx={{ overflowWrap: "anywhere", mb: 1 }}>{review.fileName}</Typography>
|
||||
{sourceUrl ? <Button component="a" href={sourceUrl} download={review.fileName} size="small">{t("cvImportDownload")}</Button> : null}
|
||||
{sourceError ? <Alert severity="warning" action={<Button color="inherit" onClick={() => setSourceAttempt((current) => current + 1)}>{t("retry")}</Button>}>{t("cvImportSourceFailed")}</Alert>
|
||||
: !sourceUrl ? <LinearProgress aria-label={t("cvImportSourceLoading")} /> : null}
|
||||
{sourceUrl && sourceIsPdf ? <Box component="iframe" src={sourceUrl} title={t("cvImportSourceTitle")} sx={{ width: "100%", height: { xs: 420, md: 680 }, border: 0, mt: 1 }} />
|
||||
: <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere", mt: 1 }}>{review.sourceText || t("cvImportSourceEmpty")}</Typography>}
|
||||
</Paper>
|
||||
<Box component="section" id={`cv-import-${runId}-review-panel`} aria-label={t("cvImportReviewPanel")} sx={{ minWidth: 0, display: { xs: mobilePanel === "review" ? "block" : "none", md: "block" } }}>
|
||||
<Typography component="h4" variant="subtitle1">{t("cvImportReviewPanel")}</Typography>
|
||||
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{t("cvImportReviewedCount", { reviewed: reviewedCount, total: fields.length })}</Typography>
|
||||
{review.groups.map((group) => <Box component="fieldset" key={group.id} disabled={disabled || applying || loading} sx={{ m: 0, mb: 1.5, p: 1.5, minWidth: 0, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||
<Typography component="legend" variant="subtitle2">{label(group.label)}</Typography>
|
||||
{group.matchDescription ? <Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "anywhere", mb: 1 }}>{group.matchDescription === "Matched existing record" ? t("cvImportMatchedRecord") : group.matchDescription === "New record" ? t("cvImportNewRecord") : group.matchDescription}</Typography> : null}
|
||||
<Button size="small" onClick={() => setChoices((current) => ({ ...current, ...Object.fromEntries(group.fields.map((field) => [field.id, { action: field.currentValue === null ? "add" : "use", confirmed: true }])) }))}>{t("cvImportUseGroup")}</Button>
|
||||
{group.fields.map((field) => {
|
||||
const choice = choices[field.id];
|
||||
const fieldKey = field.id.split(".").filter((part) => !/^\d+$/.test(part)).at(-1) ?? field.label;
|
||||
const fieldLabel = label(Object.hasOwn(labelKeys, fieldKey) ? fieldKey : field.label);
|
||||
const inputId = `cv-import-${runId}-${field.id}`;
|
||||
return <Box key={field.id} sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider", overflowWrap: "anywhere" }}>
|
||||
<Typography component="h5" variant="subtitle2">{fieldLabel}</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{t("careerImportCurrentValue", { value: field.currentValue || t("careerImportEmptyValue") })}</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{t("careerImportIncomingValue", { value: field.extractedValue || t("careerImportEmptyValue") })}</Typography>
|
||||
<Box sx={{ my: 1, pl: 1, borderLeft: "2px solid", borderColor: "divider" }}>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvImportEvidence")}{field.sourcePage ? ` · ${t("cvImportPage", { page: field.sourcePage })}` : ""}</Typography>
|
||||
{field.sourcePage ? <Typography variant="caption" sx={{ display: "block" }}>{t("cvImportPage", { page: field.sourcePage })}</Typography> : null}
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{field.sourceSnippet || t("cvImportNoEvidence")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t(field.method === "docling" ? "cvImportMethodDocument" : "cvImportMethodDeterministic")}</Typography>
|
||||
</Box>
|
||||
<FormControl variant="standard" fullWidth sx={{ mt: 1 }}>
|
||||
<InputLabel htmlFor={inputId} shrink>{t("cvImportDecision", { field: fieldLabel })}</InputLabel>
|
||||
<NativeSelect value={choice?.confirmed ? choice.action : ""} inputProps={{ id: inputId }} onChange={(event) => {
|
||||
const action = event.target.value as Choice["action"];
|
||||
setChoices((current) => ({ ...current, [field.id]: { action, value: current[field.id]?.value ?? field.extractedValue, confirmed: Boolean(action) } }));
|
||||
}}>
|
||||
<option value="">{t("cvImportChoose")}</option>
|
||||
<option value="keep">{t("cvImportKeep")}</option>
|
||||
<option value={field.currentValue === null ? "add" : "use"}>{t("cvImportUse")}</option>
|
||||
<option value="correct">{t("cvImportCorrect")}</option>
|
||||
</NativeSelect>
|
||||
</FormControl>
|
||||
{choice?.action === "correct" ? <TextField fullWidth multiline minRows={1} maxRows={10} label={t("cvImportCorrection", { field: fieldLabel })} value={choice.value ?? field.extractedValue} onChange={(event) => setChoices((current) => ({ ...current, [field.id]: { ...current[field.id], value: event.target.value } }))} sx={{ mt: 2 }} /> : null}
|
||||
</Box>;
|
||||
})}
|
||||
</Box>)}
|
||||
{fields.length === 0 ? <Alert severity="info" sx={{ mb: 1 }}>{t("cvImportNoFields")}</Alert> : null}
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="contained" disabled={disabled || applying || loading || !comparisonCurrent || !allReviewed} onClick={() => void apply()}>{t(applying ? "careerImportApplying" : "cvImportApplyReviewed")}</Button>
|
||||
<Button color="inherit" disabled={disabled || applying} onClick={onDiscard}>{t("profileCvDiscard")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>}
|
||||
</Box>;
|
||||
}
|
||||
Reference in New Issue
Block a user