Use Ollama rewrite path for CV generation
This commit is contained in:
@@ -11,7 +11,7 @@ namespace JobTrackerApi.Tests;
|
|||||||
public sealed class SummarizerServiceTests
|
public sealed class SummarizerServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[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 handler = new CapturingHandler();
|
||||||
var httpClient = new HttpClient(handler)
|
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);
|
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.NotNull(handler.LastBody);
|
||||||
Assert.Contains("\"max_length\":256", handler.LastBody);
|
Assert.Contains("\"max_length\":256", handler.LastBody);
|
||||||
Assert.Contains("\"min_length\":180", handler.LastBody);
|
Assert.Contains("\"min_length\":180", handler.LastBody);
|
||||||
@@ -36,13 +60,18 @@ public sealed class SummarizerServiceTests
|
|||||||
private sealed class CapturingHandler : HttpMessageHandler
|
private sealed class CapturingHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
public string? LastBody { get; private set; }
|
public string? LastBody { get; private set; }
|
||||||
|
public string? LastPath { get; private set; }
|
||||||
|
|
||||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
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);
|
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)
|
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)
|
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);
|
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(null);
|
||||||
var composed = ComposeBoundedPrompt(instruction.Trim(), text.Trim());
|
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength);
|
||||||
return SummarizeCoreAsync(composed, maxLength, minLength);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComposeBoundedPrompt(string instruction, string text)
|
private static string ComposeBoundedPrompt(string instruction, string text)
|
||||||
@@ -174,6 +173,95 @@ namespace JobTrackerApi.Services
|
|||||||
return prefix + text[..remaining];
|
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)
|
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
|
||||||
{
|
{
|
||||||
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
|
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ class SummarizeRequest(BaseModel):
|
|||||||
top_skills: int = Field(default=8, ge=3, le=12)
|
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):
|
class CvNormalizeRequest(BaseModel):
|
||||||
text: str = Field(min_length=1, max_length=50000)
|
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.")
|
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")
|
@app.post("/cv/normalize")
|
||||||
async def normalize_cv(req: CvNormalizeRequest):
|
async def normalize_cv(req: CvNormalizeRequest):
|
||||||
prompt = f"""
|
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")
|
@app.post("/summarize")
|
||||||
async def summarize(req: SummarizeRequest):
|
async def summarize(req: SummarizeRequest):
|
||||||
if req.min_length >= req.max_length:
|
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
|
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):
|
def test_classify_block_returns_structured_json(monkeypatch):
|
||||||
module = load_app_module(monkeypatch)
|
module = load_app_module(monkeypatch)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user