diff --git a/README.md b/README.md index 4facb12..9baf71c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Release](https://github.com/sir-ad/knight/actions/workflows/release.yml/badge.svg)](https://github.com/sir-ad/knight/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-0f172a.svg)](./LICENSE) -Privacy-first Chrome extension for job applications. Knight parses your resume locally with Ollama, autofills common ATS portals, logs applications, syncs Gmail status updates, and drafts follow-ups without sending your profile to a hosted LLM. +Privacy-first Chrome extension for job applications. Knight parses your resume locally or through your chosen provider, autofills common ATS portals, logs applications, syncs Gmail status updates, and drafts follow-ups without forcing your profile through a hosted AI workflow. ![Knight icon](./careerflow/assets/icon128.png) @@ -14,11 +14,13 @@ Website: [sir-ad.github.io/knight](https://sir-ad.github.io/knight/) ## What It Does - Local-first resume parsing for `PDF`, `DOCX`, and `TXT` +- AI provider support for `Ollama`, `OpenAI`, `Anthropic`, `Google Gemini`, and `OpenRouter` +- Smart defaults that auto-discover installed Ollama models and recommend the best reachable provider/model - Autofill support for `Workday`, `Greenhouse`, `Lever`, `Naukri`, `iCIMS`, `SmartRecruiters`, `Taleo`, `SuccessFactors`, plus a generic fallback - In-page review overlay before filling - Local application log with status history - Gmail read-only sync for confirmations, interviews, rejections, and offers -- Follow-up email draft generation with Ollama +- Follow-up email draft generation with the active provider ## Repo Layout @@ -40,15 +42,30 @@ Then load `careerflow/build/chrome-mv3-prod` in `chrome://extensions` with Devel ## Local Requirements - Node.js 20+ -- Ollama running locally +- Ollama running locally for the privacy-first path - A pulled model such as `llama3.2:3b` +- Optional cloud provider API key for `OpenAI`, `Anthropic`, `Google Gemini`, or `OpenRouter` - Optional Gmail OAuth setup using `PLASMO_PUBLIC_GOOGLE_CLIENT_ID` ```bash -ollama serve +OLLAMA_ORIGINS=chrome-extension://* ollama serve ollama pull llama3.2:3b ``` +Knight expects the Ollama endpoint as the host root only, for example `http://localhost:11434`. Do not add `/api` to the saved endpoint. + +## Supported Portals + +- [Workday](https://www.workday.com/) via `*.myworkdayjobs.com` +- [Greenhouse](https://www.greenhouse.com/) via `boards.greenhouse.io` and `*.greenhouse.io` +- [Lever](https://www.lever.co/) via `jobs.lever.co` and `*.lever.co` +- [Naukri](https://www.naukri.com/) via `*.naukri.com` +- [iCIMS](https://www.icims.com/) via `*.icims.com` +- [SmartRecruiters](https://www.smartrecruiters.com/) via `*.smartrecruiters.com` +- [Oracle Taleo](https://www.oracle.com/human-capital-management/taleo/) via `*.taleo.net` +- [SAP SuccessFactors](https://www.sap.com/products/hcm.html) via `*successfactors*` and `*.jobs2web.com` +- Generic fallback for employer-hosted forms that expose standard labels, placeholders, or aria metadata + ## Validation From [`careerflow`](./careerflow): @@ -68,7 +85,7 @@ npm run package ## Tags -`chrome-extension` `job-search` `ats-autofill` `ollama` `gmail` `privacy-first` `plasmo` `typescript` +`chrome-extension` `job-search` `ats-autofill` `ollama` `openai` `anthropic` `gemini` `openrouter` `gmail` `privacy-first` `plasmo` `typescript` ## Notes diff --git a/careerflow/README.md b/careerflow/README.md index 0a996e2..5cd13c3 100644 --- a/careerflow/README.md +++ b/careerflow/README.md @@ -2,24 +2,26 @@ Chrome MV3 extension for local-first job application automation. -Knight is built with Plasmo and centered on one constraint: resume data should stay on the user’s machine. The extension parses resumes with Ollama, injects a review-first autofill overlay on common ATS sites, logs applications locally, reads Gmail with a read-only scope for status changes, and drafts follow-ups from local context. +Knight is built with Plasmo and centered on one constraint: resume data should stay on the user’s machine whenever possible. The extension parses resumes with a configurable provider stack, injects a review-first autofill overlay on common ATS sites, logs applications locally, reads Gmail with a read-only scope for status changes, and drafts follow-ups from local context. ## Current Scope - Resume parsing for `PDF`, `DOCX`, and `TXT` +- AI provider support for `Ollama`, `OpenAI`, `Anthropic`, `Google Gemini`, and `OpenRouter` +- Smart defaults that auto-discover installed Ollama models and recommend the best reachable provider/model - Autofill support for: - - `Workday` - - `Greenhouse` - - `Lever` - - `Naukri` - - `iCIMS` - - `SmartRecruiters` - - `Taleo` - - `SuccessFactors` + - `Workday` via `*.myworkdayjobs.com` + - `Greenhouse` via `boards.greenhouse.io` and `*.greenhouse.io` + - `Lever` via `jobs.lever.co` and `*.lever.co` + - `Naukri` via `*.naukri.com` + - `iCIMS` via `*.icims.com` + - `SmartRecruiters` via `*.smartrecruiters.com` + - `Taleo` via `*.taleo.net` + - `SuccessFactors` / `Jobs2Web` - Generic fallback forms - Local application tracking with status history - Gmail read-only sync for confirmation, interview, rejection, and offer emails -- Ollama-based follow-up drafting +- Follow-up drafting with the active provider ## Stack @@ -28,6 +30,10 @@ Knight is built with Plasmo and centered on one constraint: resume data should s - `React 18` - `Tailwind CSS` - `Ollama` +- `OpenAI` +- `Anthropic` +- `Google Gemini` +- `OpenRouter` - `Jest` ## Local Setup @@ -35,10 +41,12 @@ Knight is built with Plasmo and centered on one constraint: resume data should s ```bash cd careerflow npm install -ollama serve +OLLAMA_ORIGINS=chrome-extension://* ollama serve ollama pull llama3.2:3b ``` +Knight expects the Ollama endpoint as the host root only, for example `http://localhost:11434`. Do not save `/api`, `/api/tags`, `/api/generate`, or `/api/chat` in Settings. + Optional environment variable: ```bash @@ -82,4 +90,5 @@ npm run package - The Gmail flow uses Chrome Identity with token-based OAuth and a read-only Gmail scope. - Local storage migration handles prior `knight_*` keys. -- The extension defaults to the configured Ollama endpoint and model from Settings. +- The extension keeps provider API keys in local Chrome storage and excludes them from export. +- Smart defaults prefer a reachable local Ollama model, then fall back to the best configured cloud provider. diff --git a/careerflow/package.json b/careerflow/package.json index cca4bde..600c5ef 100644 --- a/careerflow/package.json +++ b/careerflow/package.json @@ -1,7 +1,7 @@ { "name": "knight-extension", "displayName": "Knight", - "version": "0.1.2", + "version": "0.2.0", "description": "Knight - Universal Job Application & Tracking System - Privacy-first browser extension", "license": "MIT", "author": "sir-ad", @@ -68,9 +68,13 @@ }, "name": "Knight", "description": "Knight - Universal Job Application & Tracking System", - "version": "0.1.2", + "version": "0.2.0", "host_permissions": [ "https://www.googleapis.com/*", + "https://api.openai.com/*", + "https://api.anthropic.com/*", + "https://generativelanguage.googleapis.com/*", + "https://openrouter.ai/*", "http://localhost:11434/*", "http://127.0.0.1:11434/*" ], diff --git a/careerflow/src/background/service-worker.ts b/careerflow/src/background/service-worker.ts index 8a40c57..390c549 100644 --- a/careerflow/src/background/service-worker.ts +++ b/careerflow/src/background/service-worker.ts @@ -3,18 +3,105 @@ import { gmailClient } from "./gmail-client" import { ApplicationTracker } from "../lib/application-tracker" import { classifyEmail } from "../lib/email-classifier" import * as db from "../lib/db" -import { storageManager } from "../lib/storage-manager" +import { storageManager, normalizeProviderSettings } from "../lib/storage-manager" +import { + buildLLMConfig, + diagnoseProvider, + discoverProviderModels, + generateProviderChat, + generateProviderStructured, + generateProviderText, + providerSupportsApiKey, + recommendLLMConfiguration, +} from "../lib/llm/provider-service" +import { SUPPORTED_PORTALS } from "../lib/supported-portals" import type { ApplicationLogPayload, ApplicationStatus, ATSAdapterName, GmailStatus, + ProviderModelCatalog, + ProviderSecretStore, + ProviderSettings, RuntimeMessage, RuntimeResponse, } from "../lib/types" +import type { LLMConfig } from "../lib/llm/types" const applicationTracker = new ApplicationTracker() +async function resolveLLMRequest(payload?: { + config?: Partial + apiKey?: string +}): Promise<{ + config: LLMConfig + secrets: ProviderSecretStore + settings: Awaited> + providerSettings: ProviderSettings +}> { + const [settings, storedSecrets] = await Promise.all([ + storageManager.getSettings(), + storageManager.getProviderSecrets(), + ]) + + const providerSettings = normalizeProviderSettings({ + ...(settings.llmConfig as ProviderSettings), + ...(payload?.config || {}), + }) + + const secrets = { ...storedSecrets } + if (providerSupportsApiKey(providerSettings.provider) && payload?.apiKey) { + secrets[providerSettings.provider] = payload.apiKey.trim() + } + + return { + config: buildLLMConfig(providerSettings, secrets, payload?.config), + secrets, + settings, + providerSettings, + } +} + +async function persistCatalogAndRecommendation( + catalog: ProviderModelCatalog, + settings: Awaited>, + secrets: ProviderSecretStore, + providerSettings: ProviderSettings +) { + const nextCatalogs = { + ...settings.providerCatalogs, + [catalog.provider]: catalog, + } + + const nextConfig = + settings.autoMode === "smart-defaults" + ? normalizeProviderSettings({ + ...providerSettings, + model: catalog.recommendedModel, + ...(providerSettings.provider === "ollama" && catalog.endpoint + ? { endpoint: catalog.endpoint } + : {}), + }) + : settings.llmConfig + + const recommendation = recommendLLMConfiguration( + { + ...settings, + llmConfig: nextConfig, + providerCatalogs: nextCatalogs, + }, + secrets + ) + + await storageManager.saveSettings({ + llmConfig: nextConfig, + providerCatalogs: nextCatalogs, + lastRecommendation: recommendation, + }) + + return recommendation +} + function mapClassificationToStatus(classification: string): ApplicationStatus { switch (classification) { case "confirmation": @@ -244,6 +331,71 @@ chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResp } satisfies RuntimeResponse) break + case "TEST_LLM_PROVIDER": { + const { config } = await resolveLLMRequest(message.payload) + sendResponse({ + success: true, + data: await diagnoseProvider(config), + } satisfies RuntimeResponse) + break + } + + case "DISCOVER_LLM_MODELS": { + const { config, secrets, settings, providerSettings } = await resolveLLMRequest( + message.payload + ) + const catalog = await discoverProviderModels(config) + const recommendation = await persistCatalogAndRecommendation( + catalog, + settings, + secrets, + providerSettings + ) + + sendResponse({ + success: true, + data: { + catalog, + recommendation, + }, + } satisfies RuntimeResponse) + break + } + + case "GENERATE_LLM_TEXT": { + const { config } = await resolveLLMRequest(message.payload) + sendResponse({ + success: true, + data: await generateProviderText(config, message.payload.prompt), + } satisfies RuntimeResponse) + break + } + + case "GENERATE_LLM_STRUCTURED": { + const { config } = await resolveLLMRequest(message.payload) + sendResponse({ + success: true, + data: await generateProviderStructured(config, message.payload.prompt), + } satisfies RuntimeResponse) + break + } + + case "GENERATE_LLM_CHAT": { + const { config } = await resolveLLMRequest(message.payload) + sendResponse({ + success: true, + data: await generateProviderChat(config, message.payload.messages), + } satisfies RuntimeResponse) + break + } + + case "GET_SUPPORTED_PORTALS": + sendResponse({ + success: true, + data: SUPPORTED_PORTALS, + } satisfies RuntimeResponse) + break + default: sendResponse({ success: false, diff --git a/careerflow/src/lib/email-classifier.ts b/careerflow/src/lib/email-classifier.ts index 4c9247c..869442f 100644 --- a/careerflow/src/lib/email-classifier.ts +++ b/careerflow/src/lib/email-classifier.ts @@ -1,4 +1,5 @@ -import { ollamaClient } from "./ollama-client" +import { storageManager } from "./storage-manager" +import { buildLLMConfig, generateProviderStructured } from "./llm/provider-service" export type EmailClassification = | "confirmation" @@ -216,7 +217,15 @@ export async function classifyEmail(emailContent: { body: string }): Promise { try { - const response = await ollamaClient.generateStructured( + const [settings, secrets] = await Promise.all([ + storageManager.getSettings(), + storageManager.getProviderSecrets(), + ]) + const response = await generateProviderStructured( + buildLLMConfig(settings.llmConfig, secrets, { + temperature: 0.2, + maxTokens: 400, + }), CLASSIFICATION_PROMPT.replace("{subject}", emailContent.subject) .replace("{from}", emailContent.from) .replace("{body}", emailContent.body.substring(0, 4000)) diff --git a/careerflow/src/lib/followup-generator.ts b/careerflow/src/lib/followup-generator.ts index 89377df..2beb1bb 100644 --- a/careerflow/src/lib/followup-generator.ts +++ b/careerflow/src/lib/followup-generator.ts @@ -1,4 +1,4 @@ -import { getLLMClient } from "./llm" +import { generateStructuredWithActiveProvider } from "./runtime-client" import type { ApplicationRecord, Profile } from "./types" export interface FollowUpEmail { @@ -70,9 +70,7 @@ export async function generateFollowUpEmail( profile: Profile ): Promise { const prompt = buildPrompt(application, profile) - const client = getLLMClient() - - const response = await client.generateStructured(prompt, { + const response = await generateStructuredWithActiveProvider(prompt, { temperature: 0.5, maxTokens: 500, }) diff --git a/careerflow/src/lib/llm/anthropic-provider.ts b/careerflow/src/lib/llm/anthropic-provider.ts index a0935ea..eb68420 100644 --- a/careerflow/src/lib/llm/anthropic-provider.ts +++ b/careerflow/src/lib/llm/anthropic-provider.ts @@ -126,6 +126,10 @@ export class AnthropicProvider implements LLMProviderInterface { } } + async discoverModels(): Promise { + return this.models + } + getModels(): string[] { return this.models } diff --git a/careerflow/src/lib/llm/google-provider.ts b/careerflow/src/lib/llm/google-provider.ts index 8e1e3ae..b893573 100644 --- a/careerflow/src/lib/llm/google-provider.ts +++ b/careerflow/src/lib/llm/google-provider.ts @@ -8,7 +8,7 @@ export class GoogleProvider implements LLMProviderInterface { "gemini-1.5-pro", "gemini-1.5-flash" ] - defaultModel = "gemini-1.5-flash" + defaultModel = "gemini-2.0-flash" requiresApiKey = true private endpoint = "https://generativelanguage.googleapis.com/v1beta" @@ -124,6 +124,10 @@ export class GoogleProvider implements LLMProviderInterface { } } + async discoverModels(): Promise { + return this.models + } + getModels(): string[] { return this.models } diff --git a/careerflow/src/lib/llm/ollama-provider.ts b/careerflow/src/lib/llm/ollama-provider.ts index 4f46cec..91b7e1b 100644 --- a/careerflow/src/lib/llm/ollama-provider.ts +++ b/careerflow/src/lib/llm/ollama-provider.ts @@ -46,6 +46,10 @@ export class OllamaProvider implements LLMProviderInterface { return this.models } + async discoverModels(config: LLMConfig): Promise { + return this.fetchInstalledModels(config) + } + async generate(config: LLMConfig, prompt: string): Promise { const endpoint = config.endpoint || "http://localhost:11434" const response = await fetch(`${endpoint}/api/generate`, { diff --git a/careerflow/src/lib/llm/openai-provider.ts b/careerflow/src/lib/llm/openai-provider.ts index b27454d..b37a701 100644 --- a/careerflow/src/lib/llm/openai-provider.ts +++ b/careerflow/src/lib/llm/openai-provider.ts @@ -125,6 +125,31 @@ export class OpenAIProvider implements LLMProviderInterface { } } + async discoverModels(config: LLMConfig): Promise { + if (!config.apiKey) { + return this.models + } + + const response = await fetch(`${this.endpoint}/models`, { + headers: { Authorization: `Bearer ${config.apiKey}` }, + }) + + if (!response.ok) { + return this.models + } + + const data = (await response.json()) as { data?: Array<{ id?: string }> } + const discovered = (data.data || []) + .map((model) => model.id) + .filter((value): value is string => Boolean(value)) + + if (discovered.length > 0) { + this.models = discovered + } + + return this.models + } + getModels(): string[] { return this.models } diff --git a/careerflow/src/lib/llm/openrouter-provider.ts b/careerflow/src/lib/llm/openrouter-provider.ts index b44f676..fc02163 100644 --- a/careerflow/src/lib/llm/openrouter-provider.ts +++ b/careerflow/src/lib/llm/openrouter-provider.ts @@ -122,6 +122,31 @@ export class OpenRouterProvider implements LLMProviderInterface { } } + async discoverModels(config: LLMConfig): Promise { + if (!config.apiKey) { + return this.models + } + + const response = await fetch(`${this.endpoint}/models`, { + headers: { Authorization: `Bearer ${config.apiKey}` }, + }) + + if (!response.ok) { + return this.models + } + + const data = (await response.json()) as { data?: Array<{ id?: string }> } + const discovered = (data.data || []) + .map((model) => model.id) + .filter((value): value is string => Boolean(value)) + + if (discovered.length > 0) { + this.models = discovered + } + + return this.models + } + getModels(): string[] { return this.models } diff --git a/careerflow/src/lib/llm/provider-service.ts b/careerflow/src/lib/llm/provider-service.ts new file mode 100644 index 0000000..15e4cee --- /dev/null +++ b/careerflow/src/lib/llm/provider-service.ts @@ -0,0 +1,560 @@ +import type { + ExtensionSettings, + LLMRecommendation, + ProviderDiagnostics, + ProviderModelCatalog, + ProviderSecretStore, + ProviderSettings, +} from "../types" +import type { LLMConfig, LLMMessage, LLMProvider } from "./types" +import { AnthropicProvider } from "./anthropic-provider" +import { GoogleProvider } from "./google-provider" +import { OllamaProvider } from "./ollama-provider" +import { OpenAIProvider } from "./openai-provider" +import { OpenRouterProvider } from "./openrouter-provider" + +const PROVIDERS = { + ollama: new OllamaProvider(), + openai: new OpenAIProvider(), + anthropic: new AnthropicProvider(), + google: new GoogleProvider(), + openrouter: new OpenRouterProvider(), +} as const + +const PROVIDER_LABELS: Record = { + ollama: "Ollama", + openai: "OpenAI", + anthropic: "Anthropic", + google: "Google Gemini", + openrouter: "OpenRouter", +} + +export function getProviderLabel(provider: LLMProvider): string { + return PROVIDER_LABELS[provider] +} + +export function normalizeOllamaEndpoint(endpoint?: string): string { + const raw = (endpoint || "http://localhost:11434").trim().replace(/\/+$/, "") + const normalized = raw.replace(/\/api(?:\/tags|\/generate|\/chat)?$/i, "") + return normalized || "http://localhost:11434" +} + +function getProviderInstance(provider: LLMProvider) { + return PROVIDERS[provider] +} + +function defaultModelForProvider(provider: LLMProvider): string { + return getProviderInstance(provider).defaultModel +} + +function chooseRecommendedModel( + config: LLMConfig, + provider: LLMProvider, + models: string[] +): string { + const defaultModel = defaultModelForProvider(provider) + const requested = config.model?.trim() + + if (requested && models.includes(requested)) { + return requested + } + + if (models.includes(defaultModel)) { + return defaultModel + } + + return models[0] || defaultModel +} + +async function parseJsonSafe(response: Response): Promise { + try { + return await response.json() + } catch { + return null + } +} + +function providerError( + provider: LLMProvider, + overrides: Partial +): ProviderDiagnostics { + return { + provider, + ok: false, + kind: "provider_error", + message: `${getProviderLabel(provider)} request failed.`, + ...overrides, + } +} + +export function buildLLMConfig( + settings: ProviderSettings, + secrets?: ProviderSecretStore, + overrides?: Partial +): LLMConfig { + const provider = overrides?.provider || settings.provider + const baseConfig: LLMConfig = { + provider, + model: overrides?.model || settings.model || defaultModelForProvider(provider), + temperature: + overrides?.temperature ?? + settings.temperature ?? + (provider === "ollama" ? 0.3 : 0.7), + maxTokens: overrides?.maxTokens ?? settings.maxTokens ?? 2048, + } + + if (provider === "ollama") { + const endpoint = + "endpoint" in settings && typeof settings.endpoint === "string" + ? settings.endpoint + : "http://localhost:11434" + + baseConfig.endpoint = normalizeOllamaEndpoint( + overrides?.endpoint || endpoint + ) + } else { + baseConfig.apiKey = overrides?.apiKey || secrets?.[provider] + } + + return baseConfig +} + +export async function discoverProviderModels(config: LLMConfig): Promise { + const provider = getProviderInstance(config.provider) + let source: ProviderModelCatalog["source"] = "fallback" + let models = provider.getModels() + + if (provider.discoverModels) { + try { + const discovered = await provider.discoverModels(config) + if (discovered.length > 0) { + models = discovered + source = "live" + } + } catch { + source = "fallback" + } + } + + return { + provider: config.provider, + models, + defaultModel: provider.defaultModel, + recommendedModel: chooseRecommendedModel(config, config.provider, models), + source, + discoveredAt: new Date().toISOString(), + endpoint: config.provider === "ollama" ? config.endpoint : undefined, + } +} + +export async function generateProviderText(config: LLMConfig, prompt: string): Promise { + return getProviderInstance(config.provider).generate(config, prompt) +} + +export async function generateProviderStructured(config: LLMConfig, prompt: string): Promise { + return getProviderInstance(config.provider).generateStructured(config, prompt) +} + +export async function generateProviderChat( + config: LLMConfig, + messages: LLMMessage[] +): Promise { + return getProviderInstance(config.provider).generateChat(config, messages) +} + +export async function diagnoseProvider(config: LLMConfig): Promise { + switch (config.provider) { + case "ollama": + return diagnoseOllama(config) + case "openai": + return diagnoseOpenAI(config) + case "anthropic": + return diagnoseAnthropic(config) + case "google": + return diagnoseGoogle(config) + case "openrouter": + return diagnoseOpenRouter(config) + } +} + +async function diagnoseOllama(config: LLMConfig): Promise { + const endpoint = normalizeOllamaEndpoint(config.endpoint) + + try { + const response = await fetch(`${endpoint}/api/tags`) + const data = await parseJsonSafe(response) + const models = Array.isArray(data?.models) + ? data.models + .map((model: { name?: string }) => model.name) + .filter((value: unknown): value is string => typeof value === "string") + : [] + + if (response.ok) { + return { + provider: "ollama", + ok: true, + kind: "ok", + message: + models.length > 0 + ? `Connected to Ollama. ${models.length} installed model${models.length === 1 ? "" : "s"} found.` + : "Connected to Ollama.", + normalizedEndpoint: endpoint, + discoveredModels: models, + recommendedModel: chooseRecommendedModel( + { + ...config, + endpoint, + }, + "ollama", + models.length > 0 ? models : PROVIDERS.ollama.getModels() + ), + source: models.length > 0 ? "live" : "fallback", + } + } + + if (response.status === 403) { + return { + provider: "ollama", + ok: false, + kind: "forbidden_origin", + status: 403, + normalizedEndpoint: endpoint, + message: + "Ollama rejected the extension origin. Start Ollama with OLLAMA_ORIGINS=chrome-extension://* and use only the host root, for example http://localhost:11434.", + } + } + + if (response.status === 405) { + return { + provider: "ollama", + ok: false, + kind: "wrong_method", + status: 405, + normalizedEndpoint: endpoint, + message: + "Ollama rejected the request method or preflight. Use the host root only, no /api path, and allow the extension origin with OLLAMA_ORIGINS=chrome-extension://*.", + } + } + + if (response.status === 404) { + return { + provider: "ollama", + ok: false, + kind: "wrong_path", + status: 404, + normalizedEndpoint: endpoint, + message: "Ollama endpoint is wrong. Use the host root only, for example http://localhost:11434.", + } + } + + return providerError("ollama", { + status: response.status, + normalizedEndpoint: endpoint, + message: `Ollama returned ${response.status}.`, + }) + } catch (error) { + return { + provider: "ollama", + ok: false, + kind: "network", + normalizedEndpoint: endpoint, + message: + error instanceof Error + ? `Knight could not reach Ollama at ${endpoint}. ${error.message}` + : `Knight could not reach Ollama at ${endpoint}.`, + } + } +} + +async function diagnoseOpenAI(config: LLMConfig): Promise { + if (!config.apiKey) { + return { + provider: "openai", + ok: false, + kind: "missing_api_key", + message: "OpenAI requires an API key.", + } + } + + try { + const response = await fetch("https://api.openai.com/v1/models", { + headers: { Authorization: `Bearer ${config.apiKey}` }, + }) + + if (response.ok) { + const data = (await parseJsonSafe(response)) as { data?: Array<{ id?: string }> } | null + const models = (data?.data || []) + .map((model) => model.id) + .filter((value): value is string => Boolean(value)) + + return { + provider: "openai", + ok: true, + kind: "ok", + message: "Connected to OpenAI.", + discoveredModels: models, + recommendedModel: chooseRecommendedModel(config, "openai", models.length > 0 ? models : PROVIDERS.openai.getModels()), + source: models.length > 0 ? "live" : "fallback", + } + } + + if (response.status === 401 || response.status === 403) { + return { + provider: "openai", + ok: false, + kind: "invalid_api_key", + status: response.status, + message: "OpenAI rejected the API key.", + } + } + + return providerError("openai", { + status: response.status, + message: `OpenAI returned ${response.status}.`, + }) + } catch (error) { + return { + provider: "openai", + ok: false, + kind: "network", + message: + error instanceof Error + ? `Knight could not reach OpenAI. ${error.message}` + : "Knight could not reach OpenAI.", + } + } +} + +async function diagnoseAnthropic(config: LLMConfig): Promise { + if (!config.apiKey) { + return { + provider: "anthropic", + ok: false, + kind: "missing_api_key", + message: "Anthropic requires an API key.", + } + } + + try { + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": config.apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: config.model || PROVIDERS.anthropic.defaultModel, + max_tokens: 8, + messages: [{ role: "user", content: "ping" }], + }), + }) + + if (response.ok || response.status === 400) { + const models = PROVIDERS.anthropic.getModels() + return { + provider: "anthropic", + ok: true, + kind: "ok", + message: "Connected to Anthropic.", + discoveredModels: models, + recommendedModel: chooseRecommendedModel(config, "anthropic", models), + source: "fallback", + } + } + + if (response.status === 401 || response.status === 403) { + return { + provider: "anthropic", + ok: false, + kind: "invalid_api_key", + status: response.status, + message: "Anthropic rejected the API key or the selected model is not accessible.", + } + } + + return providerError("anthropic", { + status: response.status, + message: `Anthropic returned ${response.status}.`, + }) + } catch (error) { + return { + provider: "anthropic", + ok: false, + kind: "network", + message: + error instanceof Error + ? `Knight could not reach Anthropic. ${error.message}` + : "Knight could not reach Anthropic.", + } + } +} + +async function diagnoseGoogle(config: LLMConfig): Promise { + if (!config.apiKey) { + return { + provider: "google", + ok: false, + kind: "missing_api_key", + message: "Google Gemini requires an API key.", + } + } + + try { + const model = config.model || PROVIDERS.google.defaultModel + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${config.apiKey}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: "ping" }] }], + generationConfig: { maxOutputTokens: 4 }, + }), + } + ) + + if (response.ok || response.status === 400) { + const models = PROVIDERS.google.getModels() + return { + provider: "google", + ok: true, + kind: "ok", + message: "Connected to Google Gemini.", + discoveredModels: models, + recommendedModel: chooseRecommendedModel(config, "google", models), + source: "fallback", + } + } + + if (response.status === 401 || response.status === 403) { + return { + provider: "google", + ok: false, + kind: "invalid_api_key", + status: response.status, + message: "Google Gemini rejected the API key.", + } + } + + return providerError("google", { + status: response.status, + message: `Google Gemini returned ${response.status}.`, + }) + } catch (error) { + return { + provider: "google", + ok: false, + kind: "network", + message: + error instanceof Error + ? `Knight could not reach Google Gemini. ${error.message}` + : "Knight could not reach Google Gemini.", + } + } +} + +async function diagnoseOpenRouter(config: LLMConfig): Promise { + if (!config.apiKey) { + return { + provider: "openrouter", + ok: false, + kind: "missing_api_key", + message: "OpenRouter requires an API key.", + } + } + + try { + const response = await fetch("https://openrouter.ai/api/v1/models", { + headers: { Authorization: `Bearer ${config.apiKey}` }, + }) + + if (response.ok) { + const data = (await parseJsonSafe(response)) as { data?: Array<{ id?: string }> } | null + const models = (data?.data || []) + .map((model) => model.id) + .filter((value): value is string => Boolean(value)) + + return { + provider: "openrouter", + ok: true, + kind: "ok", + message: "Connected to OpenRouter.", + discoveredModels: models, + recommendedModel: chooseRecommendedModel( + config, + "openrouter", + models.length > 0 ? models : PROVIDERS.openrouter.getModels() + ), + source: models.length > 0 ? "live" : "fallback", + } + } + + if (response.status === 401 || response.status === 403) { + return { + provider: "openrouter", + ok: false, + kind: "invalid_api_key", + status: response.status, + message: "OpenRouter rejected the API key.", + } + } + + return providerError("openrouter", { + status: response.status, + message: `OpenRouter returned ${response.status}.`, + }) + } catch (error) { + return { + provider: "openrouter", + ok: false, + kind: "network", + message: + error instanceof Error + ? `Knight could not reach OpenRouter. ${error.message}` + : "Knight could not reach OpenRouter.", + } + } +} + +export function recommendLLMConfiguration( + settings: ExtensionSettings, + secrets: ProviderSecretStore +): LLMRecommendation { + const ollamaCatalog = settings.providerCatalogs.ollama + if (ollamaCatalog?.models?.length) { + return { + provider: "ollama", + model: ollamaCatalog.recommendedModel, + reason: "Using the first reachable local Ollama model keeps resume data local.", + } + } + + const cloudPreference: Array> = [ + "openai", + "anthropic", + "google", + "openrouter", + ] + + for (const provider of cloudPreference) { + if (secrets[provider]) { + const catalog = settings.providerCatalogs[provider] + return { + provider, + model: catalog?.recommendedModel || defaultModelForProvider(provider), + reason: `${getProviderLabel(provider)} is configured and ready to use.`, + } + } + } + + return { + provider: settings.llmConfig.provider, + model: settings.llmConfig.model, + reason: "Using the currently selected provider configuration.", + } +} + +export function providerSupportsApiKey(provider: LLMProvider): provider is Exclude { + return provider !== "ollama" +} diff --git a/careerflow/src/lib/llm/types.ts b/careerflow/src/lib/llm/types.ts index f2eefc6..82a8982 100644 --- a/careerflow/src/lib/llm/types.ts +++ b/careerflow/src/lib/llm/types.ts @@ -30,10 +30,11 @@ export interface LLMProviderInterface { models: string[] defaultModel: string requiresApiKey: boolean - + generate(config: LLMConfig, prompt: string): Promise generateStructured(config: LLMConfig, prompt: string): Promise generateChat(config: LLMConfig, messages: LLMMessage[]): Promise testConnection(config: LLMConfig): Promise + discoverModels?(config: LLMConfig): Promise getModels(): string[] } diff --git a/careerflow/src/lib/llm/unified-client.ts b/careerflow/src/lib/llm/unified-client.ts index 36b662b..2867340 100644 --- a/careerflow/src/lib/llm/unified-client.ts +++ b/careerflow/src/lib/llm/unified-client.ts @@ -80,6 +80,17 @@ export class UnifiedLLMClient { return providerInstance.getModels() } + async discoverModels(config?: Partial): Promise { + const finalConfig = { ...this.config, ...config } + const provider = this.getProvider(finalConfig.provider) + + if (provider.discoverModels) { + return provider.discoverModels(finalConfig) + } + + return provider.getModels() + } + async testAllProviders(): Promise> { const results: Record = {} as any diff --git a/careerflow/src/lib/resume-parser.ts b/careerflow/src/lib/resume-parser.ts index 153f528..aea3510 100644 --- a/careerflow/src/lib/resume-parser.ts +++ b/careerflow/src/lib/resume-parser.ts @@ -1,5 +1,5 @@ import mammoth from "mammoth" -import { ollamaClient } from "./ollama-client" +import { generateStructuredWithActiveProvider } from "./runtime-client" import type { ParsedResume, Profile } from "./types" async function readArrayBuffer(file: File): Promise { @@ -73,6 +73,83 @@ async function extractTextFromTXT(arrayBuffer: ArrayBuffer): Promise { return new TextDecoder().decode(arrayBuffer).trim() } +function buildResumeExtractionPrompt(resumeText: string): string { + return `You are a resume parser. Extract information from the following resume and return a JSON object with this exact structure: + +{ + "identity": { + "name": "Full Name", + "email": "email@example.com", + "phone": "phone number", + "location": "City, Country", + "linkedin": "LinkedIn URL", + "github": "GitHub URL", + "portfolio": "Portfolio URL" + }, + "work_history": [ + { + "company": "Company Name", + "title": "Job Title", + "start_date": "YYYY-MM-DD", + "end_date": "YYYY-MM-DD or null if current", + "current": true/false, + "location": "City, Country", + "description": "Job description", + "achievements": ["achievement 1", "achievement 2"], + "skills_used": ["skill1", "skill2"] + } + ], + "education": [ + { + "degree": "Degree Name", + "field": "Field of Study", + "institution": "University Name", + "start_date": "YYYY-MM-DD", + "end_date": "YYYY-MM-DD", + "gpa": 3.8, + "honors": ["honor 1"] + } + ], + "skills": { + "technical": ["skill1", "skill2"], + "soft": ["soft skill1"], + "tools": ["tool1"], + "languages": ["language1"] + }, + "projects": [ + { + "name": "Project Name", + "description": "Description", + "url": "https://...", + "tech_stack": ["tech1", "tech2"], + "highlights": ["highlight 1"] + } + ], + "certifications": [ + { + "name": "Certification Name", + "issuer": "Issuing Organization", + "date": "YYYY-MM-DD", + "url": "https://..." + } + ], + "meta": { + "notice_period_days": 30, + "current_ctc": 1000000, + "expected_ctc": 1200000, + "work_mode_preference": "remote", + "visa_status": "status" + } +} + +Resume text: +--- +${resumeText} +--- + +Return ONLY valid JSON, no markdown formatting or explanations. Use null for missing values. Dates should be in YYYY-MM-DD format where possible, or YYYY if full date not available.` +} + export async function extractTextFromFile(file: File): Promise { if (file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf")) { const arrayBuffer = await readArrayBuffer(file) @@ -110,9 +187,17 @@ export async function parseResume(file: File): Promise { } } - const result = await ollamaClient.extractProfile(resumeText) + const profile = await generateStructuredWithActiveProvider( + buildResumeExtractionPrompt(resumeText), + { + temperature: 0.2, + maxTokens: 2500, + } + ) + return { - ...result, + success: true, + profile, parse_time_ms: Date.now() - startedAt, extracted_text: resumeText, } diff --git a/careerflow/src/lib/runtime-client.ts b/careerflow/src/lib/runtime-client.ts new file mode 100644 index 0000000..a7cf58c --- /dev/null +++ b/careerflow/src/lib/runtime-client.ts @@ -0,0 +1,141 @@ +import { storageManager } from "./storage-manager" +import type { + LLMRecommendation, + ProviderDiagnostics, + ProviderModelCatalog, + RuntimeMessage, + RuntimeResponse, + SupportedPortalDefinition, +} from "./types" +import type { LLMConfig, LLMMessage } from "./llm/types" +import { buildLLMConfig, providerSupportsApiKey } from "./llm/provider-service" + +export async function sendRuntimeMessage(message: RuntimeMessage): Promise> { + return chrome.runtime.sendMessage(message) as Promise> +} + +async function getActiveRuntimePayload(config?: Partial, apiKeyOverride?: string) { + const [settings, secrets] = await Promise.all([ + storageManager.getSettings(), + storageManager.getProviderSecrets(), + ]) + + const provider = config?.provider || settings.llmConfig.provider + return { + config: buildLLMConfig(settings.llmConfig, secrets, config), + apiKey: + providerSupportsApiKey(provider) ? apiKeyOverride || secrets[provider] : undefined, + } +} + +export async function testActiveProvider( + config?: Partial, + apiKeyOverride?: string +): Promise { + const payload = await getActiveRuntimePayload(config, apiKeyOverride) + const response = await sendRuntimeMessage({ + type: "TEST_LLM_PROVIDER", + payload, + }) + + if (!response.success || !response.data) { + throw new Error(response.error || "Provider test failed.") + } + + return response.data +} + +export async function discoverActiveProviderModels( + config?: Partial, + apiKeyOverride?: string +): Promise<{ catalog: ProviderModelCatalog; recommendation: LLMRecommendation }> { + const payload = await getActiveRuntimePayload(config, apiKeyOverride) + const response = await sendRuntimeMessage<{ + catalog: ProviderModelCatalog + recommendation: LLMRecommendation + }>({ + type: "DISCOVER_LLM_MODELS", + payload, + }) + + if (!response.success || !response.data) { + throw new Error(response.error || "Model discovery failed.") + } + + return response.data +} + +export async function generateStructuredWithActiveProvider( + prompt: string, + config?: Partial, + apiKeyOverride?: string +): Promise { + const payload = await getActiveRuntimePayload(config, apiKeyOverride) + const response = await sendRuntimeMessage({ + type: "GENERATE_LLM_STRUCTURED", + payload: { + ...payload, + prompt, + }, + }) + + if (!response.success || response.data === undefined) { + throw new Error(response.error || "Structured generation failed.") + } + + return response.data +} + +export async function generateTextWithActiveProvider( + prompt: string, + config?: Partial, + apiKeyOverride?: string +): Promise { + const payload = await getActiveRuntimePayload(config, apiKeyOverride) + const response = await sendRuntimeMessage({ + type: "GENERATE_LLM_TEXT", + payload: { + ...payload, + prompt, + }, + }) + + if (!response.success || !response.data) { + throw new Error(response.error || "Text generation failed.") + } + + return response.data +} + +export async function generateChatWithActiveProvider( + messages: LLMMessage[], + config?: Partial, + apiKeyOverride?: string +): Promise { + const payload = await getActiveRuntimePayload(config, apiKeyOverride) + const response = await sendRuntimeMessage({ + type: "GENERATE_LLM_CHAT", + payload: { + ...payload, + messages, + }, + }) + + if (!response.success || !response.data) { + throw new Error(response.error || "Chat generation failed.") + } + + return response.data +} + +export async function getSupportedPortals(): Promise { + const response = await sendRuntimeMessage({ + type: "GET_SUPPORTED_PORTALS", + }) + + if (!response.success || !response.data) { + throw new Error(response.error || "Failed to load supported portals.") + } + + return response.data +} diff --git a/careerflow/src/lib/storage-manager.ts b/careerflow/src/lib/storage-manager.ts index 4b6398c..2f7c425 100644 --- a/careerflow/src/lib/storage-manager.ts +++ b/careerflow/src/lib/storage-manager.ts @@ -1,10 +1,19 @@ -import type { ApplicationRecord, ExtensionSettings, Profile } from "./types" +import type { + ApplicationRecord, + ExtensionSettings, + OllamaProviderSettings, + Profile, + ProviderSecretStore, + ProviderSettings, +} from "./types" +import type { LLMProvider } from "./llm/types" export const STORAGE_KEYS = { PROFILE: "careerflow_profile", SETTINGS: "careerflow_settings", APPLICATIONS: "careerflow_applications", GMAIL_TOKEN: "careerflow_gmail_token", + PROVIDER_SECRETS: "careerflow_provider_secrets", } as const const LEGACY_KEYS = { @@ -17,7 +26,13 @@ export const DEFAULT_SETTINGS: ExtensionSettings = { provider: "ollama", endpoint: "http://localhost:11434", model: "llama3.2:3b", + temperature: 0.3, + maxTokens: 2048, + autoDetectModel: true, }, + autoMode: "smart-defaults", + providerCatalogs: {}, + lastRecommendation: null, gmailClientId: process.env.PLASMO_PUBLIC_GOOGLE_CLIENT_ID || "706101500110-metd89g3jf52pu073bo9005jf9ar3l75.apps.googleusercontent.com", @@ -28,6 +43,8 @@ export const DEFAULT_SETTINGS: ExtensionSettings = { syncIntervalHours: 6, } +const DEFAULT_OLLAMA_SETTINGS = DEFAULT_SETTINGS.llmConfig as OllamaProviderSettings + async function getLocalStorage>( keys: string | string[] | null ): Promise { @@ -38,6 +55,55 @@ async function setLocalStorage(values: Record): Promise { await chrome.storage.local.set(values) } +export function normalizeProviderSettings(settings?: Partial): ProviderSettings { + const provider = settings?.provider || DEFAULT_SETTINGS.llmConfig.provider + + if (provider === "ollama") { + const currentEndpoint = + settings && "endpoint" in settings && typeof settings.endpoint === "string" + ? settings.endpoint + : undefined + + return { + ...DEFAULT_OLLAMA_SETTINGS, + ...settings, + provider: "ollama", + endpoint: currentEndpoint?.trim() || DEFAULT_OLLAMA_SETTINGS.endpoint, + model: + (typeof settings?.model === "string" && settings.model.trim()) || + DEFAULT_OLLAMA_SETTINGS.model, + } + } + + return { + provider, + model: + (typeof settings?.model === "string" && settings.model.trim()) || + getDefaultModelForProvider(provider), + temperature: + typeof settings?.temperature === "number" + ? settings.temperature + : DEFAULT_SETTINGS.llmConfig.temperature, + maxTokens: + typeof settings?.maxTokens === "number" + ? settings.maxTokens + : DEFAULT_SETTINGS.llmConfig.maxTokens, + } +} + +function getDefaultModelForProvider(provider: Exclude): string { + switch (provider) { + case "openai": + return "gpt-4o-mini" + case "anthropic": + return "claude-3-5-haiku-20241022" + case "google": + return "gemini-2.0-flash" + case "openrouter": + return "meta-llama/llama-3.2-3b-instruct:free" + } +} + export class ChromeStorageManager { async migrateLegacyData(): Promise { const result = await getLocalStorage>([ @@ -46,6 +112,7 @@ export class ChromeStorageManager { STORAGE_KEYS.GMAIL_TOKEN, LEGACY_KEYS.GMAIL_TOKEN, STORAGE_KEYS.SETTINGS, + STORAGE_KEYS.PROVIDER_SECRETS, ]) const updates: Record = {} @@ -82,11 +149,12 @@ export class ChromeStorageManager { return { ...DEFAULT_SETTINGS, ...settings, - llmConfig: { - ...DEFAULT_SETTINGS.llmConfig, - ...(settings?.llmConfig || {}), - provider: "ollama", - }, + providerCatalogs: settings?.providerCatalogs || DEFAULT_SETTINGS.providerCatalogs, + lastRecommendation: + settings?.lastRecommendation === undefined + ? DEFAULT_SETTINGS.lastRecommendation + : settings.lastRecommendation, + llmConfig: normalizeProviderSettings(settings?.llmConfig), } } @@ -121,10 +189,10 @@ export class ChromeStorageManager { const next = this.normalizeSettings({ ...current, ...settings, - llmConfig: { - ...current.llmConfig, + llmConfig: normalizeProviderSettings({ + ...(current.llmConfig as ProviderSettings), ...(settings.llmConfig || {}), - }, + }), }) await setLocalStorage({ @@ -156,12 +224,53 @@ export class ChromeStorageManager { }) } + async getProviderSecrets(): Promise { + const result = await getLocalStorage>( + STORAGE_KEYS.PROVIDER_SECRETS + ) + return result[STORAGE_KEYS.PROVIDER_SECRETS] || {} + } + + async saveProviderSecret( + provider: Exclude, + apiKey: string + ): Promise { + const current = await this.getProviderSecrets() + const next = { + ...current, + [provider]: apiKey.trim(), + } + + await setLocalStorage({ + [STORAGE_KEYS.PROVIDER_SECRETS]: next, + }) + + return next + } + + async removeProviderSecret(provider: Exclude): Promise { + const current = await this.getProviderSecrets() + const next = { ...current } + delete next[provider] + + await setLocalStorage({ + [STORAGE_KEYS.PROVIDER_SECRETS]: next, + }) + + return next + } + + async getProviderApiKey(provider: Exclude): Promise { + const secrets = await this.getProviderSecrets() + return secrets[provider] + } + async exportAllData(): Promise { const result = await getLocalStorage>(null) const careerflowData: Record = {} for (const [key, value] of Object.entries(result)) { - if (key.startsWith("careerflow_")) { + if (key.startsWith("careerflow_") && key !== STORAGE_KEYS.PROVIDER_SECRETS) { careerflowData[key] = value } } @@ -171,6 +280,13 @@ export class ChromeStorageManager { async importData(jsonString: string): Promise { const data = JSON.parse(jsonString) + if (data && typeof data === "object") { + delete data[STORAGE_KEYS.PROVIDER_SECRETS] + if (data[STORAGE_KEYS.SETTINGS]) { + data[STORAGE_KEYS.SETTINGS] = this.normalizeSettings(data[STORAGE_KEYS.SETTINGS]) + } + } + await setLocalStorage(data) } diff --git a/careerflow/src/lib/supported-portals.ts b/careerflow/src/lib/supported-portals.ts new file mode 100644 index 0000000..b9ecec9 --- /dev/null +++ b/careerflow/src/lib/supported-portals.ts @@ -0,0 +1,70 @@ +import type { SupportedPortalDefinition } from "./types" + +export const SUPPORTED_PORTALS: SupportedPortalDefinition[] = [ + { + id: "workday", + name: "Workday", + vendorUrl: "https://www.workday.com/", + supportedDomains: ["*.myworkdayjobs.com"], + note: "Strong support for the standard Workday candidate flow and field labels.", + }, + { + id: "greenhouse", + name: "Greenhouse", + vendorUrl: "https://www.greenhouse.com/", + supportedDomains: ["boards.greenhouse.io", "*.greenhouse.io"], + note: "Covers the common Greenhouse hosted application board variants.", + }, + { + id: "lever", + name: "Lever", + vendorUrl: "https://www.lever.co/", + supportedDomains: ["jobs.lever.co", "*.lever.co"], + note: "Supports the standard Lever hosted job application flow.", + }, + { + id: "naukri", + name: "Naukri", + vendorUrl: "https://www.naukri.com/", + supportedDomains: ["*.naukri.com"], + note: "Handles core Naukri profile and application fields.", + }, + { + id: "icims", + name: "iCIMS", + vendorUrl: "https://www.icims.com/", + supportedDomains: ["*.icims.com"], + note: "Supports common iCIMS application layouts and hosted forms.", + }, + { + id: "smartrecruiters", + name: "SmartRecruiters", + vendorUrl: "https://www.smartrecruiters.com/", + supportedDomains: ["*.smartrecruiters.com"], + note: "Supports the standard SmartRecruiters apply experience.", + }, + { + id: "taleo", + name: "Oracle Taleo", + vendorUrl: "https://www.oracle.com/human-capital-management/taleo/", + supportedDomains: ["*.taleo.net"], + note: "Targets common Taleo form structures and multi-step flows.", + }, + { + id: "successfactors", + name: "SAP SuccessFactors", + vendorUrl: "https://www.sap.com/products/hcm.html", + supportedDomains: ["*successfactors*", "*.jobs2web.com"], + note: "Supports SuccessFactors and Jobs2Web-hosted application surfaces.", + }, + { + id: "generic", + name: "Generic Career Portal", + supportedDomains: ["career portals with standard form labels and aria hints"], + note: "Fallback heuristics cover standard text, textarea, select, checkbox, and radio inputs.", + }, +] + +export function getSupportedPortalDefinition(id: SupportedPortalDefinition["id"]) { + return SUPPORTED_PORTALS.find((portal) => portal.id === id) || null +} diff --git a/careerflow/src/lib/types.ts b/careerflow/src/lib/types.ts index 853cebf..64d5f74 100644 --- a/careerflow/src/lib/types.ts +++ b/careerflow/src/lib/types.ts @@ -1,3 +1,5 @@ +import type { LLMConfig, LLMMessage, LLMProvider } from "./llm/types" + export interface ProfileIdentity { name: string email: string @@ -157,14 +159,109 @@ export interface ApplicationLogPayload { notes?: string } -export interface OllamaSettings { +export interface OllamaProviderSettings { provider: "ollama" endpoint: string model: string + temperature?: number + maxTokens?: number + autoDetectModel?: boolean +} + +export interface OpenAIProviderSettings { + provider: "openai" + model: string + temperature?: number + maxTokens?: number +} + +export interface AnthropicProviderSettings { + provider: "anthropic" + model: string + temperature?: number + maxTokens?: number +} + +export interface GoogleProviderSettings { + provider: "google" + model: string + temperature?: number + maxTokens?: number +} + +export interface OpenRouterProviderSettings { + provider: "openrouter" + model: string + temperature?: number + maxTokens?: number +} + +export type ProviderSettings = + | OllamaProviderSettings + | OpenAIProviderSettings + | AnthropicProviderSettings + | GoogleProviderSettings + | OpenRouterProviderSettings + +export interface ProviderSecretStore { + openai?: string + anthropic?: string + google?: string + openrouter?: string +} + +export interface ProviderModelCatalog { + provider: LLMProvider + models: string[] + defaultModel: string + recommendedModel: string + source: "live" | "fallback" + discoveredAt: string | null + endpoint?: string +} + +export type ProviderDiagnosticKind = + | "ok" + | "network" + | "forbidden_origin" + | "wrong_method" + | "wrong_path" + | "missing_api_key" + | "invalid_api_key" + | "model_missing" + | "provider_error" + +export interface ProviderDiagnostics { + provider: LLMProvider + ok: boolean + kind: ProviderDiagnosticKind + message: string + status?: number + normalizedEndpoint?: string + discoveredModels?: string[] + recommendedModel?: string + source?: "live" | "fallback" +} + +export interface SupportedPortalDefinition { + id: ATSAdapterName + name: string + vendorUrl?: string + supportedDomains: string[] + note: string +} + +export interface LLMRecommendation { + provider: LLMProvider + model: string + reason: string } export interface ExtensionSettings { - llmConfig: OllamaSettings + llmConfig: ProviderSettings + autoMode: "smart-defaults" | "manual" + providerCatalogs: Partial> + lastRecommendation?: LLMRecommendation | null gmailClientId: string gmailConnected: boolean lastSync: string | null @@ -194,6 +291,18 @@ export type RuntimeMessage = | { type: "DISCONNECT_GMAIL" } | { type: "SYNC_GMAIL" } | { type: "GET_GMAIL_STATUS" } + | { type: "TEST_LLM_PROVIDER"; payload?: { config?: Partial; apiKey?: string } } + | { type: "DISCOVER_LLM_MODELS"; payload?: { config?: Partial; apiKey?: string } } + | { type: "GENERATE_LLM_TEXT"; payload: { prompt: string; config?: Partial; apiKey?: string } } + | { + type: "GENERATE_LLM_STRUCTURED" + payload: { prompt: string; config?: Partial; apiKey?: string } + } + | { + type: "GENERATE_LLM_CHAT" + payload: { messages: LLMMessage[]; config?: Partial; apiKey?: string } + } + | { type: "GET_SUPPORTED_PORTALS" } export interface RuntimeResponse { success: boolean diff --git a/careerflow/src/popup/profile/ProfileTab.tsx b/careerflow/src/popup/profile/ProfileTab.tsx index f4209f4..7d2c197 100644 --- a/careerflow/src/popup/profile/ProfileTab.tsx +++ b/careerflow/src/popup/profile/ProfileTab.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react" -import { getLLMClient } from "../../lib/llm" -import { ollamaClient } from "../../lib/ollama-client" +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" @@ -11,7 +11,8 @@ export function ProfileTab() { const [isEditing, setIsEditing] = useState(false) const [error, setError] = useState(null) const [parseTime, setParseTime] = useState(null) - const [ollamaStatus, setOllamaStatus] = useState<"unknown" | "connected" | "disconnected">( + const [providerName, setProviderName] = useState("Ollama") + const [providerStatus, setProviderStatus] = useState<"unknown" | "connected" | "disconnected">( "unknown" ) const fileInputRef = useRef(null) @@ -27,10 +28,10 @@ export function ProfileTab() { ]) setProfile(storedProfile) + setProviderName(getProviderLabel(settings.llmConfig.provider)) - const client = getLLMClient(settings.llmConfig) - const connected = await client.testConnection().catch(() => false) - setOllamaStatus(connected ? "connected" : "disconnected") + const diagnostics = await testActiveProvider().catch(() => null) + setProviderStatus(diagnostics?.ok ? "connected" : "disconnected") } const handleFileUpload = async (event: React.ChangeEvent) => { @@ -45,16 +46,13 @@ export function ProfileTab() { try { const settings = await storageManager.getSettings() - const client = getLLMClient(settings.llmConfig) - const connected = await client.testConnection() + setProviderName(getProviderLabel(settings.llmConfig.provider)) - if (!connected) { - throw new Error("Cannot connect to Ollama. Check the endpoint in Settings.") + const diagnostics = await testActiveProvider() + if (!diagnostics.ok) { + throw new Error(diagnostics.message) } - ollamaClient.setEndpoint(settings.llmConfig.endpoint) - ollamaClient.setModel(settings.llmConfig.model) - const result = await parseResume(file) if (!result.success || !result.profile) { throw new Error(result.error || "Failed to parse resume.") @@ -68,9 +66,10 @@ export function ProfileTab() { await storageManager.saveProfile(result.profile) setProfile(result.profile) setParseTime(result.parse_time_ms || null) - setOllamaStatus("connected") + setProviderStatus("connected") } catch (reason) { setError(reason instanceof Error ? reason.message : "Unknown error") + setProviderStatus("disconnected") } finally { setIsUploading(false) if (fileInputRef.current) { @@ -92,17 +91,17 @@ export function ProfileTab() {
- Ollama Status + {providerName} Status - {ollamaStatus} + {providerStatus}
diff --git a/careerflow/src/popup/settings/LLMProviderConfig.tsx b/careerflow/src/popup/settings/LLMProviderConfig.tsx index 17350c9..7abc6ef 100644 --- a/careerflow/src/popup/settings/LLMProviderConfig.tsx +++ b/careerflow/src/popup/settings/LLMProviderConfig.tsx @@ -1,85 +1,291 @@ import { useEffect, useState } from "react" -import { getLLMClient } from "../../lib/llm" +import { + getProviderLabel, + providerSupportsApiKey, +} from "../../lib/llm/provider-service" +import { + discoverActiveProviderModels, + testActiveProvider, +} from "../../lib/runtime-client" import { storageManager } from "../../lib/storage-manager" +import type { ProviderModelCatalog, ProviderSecretStore, ProviderSettings } from "../../lib/types" +import type { LLMProvider } from "../../lib/llm/types" + +const DEFAULT_MODELS: Record = { + ollama: "llama3.2:3b", + openai: "gpt-4o-mini", + anthropic: "claude-3-5-haiku-20241022", + google: "gemini-2.0-flash", + openrouter: "meta-llama/llama-3.2-3b-instruct:free", +} + +const PROVIDERS: LLMProvider[] = ["ollama", "openai", "anthropic", "google", "openrouter"] export function LLMProviderConfig() { + const [provider, setProvider] = useState("ollama") const [endpoint, setEndpoint] = useState("http://localhost:11434") - const [model, setModel] = useState("llama3.2:3b") + const [model, setModel] = useState(DEFAULT_MODELS.ollama) + const [autoMode, setAutoMode] = useState<"smart-defaults" | "manual">("smart-defaults") + const [secrets, setSecrets] = useState({}) + const [catalogs, setCatalogs] = useState>>({}) + const [availableModels, setAvailableModels] = useState([DEFAULT_MODELS.ollama]) const [status, setStatus] = useState<"idle" | "testing" | "connected" | "failed">("idle") - const [error, setError] = useState(null) + const [message, setMessage] = useState(null) + const [recommendation, setRecommendation] = useState(null) useEffect(() => { - void (async () => { - const settings = await storageManager.getSettings() - setEndpoint(settings.llmConfig.endpoint) - setModel(settings.llmConfig.model) - })() + void load() }, []) + const load = async () => { + const [settings, storedSecrets] = await Promise.all([ + storageManager.getSettings(), + storageManager.getProviderSecrets(), + ]) + + setProvider(settings.llmConfig.provider) + setModel(settings.llmConfig.model) + setAutoMode(settings.autoMode) + setSecrets(storedSecrets) + setCatalogs(settings.providerCatalogs) + setRecommendation( + settings.lastRecommendation + ? `${getProviderLabel(settings.lastRecommendation.provider)} · ${settings.lastRecommendation.model}` + : null + ) + + if (settings.llmConfig.provider === "ollama") { + setEndpoint(settings.llmConfig.endpoint) + } + + const currentCatalog = settings.providerCatalogs[settings.llmConfig.provider] + if (currentCatalog?.models?.length) { + setAvailableModels(currentCatalog.models) + } + } + + const currentApiKey = providerSupportsApiKey(provider) ? secrets[provider] || "" : "" + + const updateProvider = (nextProvider: LLMProvider) => { + setProvider(nextProvider) + setStatus("idle") + setMessage(null) + setModel(catalogs[nextProvider]?.recommendedModel || DEFAULT_MODELS[nextProvider]) + setAvailableModels(catalogs[nextProvider]?.models || [DEFAULT_MODELS[nextProvider]]) + if (nextProvider === "ollama") { + setEndpoint("http://localhost:11434") + } + } + + const updateApiKey = (value: string) => { + if (!providerSupportsApiKey(provider)) { + return + } + + setSecrets((current) => ({ + ...current, + [provider]: value, + })) + } + + const getDraftConfig = (): ProviderSettings => + provider === "ollama" + ? { + provider: "ollama", + endpoint, + model, + temperature: 0.3, + maxTokens: 2048, + autoDetectModel: autoMode === "smart-defaults", + } + : { + provider, + model, + temperature: 0.3, + maxTokens: 2048, + } + + const discoverModels = async () => { + setStatus("testing") + setMessage(null) + + try { + const result = await discoverActiveProviderModels( + provider === "ollama" + ? { provider, endpoint, model } + : { provider, model }, + currentApiKey + ) + + setCatalogs((current) => ({ + ...current, + [provider]: result.catalog, + })) + setAvailableModels(result.catalog.models.length > 0 ? result.catalog.models : [model]) + if (autoMode === "smart-defaults") { + setModel(result.catalog.recommendedModel) + } + setRecommendation( + `${getProviderLabel(result.recommendation.provider)} · ${result.recommendation.model}` + ) + setStatus("connected") + setMessage( + result.catalog.source === "live" + ? "Fetched available models from the provider." + : "Using the built-in model list for this provider." + ) + } catch (reason) { + setStatus("failed") + setMessage(reason instanceof Error ? reason.message : "Model discovery failed.") + } + } + const testConnection = async () => { setStatus("testing") - setError(null) + setMessage(null) try { - const client = getLLMClient({ - provider: "ollama", - endpoint, - model, - }) - const connected = await client.testConnection() - setStatus(connected ? "connected" : "failed") - if (!connected) { - setError("Ollama is not reachable at the configured endpoint.") + const diagnostics = await testActiveProvider( + provider === "ollama" + ? { provider, endpoint, model } + : { provider, model }, + currentApiKey + ) + + setStatus(diagnostics.ok ? "connected" : "failed") + setMessage(diagnostics.message) + if (diagnostics.discoveredModels?.length) { + setAvailableModels(diagnostics.discoveredModels) } } catch (reason) { setStatus("failed") - setError(reason instanceof Error ? reason.message : "Connection test failed.") + setMessage(reason instanceof Error ? reason.message : "Connection test failed.") } } const save = async () => { + const nextConfig = getDraftConfig() + + if (providerSupportsApiKey(provider)) { + if (currentApiKey.trim()) { + await storageManager.saveProviderSecret(provider, currentApiKey) + } else { + await storageManager.removeProviderSecret(provider) + } + } + await storageManager.saveSettings({ - llmConfig: { - provider: "ollama", - endpoint, - model, - }, + autoMode, + llmConfig: nextConfig, }) setStatus("idle") - setError(null) + setMessage("Provider settings saved.") + await discoverModels().catch(() => undefined) } return (
-

Ollama Configuration

+
+
+

AI Provider

+

+ Knight can use Ollama, OpenAI, Anthropic, Google Gemini, or OpenRouter. +

+
+ +
+ {provider === "ollama" && ( +
+ + setEndpoint(event.target.value)} + placeholder="http://localhost:11434" + /> +

+ Use the host root only, no /api. If you see 403/405, restart Ollama + with OLLAMA_ORIGINS=chrome-extension://*. +

+
+ )} + + {providerSupportsApiKey(provider) && ( +
+ + updateApiKey(event.target.value)} + placeholder={`Enter your ${getProviderLabel(provider)} API key`} + /> +

+ Stored locally only. API keys are excluded from data exports. +

+
+ )} +
- - setEndpoint(event.target.value)} - placeholder="http://localhost:11434" - /> + + {availableModels.length > 0 ? ( + + ) : ( + setModel(event.target.value)} + /> + )}
- - Automatic Intelligence + +

