Use Ollama rewrite path for CV generation
This commit is contained in:
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user