diff --git a/Job tracker.sln b/Job tracker.sln index 8438c36..936e9eb 100644 --- a/Job tracker.sln +++ b/Job tracker.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi", "JobTracker EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi.Tests", "JobTrackerApi.Tests\JobTrackerApi.Tests.csproj", "{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerBackend", "JobTrackerBackend\JobTrackerBackend.csproj", "{709F069F-DD13-42CC-9C5E-99923A545790}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +43,18 @@ Global {4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x64.Build.0 = Release|Any CPU {4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.ActiveCfg = Release|Any CPU {4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.Build.0 = Release|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.Build.0 = Debug|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.ActiveCfg = Debug|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.Build.0 = Debug|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.ActiveCfg = Debug|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.Build.0 = Debug|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.ActiveCfg = Release|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.Build.0 = Release|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.ActiveCfg = Release|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.Build.0 = Release|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.ActiveCfg = Release|Any CPU + {709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj b/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj index 5024cf6..101b07d 100644 --- a/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj +++ b/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 enable @@ -14,14 +14,12 @@ all - - - + + - - + diff --git a/JobTrackerApi.Tests/ProfileCvControllerTests.cs b/JobTrackerApi.Tests/ProfileCvControllerTests.cs index 8ca17e8..ff9cf1e 100644 --- a/JobTrackerApi.Tests/ProfileCvControllerTests.cs +++ b/JobTrackerApi.Tests/ProfileCvControllerTests.cs @@ -580,6 +580,81 @@ public sealed class ProfileCvControllerTests Assert.Equal("Connor Babbington", structured.Contact.FullName); } + [Fact] + public async Task Parse_uses_classifier_fallback_when_plain_text_has_no_real_sections() + { + var source = "Senior Platform Engineer at Atlas Systems\nOslo\n2019 - Present\nBuilt event-driven APIs and migration tooling.\n\nPython\nSQL\nAzure"; + var user = new ApplicationUser { Id = "user-1", ProfileCvText = source }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var aiService = new Mock(); + aiService + .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900)) + .ReturnsAsync("not-json"); + + var classifier = new Mock(); + classifier + .Setup(x => x.ClassifyBlockAsync(It.Is(block => block.Contains("Atlas Systems", StringComparison.Ordinal)), It.IsAny())) + .ReturnsAsync(new CvBlockClassificationResult("Work Experience", 0.93, "job block", "Senior Platform Engineer", "Atlas Systems", "Oslo", "2019", "Present", new List { "Built event-driven APIs and migration tooling." })); + classifier + .Setup(x => x.ClassifyBlockAsync(It.Is(block => block.Contains("Python", StringComparison.Ordinal)), It.IsAny())) + .ReturnsAsync(new CvBlockClassificationResult("Skills", 0.88, "skills block", null, null, null, null, null, new List())); + + await using var db = CreateDb(); + var paths = CreatePaths(); + var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); + + var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + + var ok = Assert.IsType(result.Result); + var json = JsonSerializer.Serialize(ok.Value); + Assert.Contains("Senior Platform Engineer", json); + Assert.Contains("Atlas Systems", json); + + var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + var matchedJob = structured.Jobs.FirstOrDefault(job => job.Title == "Senior Platform Engineer"); + Assert.NotNull(matchedJob); + Assert.Contains("Atlas Systems", matchedJob!.Company ?? string.Empty, StringComparison.Ordinal); + Assert.Contains("Python", structured.Skills); + Assert.Contains("SQL", structured.Skills); + classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny(), It.IsAny()), Times.AtLeastOnce()); + } + + [Fact] + public async Task Parse_keeps_general_fallback_when_classifier_returns_nothing() + { + var source = "Independent consultant building internal platforms for public-sector clients across data, delivery, and migration work."; + var user = new ApplicationUser { Id = "user-1", ProfileCvText = source }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var aiService = new Mock(); + aiService + .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900)) + .ReturnsAsync("not-json"); + + var classifier = new Mock(); + classifier + .Setup(x => x.ClassifyBlockAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((CvBlockClassificationResult?)null); + + await using var db = CreateDb(); + var paths = CreatePaths(); + var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); + + var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + + var ok = Assert.IsType(result.Result); + var json = JsonSerializer.Serialize(ok.Value); + Assert.Contains("Professional Summary", json); + + var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + Assert.Contains(structured.Summary, item => item.Contains("Independent consultant", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(user.ProfileCvStructureJson); + classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny(), It.IsAny()), Times.AtLeastOnce()); + } + [Fact] public async Task Upload_accepts_markdown_cv_and_saves_text() { @@ -623,9 +698,9 @@ public sealed class ProfileCvControllerTests Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName); } - private static ProfileCvController CreateController(UserManager userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths) + private static ProfileCvController CreateController(UserManager userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null) { - return new ProfileCvController(userManager, aiService, db, paths) + return new ProfileCvController(userManager, aiService, db, paths, cvAiClassifier ?? NoOpCvAiClassifier.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; diff --git a/JobTrackerApi/JobTrackerApi.csproj b/JobTrackerApi/JobTrackerApi.csproj index 853e887..f6e0c80 100644 --- a/JobTrackerApi/JobTrackerApi.csproj +++ b/JobTrackerApi/JobTrackerApi.csproj @@ -8,18 +8,19 @@ - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive + + + all - - - + + all + + + + + + diff --git a/JobTrackerBackend/JobTrackerBackend.csproj b/JobTrackerBackend/JobTrackerBackend.csproj new file mode 100644 index 0000000..70bb2cc --- /dev/null +++ b/JobTrackerBackend/JobTrackerBackend.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + enable + enable + true + Library + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index 1f85c71..16214d4 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -181,6 +181,25 @@ test('profile page can reprocess from stored artifact history', async () => { }); }); +test('profile page keeps raw extraction collapsed until expanded', async () => { + renderPage(); + + expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); + expect(screen.getByText(/the structured cv stays front and center/i)).toBeInTheDocument(); + + const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i }); + expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false'); + const copyButton = screen.getByRole('button', { name: /copy cv text/i }); + expect(copyButton).toBeDisabled(); + + fireEvent.click(originalExtractionToggle); + + expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true'); + expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems'); + const copyButtons = screen.getAllByRole('button', { name: /copy cv text/i }); + expect(copyButtons.some((button) => !button.hasAttribute('disabled'))).toBe(true); +}); + test('saving profile persists structured cv json', async () => { renderPage(); diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index a0447df..95c47f8 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -26,6 +26,7 @@ OCR_LANGUAGES = "eng" IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "") +SKIP_MODEL_LOAD = os.getenv("AI_SERVICE_SKIP_MODEL_LOAD", "") == "1" def _load_runtime(): @@ -39,7 +40,10 @@ def _load_runtime(): return tokenizer, model, device, has_cuda, gpu_name -tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = _load_runtime() +if SKIP_MODEL_LOAD: + tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = None, None, torch.device("cpu"), False, None +else: + tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = _load_runtime() cache = TTLCache(maxsize=1024, ttl=60 * 60) @@ -298,6 +302,8 @@ def _role_focused_excerpt(text: str) -> dict: def _model_summarize(text: str, max_length: int, min_length: int) -> str: + if tokenizer is None or model is None: + raise HTTPException(status_code=503, detail="Summarizer model is not loaded.") inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=1024) input_ids = inputs.input_ids.to(device) attention_mask = inputs.attention_mask.to(device) if hasattr(inputs, "attention_mask") else None diff --git a/tools/summarizer/requirements-dev.txt b/tools/summarizer/requirements-dev.txt new file mode 100644 index 0000000..1e486bc --- /dev/null +++ b/tools/summarizer/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.3.5 +httpx==0.28.1 diff --git a/tools/summarizer/tests/test_app.py b/tools/summarizer/tests/test_app.py new file mode 100644 index 0000000..148e531 --- /dev/null +++ b/tools/summarizer/tests/test_app.py @@ -0,0 +1,77 @@ +import importlib +import os +import sys +from pathlib import Path + +from fastapi.testclient import TestClient + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def load_app_module(monkeypatch): + monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1") + monkeypatch.delenv("OLLAMA_MODEL", raising=False) + if "app" in sys.modules: + del sys.modules["app"] + module = importlib.import_module("app") + return importlib.reload(module) + + +def test_health_reports_runtime_without_ollama(monkeypatch): + module = load_app_module(monkeypatch) + client = TestClient(module.app) + + response = client.get("/health") + + assert response.status_code == 200 + payload = response.json() + assert payload["ok"] is True + assert payload["device"] == "cpu" + assert payload["ollama_configured"] is False + assert payload["ollama_model"] is None + + +def test_classify_block_returns_structured_json(monkeypatch): + module = load_app_module(monkeypatch) + + def fake_generate_json(prompt: str): + assert "Senior Platform Engineer" in prompt + return { + "section": "Work Experience", + "confidence": 0.91, + "reason": "job block", + "title": "Senior Platform Engineer", + "company": "Atlas Systems", + "location": "Oslo", + "start": "2019", + "end": "Present", + "bullets": ["Built event-driven APIs and migration tooling."], + } + + monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json) + client = TestClient(module.app) + + response = client.post("/cv/classify-block", json={"block": "Senior Platform Engineer at Atlas Systems, Oslo, 2019 - Present. Built event-driven APIs and migration tooling."}) + + assert response.status_code == 200 + payload = response.json() + assert payload["section"] == "Work Experience" + assert payload["title"] == "Senior Platform Engineer" + assert payload["company"] == "Atlas Systems" + assert payload["bullets"] == ["Built event-driven APIs and migration tooling."] + + +def test_classify_block_defaults_missing_section_to_other(monkeypatch): + module = load_app_module(monkeypatch) + monkeypatch.setattr(module, "_ollama_generate_json", lambda prompt: {"bullets": []}) + client = TestClient(module.app) + + response = client.post("/cv/classify-block", json={"block": "Miscellaneous profile text"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["section"] == "Other" + assert payload["bullets"] == []