+ Smart defaults prefer a reachable local Ollama model, then keep the selected provider + on its best known model. +

+ {recommendation && ( +
+ Recommended: {recommendation} +
+ )} +
- + {status === "connected" && "Connected"} - {status === "failed" && "Failed"} + {status === "failed" && "Needs attention"} {status === "idle" && "Not tested"} + {status === "testing" && "Working"}
- {error &&

{error}

} + {message && ( +

+ {message} +

+ )}
) diff --git a/careerflow/src/popup/settings/SettingsTab.tsx b/careerflow/src/popup/settings/SettingsTab.tsx index b2c0a49..8938922 100644 --- a/careerflow/src/popup/settings/SettingsTab.tsx +++ b/careerflow/src/popup/settings/SettingsTab.tsx @@ -1,14 +1,9 @@ import React, { useEffect, useState } from "react" +import { getSupportedPortals, sendRuntimeMessage } from "../../lib/runtime-client" import { storageManager } from "../../lib/storage-manager" -import type { GmailStatus, RuntimeResponse } from "../../lib/types" +import type { GmailStatus, SupportedPortalDefinition } from "../../lib/types" import { LLMProviderConfig } from "./LLMProviderConfig" -async function sendRuntimeMessage( - message: { type: string } -): Promise> { - return chrome.runtime.sendMessage(message) as Promise> -} - export const SettingsTab: React.FC = () => { const [gmailStatus, setGmailStatus] = useState({ available: true, @@ -18,6 +13,7 @@ export const SettingsTab: React.FC = () => { const [ghostThreshold, setGhostThreshold] = useState(21) const [followUpDays, setFollowUpDays] = useState(7) const [syncIntervalHours, setSyncIntervalHours] = useState(6) + const [supportedPortals, setSupportedPortals] = useState([]) const [toast, setToast] = useState(null) useEffect(() => { @@ -25,14 +21,16 @@ export const SettingsTab: React.FC = () => { }, []) const load = async () => { - const [settings, gmailResponse] = await Promise.all([ + const [settings, gmailResponse, portals] = await Promise.all([ storageManager.getSettings(), sendRuntimeMessage({ type: "GET_GMAIL_STATUS" }), + getSupportedPortals().catch(() => []), ]) setGhostThreshold(settings.ghostThresholdDays) setFollowUpDays(settings.followUpDays) setSyncIntervalHours(settings.syncIntervalHours) + setSupportedPortals(portals) if (gmailResponse.success && gmailResponse.data) { setGmailStatus(gmailResponse.data) } @@ -188,6 +186,41 @@ export const SettingsTab: React.FC = () => {
+
+

Supported Portals

+
+

+ Knight currently supports these ATS platforms directly, plus a generic fallback for + standard career forms. +

+
+ {supportedPortals.map((portal) => ( +
+
+
+

{portal.name}

+

{portal.supportedDomains.join(", ")}

+
+ {portal.vendorUrl ? ( + + Vendor Site + + ) : ( + Heuristic + )} +
+

{portal.note}

+
+ ))} +
+
+
+

Data Management

diff --git a/careerflow/tests/unit/email-classifier.test.ts b/careerflow/tests/unit/email-classifier.test.ts index 4ca01eb..2e9a9da 100644 --- a/careerflow/tests/unit/email-classifier.test.ts +++ b/careerflow/tests/unit/email-classifier.test.ts @@ -2,13 +2,36 @@ import { classifyEmail, extractApplicationData, } from '../../src/lib/email-classifier'; -import { ollamaClient } from '../../src/lib/ollama-client'; - -jest.mock('../../src/lib/ollama-client'); +import { generateProviderStructured } from '../../src/lib/llm/provider-service'; +import { storageManager } from '../../src/lib/storage-manager'; + +jest.mock('../../src/lib/llm/provider-service', () => ({ + generateProviderStructured: jest.fn(), + buildLLMConfig: jest.fn(() => ({ + provider: 'ollama', + model: 'llama3.2:3b', + endpoint: 'http://localhost:11434', + })), +})); + +jest.mock('../../src/lib/storage-manager', () => ({ + storageManager: { + getSettings: jest.fn(), + getProviderSecrets: jest.fn(), + }, +})); describe('email-classifier', () => { beforeEach(() => { jest.clearAllMocks(); + (storageManager.getSettings as jest.Mock).mockResolvedValue({ + llmConfig: { + provider: 'ollama', + endpoint: 'http://localhost:11434', + model: 'llama3.2:3b', + }, + }); + (storageManager.getProviderSecrets as jest.Mock).mockResolvedValue({}); }); describe('confirmation email classification', () => { @@ -19,7 +42,7 @@ describe('email-classifier', () => { body: 'Thank you for applying for the Software Engineer position at Google. We have received your application and will review it shortly.', }; - (ollamaClient.generateStructured as jest.Mock).mockResolvedValue({ + (generateProviderStructured as jest.Mock).mockResolvedValue({ classification: 'confirmation', confidence: 0.95, company: 'Google', @@ -44,7 +67,7 @@ describe('email-classifier', () => { body: 'Thank you for applying. Your application has been successfully submitted.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -59,7 +82,7 @@ describe('email-classifier', () => { body: 'Thank you for your application.', }; - (ollamaClient.generateStructured as jest.Mock).mockResolvedValue({ + (generateProviderStructured as jest.Mock).mockResolvedValue({ classification: 'confirmation', confidence: 0.9, company: null, @@ -81,7 +104,7 @@ describe('email-classifier', () => { body: 'We acknowledge application received for your submission.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -95,7 +118,7 @@ describe('email-classifier', () => { body: 'Your application was successfully submitted.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -111,7 +134,7 @@ describe('email-classifier', () => { body: 'We would like to invite you for a technical interview for the Software Engineer position. Please select a time slot for your phone screen.', }; - (ollamaClient.generateStructured as jest.Mock).mockResolvedValue({ + (generateProviderStructured as jest.Mock).mockResolvedValue({ classification: 'interview_invite', confidence: 0.92, company: 'Meta', @@ -134,7 +157,7 @@ describe('email-classifier', () => { body: 'We would like to schedule an interview with you next week.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -148,7 +171,7 @@ describe('email-classifier', () => { body: 'We would like to conduct a phone screen with you.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -162,7 +185,7 @@ describe('email-classifier', () => { body: 'Your technical interview is scheduled for next Monday.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -176,7 +199,7 @@ describe('email-classifier', () => { body: 'Welcome to your onsite interview at our office.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -190,7 +213,7 @@ describe('email-classifier', () => { body: 'Please complete the video interview at your convenience.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -204,7 +227,7 @@ describe('email-classifier', () => { body: 'Your interview is scheduled for 2024-03-15 at 10:00 AM.', }; - (ollamaClient.generateStructured as jest.Mock).mockResolvedValue({ + (generateProviderStructured as jest.Mock).mockResolvedValue({ classification: 'interview_invite', confidence: 0.9, company: 'Company', @@ -228,7 +251,7 @@ describe('email-classifier', () => { body: 'Unfortunately, we regret to inform you that we will not be proceeding with your application for the Software Engineer position at Amazon.', }; - (ollamaClient.generateStructured as jest.Mock).mockResolvedValue({ + (generateProviderStructured as jest.Mock).mockResolvedValue({ classification: 'rejection', confidence: 0.88, company: 'Amazon', @@ -252,7 +275,7 @@ describe('email-classifier', () => { body: 'Unfortunately, we have decided to move forward with other candidates.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -266,7 +289,7 @@ describe('email-classifier', () => { body: 'We are not moving forward with your application at this time.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -280,7 +303,7 @@ describe('email-classifier', () => { body: 'We regret to inform you that the position has been filled.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -294,7 +317,7 @@ describe('email-classifier', () => { body: 'The position has been filled. Thank you for your interest.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -308,7 +331,7 @@ describe('email-classifier', () => { body: 'We have selected other candidates for the next round.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -322,7 +345,7 @@ describe('email-classifier', () => { body: 'We regret to inform you that your application was rejected for the position.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -338,7 +361,7 @@ describe('email-classifier', () => { body: 'We are pleased to offer you the position of Software Engineer at Netflix. Please find attached the formal offer letter with details of your employment offer.', }; - (ollamaClient.generateStructured as jest.Mock).mockResolvedValue({ + (generateProviderStructured as jest.Mock).mockResolvedValue({ classification: 'offer', confidence: 0.95, company: 'Netflix', @@ -362,7 +385,7 @@ describe('email-classifier', () => { body: 'We are pleased to offer you the position.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -376,7 +399,7 @@ describe('email-classifier', () => { body: 'Congratulations! We would like to welcome you to our team.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -390,7 +413,7 @@ describe('email-classifier', () => { body: 'Please review the job offer details attached.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -404,7 +427,7 @@ describe('email-classifier', () => { body: 'Attached is your formal offer letter.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -418,7 +441,7 @@ describe('email-classifier', () => { body: 'This employment offer is contingent upon background check.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -432,7 +455,7 @@ describe('email-classifier', () => { body: 'We are excited to offer you this opportunity to join us.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -448,7 +471,7 @@ describe('email-classifier', () => { body: 'Thank you for applying.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -462,7 +485,7 @@ describe('email-classifier', () => { body: 'Thank you for applying.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -476,7 +499,7 @@ describe('email-classifier', () => { body: 'Thank you for applying at Microsoft for the Engineer role.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -492,7 +515,7 @@ describe('email-classifier', () => { body: 'Thank you for applying.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -506,7 +529,7 @@ describe('email-classifier', () => { body: 'Position: Senior Software Engineer at our company.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -520,7 +543,7 @@ describe('email-classifier', () => { body: 'Role: Product Manager for the mobile team.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -534,7 +557,7 @@ describe('email-classifier', () => { body: 'Application for: Data Scientist position.', }; - (ollamaClient.generateStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateProviderStructured as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await classifyEmail(emailContent); @@ -550,7 +573,7 @@ describe('email-classifier', () => { body: 'Here are the latest technology news updates.', }; - (ollamaClient.generateStructured as jest.Mock).mockResolvedValue({ + (generateProviderStructured as jest.Mock).mockResolvedValue({ classification: 'other', confidence: 0.8, company: null, @@ -574,7 +597,7 @@ describe('email-classifier', () => { body: 'We invite you for an interview.', }; - (ollamaClient.generateStructured as jest.Mock).mockResolvedValue({ + (generateProviderStructured as jest.Mock).mockResolvedValue({ classification: 'interview_invite', confidence: 0.9, company: 'Company', diff --git a/careerflow/tests/unit/resume-parser.test.ts b/careerflow/tests/unit/resume-parser.test.ts index b386f81..431cd10 100644 --- a/careerflow/tests/unit/resume-parser.test.ts +++ b/careerflow/tests/unit/resume-parser.test.ts @@ -1,8 +1,10 @@ import { extractTextFromFile, parseResume, validateProfile } from '../../src/lib/resume-parser'; -import { ollamaClient } from '../../src/lib/ollama-client'; +import { generateStructuredWithActiveProvider } from '../../src/lib/runtime-client'; import type { Profile } from '../../src/lib/types'; -jest.mock('../../src/lib/ollama-client'); +jest.mock('../../src/lib/runtime-client', () => ({ + generateStructuredWithActiveProvider: jest.fn(), +})); class CustomFileReader { result: ArrayBuffer | null = null; @@ -404,7 +406,7 @@ describe('resume-parser', () => { } (global as any).FileReader = TestFileReader; - (ollamaClient.extractProfile as jest.Mock).mockResolvedValue({ + (generateStructuredWithActiveProvider as jest.Mock).mockResolvedValue({ success: true, profile: { identity: { name: 'Test', email: 'test@test.com' }, @@ -438,7 +440,7 @@ describe('resume-parser', () => { } (global as any).FileReader = TestFileReader; - (ollamaClient.extractProfile as jest.Mock).mockRejectedValue(new Error('LLM failed')); + (generateStructuredWithActiveProvider as jest.Mock).mockRejectedValue(new Error('LLM failed')); const result = await parseResume(mockFile); expect(result.success).toBe(false); diff --git a/docs/index.html b/docs/index.html index 44ce8d6..dd366ac 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ Knight | Local-First Job Application Autofill
-

Chrome extension • Ollama-first • GitHub Pages

+

Chrome extension • Multi-provider • GitHub Pages

Apply faster without handing your resume to a hosted AI.

Knight parses your resume locally, maps it onto common ATS forms, logs applications, - reads Gmail updates with a read-only scope, and drafts follow-ups with Ollama. + reads Gmail updates with a read-only scope, and drafts follow-ups with Ollama, + OpenAI, Anthropic, Google Gemini, or OpenRouter.

    -
  • No cloud resume parsing required
  • +
  • Local-first with optional cloud providers
  • Works across major ATS portals and generic forms
  • -
  • Tracks confirmations, interviews, rejections, and offers
  • +
  • Auto-discovers installed Ollama models and tracks Gmail status drift
@@ -66,12 +67,12 @@

Apply faster without handing your resume to a hosted AI.

- 8+ - ATS families supported + 5 + LLM providers supported
- 100% - Local-first resume parsing path + 9 + Portal paths including generic fallback
129 @@ -79,9 +80,9 @@

Apply faster without handing your resume to a hosted AI.

ResumePDF / DOCX / TXT
-
AutofillReview-first overlay
+
ProvidersOllama / OpenAI / Anthropic / Gemini / OpenRouter
TrackingGmail read-only sync
-
Follow-upOllama drafted replies
+
Follow-upActive provider drafted replies
@@ -101,7 +102,8 @@

Built around the actual application loop

1. Parse once

Import your resume as PDF, DOCX, or TXT. Knight extracts a reusable profile locally - and stores it in Chrome storage. + and stores it in Chrome storage. Ollama stays the privacy-first default, but cloud + providers are supported when you need them.

@@ -122,7 +124,7 @@

3. Track status drift

4. Draft the follow-up

When a role stalls, Knight uses your profile plus application history to prepare a - concise follow-up draft with Ollama. + concise follow-up draft with the currently selected provider.

@@ -148,7 +150,9 @@

Common job platforms supported first

Knight also includes a generic form-mapping fallback for employer-hosted portals that - expose standard inputs, labels, placeholders, or aria metadata. + expose standard inputs, labels, placeholders, or aria metadata. In the extension + settings, each supported platform is listed with its canonical vendor link and domain + coverage.

@@ -164,6 +168,8 @@

Local setup

git clone git@github.com:sir-ad/knight.git
 cd knight/careerflow
 npm install
+OLLAMA_ORIGINS=chrome-extension://* ollama serve
+ollama pull llama3.2:3b
 npm test -- --runInBand
 npm run build
 
@@ -181,8 +187,9 @@

Chrome load

Local requirements

  • Node.js 20+
  • -
  • Ollama running locally
  • -
  • llama3.2:3b or similar pulled model
  • +
  • Ollama running locally for the default privacy-first path
  • +
  • llama3.2:3b or another installed Ollama model
  • +
  • Optional API keys for OpenAI, Anthropic, Google Gemini, or OpenRouter
  • Optional Google OAuth client for Gmail sync