From f8466c2ebcea8e6669122ca857e217abf8165e53 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 30 Jul 2026 22:27:10 +0200 Subject: [PATCH] feat: advance phase 5 AI workflow --- docs/00-ai-context.md | 29 +++++-------------- docs/decisions/ADR-004-ai-provider-system.md | 23 +++++++++++++++ docs/implementation-roadmap.md | 8 ++--- job-tracker-ui/src/components/AddJobModal.tsx | 18 +++++++++++- job-tracker-ui/src/quick-capture.test.tsx | 21 +++++++++++++- 5 files changed, 72 insertions(+), 27 deletions(-) create mode 100644 docs/decisions/ADR-004-ai-provider-system.md diff --git a/docs/00-ai-context.md b/docs/00-ai-context.md index 3772a94..c9d5d42 100644 --- a/docs/00-ai-context.md +++ b/docs/00-ai-context.md @@ -3,7 +3,7 @@ Version: 1.0 Status: Living document -Last Updated: YYYY-MM-DD +Last Updated: 2026-07-30 --- @@ -491,27 +491,14 @@ Security features: # AI -The application supports multiple providers. +The deployment selects one AI provider with the `AI_PROVIDER` environment variable. The supported +providers are Ollama, Gemini, and Groq; OpenAI and Claude are not implemented. The .NET API calls the +private `ai-service` through `ISummarizerService`, and the provider is never selected by an end user or +an administrator at request time. -Architecture: - -Provider Interface - -↓ - -OpenAI - -Gemini - -Claude - -Ollama - -Future Providers - -The admin controls available providers. - -Users should never be locked into one AI model. +Ollama is the privacy-first local option. Cloud providers are deployment choices for operators who +accept their data-handling and cost trade-offs. Keep this boundary until customer demand justifies a +more complex provider router. See `docs/decisions/ADR-004-ai-provider-system.md`. --- diff --git a/docs/decisions/ADR-004-ai-provider-system.md b/docs/decisions/ADR-004-ai-provider-system.md new file mode 100644 index 0000000..2d8a853 --- /dev/null +++ b/docs/decisions/ADR-004-ai-provider-system.md @@ -0,0 +1,23 @@ +# ADR-004 — Deployment-selected AI provider + +- **Status:** Accepted +- **Date:** 2026-07-30 +- **Phase:** 5 (AI improvements) + +## Context + +The AI sidecar supports Ollama, Gemini, and Groq. Earlier documentation described a per-request provider abstraction with OpenAI, Claude, administrator controls, and user choice; none of those capabilities exist. Adding them now would increase credential handling, privacy exposure, testing, and billing complexity without demonstrated customer demand. + +## Decision + +Each deployment selects exactly one provider through `AI_PROVIDER`. The .NET application continues to call the private `ai-service` through `ISummarizerService`; application code does not branch on provider. Ollama remains the local privacy-first option. Gemini and Groq are operator-selected cloud alternatives. + +Provider choice is not exposed to users or administrators. Revisit this only when a customer requires provider choice and the deployment has explicit credential, privacy, quota, and audit rules for every enabled provider. + +## Consequences + +- One provider configuration and failure mode per deployment. +- No OpenAI or Claude support is implied. +- AI results remain suggestions that require user review. +- Usage metering belongs at the application interaction boundary, independent of provider. +- Cloud-provider privacy and cost are deployment responsibilities until SaaS quotas are introduced. diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index e0f9a45..300ea84 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -152,11 +152,11 @@ Goal: polish. This is the healthiest area — grounding in the structured profil | # | Task | Priority | Difficulty | Dependencies | Expected value | |---|---|---|---|---|---| -| 5.1 | **Fix `docs/00-ai-context.md` to match the code.** **Decided 2026-07-17: do NOT build the abstraction.** | **P1** | **S** | none | The doc describes a provider interface over OpenAI/Gemini/Claude/Ollama with admin control and per-user choice. Reality: one `AI_PROVIDER` env var over Ollama/Gemini/Groq. Multi-provider cloud AI also undermines the privacy moat (see `docs/research/competitors.md` §4). Revisit only if a customer asks. `docs/architecture/current.md` §9 already records the truth. | +| 5.1 | ✅ **DONE (2026-07-30)** — fixed `docs/00-ai-context.md` to match the code. **Decided 2026-07-17: do NOT build the abstraction.** | **P1** | **S** | none | The doc describes a provider interface over OpenAI/Gemini/Claude/Ollama with admin control and per-user choice. Reality: one `AI_PROVIDER` env var over Ollama/Gemini/Groq. Multi-provider cloud AI also undermines the privacy moat (see `docs/research/competitors.md` §4). Revisit only if a customer asks. `docs/architecture/current.md` §9 already records the truth. | | 5.2 | **AI usage metering** | **P1** | **M** | 1.5 | No quota, no tracking, no ceiling. Hard blocker for Phase 7; a cost risk today with `AI_PROVIDER=gemini`. | -| 5.3 | **Surface CV generation inside the add-job wizard** | **P2** | **S** | 1.4 | The target workflow says "Generate CV if needed" at step 3. `POST /generate-tailored-cv-draft` exists but only post-save. | -| 5.4 | **Keyword-gap analysis on match score** | **P2** | **M** | none | `JobCvMatchService` + `/match-score` exist. Gap analysis is the specific thing people pay Jobscan $49.95/mo for. | -| 5.5 | **Write ADR-004 (AI provider system)** | **P2** | **S** | 5.1 | 0-byte file naming a real decision. | +| 5.3 | ✅ **DONE (2026-07-30)** — surfaced optional CV generation inside the add-job wizard | **P2** | **S** | 1.4 | The target workflow says "Generate CV if needed" at step 3. `POST /generate-tailored-cv-draft` exists but only post-save. | +| 5.4 | ✅ **DONE** — keyword-gap analysis on match score | **P2** | **M** | none | `JobCvMatchService` + `/match-score` exist. Gap analysis is the specific thing people pay Jobscan $49.95/mo for. | +| 5.5 | ✅ **DONE (2026-07-30)** — wrote ADR-004 (AI provider system) | **P2** | **S** | 5.1 | 0-byte file naming a real decision. | --- diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index d37e9ec..bf38ed4 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -7,12 +7,14 @@ import { Autocomplete, Box, Button, + Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, + FormControlLabel, IconButton, List, ListItem, @@ -136,6 +138,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr const [tags, setTags] = useState([]); const [notes, setNotes] = useState(""); + const [generateTailoredCv, setGenerateTailoredCv] = useState(false); const [attachments, setAttachments] = useState(() => emptyAttachmentBuckets()); useEffect(() => { @@ -179,6 +182,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr setDescriptionLanguage(""); setTags([]); setNotes(""); + setGenerateTailoredCv(false); setAttachments(emptyAttachmentBuckets()); setDuplicateCheck(null); }; @@ -339,6 +343,14 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr dateApplied, }); + if (response.data?.id && generateTailoredCv) { + try { + await api.post(`/jobapplications/${response.data.id}/generate-tailored-cv-draft`); + } catch (error: any) { + toast(getApiErrorMessage(error, "Job created, but the tailored CV could not be generated."), "warning"); + } + } + if (response.data?.id && attachmentCount > 0) { try { await uploadAttachments(response.data.id); @@ -587,7 +599,11 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr {activeStep === 5 ? "Certificates, references, and other supporting documents are optional." : null} - {activeStep === 2 ? uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp")) : null} + {activeStep === 2 ? <> + setGenerateTailoredCv(event.target.checked)} />} label="Generate a tailored CV draft after creating this job" /> + Uses your reviewed Career Profile and keeps the result as an editable suggestion. + {uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))} + : null} {activeStep === 3 ? uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp")) : null} {activeStep === 4 ? uploadField("portfolio", t("addJobModalPortfolio"), t("addJobModalPortfolioHelp")) : null} {activeStep === 5 ? uploadField("other", t("addJobModalOtherFiles"), t("addJobModalOtherFilesHelp")) : null} diff --git a/job-tracker-ui/src/quick-capture.test.tsx b/job-tracker-ui/src/quick-capture.test.tsx index c996d13..c0ae584 100644 --- a/job-tracker-ui/src/quick-capture.test.tsx +++ b/job-tracker-ui/src/quick-capture.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ToastProvider } from './toast'; import { I18nProvider } from './i18n/I18nProvider'; import { api } from './api'; @@ -68,3 +68,22 @@ test('does not auto-import when no initialUrl is given', async () => { expect(await screen.findByRole('dialog')).toBeInTheDocument(); expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything()); }); + + +test('can generate a tailored CV after creating a job', async () => { + mockedApi.post.mockImplementation((url: string) => { + if (url === '/jobimport/preview') return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', description: 'desc' } } as any); + if (url === '/companies') return Promise.resolve({ data: { id: 1, name: 'Acme' } } as any); + if (url === '/jobapplications') return Promise.resolve({ data: { id: 42 } } as any); + return Promise.resolve({ data: {} } as any); + }); + renderModal('https://example.com/jobs/123'); + + expect(await screen.findByDisplayValue('Imported Backend Role')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + fireEvent.click(await screen.findByLabelText('Generate a tailored CV draft after creating this job')); + for (let step = 0; step < 3; step += 1) fireEvent.click(screen.getByRole('button', { name: 'Skip and continue' })); + fireEvent.click(screen.getByRole('button', { name: /create job/i })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/generate-tailored-cv-draft')); +});