Extend CV classifier contract and provenance UI
This commit is contained in:
@@ -595,10 +595,10 @@ public sealed class ProfileCvControllerTests
|
|||||||
var classifier = new Mock<ICvAiClassifier>();
|
var classifier = new Mock<ICvAiClassifier>();
|
||||||
classifier
|
classifier
|
||||||
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Atlas Systems", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
|
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Atlas Systems", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new CvBlockClassificationResult("Work Experience", 0.93, "job block", "Senior Platform Engineer", "Atlas Systems", "Oslo", "2019", "Present", new List<string> { "Built event-driven APIs and migration tooling." }));
|
.ReturnsAsync(new CvBlockClassificationResult("Work Experience", 0.93, "job block", "Senior Platform Engineer", "Atlas Systems", "Oslo", "2019", "Present", new List<string> { "Built event-driven APIs and migration tooling." }, null, new List<string> { "Python", "SQL" }));
|
||||||
classifier
|
classifier
|
||||||
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Python", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
|
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Python", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new CvBlockClassificationResult("Skills", 0.88, "skills block", null, null, null, null, null, new List<string>()));
|
.ReturnsAsync(new CvBlockClassificationResult("Skills", 0.88, "skills block", null, null, null, null, null, new List<string>(), null, new List<string> { "Python", "SQL", "Azure" }));
|
||||||
|
|
||||||
await using var db = CreateDb();
|
await using var db = CreateDb();
|
||||||
var paths = CreatePaths();
|
var paths = CreatePaths();
|
||||||
@@ -638,7 +638,7 @@ public sealed class ProfileCvControllerTests
|
|||||||
var classifier = new Mock<ICvAiClassifier>();
|
var classifier = new Mock<ICvAiClassifier>();
|
||||||
classifier
|
classifier
|
||||||
.Setup(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
.Setup(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new CvBlockClassificationResult("Education", 0.87, "education block", "BSc Computer Science", "University of Oslo", "Oslo", "2016", "2019", new List<string> { "Graduated with focus on distributed systems." }));
|
.ReturnsAsync(new CvBlockClassificationResult("Education", 0.87, "education block", "BSc Computer Science", "University of Oslo", "Oslo", "2016", "2019", new List<string> { "Graduated with focus on distributed systems." }, null, null));
|
||||||
|
|
||||||
await using var db = CreateDb();
|
await using var db = CreateDb();
|
||||||
var paths = CreatePaths();
|
var paths = CreatePaths();
|
||||||
|
|||||||
@@ -934,14 +934,18 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
switch (block.SectionName)
|
switch (block.SectionName)
|
||||||
{
|
{
|
||||||
case "Professional Summary":
|
case "Professional Summary":
|
||||||
foreach (var item in SplitClassifierContent(block.Content, 5))
|
foreach (var item in (block.Classification?.Summary is { Count: > 0 }
|
||||||
|
? block.Classification.Summary
|
||||||
|
: SplitClassifierContent(block.Content, 5)))
|
||||||
{
|
{
|
||||||
summary.Add(item);
|
summary.Add(item);
|
||||||
}
|
}
|
||||||
ApplyClassifierFieldMetadata(profile, "summary", summary.FirstOrDefault(), block, now);
|
ApplyClassifierFieldMetadata(profile, "summary", summary.FirstOrDefault(), block, now);
|
||||||
break;
|
break;
|
||||||
case "Skills":
|
case "Skills":
|
||||||
foreach (var item in SplitClassifierSkills(block.Content))
|
foreach (var item in (block.Classification?.Skills is { Count: > 0 }
|
||||||
|
? block.Classification.Skills.Where(skill => !string.IsNullOrWhiteSpace(skill)).Select(skill => skill.Trim())
|
||||||
|
: SplitClassifierSkills(block.Content)))
|
||||||
{
|
{
|
||||||
skills.Add(item);
|
skills.Add(item);
|
||||||
}
|
}
|
||||||
@@ -1013,7 +1017,9 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
End = NullIfWhitespace(classification.End),
|
End = NullIfWhitespace(classification.End),
|
||||||
IsCurrent = string.Equals(classification.End, "Present", StringComparison.OrdinalIgnoreCase) || string.Equals(classification.End, "Current", StringComparison.OrdinalIgnoreCase),
|
IsCurrent = string.Equals(classification.End, "Present", StringComparison.OrdinalIgnoreCase) || string.Equals(classification.End, "Current", StringComparison.OrdinalIgnoreCase),
|
||||||
Bullets = bullets,
|
Bullets = bullets,
|
||||||
Skills = SplitClassifierSkills(block.OriginalBlock)
|
Skills = classification.Skills is { Count: > 0 }
|
||||||
|
? classification.Skills.Where(skill => !string.IsNullOrWhiteSpace(skill)).Select(skill => skill.Trim()).ToList()
|
||||||
|
: SplitClassifierSkills(block.OriginalBlock)
|
||||||
};
|
};
|
||||||
|
|
||||||
return StructuredCvProfileJson.Normalize(new StructuredCvProfile { Jobs = new List<StructuredCvJob> { job } }).Jobs.FirstOrDefault();
|
return StructuredCvProfileJson.Normalize(new StructuredCvProfile { Jobs = new List<StructuredCvJob> { job } }).Jobs.FirstOrDefault();
|
||||||
@@ -1140,12 +1146,20 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
}
|
}
|
||||||
else if (string.Equals(sectionName, "Skills", StringComparison.OrdinalIgnoreCase))
|
else if (string.Equals(sectionName, "Skills", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var items = SplitClassifierSkills(block);
|
var items = classification?.Skills is { Count: > 0 }
|
||||||
|
? classification.Skills.Where(skill => !string.IsNullOrWhiteSpace(skill)).Select(skill => skill.Trim()).ToList()
|
||||||
|
: SplitClassifierSkills(block);
|
||||||
if (items.Count > 0) content = string.Join("\n", items);
|
if (items.Count > 0) content = string.Join("\n", items);
|
||||||
}
|
}
|
||||||
else if (string.Equals(sectionName, "Professional Summary", StringComparison.OrdinalIgnoreCase) && classification?.Bullets is { Count: > 0 })
|
else if (string.Equals(sectionName, "Professional Summary", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
content = string.Join("\n", classification.Bullets.Where(bullet => !string.IsNullOrWhiteSpace(bullet)).Select(bullet => $"- {bullet.Trim()}"));
|
var items = classification?.Summary is { Count: > 0 }
|
||||||
|
? classification.Summary.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => $"- {line.Trim()}")
|
||||||
|
: classification?.Bullets is { Count: > 0 }
|
||||||
|
? classification.Bullets.Where(bullet => !string.IsNullOrWhiteSpace(bullet)).Select(bullet => $"- {bullet.Trim()}")
|
||||||
|
: Enumerable.Empty<string>();
|
||||||
|
var materialized = items.ToList();
|
||||||
|
if (materialized.Count > 0) content = string.Join("\n", materialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
results.Add(new ClassifiedCvBlock(index + 1, block, sectionName, content, classification));
|
results.Add(new ClassifiedCvBlock(index + 1, block, sectionName, content, classification));
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ public sealed record CvBlockClassificationResult(
|
|||||||
string? Location,
|
string? Location,
|
||||||
string? Start,
|
string? Start,
|
||||||
string? End,
|
string? End,
|
||||||
List<string>? Bullets);
|
List<string>? Bullets,
|
||||||
|
List<string>? Summary,
|
||||||
|
List<string>? Skills);
|
||||||
|
|
||||||
public interface ICvAiClassifier
|
public interface ICvAiClassifier
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata })
|
|||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
|
||||||
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
|
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
|
||||||
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
|
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
|
||||||
|
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
|
||||||
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
|
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
|
||||||
{metadata.sourceSnippet ? (
|
{metadata.sourceSnippet ? (
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ const structuredCv = {
|
|||||||
appliedExtractionRunId: 12,
|
appliedExtractionRunId: 12,
|
||||||
updatedAtUtc: '2026-03-28T12:00:00Z',
|
updatedAtUtc: '2026-03-28T12:00:00Z',
|
||||||
fields: {
|
fields: {
|
||||||
'contact.fullName': { confidence: 0.92, method: 'llm', reviewState: 'suggested', sourceSnippet: 'Demo User' },
|
'contact.fullName': { confidence: 0.92, method: 'llm', sourceBlockId: 'block-1', reviewState: 'suggested', sourceSnippet: 'Demo User' },
|
||||||
summary: { confidence: 0.71, method: 'deterministic', reviewState: 'suggested', sourceSnippet: 'Built backend systems' },
|
summary: { confidence: 0.71, method: 'deterministic', sourceBlockId: 'block-2', reviewState: 'suggested', sourceSnippet: 'Built backend systems' },
|
||||||
skills: { confidence: 0.68, method: 'deterministic', reviewState: 'suggested', sourceSnippet: '.NET' },
|
skills: { confidence: 0.68, method: 'deterministic', sourceBlockId: 'block-3', reviewState: 'suggested', sourceSnippet: '.NET' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
@@ -153,6 +153,7 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
|
|||||||
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
||||||
expect(screen.getByText(/high 92%/i)).toBeInTheDocument();
|
expect(screen.getByText(/high 92%/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/block-1/i)).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(originalExtractionToggle);
|
fireEvent.click(originalExtractionToggle);
|
||||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|||||||
+12
-4
@@ -376,18 +376,24 @@ Return ONLY valid JSON with this exact shape:
|
|||||||
"location": string|null,
|
"location": string|null,
|
||||||
"start": string|null,
|
"start": string|null,
|
||||||
"end": string|null,
|
"end": string|null,
|
||||||
"bullets": string[]
|
"bullets": string[],
|
||||||
|
"summary": string[],
|
||||||
|
"skills": string[]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Preserve facts only.
|
- Preserve facts only.
|
||||||
- section must be one of the listed values.
|
- section must be one of the listed values.
|
||||||
- Use Work Experience only for job/employment blocks.
|
- Use Work Experience only for job/employment blocks.
|
||||||
- For Contact blocks, keep title/company/start/end null and bullets empty.
|
- Use Education only for degree/course/certification blocks.
|
||||||
- For non-work blocks, title/company/start/end should usually be null.
|
- For Contact blocks, keep title/company/start/end null and bullets/summary/skills empty.
|
||||||
|
- For Professional Summary blocks, prefer summary for concise summary lines and keep bullets empty unless the source is already bullet-like.
|
||||||
|
- For Skills blocks, prefer skills for normalized skill items and keep title/company/start/end null.
|
||||||
|
- For non-work and non-education blocks, title/company/start/end should usually be null.
|
||||||
- location must look like a place, not a sentence.
|
- location must look like a place, not a sentence.
|
||||||
- dates must be one of: year, month+year, dd/mm/yyyy, Present, Current.
|
- dates must be one of: year, month+year, dd/mm/yyyy, Present, Current.
|
||||||
- bullets should only be job tasks/achievements, not titles, companies, dates, or headings.
|
- bullets should only be concrete tasks/achievements/details, not titles, companies, dates, or headings.
|
||||||
|
- skills should be short normalized skill/tool terms, not sentences.
|
||||||
- If unsure, choose Other and keep fields null/empty.
|
- If unsure, choose Other and keep fields null/empty.
|
||||||
|
|
||||||
Block:
|
Block:
|
||||||
@@ -405,6 +411,8 @@ Block:
|
|||||||
"start": parsed.get("start"),
|
"start": parsed.get("start"),
|
||||||
"end": parsed.get("end"),
|
"end": parsed.get("end"),
|
||||||
"bullets": parsed.get("bullets") or [],
|
"bullets": parsed.get("bullets") or [],
|
||||||
|
"summary": parsed.get("summary") or [],
|
||||||
|
"skills": parsed.get("skills") or [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ def test_classify_block_returns_structured_json(monkeypatch):
|
|||||||
"start": "2019",
|
"start": "2019",
|
||||||
"end": "Present",
|
"end": "Present",
|
||||||
"bullets": ["Built event-driven APIs and migration tooling."],
|
"bullets": ["Built event-driven APIs and migration tooling."],
|
||||||
|
"summary": [],
|
||||||
|
"skills": ["Python", "SQL"],
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json)
|
monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json)
|
||||||
@@ -62,6 +64,8 @@ def test_classify_block_returns_structured_json(monkeypatch):
|
|||||||
assert payload["title"] == "Senior Platform Engineer"
|
assert payload["title"] == "Senior Platform Engineer"
|
||||||
assert payload["company"] == "Atlas Systems"
|
assert payload["company"] == "Atlas Systems"
|
||||||
assert payload["bullets"] == ["Built event-driven APIs and migration tooling."]
|
assert payload["bullets"] == ["Built event-driven APIs and migration tooling."]
|
||||||
|
assert payload["summary"] == []
|
||||||
|
assert payload["skills"] == ["Python", "SQL"]
|
||||||
|
|
||||||
|
|
||||||
def test_classify_block_defaults_missing_section_to_other(monkeypatch):
|
def test_classify_block_defaults_missing_section_to_other(monkeypatch):
|
||||||
@@ -75,3 +79,5 @@ def test_classify_block_defaults_missing_section_to_other(monkeypatch):
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload["section"] == "Other"
|
assert payload["section"] == "Other"
|
||||||
assert payload["bullets"] == []
|
assert payload["bullets"] == []
|
||||||
|
assert payload["summary"] == []
|
||||||
|
assert payload["skills"] == []
|
||||||
|
|||||||
Reference in New Issue
Block a user