Use Ollama rewrite path for CV generation

This commit is contained in:
2026-04-11 22:26:03 +02:00
parent 591c9b8a64
commit 54abc9f546
4 changed files with 223 additions and 5 deletions
+32 -3
View File
@@ -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<IHttpClientFactory>();
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<HttpResponseMessage> 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")
};
}
}
+90 -2
View File
@@ -153,8 +153,7 @@ namespace JobTrackerApi.Services
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
{
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(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<string?> 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<string>(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<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
{
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
+83
View File
@@ -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:
+18
View File
@@ -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)