feat(cv): expand builder studio tools

This commit is contained in:
cesnimda
2026-08-29 01:45:30 +02:00
parent f794265e3e
commit 0f17d95c57
10 changed files with 434 additions and 47 deletions
@@ -224,9 +224,11 @@ public sealed class CvVariantController : ControllerBase
if (user is null) return Unauthorized();
var text = (request.Text ?? string.Empty).Trim();
if (text.Length == 0) return BadRequest("Provide text to work on.");
if (text.Length > 60_000) return BadRequest("The requested CV content is too large to process in one operation.");
var instruction = BuildAiInstruction(request.Action, request.Role, request.Language, request.Context);
var result = await _ai.SummarizeSectionAsync(instruction, text, 1200, 300);
var isDocumentAction = (request.Action ?? string.Empty).StartsWith("document-", StringComparison.OrdinalIgnoreCase);
var result = await _ai.SummarizeSectionAsync(instruction, text, isDocumentAction ? 16_000 : 1200, isDocumentAction ? 50 : 300);
if (string.IsNullOrWhiteSpace(result))
{
var metrics = await _ai.GetMetricsAsync(ct);
@@ -239,11 +241,15 @@ public sealed class CvVariantController : ControllerBase
private static string BuildAiInstruction(string? action, string? role, string? language, string? context)
{
var normalizedAction = (action ?? "improve").Trim().ToLowerInvariant();
var target = string.IsNullOrWhiteSpace(role) ? null : role.Trim();
var lang = string.IsNullOrWhiteSpace(language) ? "the same language as the input" : language.Trim();
var extra = string.IsNullOrWhiteSpace(context) ? string.Empty : $" Context: {context.Trim()}.";
var task = (action ?? "improve").Trim().ToLowerInvariant() switch
var task = normalizedAction switch
{
"document-translate" => "Translate every user-facing CV value into the requested language. Return only valid JSON with the exact same array structure, ids and fields. Preserve names, employers, dates, qualifications, URLs and technical terms unless they have a standard translation.",
"document-improve" => "Improve clarity, concision and professional impact across the CV. Return only valid JSON with the exact same array structure, ids and fields. Preserve every fact and never invent achievements, metrics, skills or experience.",
"document-grammar" => "Correct spelling, grammar and punctuation across the CV without changing meaning. Return only valid JSON with the exact same array structure, ids and fields.",
"professional" => "Rewrite the text in a more professional, confident tone.",
"shorten" => "Make the text more concise without losing meaning.",
"expand" => "Expand the text with more concrete, relevant detail — but never invent facts.",
@@ -256,7 +262,10 @@ public sealed class CvVariantController : ControllerBase
"tailor" => $"Rewrite the text to align with the target role{(target is null ? string.Empty : $" '{target}'")}, emphasising the most relevant experience. Do not invent facts.",
_ => "Improve the clarity, impact and phrasing of the text while preserving all facts.",
};
return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. Return only the rewritten text with no preamble.{extra}";
var outputInstruction = normalizedAction.StartsWith("document-", StringComparison.Ordinal)
? "Return only the JSON array with no Markdown fence or preamble."
: "Return only the rewritten text with no preamble.";
return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. {outputInstruction}{extra}";
}
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
@@ -66,6 +66,10 @@ public sealed class CvSectionSetting
// Optional CV-specific content for bullet/tag sections. Null uses master data; an empty list is
// an intentional empty override. Entry sections continue to use stable per-item overrides.
public List<string>? Items { get; set; }
// Optional presentation hint consumed by the shared renderer, so preview and PDF always agree.
// Unsupported combinations safely fall back to the normal row layout.
public string? Presentation { get; set; } // rows | grid | compact | bubble
public int? Columns { get; set; } // 1 | 2
}
public sealed class CvItemOverride
@@ -167,6 +171,11 @@ public static class CvVariantSettingsJson
Items = group.Items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => item.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Take(100).ToList(),
})
.ToList();
foreach (var section in s.Sections)
{
section.Presentation = NormalizeChoice(section.Presentation, "rows", "grid", "compact", "bubble");
section.Columns = section.Columns is 1 or 2 ? section.Columns : null;
}
foreach (var section in s.CustomSections)
{
section.ContentType = NormalizeChoice(section.ContentType, "paragraphs", "bullets", "entries") ?? "bullets";
+4
View File
@@ -31,6 +31,8 @@ public sealed class CvRenderSection
public List<string> Tags { get; set; } = new();
public List<CvRenderEntry> Entries { get; set; } = new();
public List<CvRenderSkillGroup> SkillGroups { get; set; } = new();
public string? Presentation { get; set; }
public int Columns { get; set; } = 1;
public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0 && SkillGroups.Count == 0;
}
@@ -158,6 +160,8 @@ public static class CvVariantResolver
{
section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder);
}
section.Presentation = cfg.Presentation;
section.Columns = cfg.Columns is 2 ? 2 : 1;
}
else if (customHiddenByKey.TryGetValue(key, out var customHidden) && customHidden)
{
+12 -1
View File
@@ -141,7 +141,9 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
_ => string.Join("", section.Entries.Select(RenderEntry)),
};
var flowClass = IsFlowingSection(section) ? " section-flow" : string.Empty;
return $@"<section class=""section section-{Attr(section.Key)}{flowClass}""><h2 class=""section-title""><span class=""section-title-text"">{Enc(section.Title)}</span></h2>{inner}</section>";
var presentation = section.Presentation is "grid" or "compact" or "bubble" ? section.Presentation : "rows";
var columns = presentation == "grid" && section.Columns == 2 ? 2 : 1;
return $@"<section class=""section section-{Attr(section.Key)}{flowClass} section-presentation-{presentation}"" style=""--cv-section-columns:{columns}""><h2 class=""section-title""><span class=""section-title-text"">{Enc(section.Title)}</span></h2>{inner}</section>";
}
private static string RenderSkillGroup(CvRenderSkillGroup group)
@@ -219,6 +221,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var margin = F((settings.PageMarginMm ?? t.PageMarginMm) * density);
var sectionGap = F((settings.SectionGapMm ?? t.SectionGapMm) * density);
var entryGap = F((settings.EntryGapMm ?? t.EntryGapMm) * density);
var compactEntryGap = F(Math.Max(1.5, (settings.EntryGapMm ?? t.EntryGapMm) * density * .55));
var bodySize = settings.BaseFontSizePt ?? t.BodySizePt;
var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt;
var lineHeight = settings.LineHeight ?? t.LineHeight;
@@ -283,6 +286,14 @@ h1,h2{{font-family:{headingFont};}}
.section:first-child{{margin-top:0;}}
.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(headingSize)}pt;font-weight:700;color:{headingColor};}}
{headingCss}
.section-presentation-grid{{display:grid;grid-template-columns:repeat(var(--cv-section-columns,1),minmax(0,1fr));column-gap:4mm;align-items:start;}}
.section-presentation-grid>.section-title{{grid-column:1/-1;}}
.section-presentation-grid>.entry{{min-width:0;}}
.section-presentation-compact>.entry{{margin-bottom:{compactEntryGap}mm;}}
.section-presentation-compact>.bullets li{{margin-bottom:{F(.75 * density)}mm;}}
.section-presentation-bubble>.tags,.section-presentation-bubble>.bullets{{list-style:none;padding:0;display:flex;flex-wrap:wrap;gap:1.4mm;}}
.section-presentation-bubble>.bullets li,.section-presentation-bubble>.tags li{{margin:0;border:1px solid var(--cv-accent-soft);border-radius:999px;padding:.7mm 2.2mm;}}
.section-presentation-bubble>.bullets li::marker{{content:"";}}
.bullets{{margin:0;padding-left:4.5mm;}}
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
.bullets li::marker{{color:var(--cv-accent-color);}}