feat(ai): prompt-injection delimiters + synonym-aware match scoring
Wave 4 hardening. Wrap untrusted CV/job-description/instruction text in tools/summarizer prompts with explicit delimiters and an ignore-embedded-instructions rule, since JD text, recruiter emails, and free-text candidate background all flow into rewrite/normalize prompts unescaped today. Match score previously normalized synonyms (JS/Kubernetes/K8s/etc) only when scanning the job posting, not when checking the CV corpus, so a CV using an abbreviation the job spelled out never matched. SkillTagger.MatchesTag reuses the same synonym regex for both sides.
This commit is contained in:
@@ -5,8 +5,9 @@ using JobTrackerApi.Services.JobImport;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
/// <summary>One keyword drawn from the job posting and whether the CV covers it.</summary>
|
||||
public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched);
|
||||
/// <summary>One keyword drawn from the job posting and whether the CV covers it.
|
||||
/// IsCuratedTag marks keywords sourced from SkillTagger, whose synonym regex is reused for CV matching.</summary>
|
||||
public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched, bool IsCuratedTag = false);
|
||||
|
||||
/// <summary>How many of the matched keywords appear in a given CV section.</summary>
|
||||
public sealed record MatchSectionCoverage(string Section, int Matched, int Total);
|
||||
@@ -80,8 +81,17 @@ namespace JobTrackerApi.Services
|
||||
.ToDictionary(kvp => kvp.Key, kvp => Normalize(kvp.Value), StringComparer.OrdinalIgnoreCase);
|
||||
var fullCorpus = string.Join(" \n ", sectionCorpora.Values);
|
||||
|
||||
// Raw (non-normalized) text for curated tags, whose synonym regex needs real word boundaries/punctuation.
|
||||
var rawSections = cvSections
|
||||
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
|
||||
var rawCorpus = string.Join(" \n ", rawSections.Values);
|
||||
|
||||
var evaluated = keywords
|
||||
.Select(k => k with { Matched = CorpusContains(fullCorpus, k.Keyword) })
|
||||
.Select(k => k with
|
||||
{
|
||||
Matched = k.IsCuratedTag ? SkillTagger.MatchesTag(k.Keyword, rawCorpus) : CorpusContains(fullCorpus, k.Keyword),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var totalWeight = evaluated.Sum(k => k.Weight);
|
||||
@@ -103,7 +113,9 @@ namespace JobTrackerApi.Services
|
||||
var sectionCoverage = sectionCorpora
|
||||
.Select(section => new MatchSectionCoverage(
|
||||
section.Key,
|
||||
evaluated.Count(k => CorpusContains(section.Value, k.Keyword)),
|
||||
evaluated.Count(k => k.IsCuratedTag
|
||||
? SkillTagger.MatchesTag(k.Keyword, rawSections.GetValueOrDefault(section.Key))
|
||||
: CorpusContains(section.Value, k.Keyword)),
|
||||
evaluated.Count))
|
||||
.Where(sc => sc.Total > 0)
|
||||
.OrderByDescending(sc => sc.Matched)
|
||||
@@ -129,7 +141,7 @@ namespace JobTrackerApi.Services
|
||||
foreach (var tag in SkillTagger.Detect(combined))
|
||||
{
|
||||
var inTitle = TitleContains(jobTitle, tag);
|
||||
byKey[tag] = new MatchKeyword(tag, CuratedTagWeight + (inTitle ? TitleBonus : 0), inTitle, false);
|
||||
byKey[tag] = new MatchKeyword(tag, CuratedTagWeight + (inTitle ? TitleBonus : 0), inTitle, false, IsCuratedTag: true);
|
||||
}
|
||||
|
||||
// 2) Salient posting terms: frequency-ranked content words from the description.
|
||||
|
||||
Reference in New Issue
Block a user