diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4e3265..7dfa967 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,37 @@ on: branches: ["main"] jobs: + resume-parser-sidecar: + runs-on: ubuntu-latest + defaults: + run: + working-directory: resume-parser-sidecar + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Tesseract + run: | + sudo apt-get update + sudo apt-get install -y tesseract-ocr + + - name: Install sidecar dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' + + - name: Run sidecar tests + run: pytest tests -q + extension: runs-on: ubuntu-latest + needs: resume-parser-sidecar defaults: run: working-directory: careerflow diff --git a/.gitignore b/.gitignore index ffa644c..18a468c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,11 @@ careerflow/*.crx careerflow/*.pem docs/.DS_Store + +resume-parser-sidecar/.venv/ +resume-parser-sidecar/.pytest_cache/ +resume-parser-sidecar/.mypy_cache/ +resume-parser-sidecar/.ruff_cache/ +resume-parser-sidecar/dist/ +resume-parser-sidecar/*.egg-info/ +resume-parser-sidecar/**/__pycache__/ diff --git a/README.md b/README.md index 9baf71c..c73838a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ Website: [sir-ad.github.io/knight](https://sir-ad.github.io/knight/) ```bash git clone git@github.com:sir-ad/knight.git +cd knight/resume-parser-sidecar +./scripts/setup-venv.sh +./scripts/run.sh +``` + +In another terminal: + +```bash cd knight/careerflow npm install npm run build @@ -77,9 +85,17 @@ npm run build npm run package ``` +From [`resume-parser-sidecar`](./resume-parser-sidecar): + +```bash +source .venv/bin/activate +pytest tests -q +``` + ## Release Artifacts - CI verifies typecheck, tests, build, and packaged extension output +- CI also installs `tesseract-ocr` and runs the sidecar `pytest` suite - Release workflow attaches the packaged Chrome extension zip to GitHub Releases - Pages workflow deploys the docs site from [`docs`](./docs) @@ -92,3 +108,4 @@ npm run package - Confidential research docs are intentionally excluded from version control. - Local tool settings in `.claude/` are also ignored. - Product and implementation details for the extension live in [`careerflow/README.md`](./careerflow/README.md). +- Local parser service details live in [`resume-parser-sidecar/README.md`](./resume-parser-sidecar/README.md). diff --git a/careerflow/README.md b/careerflow/README.md index 5cd13c3..07d8eda 100644 --- a/careerflow/README.md +++ b/careerflow/README.md @@ -63,6 +63,14 @@ npm run typecheck npm test -- --runInBand ``` +Sidecar validation: + +```bash +cd ../resume-parser-sidecar +source .venv/bin/activate +pytest tests -q +``` + ## Load In Chrome 1. Run `npm run build` diff --git a/careerflow/package.json b/careerflow/package.json index 600c5ef..59b570d 100644 --- a/careerflow/package.json +++ b/careerflow/package.json @@ -76,7 +76,9 @@ "https://generativelanguage.googleapis.com/*", "https://openrouter.ai/*", "http://localhost:11434/*", - "http://127.0.0.1:11434/*" + "http://127.0.0.1:11434/*", + "http://localhost:43118/*", + "http://127.0.0.1:43118/*" ], "permissions": [ "storage", diff --git a/careerflow/src/background/resume-parser-service.ts b/careerflow/src/background/resume-parser-service.ts new file mode 100644 index 0000000..c4ce02f --- /dev/null +++ b/careerflow/src/background/resume-parser-service.ts @@ -0,0 +1,503 @@ +import { buildLLMConfig, diagnoseProvider } from "../lib/llm/provider-service" +import { + createProfileDraft, + normalizeProfileCandidate, + validateProfile, +} from "../lib/profile-safety" +import { + DEFAULT_PARSER_SERVICE_URL, + normalizeProviderSettings, +} from "../lib/storage-manager" +import type { + ExtensionSettings, + ParsedResume, + ProviderSecretStore, + ProviderSettings, + ResumeExtractionMetadata, + ResumeParseDiagnostic, + ResumeParseInput, + ResumeParserServiceStatus, +} from "../lib/types" +import type { LLMConfig, LLMProvider } from "../lib/llm/types" + +interface SidecarHealthResponse { + version?: string + ready?: boolean + ocr_available?: boolean + message?: string +} + +interface SidecarParseResponse { + status?: "ok" | "repair" | "error" + extracted_text?: string + profile?: unknown + validation_errors?: string[] + error?: string + raw_model_output?: unknown + diagnostics?: Array<{ + stage?: ResumeParseDiagnostic["stage"] + code?: string + message?: string + }> + extraction?: { + source?: ResumeExtractionMetadata["source"] + method?: ResumeExtractionMetadata["method"] + ok?: boolean + used_ocr?: boolean + suspicious?: boolean + error?: string + } + parse_time_ms?: number +} + +interface SidecarProviderPayload { + provider: LLMProvider + model: string + endpoint?: string + api_key?: string + temperature?: number + max_tokens?: number +} + +function buildDiagnostic( + stage: ResumeParseDiagnostic["stage"], + code: string, + message: string +): ResumeParseDiagnostic { + return { stage, code, message } +} + +function normalizeParserServiceUrl(value?: string | null): string { + const next = value?.trim().replace(/\/+$/, "") + return next || DEFAULT_PARSER_SERVICE_URL +} + +function arrayBufferToBase64(arrayBuffer: ArrayBuffer): string { + const bytes = new Uint8Array(arrayBuffer) + + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64") + } + + let binary = "" + const chunkSize = 0x8000 + + for (let index = 0; index < bytes.length; index += chunkSize) { + const chunk = bytes.subarray(index, index + chunkSize) + binary += String.fromCharCode(...chunk) + } + + return btoa(binary) +} + +function resolveResumeParserProviderSettings(settings: ExtensionSettings): ProviderSettings { + const provider = settings.resumeParseProviderOverride || settings.llmConfig.provider + const preferredModel = settings.resumeParseModel || undefined + + if (provider === settings.llmConfig.provider) { + return normalizeProviderSettings({ + ...(settings.llmConfig as ProviderSettings), + ...(preferredModel ? { model: preferredModel } : {}), + }) + } + + const recommendedModel = settings.providerCatalogs[provider]?.recommendedModel + + if (provider === "ollama") { + return normalizeProviderSettings({ + provider: "ollama", + endpoint: "http://localhost:11434", + model: preferredModel || recommendedModel, + autoDetectModel: true, + temperature: settings.llmConfig.temperature, + maxTokens: settings.llmConfig.maxTokens, + }) + } + + return normalizeProviderSettings({ + provider, + model: preferredModel || recommendedModel, + temperature: settings.llmConfig.temperature, + maxTokens: settings.llmConfig.maxTokens, + }) +} + +function resolveResumeParserConfig( + settings: ExtensionSettings, + secrets: ProviderSecretStore +): { + config: LLMConfig + preferredModel: string | null +} { + const providerSettings = resolveResumeParserProviderSettings(settings) + + return { + config: buildLLMConfig(providerSettings, secrets), + preferredModel: settings.resumeParseModel, + } +} + +function mapProviderDiagnosticToResumeDiagnostic( + message: string, + code = "provider_error" +): ResumeParseDiagnostic { + return buildDiagnostic("provider", code, message) +} + +function mapSidecarDiagnostics( + diagnostics?: SidecarParseResponse["diagnostics"] +): ResumeParseDiagnostic[] { + return (diagnostics || []) + .map((diagnostic) => { + if (!diagnostic?.message) { + return null + } + + return buildDiagnostic( + diagnostic.stage || "service", + diagnostic.code || "sidecar_error", + diagnostic.message + ) + }) + .filter((diagnostic): diagnostic is ResumeParseDiagnostic => Boolean(diagnostic)) +} + +function mapExtraction( + extraction?: SidecarParseResponse["extraction"] +): ResumeExtractionMetadata | undefined { + if (!extraction?.source || !extraction?.method) { + return undefined + } + + return { + source: extraction.source, + method: extraction.method, + ok: Boolean(extraction.ok), + usedOcr: Boolean(extraction.used_ocr), + suspicious: extraction.suspicious, + error: extraction.error, + } +} + +function mapSidecarResponse(response: SidecarParseResponse): ParsedResume { + const diagnostics = mapSidecarDiagnostics(response.diagnostics) + const extraction = mapExtraction(response.extraction) + const status = response.status || (response.profile ? "ok" : "error") + + if (status === "error" || !response.profile) { + return { + success: false, + status: "error", + error: + response.error || + diagnostics[0]?.message || + extraction?.error || + "Resume parser service could not parse the resume.", + parse_time_ms: response.parse_time_ms, + extracted_text: response.extracted_text, + raw_response: response.raw_model_output, + extraction, + diagnostics, + } + } + + const normalizedProfile = normalizeProfileCandidate(response.profile) + const validation = validateProfile(normalizedProfile) + + if (status === "repair" || !validation.valid) { + const draftProfile = createProfileDraft(response.profile, { + extractedText: response.extracted_text || "", + rawResponse: response.raw_model_output, + source: "parse", + }) + + return { + success: true, + status: "repair", + draftProfile, + validationErrors: + response.validation_errors && response.validation_errors.length > 0 + ? response.validation_errors + : validation.errors, + parse_time_ms: response.parse_time_ms, + extracted_text: response.extracted_text, + raw_response: response.raw_model_output, + extraction, + diagnostics: + diagnostics.length > 0 + ? diagnostics + : validation.errors.map((error) => + buildDiagnostic("validation", "validation_error", error) + ), + } + } + + return { + success: true, + status: "ok", + profile: normalizedProfile, + parse_time_ms: response.parse_time_ms, + extracted_text: response.extracted_text, + raw_response: response.raw_model_output, + extraction, + diagnostics, + } +} + +function buildSidecarProviderPayload(config: LLMConfig): SidecarProviderPayload { + return { + provider: config.provider, + model: config.model, + endpoint: config.endpoint, + api_key: config.apiKey, + temperature: config.temperature, + max_tokens: config.maxTokens, + } +} + +function buildParseRequestBody( + input: ResumeParseInput, + config: LLMConfig, + preferredModel: string | null +) { + return { + input: + input.kind === "file" + ? { + kind: "file", + file_name: input.fileName, + mime_type: input.mimeType, + content_base64: arrayBufferToBase64(input.arrayBuffer), + } + : { + kind: "text", + text: input.text, + file_name: input.fileName || null, + mime_type: input.mimeType || "text/plain", + }, + provider: buildSidecarProviderPayload(config), + options: { + preferred_model: preferredModel, + }, + } +} + +async function parseJsonSafe(response: Response): Promise { + try { + return (await response.json()) as T + } catch { + return null + } +} + +export async function getResumeParserServiceStatus( + settings: ExtensionSettings, + secrets: ProviderSecretStore +): Promise { + const serviceUrl = normalizeParserServiceUrl(settings.parserServiceUrl) + const { config } = resolveResumeParserConfig(settings, secrets) + const providerDiagnostics = await diagnoseProvider(config) + const diagnostics: ResumeParseDiagnostic[] = [] + + if (!settings.parserEnabled) { + diagnostics.push( + buildDiagnostic( + "service", + "parser_disabled", + "Resume parser service is disabled in settings." + ) + ) + + if (!providerDiagnostics.ok) { + diagnostics.push( + mapProviderDiagnosticToResumeDiagnostic( + providerDiagnostics.message, + providerDiagnostics.kind + ) + ) + } + + return { + ok: false, + ready: false, + parserEnabled: false, + serviceUrl, + message: "Resume parser service is disabled in settings.", + ocrAvailable: false, + provider: { + provider: config.provider, + model: config.model, + reachable: providerDiagnostics.ok, + message: providerDiagnostics.message, + }, + diagnostics, + } + } + + try { + const response = await fetch(`${serviceUrl}/health`) + const data = await parseJsonSafe(response) + const ready = response.ok && data?.ready !== false + const ocrAvailable = Boolean(data?.ocr_available) + + if (!ready) { + diagnostics.push( + buildDiagnostic( + "service", + "service_unavailable", + data?.message || + `Resume parser service at ${serviceUrl} is not ready. Start the sidecar with uvicorn on 127.0.0.1:43118.` + ) + ) + } + + if (!providerDiagnostics.ok) { + diagnostics.push( + mapProviderDiagnosticToResumeDiagnostic( + providerDiagnostics.message, + providerDiagnostics.kind + ) + ) + } + + if (ready && !ocrAvailable) { + diagnostics.push( + buildDiagnostic( + "service", + "ocr_unavailable", + "OCR is unavailable. Install Tesseract locally to parse scanned PDF resumes." + ) + ) + } + + return { + ok: ready && providerDiagnostics.ok, + ready, + parserEnabled: true, + serviceUrl, + version: data?.version, + message: + diagnostics[0]?.message || + data?.message || + "Resume parser service is ready.", + ocrAvailable, + provider: { + provider: config.provider, + model: config.model, + reachable: providerDiagnostics.ok, + message: providerDiagnostics.message, + }, + diagnostics, + } + } catch (error) { + diagnostics.push( + buildDiagnostic( + "service", + "service_unreachable", + error instanceof Error + ? `Knight could not reach the resume parser service at ${serviceUrl}. ${error.message}` + : `Knight could not reach the resume parser service at ${serviceUrl}.` + ) + ) + + if (!providerDiagnostics.ok) { + diagnostics.push( + mapProviderDiagnosticToResumeDiagnostic( + providerDiagnostics.message, + providerDiagnostics.kind + ) + ) + } + + return { + ok: false, + ready: false, + parserEnabled: true, + serviceUrl, + message: diagnostics[0].message, + ocrAvailable: false, + provider: { + provider: config.provider, + model: config.model, + reachable: providerDiagnostics.ok, + message: providerDiagnostics.message, + }, + diagnostics, + } + } +} + +export async function parseResumeWithParserService( + input: ResumeParseInput, + settings: ExtensionSettings, + secrets: ProviderSecretStore +): Promise { + const serviceUrl = normalizeParserServiceUrl(settings.parserServiceUrl) + + if (!settings.parserEnabled) { + return { + success: false, + status: "error", + error: "Resume parser service is disabled in settings.", + diagnostics: [ + buildDiagnostic( + "service", + "parser_disabled", + "Resume parser service is disabled in settings." + ), + ], + } + } + + const { config, preferredModel } = resolveResumeParserConfig(settings, secrets) + + try { + const response = await fetch(`${serviceUrl}/v1/resume/parse`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(buildParseRequestBody(input, config, preferredModel)), + }) + + const data = await parseJsonSafe(response) + + if (!response.ok || !data) { + return { + success: false, + status: "error", + error: + data?.error || + `Resume parser service returned ${response.status}. Start the sidecar with uvicorn on 127.0.0.1:43118.`, + diagnostics: [ + buildDiagnostic( + "service", + "service_request_failed", + data?.error || + `Resume parser service returned ${response.status}. Start the sidecar with uvicorn on 127.0.0.1:43118.` + ), + ], + } + } + + return mapSidecarResponse(data) + } catch (error) { + return { + success: false, + status: "error", + error: + error instanceof Error + ? `Knight could not reach the resume parser service at ${serviceUrl}. ${error.message}` + : `Knight could not reach the resume parser service at ${serviceUrl}.`, + diagnostics: [ + buildDiagnostic( + "service", + "service_unreachable", + error instanceof Error + ? `Knight could not reach the resume parser service at ${serviceUrl}. ${error.message}` + : `Knight could not reach the resume parser service at ${serviceUrl}.` + ), + ], + } + } +} diff --git a/careerflow/src/background/service-worker.ts b/careerflow/src/background/service-worker.ts index 390c549..ed2ef72 100644 --- a/careerflow/src/background/service-worker.ts +++ b/careerflow/src/background/service-worker.ts @@ -1,4 +1,8 @@ import { setupAlarms, handleAlarm, ALARM_NAMES } from "./alarm-manager" +import { + getResumeParserServiceStatus, + parseResumeWithParserService, +} from "./resume-parser-service" import { gmailClient } from "./gmail-client" import { ApplicationTracker } from "../lib/application-tracker" import { classifyEmail } from "../lib/email-classifier" diff --git a/careerflow/src/lib/profile-safety.ts b/careerflow/src/lib/profile-safety.ts new file mode 100644 index 0000000..40f7e39 --- /dev/null +++ b/careerflow/src/lib/profile-safety.ts @@ -0,0 +1,288 @@ +import type { + Certification, + Education, + Profile, + ProfileDraft, + ProfileDraftSource, + ProfileIdentity, + ProfileMeta, + Project, + Skills, + WorkExperience, +} from "./types" + +export interface ProfileValidationResult { + valid: boolean + errors: string[] +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null + } + + return value as Record +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() : undefined +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + + return value + .map((entry) => asString(entry)) + .filter((entry): entry is string => Boolean(entry)) +} + +function mapArray(value: unknown, mapper: (entry: unknown) => T | null): T[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + + return value + .map((entry) => mapper(entry)) + .filter((entry): entry is T => entry !== null) +} + +export function createEmptyWorkExperience(): WorkExperience { + return { + company: "", + title: "", + start_date: "", + end_date: "", + current: false, + location: "", + description: "", + achievements: [], + skills_used: [], + } +} + +export function createEmptyProfile(): Profile { + return { + identity: { + name: "", + email: "", + phone: "", + location: "", + linkedin: "", + github: "", + portfolio: "", + }, + work_history: [], + education: [], + skills: { + technical: [], + soft: [], + tools: [], + languages: [], + }, + projects: [], + certifications: [], + meta: {}, + } +} + +function normalizeIdentity(candidate: unknown): ProfileIdentity { + const record = asRecord(candidate) + return { + name: asString(record?.name) || "", + email: asString(record?.email) || "", + phone: asString(record?.phone) || "", + location: asString(record?.location) || "", + linkedin: asString(record?.linkedin) || "", + github: asString(record?.github) || "", + portfolio: asString(record?.portfolio) || "", + } +} + +function normalizeWorkExperience(candidate: unknown): WorkExperience | null { + const record = asRecord(candidate) + if (!record) { + return null + } + + return { + company: asString(record.company) || "", + title: asString(record.title) || "", + start_date: asString(record.start_date) || "", + end_date: asString(record.end_date) || "", + current: asBoolean(record.current) || false, + location: asString(record.location) || "", + description: asString(record.description) || "", + achievements: asStringArray(record.achievements) || [], + skills_used: asStringArray(record.skills_used) || [], + } +} + +function normalizeEducation(candidate: unknown): Education | null { + const record = asRecord(candidate) + if (!record) { + return null + } + + return { + degree: asString(record.degree) || "", + field: asString(record.field) || "", + institution: asString(record.institution) || "", + start_date: asString(record.start_date) || "", + end_date: asString(record.end_date) || "", + gpa: asNumber(record.gpa), + honors: asStringArray(record.honors) || [], + } +} + +function normalizeSkills(candidate: unknown): Skills { + const record = asRecord(candidate) + return { + technical: asStringArray(record?.technical) || [], + soft: asStringArray(record?.soft) || [], + tools: asStringArray(record?.tools) || [], + languages: asStringArray(record?.languages) || [], + } +} + +function normalizeProject(candidate: unknown): Project | null { + const record = asRecord(candidate) + if (!record) { + return null + } + + return { + name: asString(record.name) || "", + description: asString(record.description) || "", + url: asString(record.url) || "", + tech_stack: asStringArray(record.tech_stack) || [], + highlights: asStringArray(record.highlights) || [], + } +} + +function normalizeCertification(candidate: unknown): Certification | null { + const record = asRecord(candidate) + if (!record) { + return null + } + + return { + name: asString(record.name) || "", + issuer: asString(record.issuer) || "", + date: asString(record.date) || "", + url: asString(record.url) || "", + } +} + +function normalizeMeta(candidate: unknown): ProfileMeta { + const record = asRecord(candidate) + const workMode = asString(record?.work_mode_preference) + const safeWorkMode = + workMode === "remote" || workMode === "hybrid" || workMode === "onsite" || workMode === "flexible" + ? workMode + : undefined + + return { + notice_period_days: asNumber(record?.notice_period_days), + current_ctc: asNumber(record?.current_ctc), + expected_ctc: asNumber(record?.expected_ctc), + work_mode_preference: safeWorkMode, + visa_status: asString(record?.visa_status) || "", + } +} + +export function normalizeProfileCandidate(candidate: unknown): Profile { + const record = asRecord(candidate) + const base = createEmptyProfile() + + return { + ...base, + identity: normalizeIdentity(record?.identity), + work_history: mapArray(record?.work_history, normalizeWorkExperience) || [], + education: mapArray(record?.education, normalizeEducation) || [], + skills: normalizeSkills(record?.skills), + projects: mapArray(record?.projects, normalizeProject) || [], + certifications: mapArray(record?.certifications, normalizeCertification) || [], + meta: normalizeMeta(record?.meta), + } +} + +export function validateProfile(profile: Profile): ProfileValidationResult { + const errors: string[] = [] + + if (!profile.identity?.name?.trim()) { + errors.push("Name is required") + } + + if (!profile.identity?.email?.trim()) { + errors.push("Email is required") + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.identity.email)) { + errors.push("Invalid email format") + } + + if (!profile.work_history || profile.work_history.length === 0) { + errors.push("At least one work experience is required") + } + + profile.work_history?.forEach((work, index) => { + if (!work.company?.trim()) { + errors.push(`Work experience ${index + 1}: Company is required`) + } + if (!work.title?.trim()) { + errors.push(`Work experience ${index + 1}: Title is required`) + } + if (!work.start_date?.trim()) { + errors.push(`Work experience ${index + 1}: Start date is required`) + } + }) + + return { + valid: errors.length === 0, + errors, + } +} + +export function createProfileDraft( + candidate: unknown, + options?: { + extractedText?: string + rawResponse?: unknown + source?: ProfileDraftSource + updatedAt?: string + } +): ProfileDraft { + const profile = normalizeProfileCandidate(candidate) + const validation = validateProfile(profile) + + return { + profile, + validationErrors: validation.errors, + extractedText: options?.extractedText || "", + rawResponse: options?.rawResponse, + updatedAt: options?.updatedAt || new Date().toISOString(), + source: options?.source || "parse", + } +} + +export function normalizeProfileDraftCandidate(candidate: unknown): ProfileDraft | null { + const record = asRecord(candidate) + if (!record) { + return null + } + + return createProfileDraft(record.profile || {}, { + extractedText: asString(record.extractedText) || "", + rawResponse: record.rawResponse, + source: record.source === "recovered" ? "recovered" : "parse", + updatedAt: asString(record.updatedAt) || new Date().toISOString(), + }) +} diff --git a/careerflow/src/lib/resume-parser.ts b/careerflow/src/lib/resume-parser.ts index aea3510..d92ae07 100644 --- a/careerflow/src/lib/resume-parser.ts +++ b/careerflow/src/lib/resume-parser.ts @@ -15,62 +15,25 @@ async function readArrayBuffer(file: File): Promise { }) } -function hasMagicNumber(arrayBuffer: ArrayBuffer, bytes: number[]): boolean { - const view = new Uint8Array(arrayBuffer.slice(0, bytes.length)) - return bytes.every((byte, index) => view[index] === byte) -} - -async function extractTextFromPDF(arrayBuffer: ArrayBuffer): Promise { - if (!hasMagicNumber(arrayBuffer, [0x25, 0x50, 0x44, 0x46])) { - return new TextDecoder() - .decode(arrayBuffer) - .replace(/[^\x20-\x7E\n\r\t]/g, " ") - .replace(/\s+/g, " ") - .trim() +function inferMimeType(file: File): string { + if (file.type) { + return file.type } - try { - const pdfParseModule = await import("pdf-parse") - const pdfParse = (pdfParseModule.default || pdfParseModule) as ( - data: Uint8Array - ) => Promise<{ text?: string }> - - const result = await pdfParse(new Uint8Array(arrayBuffer)) - return result.text?.trim() || "" - } catch { - return new TextDecoder() - .decode(arrayBuffer) - .replace(/[^\x20-\x7E\n\r\t]/g, " ") - .replace(/\s+/g, " ") - .trim() + const name = file.name.toLowerCase() + if (name.endsWith(".pdf")) { + return "application/pdf" } -} -async function extractTextFromDOCX(arrayBuffer: ArrayBuffer): Promise { - if (!hasMagicNumber(arrayBuffer, [0x50, 0x4b])) { - return new TextDecoder() - .decode(arrayBuffer) - .replace(/<[^>]+>/g, " ") - .replace(/[^\x20-\x7E\n\r\t]/g, " ") - .replace(/\s+/g, " ") - .trim() + if (name.endsWith(".docx")) { + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" } - try { - const result = await mammoth.extractRawText({ arrayBuffer }) - return result.value.trim() - } catch { - return new TextDecoder() - .decode(arrayBuffer) - .replace(/<[^>]+>/g, " ") - .replace(/[^\x20-\x7E\n\r\t]/g, " ") - .replace(/\s+/g, " ") - .trim() + if (name.endsWith(".txt")) { + return "text/plain" } -} -async function extractTextFromTXT(arrayBuffer: ArrayBuffer): Promise { - return new TextDecoder().decode(arrayBuffer).trim() + return "" } function buildResumeExtractionPrompt(resumeText: string): string { @@ -157,35 +120,21 @@ export async function extractTextFromFile(file: File): Promise { } if ( - file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || - file.name.toLowerCase().endsWith(".docx") + mimeType === "application/pdf" || + mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || + mimeType === "text/plain" || + name.endsWith(".pdf") || + name.endsWith(".docx") || + name.endsWith(".txt") ) { - const arrayBuffer = await readArrayBuffer(file) - return extractTextFromDOCX(arrayBuffer) - } - - if (file.type === "text/plain" || file.name.toLowerCase().endsWith(".txt")) { - const arrayBuffer = await readArrayBuffer(file) - return extractTextFromTXT(arrayBuffer) + return } throw new Error("Unsupported file format. Please upload PDF, DOCX, or TXT.") } -export async function parseResume(file: File): Promise { - const startedAt = Date.now() - - try { - const resumeText = await extractTextFromFile(file) - - if (resumeText.length < 40) { - return { - success: false, - error: "Could not extract enough text from the resume.", - parse_time_ms: Date.now() - startedAt, - extracted_text: resumeText, - } - } +async function toFileInput(file: File): Promise { + validateSupportedFile(file) const profile = await generateStructuredWithActiveProvider( buildResumeExtractionPrompt(resumeText), @@ -210,37 +159,34 @@ export async function parseResume(file: File): Promise { } } -export function validateProfile(profile: Profile): { valid: boolean; errors: string[] } { - const errors: string[] = [] +export async function extractTextFromFile(file: File): Promise { + const result = await parseResumeWithService(await toFileInput(file)) - if (!profile.identity?.name?.trim()) { - errors.push("Name is required") + if (!result.success && !result.extracted_text) { + throw new Error(result.error || "Failed to extract text from resume.") } - if (!profile.identity?.email?.trim()) { - errors.push("Email is required") - } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.identity.email)) { - errors.push("Invalid email format") - } + return result.extracted_text || "" +} - if (!profile.work_history || profile.work_history.length === 0) { - errors.push("At least one work experience is required") - } +export async function parseResume(file: File): Promise { + return parseResumeWithService(await toFileInput(file)) +} - profile.work_history?.forEach((work, index) => { - if (!work.company) { - errors.push(`Work experience ${index + 1}: Company is required`) - } - if (!work.title) { - errors.push(`Work experience ${index + 1}: Title is required`) - } - if (!work.start_date) { - errors.push(`Work experience ${index + 1}: Start date is required`) +export async function parseResumeFromText(text: string): Promise { + const trimmed = text.trim() + if (!trimmed) { + return { + success: false, + status: "error", + error: "Paste some resume text before retrying parse.", } - }) - - return { - valid: errors.length === 0, - errors, } + + return parseResumeWithService({ + kind: "text", + text: trimmed, + }) } + +export { createProfileDraft, normalizeProfileCandidate, validateProfile } from "./profile-safety" diff --git a/careerflow/src/lib/storage-manager.ts b/careerflow/src/lib/storage-manager.ts index 2f7c425..558079c 100644 --- a/careerflow/src/lib/storage-manager.ts +++ b/careerflow/src/lib/storage-manager.ts @@ -10,12 +10,15 @@ import type { LLMProvider } from "./llm/types" export const STORAGE_KEYS = { PROFILE: "careerflow_profile", + PROFILE_DRAFT: "careerflow_profile_draft", SETTINGS: "careerflow_settings", APPLICATIONS: "careerflow_applications", GMAIL_TOKEN: "careerflow_gmail_token", PROVIDER_SECRETS: "careerflow_provider_secrets", } as const +export const DEFAULT_PARSER_SERVICE_URL = "http://127.0.0.1:43118" + const LEGACY_KEYS = { APPLICATIONS: "knight_applications", GMAIL_TOKEN: "gmail_token", @@ -136,6 +139,20 @@ export class ChromeStorageManager { ) } + if (result[STORAGE_KEYS.PROFILE]) { + const normalizedProfile = normalizeProfileCandidate(result[STORAGE_KEYS.PROFILE]) + const validation = validateProfile(normalizedProfile) + if (!validation.valid) { + updates[STORAGE_KEYS.PROFILE_DRAFT] = + result[STORAGE_KEYS.PROFILE_DRAFT] || + createProfileDraft(result[STORAGE_KEYS.PROFILE], { + rawResponse: result[STORAGE_KEYS.PROFILE], + source: "recovered", + }) + removals.push(STORAGE_KEYS.PROFILE) + } + } + if (Object.keys(updates).length > 0) { await setLocalStorage(updates) } @@ -146,6 +163,14 @@ export class ChromeStorageManager { } normalizeSettings(settings?: Partial): ExtensionSettings { + const providerOverride = + settings?.resumeParseProviderOverride && + ["ollama", "openai", "anthropic", "google", "openrouter"].includes( + settings.resumeParseProviderOverride + ) + ? settings.resumeParseProviderOverride + : null + return { ...DEFAULT_SETTINGS, ...settings, @@ -163,19 +188,77 @@ export class ChromeStorageManager { const result = await getLocalStorage>( STORAGE_KEYS.PROFILE ) - return result[STORAGE_KEYS.PROFILE] || null + const storedProfile = result[STORAGE_KEYS.PROFILE] + if (!storedProfile) { + return null + } + + const normalizedProfile = normalizeProfileCandidate(storedProfile) + const validation = validateProfile(normalizedProfile) + + if (!validation.valid) { + await this.saveProfileDraft( + createProfileDraft(storedProfile, { + rawResponse: storedProfile, + source: "recovered", + }) + ) + await chrome.storage.local.remove(STORAGE_KEYS.PROFILE) + return null + } + + return normalizedProfile } async saveProfile(profile: Profile): Promise { + const normalizedProfile = normalizeProfileCandidate(profile) + const validation = validateProfile(normalizedProfile) + + if (!validation.valid) { + throw new Error(validation.errors.join(", ")) + } + await setLocalStorage({ - [STORAGE_KEYS.PROFILE]: profile, + [STORAGE_KEYS.PROFILE]: normalizedProfile, }) + await chrome.storage.local.remove(STORAGE_KEYS.PROFILE_DRAFT) } async deleteProfile(): Promise { await chrome.storage.local.remove(STORAGE_KEYS.PROFILE) } + async getProfileDraft(): Promise { + await this.migrateLegacyData() + const result = await getLocalStorage>( + STORAGE_KEYS.PROFILE_DRAFT + ) + return normalizeProfileDraftCandidate(result[STORAGE_KEYS.PROFILE_DRAFT]) || null + } + + async saveProfileDraft(draft: ProfileDraft): Promise { + const normalizedDraft = createProfileDraft(draft.profile, { + extractedText: draft.extractedText, + rawResponse: draft.rawResponse, + source: draft.source, + updatedAt: new Date().toISOString(), + }) + + await setLocalStorage({ + [STORAGE_KEYS.PROFILE_DRAFT]: normalizedDraft, + }) + + return normalizedDraft + } + + async deleteProfileDraft(): Promise { + await chrome.storage.local.remove(STORAGE_KEYS.PROFILE_DRAFT) + } + + async clearProfileData(): Promise { + await chrome.storage.local.remove([STORAGE_KEYS.PROFILE, STORAGE_KEYS.PROFILE_DRAFT]) + } + async getSettings(): Promise { await this.migrateLegacyData() const result = await getLocalStorage | undefined>>( diff --git a/careerflow/src/lib/types.ts b/careerflow/src/lib/types.ts index 64d5f74..048d15c 100644 --- a/careerflow/src/lib/types.ts +++ b/careerflow/src/lib/types.ts @@ -72,12 +72,90 @@ export interface Profile { meta?: ProfileMeta } +export type ProfileDraftSource = "parse" | "recovered" + +export interface ProfileDraft { + profile: Profile + validationErrors: string[] + extractedText: string + rawResponse?: unknown + updatedAt: string + source: ProfileDraftSource +} + +export type ResumeParseStatus = "ok" | "repair" | "error" + +export type ResumeExtractionSource = "pdf" | "docx" | "txt" | "pasted" + +export type ResumeExtractionMethod = + | "pdf-text" + | "pdf-ocr" + | "docx2txt" + | "plain-text" + | "manual" + +export interface ResumeExtractionMetadata { + source: ResumeExtractionSource + method: ResumeExtractionMethod + ok: boolean + usedOcr: boolean + suspicious?: boolean + error?: string +} + +export interface ResumeParseDiagnostic { + stage: "service" | "extraction" | "provider" | "validation" + code: string + message: string +} + +export interface ResumeParserProviderStatus { + provider: LLMProvider + model: string + reachable: boolean + message: string +} + +export interface ResumeParserServiceStatus { + ok: boolean + ready: boolean + parserEnabled: boolean + serviceUrl: string + version?: string + message: string + ocrAvailable: boolean + provider: ResumeParserProviderStatus + diagnostics: ResumeParseDiagnostic[] +} + +export interface ResumeParseFileInput { + kind: "file" + fileName: string + mimeType: string + arrayBuffer: ArrayBuffer +} + +export interface ResumeParseTextInput { + kind: "text" + text: string + fileName?: string + mimeType?: string +} + +export type ResumeParseInput = ResumeParseFileInput | ResumeParseTextInput + export interface ParsedResume { success: boolean + status?: ResumeParseStatus profile?: Profile + draftProfile?: ProfileDraft + validationErrors?: string[] error?: string parse_time_ms?: number extracted_text?: string + raw_response?: unknown + extraction?: ResumeExtractionMetadata + diagnostics?: ResumeParseDiagnostic[] } export type ATSAdapterName = diff --git a/careerflow/src/popup/profile/ProfileTab.tsx b/careerflow/src/popup/profile/ProfileTab.tsx index 7d2c197..37f023e 100644 --- a/careerflow/src/popup/profile/ProfileTab.tsx +++ b/careerflow/src/popup/profile/ProfileTab.tsx @@ -3,11 +3,57 @@ import { getProviderLabel } from "../../lib/llm/provider-service" import { parseResume, validateProfile } from "../../lib/resume-parser" import { testActiveProvider } from "../../lib/runtime-client" import { storageManager } from "../../lib/storage-manager" -import type { Profile } from "../../lib/types" +import type { + ParsedResume, + Profile, + ProfileDraft, + ResumeParseDiagnostic, + ResumeParserServiceStatus, + WorkExperience, +} from "../../lib/types" + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2) + } catch { + return "Unable to render raw provider response." + } +} + +function buildDraftErrorMessage(errors: string[]): string { + if (errors.length === 0) { + return "Resume parsed, but it still needs review before Knight can save it." + } + + return `Resume parsed, but it needs review before Knight can save it: ${errors.join(", ")}` +} + +function getDraftWorkHistory(draft: ProfileDraft): WorkExperience[] { + return draft.profile.work_history.length > 0 + ? draft.profile.work_history + : [createEmptyWorkExperience()] +} + +function shouldShowManualFallback(diagnostics: ResumeParseDiagnostic[]): boolean { + return diagnostics.some((diagnostic) => diagnostic.stage === "extraction") +} + +function connectionState( + parserStatus: ResumeParserServiceStatus | null +): "unknown" | "connected" | "disconnected" { + if (!parserStatus) { + return "unknown" + } + + return parserStatus.ok ? "connected" : "disconnected" +} export function ProfileTab() { const [profile, setProfile] = useState(null) + const [draft, setDraft] = useState(null) + const [parserStatus, setParserStatus] = useState(null) const [isUploading, setIsUploading] = useState(false) + const [isManualParsing, setIsManualParsing] = useState(false) const [isEditing, setIsEditing] = useState(false) const [error, setError] = useState(null) const [parseTime, setParseTime] = useState(null) @@ -18,13 +64,29 @@ export function ProfileTab() { const fileInputRef = useRef(null) useEffect(() => { - void loadProfile() + void loadProfileState() }, []) - const loadProfile = async () => { - const [storedProfile, settings] = await Promise.all([ + const refreshParserStatus = async (): Promise => { + try { + const status = await getResumeParserStatus() + setParserStatus(status) + return status + } catch (reason) { + setParserStatus(null) + setError( + reason instanceof Error + ? reason.message + : "Failed to load resume parser service status." + ) + return null + } + } + + const loadProfileState = async () => { + const [storedProfile, storedDraft] = await Promise.all([ storageManager.getProfile(), - storageManager.getSettings(), + storageManager.getProfileDraft(), ]) setProfile(storedProfile) @@ -34,7 +96,20 @@ export function ProfileTab() { setProviderStatus(diagnostics?.ok ? "connected" : "disconnected") } - const handleFileUpload = async (event: React.ChangeEvent) => { + const ensureParserReady = async () => { + const status = await refreshParserStatus() + if (!status) { + throw new Error("Failed to load resume parser service status.") + } + + if (!status.ready || !status.provider.reachable) { + throw new Error(status.message) + } + + return status + } + + const handleFileUpload = async (event: ChangeEvent) => { const file = event.target.files?.[0] if (!file) { return @@ -43,6 +118,7 @@ export function ProfileTab() { setIsUploading(true) setError(null) setParseTime(null) + setParseDiagnostics([]) try { const settings = await storageManager.getSettings() @@ -75,20 +151,166 @@ export function ProfileTab() { if (fileInputRef.current) { fileInputRef.current.value = "" } + await refreshParserStatus() + } + } + + const retryWithPastedText = async () => { + setIsManualParsing(true) + setError(null) + setParseTime(null) + setParseDiagnostics([]) + + try { + await ensureParserReady() + const result = await parseResumeFromText(manualResumeText) + await applyParseResult(result) + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Unknown error") + } finally { + setIsManualParsing(false) + await refreshParserStatus() + } + } + + const updateDraftProfile = (nextProfile: Profile) => { + if (!draft) { + return + } + + const validation = validateProfile(nextProfile) + const nextDraft: ProfileDraft = { + ...draft, + profile: nextProfile, + validationErrors: validation.errors, + updatedAt: new Date().toISOString(), + } + + setDraft(nextDraft) + setError(validation.valid ? null : buildDraftErrorMessage(validation.errors)) + } + + const updateDraftIdentity = (field: keyof Profile["identity"], value: string) => { + if (!draft) { + return + } + + updateDraftProfile({ + ...draft.profile, + identity: { + ...draft.profile.identity, + [field]: value, + }, + }) + } + + const updateDraftWork = (index: number, field: keyof WorkExperience, value: string | boolean) => { + if (!draft) { + return + } + + const nextWorkHistory = [...draft.profile.work_history] + while (nextWorkHistory.length <= index) { + nextWorkHistory.push(createEmptyWorkExperience()) } + + nextWorkHistory[index] = { + ...nextWorkHistory[index], + [field]: value, + } + + updateDraftProfile({ + ...draft.profile, + work_history: nextWorkHistory, + }) } - const save = async () => { + const addDraftExperience = () => { + if (!draft) { + return + } + + updateDraftProfile({ + ...draft.profile, + work_history: [...draft.profile.work_history, createEmptyWorkExperience()], + }) + } + + const saveDraftAsProfile = async () => { + if (!draft) { + return + } + + const validation = validateProfile(draft.profile) + if (!validation.valid) { + setDraft({ + ...draft, + validationErrors: validation.errors, + updatedAt: new Date().toISOString(), + }) + setError(buildDraftErrorMessage(validation.errors)) + return + } + + await storageManager.saveProfile(draft.profile) + setProfile(draft.profile) + setDraft(null) + setError(null) + } + + const discardDraft = async () => { + await storageManager.deleteProfileDraft() + setDraft(null) + setError(null) + } + + const clearProfileData = async () => { + await storageManager.clearProfileData() + setProfile(null) + setDraft(null) + setError(null) + setParseTime(null) + setParseDiagnostics([]) + setManualResumeText("") + setShowManualResumeEntry(false) + setIsEditing(false) + } + + const saveProfileEdits = async () => { if (!profile) { return } + const validation = validateProfile(profile) + if (!validation.valid) { + setError(`Fix required fields before saving: ${validation.errors.join(", ")}`) + return + } + await storageManager.saveProfile(profile) setIsEditing(false) + setError(null) } + const profileValidationErrors = isEditing && profile ? validateProfile(profile).errors : [] + const renderDraft = draft + const renderWorkHistory = renderDraft ? getDraftWorkHistory(renderDraft) : [] + const statusState = connectionState(parserStatus) + const parserProviderLabel = parserStatus + ? getProviderLabel(parserStatus.provider.provider) + : "Resume Parser" + return (
+ +
{providerName} Status @@ -104,58 +326,271 @@ export function ProfileTab() { {providerStatus}
+ {parserStatus && ( +
+

+ {parserProviderLabel} ยท {parserStatus.provider.model} +

+

{parserStatus.message}

+ {!parserStatus.ocrAvailable && ( +

OCR is unavailable. Install Tesseract to parse scanned PDFs.

+ )} +
+ )}
- {!profile && ( + {error && ( +
+

{error}

+ {parseDiagnostics.length > 0 && ( +
    + {parseDiagnostics.map((diagnostic) => ( +
  • + {diagnostic.stage}: {diagnostic.message} +
  • + ))} +
+ )} +
+ )} + + {parseTime && ( +
+ Resume parsed in {(parseTime / 1000).toFixed(1)}s +
+ )} + + {!profile && !draft && (

Upload your resume once to create a reusable autofill profile.

- - - +
)} - {error && ( -
- {error} + {showManualResumeEntry && !renderDraft && ( +
+
+

Paste Resume Text

+

+ File extraction failed. Paste readable resume text and Knight will retry parsing + through the sidecar. +

+
+ +