From 54abc9f546ee134ce09ebdc1c205a3aa0551b18f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Apr 2026 22:26:03 +0200 Subject: [PATCH] Use Ollama rewrite path for CV generation --- JobTrackerApi.Tests/SummarizerServiceTests.cs | 35 ++++++- JobTrackerApi/Services/SummarizerService.cs | 92 ++++++++++++++++++- tools/summarizer/app.py | 83 +++++++++++++++++ tools/summarizer/tests/test_app.py | 18 ++++ 4 files changed, 223 insertions(+), 5 deletions(-) diff --git a/JobTrackerApi.Tests/SummarizerServiceTests.cs b/JobTrackerApi.Tests/SummarizerServiceTests.cs index c7d317a..3940727 100644 --- a/JobTrackerApi.Tests/SummarizerServiceTests.cs +++ b/JobTrackerApi.Tests/SummarizerServiceTests.cs @@ -11,7 +11,7 @@ namespace JobTrackerApi.Tests; public sealed class SummarizerServiceTests { [Fact] - public async Task Summarize_section_clamps_lengths_to_ai_service_limits() + public async Task Summarize_section_uses_cv_rewrite_endpoint() { var handler = new CapturingHandler(); var httpClient = new HttpClient(handler) @@ -27,7 +27,31 @@ public sealed class SummarizerServiceTests var result = await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400); - Assert.Equal("ok", result); + Assert.Equal("rewritten cv", result); + Assert.Equal("/cv/rewrite", handler.LastPath); + Assert.NotNull(handler.LastBody); + Assert.Contains("\"instruction\":\"Rewrite this CV\"", handler.LastBody); + Assert.Contains("\"max_length\":256", handler.LastBody); + Assert.Contains("\"min_length\":180", handler.LastBody); + } + + [Fact] + public async Task Summarize_section_clamps_lengths_to_ai_service_limits() + { + var handler = new CapturingHandler(); + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("http://localhost:8001") + }; + + var httpFactory = new Mock(); + httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient); + + using var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var service = new SummarizerService(httpFactory.Object, memoryCache); + + await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400); + Assert.NotNull(handler.LastBody); Assert.Contains("\"max_length\":256", handler.LastBody); Assert.Contains("\"min_length\":180", handler.LastBody); @@ -36,13 +60,18 @@ public sealed class SummarizerServiceTests private sealed class CapturingHandler : HttpMessageHandler { public string? LastBody { get; private set; } + public string? LastPath { get; private set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + LastPath = request.RequestUri?.AbsolutePath; LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + var responseBody = LastPath == "/cv/rewrite" + ? "{\"rewritten_text\":\"rewritten cv\"}" + : "{\"summary\":\"ok\"}"; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent("{\"summary\":\"ok\"}", Encoding.UTF8, "application/json") + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") }; } } diff --git a/JobTrackerApi/Services/SummarizerService.cs b/JobTrackerApi/Services/SummarizerService.cs index 8942c1c..acb141b 100644 --- a/JobTrackerApi/Services/SummarizerService.cs +++ b/JobTrackerApi/Services/SummarizerService.cs @@ -153,8 +153,7 @@ namespace JobTrackerApi.Services public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) { if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult(null); - var composed = ComposeBoundedPrompt(instruction.Trim(), text.Trim()); - return SummarizeCoreAsync(composed, maxLength, minLength); + return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength); } private static string ComposeBoundedPrompt(string instruction, string text) @@ -174,6 +173,95 @@ namespace JobTrackerApi.Services return prefix + text[..remaining]; } + private async Task RewriteCoreAsync(string instruction, string text, int maxLength, int minLength) + { + var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength); + var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength); + if (normalizedMinLength >= normalizedMaxLength) + { + normalizedMinLength = Math.Max(AiServiceMinMinLength, normalizedMaxLength - 1); + } + + var composed = ComposeBoundedPrompt(instruction, text); + var key = BuildCacheKey($"rewrite::{composed}", normalizedMaxLength, normalizedMinLength); + Interlocked.Increment(ref _requests); + + if (_cache.TryGetValue(key, out var cached)) + { + Interlocked.Increment(ref _cacheHits); + lock (_metricsLock) + { + _lastSuccessAt = DateTimeOffset.UtcNow; + _lastError = null; + } + return cached; + } + + Interlocked.Increment(ref _cacheMisses); + + var client = _httpFactory.CreateClient("ai-service"); + var payload = JsonSerializer.Serialize(new + { + instruction, + text, + max_length = normalizedMaxLength, + min_length = normalizedMinLength, + }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var sw = Stopwatch.StartNew(); + + try + { + var res = await client.PostAsync("/cv/rewrite", content); + sw.Stop(); + Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks); + if (!res.IsSuccessStatusCode) + { + var errorBody = await ReadErrorBodyAsync(res); + Interlocked.Increment(ref _failures); + lock (_metricsLock) + { + _lastFailureAt = DateTimeOffset.UtcNow; + _lastError = $"AI rewrite failed: {errorBody}"; + } + return null; + } + + using var stream = await res.Content.ReadAsStreamAsync(); + using var doc = await JsonDocument.ParseAsync(stream); + if (doc.RootElement.TryGetProperty("rewritten_text", out var el)) + { + var s = el.GetString(); + if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6)); + lock (_metricsLock) + { + _lastSuccessAt = DateTimeOffset.UtcNow; + _lastError = null; + } + return s; + } + + lock (_metricsLock) + { + _lastFailureAt = DateTimeOffset.UtcNow; + _lastError = "AI rewrite failed: response did not contain rewritten_text."; + } + return null; + } + catch (Exception ex) + { + sw.Stop(); + Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks); + Interlocked.Increment(ref _failures); + lock (_metricsLock) + { + _lastFailureAt = DateTimeOffset.UtcNow; + _lastError = ex.Message; + } + return null; + } + } + private async Task SummarizeCoreAsync(string text, int maxLength, int minLength) { var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength); diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index db72109..81215a4 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -86,6 +86,13 @@ class SummarizeRequest(BaseModel): top_skills: int = Field(default=8, ge=3, le=12) +class RewriteRequest(BaseModel): + instruction: str = Field(min_length=1, max_length=6000) + text: str = Field(min_length=1, max_length=MAX_INPUT_CHARS) + max_length: int = Field(default=220, ge=24, le=256) + min_length: int = Field(default=80, ge=8, le=180) + + class CvNormalizeRequest(BaseModel): text: str = Field(min_length=1, max_length=50000) @@ -424,6 +431,39 @@ def _ollama_generate_json(prompt: str): raise HTTPException(status_code=502, detail="Ollama did not return valid JSON.") +def _ollama_generate_text(prompt: str) -> str: + if not OLLAMA_MODEL: + raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.") + + payload = json.dumps({ + "model": OLLAMA_MODEL, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.2} + }).encode("utf-8") + + req = urllib_request.Request( + f"{OLLAMA_BASE_URL}/api/generate", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + with urllib_request.urlopen(req, timeout=180) as response: + body = json.loads(response.read().decode("utf-8")) + except HTTPError as ex: + raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.") + except URLError as ex: + raise HTTPException(status_code=503, detail=f"Ollama is unreachable: {ex.reason}.") + + raw = (body.get("response") or "").strip() + if not raw: + raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.") + + return raw + + @app.post("/cv/normalize") async def normalize_cv(req: CvNormalizeRequest): prompt = f""" @@ -536,6 +576,49 @@ Block: } +@app.post("/cv/rewrite") +async def rewrite_cv(req: RewriteRequest): + prompt = f""" +You are an expert CV and resume writer. +Rewrite the candidate CV into a polished, factual CV tailored to the target role. +Return ONLY the final CV text. No analysis. No commentary. No JSON. No markdown code fences. No recruiter notes. + +Non-negotiable rules: +- Preserve facts only. Never invent employers, dates, locations, salaries, education, qualifications, technologies, metrics, or achievements. +- Never output sections like 'Role summary', 'What the company wants most', 'Keywords to mirror', 'Interview focus', 'Top hard skills', or similar analysis headings. +- Do not describe the job ad. Rewrite the candidate CV. +- Use crisp CV language, not prose about what the company wants. +- Keep the output directly usable as a CV. +- If rewriting the whole CV, output a complete CV with sensible headings and bullets. +- If rewriting only one section, return only that rewritten section. +- Keep bullets concrete and concise. +- If a fact is not present in the source CV, omit it. + +Preferred whole-CV structure when the source supports it: +# Contact +# Professional Summary +# Work Experience +# Education +# Skills +# Certifications +# Projects +# Languages +# Interests + +Instruction: +{req.instruction.strip()} + +Candidate source CV: +{req.text.strip()} +""".strip() + + rewritten = _ollama_generate_text(prompt).strip() + if not rewritten: + raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.") + + return {"rewritten_text": rewritten} + + @app.post("/summarize") async def summarize(req: SummarizeRequest): if req.min_length >= req.max_length: diff --git a/tools/summarizer/tests/test_app.py b/tools/summarizer/tests/test_app.py index 8e3b730..fbfb1ae 100644 --- a/tools/summarizer/tests/test_app.py +++ b/tools/summarizer/tests/test_app.py @@ -76,6 +76,24 @@ def test_health_reports_ollama_unreachable_when_configured_but_not_available(mon assert payload["ollama_model_available"] is False +def test_rewrite_cv_returns_plain_rewritten_text(monkeypatch): + module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b") + monkeypatch.setattr(module, "_ollama_generate_text", lambda prompt: "# Professional Summary\nBuilt resilient backend systems.\n\n# Skills\n- C#\n- .NET") + client = TestClient(module.app) + + response = client.post("/cv/rewrite", json={ + "instruction": "Rewrite this CV into a cleaner master CV.", + "text": "Professional Summary\nBuilt backend systems.", + "max_length": 220, + "min_length": 80, + }) + + assert response.status_code == 200 + payload = response.json() + assert payload["rewritten_text"].startswith("# Professional Summary") + assert "Role summary:" not in payload["rewritten_text"] + + def test_classify_block_returns_structured_json(monkeypatch): module = load_app_module(monkeypatch)