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"] == []