feat: advance phase 5 AI workflow
This commit is contained in:
+8
-21
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
Version: 1.0
|
Version: 1.0
|
||||||
Status: Living document
|
Status: Living document
|
||||||
Last Updated: YYYY-MM-DD
|
Last Updated: 2026-07-30
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -491,27 +491,14 @@ Security features:
|
|||||||
|
|
||||||
# AI
|
# 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:
|
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
|
||||||
Provider Interface
|
more complex provider router. See `docs/decisions/ADR-004-ai-provider-system.md`.
|
||||||
|
|
||||||
↓
|
|
||||||
|
|
||||||
OpenAI
|
|
||||||
|
|
||||||
Gemini
|
|
||||||
|
|
||||||
Claude
|
|
||||||
|
|
||||||
Ollama
|
|
||||||
|
|
||||||
Future Providers
|
|
||||||
|
|
||||||
The admin controls available providers.
|
|
||||||
|
|
||||||
Users should never be locked into one AI model.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -152,11 +152,11 @@ Goal: polish. This is the healthiest area — grounding in the structured profil
|
|||||||
|
|
||||||
| # | Task | Priority | Difficulty | Dependencies | Expected value |
|
| # | 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.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.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 | **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.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 | **Write ADR-004 (AI provider system)** | **P2** | **S** | 5.1 | 0-byte file naming a real decision. |
|
| 5.5 | ✅ **DONE (2026-07-30)** — wrote ADR-004 (AI provider system) | **P2** | **S** | 5.1 | 0-byte file naming a real decision. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import {
|
|||||||
Autocomplete,
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Divider,
|
Divider,
|
||||||
|
FormControlLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
@@ -136,6 +138,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
|
|||||||
|
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [generateTailoredCv, setGenerateTailoredCv] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<AttachmentBuckets>(() => emptyAttachmentBuckets());
|
const [attachments, setAttachments] = useState<AttachmentBuckets>(() => emptyAttachmentBuckets());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -179,6 +182,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
|
|||||||
setDescriptionLanguage("");
|
setDescriptionLanguage("");
|
||||||
setTags([]);
|
setTags([]);
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setGenerateTailoredCv(false);
|
||||||
setAttachments(emptyAttachmentBuckets());
|
setAttachments(emptyAttachmentBuckets());
|
||||||
setDuplicateCheck(null);
|
setDuplicateCheck(null);
|
||||||
};
|
};
|
||||||
@@ -339,6 +343,14 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
|
|||||||
dateApplied,
|
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) {
|
if (response.data?.id && attachmentCount > 0) {
|
||||||
try {
|
try {
|
||||||
await uploadAttachments(response.data.id);
|
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 === 5 ? "Certificates, references, and other supporting documents are optional." : null}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{activeStep === 2 ? uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp")) : null}
|
{activeStep === 2 ? <>
|
||||||
|
<FormControlLabel control={<Checkbox checked={generateTailoredCv} onChange={(event) => setGenerateTailoredCv(event.target.checked)} />} label="Generate a tailored CV draft after creating this job" />
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", mt: -1 }}>Uses your reviewed Career Profile and keeps the result as an editable suggestion.</Typography>
|
||||||
|
{uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))}
|
||||||
|
</> : null}
|
||||||
{activeStep === 3 ? uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp")) : null}
|
{activeStep === 3 ? uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp")) : null}
|
||||||
{activeStep === 4 ? uploadField("portfolio", t("addJobModalPortfolio"), t("addJobModalPortfolioHelp")) : null}
|
{activeStep === 4 ? uploadField("portfolio", t("addJobModalPortfolio"), t("addJobModalPortfolioHelp")) : null}
|
||||||
{activeStep === 5 ? uploadField("other", t("addJobModalOtherFiles"), t("addJobModalOtherFilesHelp")) : null}
|
{activeStep === 5 ? uploadField("other", t("addJobModalOtherFiles"), t("addJobModalOtherFilesHelp")) : null}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import '@testing-library/jest-dom';
|
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 { ToastProvider } from './toast';
|
||||||
import { I18nProvider } from './i18n/I18nProvider';
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
import { api } from './api';
|
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(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||||
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything());
|
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'));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user