Add configurable evidence guard for resume tailoring

This commit is contained in:
2026-08-02 23:10:13 +03:30
parent 1c75afe0fd
commit a7a02ef141
12 changed files with 664 additions and 37 deletions
+14
View File
@@ -84,6 +84,20 @@ to the source wording and organization. At `100`, it uses the fullest supported
and strongest truthful job alignment. The slider never relaxes evidence validation; and strongest truthful job alignment. The slider never relaxes evidence validation;
unsupported requirements remain gaps or follow-up questions rather than resume claims. unsupported requirements remain gaps or follow-up questions rather than resume claims.
Below the slider, **Tailoring configuration** can switch between:
- **Strict source evidence:** every candidate-facing claim must map to one or more
evidence IDs extracted from the source resume.
- **Flexible profile-based rewrite:** the complete profile and original resume remain
the factual source, but claim-level evidence IDs are optional. This mode still tells
the model not to invent employers, dates, skills, metrics, or achievements.
Use the **Evidence guard** control in Signal's header or the Oh My CV AI dialog to
disable claim-level evidence enforcement across the entire app. The setting is persisted
in `.resume-agent/settings.json` and applies to tailoring and revision chat. Turning it
off forces flexible profile mode everywhere; the supplied-profile truth and no-invention
rules remain active.
After tailoring in the main Signal interface, use **Open in Oh My CV** to create a new After tailoring in the main Signal interface, use **Open in Oh My CV** to create a new
local Oh My CV resume from the optimized Markdown and open it directly in the editor. local Oh My CV resume from the optimized Markdown and open it directly in the editor.
+61 -12
View File
@@ -66,36 +66,40 @@ def tailor_resume(
profile: CareerProfile, profile: CareerProfile,
job_text: str, job_text: str,
tailoring_strength: int = 50, tailoring_strength: int = 50,
evidence_mode: str = "strict",
) -> TailoringPackage: ) -> TailoringPackage:
if not 0 <= tailoring_strength <= 100: if not 0 <= tailoring_strength <= 100:
raise ValueError("Tailoring strength must be between 0 and 100.") raise ValueError("Tailoring strength must be between 0 and 100.")
_validate_evidence_mode(evidence_mode)
payload = { payload = {
"canonical_profile": profile.model_dump(mode="json"), "canonical_profile": profile.model_dump(mode="json"),
"job_post": job_text, "job_post": job_text,
"tailoring_strength": tailoring_strength, "tailoring_strength": tailoring_strength,
"evidence_mode": evidence_mode,
} }
draft = llm.parse( draft = llm.parse(
TailoringPackage, TailoringPackage,
_tailoring_prompt(tailoring_strength), _tailoring_prompt(tailoring_strength, evidence_mode),
json.dumps(payload, ensure_ascii=False), json.dumps(payload, ensure_ascii=False),
) )
validate_package(profile, draft) validate_package(profile, draft, require_evidence=evidence_mode == "strict")
audit_payload = { audit_payload = {
"canonical_profile": profile.model_dump(mode="json"), "canonical_profile": profile.model_dump(mode="json"),
"proposed_package": draft.model_dump(mode="json"), "proposed_package": draft.model_dump(mode="json"),
"tailoring_strength": tailoring_strength, "tailoring_strength": tailoring_strength,
"evidence_mode": evidence_mode,
} }
audited = llm.parse( audited = llm.parse(
TailoringPackage, TailoringPackage,
AUDIT_PROMPT, _audit_prompt(evidence_mode),
json.dumps(audit_payload, ensure_ascii=False), json.dumps(audit_payload, ensure_ascii=False),
) )
validate_package(profile, audited) validate_package(profile, audited, require_evidence=evidence_mode == "strict")
return audited return audited
def _tailoring_prompt(strength: int) -> str: def _tailoring_prompt(strength: int, evidence_mode: str) -> str:
if strength <= 20: if strength <= 20:
guidance = ( guidance = (
"Stay very close to the source wording and organization. Make only small " "Stay very close to the source wording and organization. Make only small "
@@ -116,45 +120,84 @@ def _tailoring_prompt(strength: int) -> str:
f"{TAILOR_PROMPT}\n\n" f"{TAILOR_PROMPT}\n\n"
f"Tailoring strength: {strength}/100.\n" f"Tailoring strength: {strength}/100.\n"
f"{guidance}\n" f"{guidance}\n"
f"{_evidence_guidance(evidence_mode)}\n"
"This setting changes editing intensity only. It never permits fabricated, " "This setting changes editing intensity only. It never permits fabricated, "
"exaggerated, inferred, or unsupported claims." "exaggerated, inferred, or unsupported claims."
) )
def _evidence_guidance(evidence_mode: str) -> str:
if evidence_mode == "strict":
return (
"Evidence mode: strict. Every summary statement and resume item must cite "
"one or more directly supporting profile fact IDs in evidence_ids. Put IDs "
"only in evidence_ids, never in candidate-facing text."
)
return (
"Evidence mode: flexible profile-based rewrite. Use the complete canonical "
"profile as the factual source, but per-claim evidence IDs are optional and "
"evidence_ids may be empty. Do not invent information absent from the profile."
)
def _audit_prompt(evidence_mode: str) -> str:
if evidence_mode == "strict":
evidence_rules = (
"Require every candidate-facing claim to cite directly supporting fact IDs. "
"Reject unknown or mismatched IDs. Keep IDs only in evidence_ids."
)
else:
evidence_rules = (
"Audit claims against the canonical profile as a whole. Per-claim evidence "
"IDs are optional; do not reject an otherwise supported claim solely because "
"evidence_ids is empty."
)
return f"{AUDIT_PROMPT}\n\n{evidence_rules}"
def _validate_evidence_mode(evidence_mode: str) -> None:
if evidence_mode not in {"strict", "profile"}:
raise ValueError("Evidence mode must be either 'strict' or 'profile'.")
def revise_tailored_resume( def revise_tailored_resume(
llm: StructuredLLM, llm: StructuredLLM,
profile: CareerProfile, profile: CareerProfile,
current: TailoringPackage, current: TailoringPackage,
instruction: str, instruction: str,
evidence_mode: str = "strict",
) -> TailoringPackage: ) -> TailoringPackage:
instruction = instruction.strip() instruction = instruction.strip()
if len(instruction) < 3: if len(instruction) < 3:
raise ValueError("Describe how you want the tailored resume revised.") raise ValueError("Describe how you want the tailored resume revised.")
_validate_evidence_mode(evidence_mode)
revision_payload = { revision_payload = {
"canonical_profile": profile.model_dump(mode="json"), "canonical_profile": profile.model_dump(mode="json"),
"current_tailored_package": current.model_dump(mode="json"), "current_tailored_package": current.model_dump(mode="json"),
"candidate_request": instruction, "candidate_request": instruction,
"evidence_mode": evidence_mode,
} }
revised = llm.parse( revised = llm.parse(
TailoringPackage, TailoringPackage,
REVISION_PROMPT, f"{REVISION_PROMPT}\n\n{_evidence_guidance(evidence_mode)}",
json.dumps(revision_payload, ensure_ascii=False), json.dumps(revision_payload, ensure_ascii=False),
) )
_validate_revision(profile, current, revised) _validate_revision(profile, current, revised, evidence_mode)
audit_payload = { audit_payload = {
"canonical_profile": profile.model_dump(mode="json"), "canonical_profile": profile.model_dump(mode="json"),
"original_job_analysis": current.job.model_dump(mode="json"), "original_job_analysis": current.job.model_dump(mode="json"),
"candidate_request": instruction, "candidate_request": instruction,
"proposed_package": revised.model_dump(mode="json"), "proposed_package": revised.model_dump(mode="json"),
"evidence_mode": evidence_mode,
} }
audited = llm.parse( audited = llm.parse(
TailoringPackage, TailoringPackage,
AUDIT_PROMPT, _audit_prompt(evidence_mode),
json.dumps(audit_payload, ensure_ascii=False), json.dumps(audit_payload, ensure_ascii=False),
) )
_validate_revision(profile, current, audited) _validate_revision(profile, current, audited, evidence_mode)
return audited return audited
@@ -162,8 +205,9 @@ def _validate_revision(
profile: CareerProfile, profile: CareerProfile,
current: TailoringPackage, current: TailoringPackage,
revised: TailoringPackage, revised: TailoringPackage,
evidence_mode: str,
) -> None: ) -> None:
validate_package(profile, revised) validate_package(profile, revised, require_evidence=evidence_mode == "strict")
if revised.job != current.job: if revised.job != current.job:
raise ValueError("A resume revision cannot change the original job analysis.") raise ValueError("A resume revision cannot change the original job analysis.")
@@ -178,7 +222,12 @@ def validate_profile(profile: CareerProfile) -> None:
raise ValueError("Every profile fact must include a source excerpt.") raise ValueError("Every profile fact must include a source excerpt.")
def validate_package(profile: CareerProfile, package: TailoringPackage) -> None: def validate_package(
profile: CareerProfile,
package: TailoringPackage,
*,
require_evidence: bool = True,
) -> None:
valid_ids = {fact.id for fact in profile.facts} valid_ids = {fact.id for fact in profile.facts}
backed_items = list(package.resume.summary) backed_items = list(package.resume.summary)
for section in package.resume.sections: for section in package.resume.sections:
@@ -187,7 +236,7 @@ def validate_package(profile: CareerProfile, package: TailoringPackage) -> None:
if not backed_items: if not backed_items:
raise ValueError("The tailored resume contains no evidence-backed content.") raise ValueError("The tailored resume contains no evidence-backed content.")
for item in backed_items: for item in backed_items:
if not item.evidence_ids: if require_evidence and not item.evidence_ids:
raise ValueError(f"Resume claim has no evidence: {item.text}") raise ValueError(f"Resume claim has no evidence: {item.text}")
unknown = set(item.evidence_ids) - valid_ids unknown = set(item.evidence_ids) - valid_ids
if unknown: if unknown:
+13 -1
View File
@@ -136,10 +136,22 @@ def tailor(
help="Truthful tailoring strength: 0 preserves wording; 100 maximizes supported fit.", help="Truthful tailoring strength: 0 preserves wording; 100 maximizes supported fit.",
), ),
] = 50, ] = 50,
evidence_mode: Annotated[
str,
typer.Option(
help="Evidence mode: strict claim-level IDs or profile-based flexible rewriting.",
),
] = "strict",
) -> None: ) -> None:
"""Tailor the resume to a job and run a second factuality audit.""" """Tailor the resume to a job and run a second factuality audit."""
profile = load_profile(profile_path) profile = load_profile(profile_path)
package = tailor_resume(_llm(model), profile, _read_job(job), strength) package = tailor_resume(
_llm(model),
profile,
_read_job(job),
strength,
evidence_mode,
)
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
save_json(package, out_dir / "tailoring.json") save_json(package, out_dir / "tailoring.json")
(out_dir / "resume.md").write_text(render_resume(package), encoding="utf-8") (out_dir / "resume.md").write_text(render_resume(package), encoding="utf-8")
+1 -9
View File
@@ -36,10 +36,6 @@ Create an ATS-friendly resume tailored to the supplied job using the canonical p
Rules: Rules:
- The canonical profile is the only source of candidate facts. - The canonical profile is the only source of candidate facts.
- Never invent or strengthen a fact, metric, title, date, skill, responsibility, or result. - Never invent or strengthen a fact, metric, title, date, skill, responsibility, or result.
- Every summary statement and resume item must cite one or more supporting fact IDs.
- Evidence IDs must exist in the profile and directly support the exact wording.
- Put evidence IDs only in each item's evidence_ids field. Never write IDs such as
[F013], "Evidence: F013", or similar audit notation inside candidate-facing text.
- Reorder, select, and concisely rewrite facts to emphasize genuine job relevance. - Reorder, select, and concisely rewrite facts to emphasize genuine job relevance.
- Use job-post terminology only when the profile proves the corresponding capability. - Use job-post terminology only when the profile proves the corresponding capability.
- Preserve the candidate's contact information exactly. - Preserve the candidate's contact information exactly.
@@ -59,9 +55,7 @@ AUDIT_PROMPT = """
Audit the proposed tailored resume against the canonical profile. Audit the proposed tailored resume against the canonical profile.
Return a corrected TailoringPackage. Return a corrected TailoringPackage.
- Remove or rewrite every claim not directly supported by its cited fact IDs. - Remove or rewrite claims not supported by the supplied candidate information.
- Reject evidence IDs that do not exist or do not support the exact statement.
- Remove evidence-ID notation from candidate-facing text; IDs belong only in evidence_ids.
- Do not add new candidate facts. - Do not add new candidate facts.
- Preserve useful tailoring when it is truthful. - Preserve useful tailoring when it is truthful.
- Verify that strong_matches, partial_matches, and genuine_gaps account for the job's - Verify that strong_matches, partial_matches, and genuine_gaps account for the job's
@@ -82,8 +76,6 @@ Rules:
- Follow requests about tone, emphasis, ordering, length, clarity, and wording. - Follow requests about tone, emphasis, ordering, length, clarity, and wording.
- Never invent, exaggerate, infer, or strengthen experience, dates, titles, metrics, - Never invent, exaggerate, infer, or strengthen experience, dates, titles, metrics,
education, skills, responsibilities, or results. education, skills, responsibilities, or results.
- Every candidate-facing claim must cite directly supporting IDs in evidence_ids.
- Evidence IDs belong only in evidence_ids, never inside candidate-facing text.
- If the request asks for an unsupported claim, leave it out, record it as a genuine - If the request asks for an unsupported claim, leave it out, record it as a genuine
gap, and explain the limitation in warnings or questions_for_candidate. gap, and explain the limitation in warnings or questions_for_candidate.
- Reassess strong_matches, partial_matches, and genuine_gaps after the revision. - Reassess strong_matches, partial_matches, and genuine_gaps after the revision.
+9 -3
View File
@@ -55,14 +55,20 @@ def render_resume(package: TailoringPackage, include_evidence: bool = False) ->
lines.extend(["", "## Professional Summary", ""]) lines.extend(["", "## Professional Summary", ""])
for item in resume.summary: for item in resume.summary:
suffix = f" <!-- evidence: {', '.join(item.evidence_ids)} -->" if include_evidence else "" suffix = (
f" <!-- evidence: {', '.join(item.evidence_ids)} -->"
if include_evidence and item.evidence_ids
else ""
)
lines.append(f"- {_candidate_text(item.text)}{suffix}") lines.append(f"- {_candidate_text(item.text)}{suffix}")
for section in resume.sections: for section in resume.sections:
lines.extend(["", f"## {section.title}", ""]) lines.extend(["", f"## {section.title}", ""])
for item in section.items: for item in section.items:
suffix = ( suffix = (
f" <!-- evidence: {', '.join(item.evidence_ids)} -->" if include_evidence else "" f" <!-- evidence: {', '.join(item.evidence_ids)} -->"
if include_evidence and item.evidence_ids
else ""
) )
lines.append(f"- {_candidate_text(item.text)}{suffix}") lines.append(f"- {_candidate_text(item.text)}{suffix}")
@@ -125,7 +131,7 @@ def render_ohmycv_resume(
if not clean_text: if not clean_text:
continue continue
lines.append(f"- {clean_text}") lines.append(f"- {clean_text}")
if include_evidence: if include_evidence and item.evidence_ids:
lines.append(f" <!-- Signal evidence: {', '.join(item.evidence_ids)} -->") lines.append(f" <!-- Signal evidence: {', '.join(item.evidence_ids)} -->")
return "\n".join(lines).strip() + "\n" return "\n".join(lines).strip() + "\n"
+80 -2
View File
@@ -4,6 +4,8 @@ const state = {
jobMode: "url", jobMode: "url",
resumeView: "clean", resumeView: "clean",
editorAvailable: false, editorAvailable: false,
evidenceMode: "strict",
evidenceGuard: true,
}; };
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
@@ -17,6 +19,7 @@ document.addEventListener("DOMContentLoaded", async () => {
bindResultTabs(); bindResultTabs();
bindOhMyCvImport(); bindOhMyCvImport();
bindRevisionChat(); bindRevisionChat();
bindGlobalEvidenceGuard();
await loadStatus(); await loadStatus();
}); });
@@ -76,6 +79,9 @@ async function loadStatus() {
pill.classList.add(status.configured ? "ready" : "warning"); pill.classList.add(status.configured ? "ready" : "warning");
$("#tailorButton").disabled = !status.configured; $("#tailorButton").disabled = !status.configured;
state.editorAvailable = status.editor_available; state.editorAvailable = status.editor_available;
state.evidenceGuard = status.evidence_guard;
renderGlobalEvidenceGuard();
document.dispatchEvent(new Event("evidenceguardchange"));
$("#cvEditorLink").classList.toggle("hidden", !status.editor_available); $("#cvEditorLink").classList.toggle("hidden", !status.editor_available);
$("#openOhMyCvButton").classList.toggle("hidden", !status.editor_available); $("#openOhMyCvButton").classList.toggle("hidden", !status.editor_available);
@@ -88,6 +94,48 @@ async function loadStatus() {
} }
} }
function bindGlobalEvidenceGuard() {
$("#globalEvidenceGuard").addEventListener("click", async () => {
const previous = state.evidenceGuard;
state.evidenceGuard = !previous;
renderGlobalEvidenceGuard();
document.dispatchEvent(new Event("evidenceguardchange"));
try {
await api("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ evidence_guard: state.evidenceGuard }),
});
showToast(
state.evidenceGuard
? "Evidence guard enabled across the app."
: "Evidence guard disabled. Profile-based rules now apply across the app.",
);
} catch (error) {
state.evidenceGuard = previous;
renderGlobalEvidenceGuard();
document.dispatchEvent(new Event("evidenceguardchange"));
showToast(error.message, true);
}
});
}
function renderGlobalEvidenceGuard() {
const button = $("#globalEvidenceGuard");
button.classList.toggle("off", !state.evidenceGuard);
button.setAttribute("aria-pressed", String(state.evidenceGuard));
$("#globalEvidenceGuardText").textContent = state.evidenceGuard
? "Evidence guard on"
: "Evidence guard off";
$("#revisionGuardStatus").classList.toggle("off", !state.evidenceGuard);
$("#revisionGuardStatusText").textContent = state.evidenceGuard
? "Evidence guard on"
: "Profile rules active";
$("#revisionGuardDescription").textContent = state.evidenceGuard
? "Ask for changes to tone, detail, ordering, emphasis, or length. Every revision is checked against your evidence profile."
: "Ask for changes to tone, detail, ordering, emphasis, or length. Revisions use your complete profile without claim-level evidence IDs.";
}
function bindNavigation() { function bindNavigation() {
$$(".step").forEach((button) => { $$(".step").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
@@ -254,6 +302,7 @@ function bindJobForm() {
}); });
const strength = $("#tailoringStrength"); const strength = $("#tailoringStrength");
const evidenceModeButton = $("#evidenceModeButton");
const updateStrength = () => { const updateStrength = () => {
const value = Number(strength.value); const value = Number(strength.value);
$("#strengthValue").textContent = value; $("#strengthValue").textContent = value;
@@ -261,11 +310,34 @@ function bindJobForm() {
value <= 20 value <= 20
? "Minimal edits that stay very close to your current wording and structure." ? "Minimal edits that stay very close to your current wording and structure."
: value <= 70 : value <= 70
? "Balanced rewriting using only evidence from your career profile." ? state.evidenceMode === "strict"
? "Balanced rewriting with claim-level evidence from your career profile."
: "Balanced rewriting from your complete profile without claim-level IDs."
: "Fuller supported detail and stronger job-aligned wording without invention."; : "Fuller supported detail and stronger job-aligned wording without invention.";
}; };
const updateEvidenceMode = () => {
if (!state.evidenceGuard) state.evidenceMode = "profile";
const flexible = state.evidenceMode === "profile";
evidenceModeButton.disabled = !state.evidenceGuard;
evidenceModeButton.classList.toggle("active", flexible);
evidenceModeButton.setAttribute("aria-pressed", String(flexible));
$("#evidenceModeTitle").textContent = flexible
? "Flexible profile-based rewrite"
: "Strict source evidence";
$("#evidenceModeDescription").textContent = flexible
? state.evidenceGuard
? "Claims use your full profile; evidence IDs are optional."
: "App-wide guard is off; profile-based rules are enforced."
: "Every claim must cite a profile evidence ID.";
updateStrength();
};
evidenceModeButton.addEventListener("click", () => {
state.evidenceMode = state.evidenceMode === "strict" ? "profile" : "strict";
updateEvidenceMode();
});
document.addEventListener("evidenceguardchange", updateEvidenceMode);
strength.addEventListener("input", updateStrength); strength.addEventListener("input", updateStrength);
updateStrength(); updateEvidenceMode();
$("#tailorForm").addEventListener("submit", async (event) => { $("#tailorForm").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
@@ -281,11 +353,13 @@ function bindJobForm() {
job_url: $("#jobUrl").value.trim(), job_url: $("#jobUrl").value.trim(),
job_text: null, job_text: null,
tailoring_strength: Number(strength.value), tailoring_strength: Number(strength.value),
evidence_mode: state.evidenceMode,
} }
: { : {
job_url: null, job_url: null,
job_text: $("#jobText").value.trim(), job_text: $("#jobText").value.trim(),
tailoring_strength: Number(strength.value), tailoring_strength: Number(strength.value),
evidence_mode: state.evidenceMode,
}; };
if (!(body.job_url || body.job_text)) { if (!(body.job_url || body.job_text)) {
@@ -427,6 +501,10 @@ function showResults(packageData, shouldScroll = true) {
$("#targetCompany").textContent = packageData.job.company || "Company"; $("#targetCompany").textContent = packageData.job.company || "Company";
$("#strongMatchCount").textContent = packageData.match.strong_matches.length; $("#strongMatchCount").textContent = packageData.match.strong_matches.length;
$("#gapCount").textContent = packageData.match.genuine_gaps.length; $("#gapCount").textContent = packageData.match.genuine_gaps.length;
$("#auditSealText").textContent =
state.evidenceMode === "strict"
? "Factuality audit passed"
: "Profile-based review passed";
const worthDiscussing = [ const worthDiscussing = [
...new Set([ ...new Set([
+40 -4
View File
@@ -19,6 +19,16 @@
<span>Signal</span> <span>Signal</span>
</a> </a>
<div class="topbar-actions"> <div class="topbar-actions">
<button
class="guard-toggle"
id="globalEvidenceGuard"
type="button"
aria-pressed="true"
title="Toggle claim-level evidence enforcement across the app"
>
<span aria-hidden="true"></span>
<span id="globalEvidenceGuardText">Evidence guard on</span>
</button>
<a class="editor-link hidden" id="cvEditorLink" href="/cv/"> <a class="editor-link hidden" id="cvEditorLink" href="/cv/">
Open CV editor Open CV editor
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
@@ -260,6 +270,32 @@
</p> </p>
</div> </div>
<div class="tailoring-config">
<div>
<strong>Tailoring configuration</strong>
<span>Choose how strictly claims must map to source excerpts.</span>
</div>
<button
id="evidenceModeButton"
class="config-toggle"
type="button"
aria-pressed="false"
>
<span class="config-toggle-icon" aria-hidden="true"></span>
<span>
<strong id="evidenceModeTitle">Strict source evidence</strong>
<small id="evidenceModeDescription">
Every claim must cite a profile evidence ID.
</small>
</span>
</button>
<p>
Flexible mode removes per-claim evidence requirements and rewrites from
your complete profile and original resume. It still excludes information
you did not provide.
</p>
</div>
<div class="job-action"> <div class="job-action">
<div> <div>
<strong>Two-pass tailoring</strong> <strong>Two-pass tailoring</strong>
@@ -313,7 +349,7 @@
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 12.5l2.5 2.5L16 9.5" /> <path d="M8 12.5l2.5 2.5L16 9.5" />
</svg> </svg>
Factuality audit passed <span id="auditSealText">Factuality audit passed</span>
</span> </span>
</div> </div>
@@ -359,14 +395,14 @@
<div> <div>
<p class="kicker">Revision assistant</p> <p class="kicker">Revision assistant</p>
<h3 id="revisionChatTitle">Refine this tailored resume</h3> <h3 id="revisionChatTitle">Refine this tailored resume</h3>
<p> <p id="revisionGuardDescription">
Ask for changes to tone, detail, ordering, emphasis, or length. Every Ask for changes to tone, detail, ordering, emphasis, or length. Every
revision is checked against your evidence profile. revision is checked against your evidence profile.
</p> </p>
</div> </div>
<span class="chat-status"> <span class="chat-status" id="revisionGuardStatus">
<span></span> <span></span>
Evidence guard on <span id="revisionGuardStatusText">Evidence guard on</span>
</span> </span>
</div> </div>
+146 -1
View File
@@ -119,6 +119,45 @@ button {
gap: 10px; gap: 10px;
} }
.guard-toggle {
padding: 9px 13px;
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid #8db49b;
border-radius: 999px;
color: var(--green);
background: var(--lime-soft);
font-size: 10px;
font-weight: 750;
cursor: pointer;
}
.guard-toggle > span:first-child {
color: #48a26d;
font-size: 8px;
}
.guard-toggle.off {
color: #934f2e;
border-color: #e3ac90;
background: #fff0e8;
}
.guard-toggle.off > span:first-child {
color: var(--orange);
}
.chat-status.off {
color: #934f2e;
border-color: #e3ac90;
background: #fff0e8;
}
.chat-status.off > span:first-child {
background: var(--orange);
}
.editor-link { .editor-link {
padding: 9px 13px; padding: 9px 13px;
border-radius: 999px; border-radius: 999px;
@@ -855,6 +894,104 @@ input::placeholder {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.tailoring-config {
margin-top: 14px;
padding: 18px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface);
}
.tailoring-config > div > strong,
.tailoring-config > div > span {
display: block;
}
.tailoring-config > div > strong {
margin-bottom: 4px;
font-size: 12px;
}
.tailoring-config > div > span,
.tailoring-config > p {
color: var(--muted);
font-size: 9px;
line-height: 1.5;
}
.config-toggle {
width: 100%;
margin-top: 13px;
padding: 12px;
display: flex;
align-items: center;
gap: 11px;
border: 1px solid var(--line);
border-radius: 11px;
color: var(--ink);
background: var(--surface-2);
text-align: left;
cursor: pointer;
}
.config-toggle.active {
border-color: var(--orange);
background: #fff4ed;
}
.config-toggle:disabled {
opacity: 0.72;
cursor: not-allowed;
}
.config-toggle-icon {
width: 34px;
height: 20px;
padding: 2px;
flex: 0 0 auto;
border-radius: 99px;
background: #b4b8b4;
transition: background 160ms ease;
}
.config-toggle-icon::after {
width: 16px;
height: 16px;
display: block;
border-radius: 50%;
content: "";
background: white;
box-shadow: 0 1px 3px rgba(23, 32, 25, 0.25);
transition: transform 160ms ease;
}
.config-toggle.active .config-toggle-icon {
background: var(--orange);
}
.config-toggle.active .config-toggle-icon::after {
transform: translateX(14px);
}
.config-toggle strong,
.config-toggle small {
display: block;
}
.config-toggle strong {
margin-bottom: 3px;
font-size: 10px;
}
.config-toggle small {
color: var(--muted);
font-size: 9px;
}
.tailoring-config > p {
margin: 11px 0 0;
}
.job-action { .job-action {
margin-top: 24px; margin-top: 24px;
padding-top: 22px; padding-top: 22px;
@@ -1196,7 +1333,7 @@ input::placeholder {
font-weight: 750; font-weight: 750;
} }
.chat-status span { .chat-status > span:first-child {
width: 7px; width: 7px;
height: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
@@ -1506,6 +1643,14 @@ footer {
display: none; display: none;
} }
.guard-toggle {
padding: 9px;
}
.guard-toggle span:last-child {
display: none;
}
.step { .step {
min-width: 0; min-width: 0;
display: flex; display: flex;
+70 -4
View File
@@ -52,6 +52,7 @@ class TailorRequest(BaseModel):
job_url: str | None = None job_url: str | None = None
job_text: str | None = Field(default=None, max_length=200_000) job_text: str | None = Field(default=None, max_length=200_000)
tailoring_strength: int = Field(default=50, ge=0, le=100) tailoring_strength: int = Field(default=50, ge=0, le=100)
evidence_mode: Literal["strict", "profile"] = "strict"
class MarkdownProfileRequest(BaseModel): class MarkdownProfileRequest(BaseModel):
@@ -65,6 +66,7 @@ class MarkdownTailorRequest(MarkdownProfileRequest):
job_url: str | None = None job_url: str | None = None
job_text: str | None = Field(default=None, max_length=200_000) job_text: str | None = Field(default=None, max_length=200_000)
tailoring_strength: int = Field(default=50, ge=0, le=100) tailoring_strength: int = Field(default=50, ge=0, le=100)
evidence_mode: Literal["strict", "profile"] = "strict"
class MarkdownTailorResponse(BaseModel): class MarkdownTailorResponse(BaseModel):
@@ -102,6 +104,12 @@ class TaskStatusResponse(BaseModel):
error: str | None = None error: str | None = None
class AppSettings(BaseModel):
model_config = ConfigDict(extra="forbid")
evidence_guard: bool = True
class SPAStaticFiles(StaticFiles): class SPAStaticFiles(StaticFiles):
"""Serve Nuxt's client fallback for extensionless editor routes.""" """Serve Nuxt's client fallback for extensionless editor routes."""
@@ -124,7 +132,11 @@ def _provider_name() -> str:
return urlparse(base_url).hostname or "Custom provider" return urlparse(base_url).hostname or "Custom provider"
def _save_outputs(package: TailoringPackage, output_dir: Path) -> None: def _save_outputs(
package: TailoringPackage,
output_dir: Path,
evidence_mode: str = "strict",
) -> None:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
save_json(package, output_dir / "tailoring.json") save_json(package, output_dir / "tailoring.json")
(output_dir / "resume.md").write_text(render_resume(package), encoding="utf-8") (output_dir / "resume.md").write_text(render_resume(package), encoding="utf-8")
@@ -136,6 +148,30 @@ def _save_outputs(package: TailoringPackage, output_dir: Path) -> None:
render_ohmycv_resume(package, ""), render_ohmycv_resume(package, ""),
encoding="utf-8", encoding="utf-8",
) )
(output_dir / "evidence-mode.txt").write_text(evidence_mode, encoding="utf-8")
def _load_evidence_mode(output_dir: Path) -> str:
path = output_dir / "evidence-mode.txt"
if not path.is_file():
return "strict"
mode = path.read_text(encoding="utf-8").strip()
return mode if mode in {"strict", "profile"} else "strict"
def _load_app_settings(settings_path: Path) -> AppSettings:
if not settings_path.is_file():
return AppSettings()
try:
return AppSettings.model_validate_json(settings_path.read_text(encoding="utf-8"))
except Exception:
TASK_LOGGER.exception("event=settings_load_failed path=%s", settings_path)
return AppSettings()
def _save_app_settings(settings_path: Path, settings: AppSettings) -> None:
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text(settings.model_dump_json(indent=2), encoding="utf-8")
def create_app( def create_app(
@@ -144,12 +180,14 @@ def create_app(
output_dir: Path = DEFAULT_OUTPUT, output_dir: Path = DEFAULT_OUTPUT,
llm_factory: Callable[[], OpenAILLM] = OpenAILLM, llm_factory: Callable[[], OpenAILLM] = OpenAILLM,
cv_dist: Path = DEFAULT_CV_DIST, cv_dist: Path = DEFAULT_CV_DIST,
settings_path: Path | None = None,
) -> FastAPI: ) -> FastAPI:
web_app = FastAPI(title="Resume Agent", version="0.1.0") web_app = FastAPI(title="Resume Agent", version="0.1.0")
web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
editor_available = cv_dist.is_dir() editor_available = cv_dist.is_dir()
if editor_available: if editor_available:
web_app.mount("/cv", SPAStaticFiles(directory=cv_dist, html=True), name="cv-editor") web_app.mount("/cv", SPAStaticFiles(directory=cv_dist, html=True), name="cv-editor")
resolved_settings_path = settings_path or profile_path.parent / "settings.json"
task_records: dict[str, TaskStatusResponse] = {} task_records: dict[str, TaskStatusResponse] = {}
active_tasks: set[asyncio.Task[None]] = set() active_tasks: set[asyncio.Task[None]] = set()
@@ -234,8 +272,22 @@ def create_app(
"api_style": os.getenv("RESUME_AGENT_API_STYLE", "auto"), "api_style": os.getenv("RESUME_AGENT_API_STYLE", "auto"),
"profile_exists": profile_path.is_file(), "profile_exists": profile_path.is_file(),
"editor_available": editor_available, "editor_available": editor_available,
"evidence_guard": _load_app_settings(resolved_settings_path).evidence_guard,
} }
@web_app.get("/api/settings", response_model=AppSettings)
async def get_settings() -> AppSettings:
return _load_app_settings(resolved_settings_path)
@web_app.put("/api/settings", response_model=AppSettings)
async def update_settings(settings: AppSettings) -> AppSettings:
_save_app_settings(resolved_settings_path, settings)
TASK_LOGGER.info(
"event=settings_updated evidence_guard=%s",
settings.evidence_guard,
)
return settings
@web_app.get("/api/models") @web_app.get("/api/models")
async def models() -> dict[str, list[str]]: async def models() -> dict[str, list[str]]:
try: try:
@@ -313,6 +365,10 @@ def create_app(
detail="Provide exactly one job URL or pasted job description.", detail="Provide exactly one job URL or pasted job description.",
) )
try: try:
app_settings = _load_app_settings(resolved_settings_path)
evidence_mode = (
request.evidence_mode if app_settings.evidence_guard else "profile"
)
profile = load_profile(profile_path) profile = load_profile(profile_path)
if request.job_url: if request.job_url:
job_text = await run_in_threadpool(fetch_job_page, request.job_url) job_text = await run_in_threadpool(fetch_job_page, request.job_url)
@@ -326,8 +382,9 @@ def create_app(
profile, profile,
job_text, job_text,
request.tailoring_strength, request.tailoring_strength,
evidence_mode,
) )
_save_outputs(package, output_dir) _save_outputs(package, output_dir, evidence_mode)
return package return package
except Exception as exc: except Exception as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
@@ -344,6 +401,10 @@ def create_app(
detail="Provide exactly one job URL or pasted job description.", detail="Provide exactly one job URL or pasted job description.",
) )
try: try:
app_settings = _load_app_settings(resolved_settings_path)
evidence_mode = (
request.evidence_mode if app_settings.evidence_guard else "profile"
)
llm = llm_factory() llm = llm_factory()
profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about) profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about)
save_json(profile, profile_path) save_json(profile, profile_path)
@@ -361,8 +422,9 @@ def create_app(
profile, profile,
job_text, job_text,
request.tailoring_strength, request.tailoring_strength,
evidence_mode,
) )
_save_outputs(package, output_dir) _save_outputs(package, output_dir, evidence_mode)
markdown = render_ohmycv_resume(package, request.markdown) markdown = render_ohmycv_resume(package, request.markdown)
(output_dir / "resume-ohmycv.md").write_text(markdown, encoding="utf-8") (output_dir / "resume-ohmycv.md").write_text(markdown, encoding="utf-8")
return MarkdownTailorResponse( return MarkdownTailorResponse(
@@ -392,14 +454,18 @@ def create_app(
current = TailoringPackage.model_validate_json( current = TailoringPackage.model_validate_json(
package_path.read_text(encoding="utf-8") package_path.read_text(encoding="utf-8")
) )
evidence_mode = _load_evidence_mode(output_dir)
if not _load_app_settings(resolved_settings_path).evidence_guard:
evidence_mode = "profile"
package = await run_in_threadpool( package = await run_in_threadpool(
revise_tailored_resume, revise_tailored_resume,
llm_factory(), llm_factory(),
profile, profile,
current, current,
request.message, request.message,
evidence_mode,
) )
_save_outputs(package, output_dir) _save_outputs(package, output_dir, evidence_mode)
reply = _revision_reply(package) reply = _revision_reply(package)
return RevisionResponse(package=package, reply=reply) return RevisionResponse(package=package, reply=reply)
except Exception as exc: except Exception as exc:
+28
View File
@@ -140,6 +140,34 @@ def test_tailoring_strength_rejects_out_of_range_values() -> None:
tailor_resume(FakeLLM([]), profile(), "long job post", tailoring_strength=101) tailor_resume(FakeLLM([]), profile(), "long job post", tailoring_strength=101)
def test_profile_evidence_mode_allows_claims_without_fact_ids() -> None:
flexible = package()
for item in flexible.resume.summary:
item.evidence_ids = []
for section in flexible.resume.sections:
for item in section.items:
item.evidence_ids = []
llm = FakeLLM([flexible, flexible])
result = tailor_resume(
llm,
profile(),
"long job post",
evidence_mode="profile",
)
assert result.resume.summary[0].evidence_ids == []
assert "Per-claim evidence IDs are optional" in llm.calls[1][0]
def test_strict_evidence_mode_still_rejects_missing_fact_ids() -> None:
flexible = package()
flexible.resume.summary[0].evidence_ids = []
with pytest.raises(ValueError, match="no evidence"):
tailor_resume(FakeLLM([flexible]), profile(), "long job post")
def test_revision_chat_rewrites_and_audits_current_package() -> None: def test_revision_chat_rewrites_and_audits_current_package() -> None:
current = package() current = package()
revised = package() revised = package()
+71
View File
@@ -217,6 +217,77 @@ def test_background_tailoring_returns_pollable_task(tmp_path: Path) -> None:
assert (output_dir / "resume-ohmycv.md").is_file() assert (output_dir / "resume-ohmycv.md").is_file()
def test_profile_based_tailoring_allows_empty_evidence_ids(tmp_path: Path) -> None:
profile_path = tmp_path / "profile.json"
output_dir = tmp_path / "output"
save_json(sample_profile(), profile_path)
flexible = sample_package()
for item in flexible.resume.summary:
item.evidence_ids = []
for section in flexible.resume.sections:
for item in section.items:
item.evidence_ids = []
app = create_app(
profile_path=profile_path,
output_dir=output_dir,
llm_factory=lambda: FakeLLM([flexible, flexible]), # type: ignore[arg-type]
)
with TestClient(app) as client:
response = client.post(
"/api/tailor",
json={
"job_url": None,
"job_text": "Backend engineer role requiring Python and API performance. " * 3,
"evidence_mode": "profile",
},
)
assert response.status_code == 200
assert response.json()["resume"]["summary"][0]["evidence_ids"] == []
assert (output_dir / "evidence-mode.txt").read_text() == "profile"
assert "<!-- evidence:" not in (output_dir / "resume-audited.md").read_text()
def test_app_wide_guard_off_forces_profile_mode_and_persists(tmp_path: Path) -> None:
profile_path = tmp_path / "profile.json"
output_dir = tmp_path / "output"
settings_path = tmp_path / "app-settings.json"
save_json(sample_profile(), profile_path)
flexible = sample_package()
for item in flexible.resume.summary:
item.evidence_ids = []
for section in flexible.resume.sections:
for item in section.items:
item.evidence_ids = []
app = create_app(
profile_path=profile_path,
output_dir=output_dir,
settings_path=settings_path,
llm_factory=lambda: FakeLLM([flexible, flexible]), # type: ignore[arg-type]
)
with TestClient(app) as client:
updated = client.put("/api/settings", json={"evidence_guard": False})
status = client.get("/api/status")
response = client.post(
"/api/tailor",
json={
"job_url": None,
"job_text": "Backend engineer role requiring Python and API performance. " * 3,
"evidence_mode": "strict",
},
)
assert updated.status_code == 200
assert updated.json() == {"evidence_guard": False}
assert status.json()["evidence_guard"] is False
assert response.status_code == 200
assert response.json()["resume"]["summary"][0]["evidence_ids"] == []
assert (output_dir / "evidence-mode.txt").read_text() == "profile"
assert '"evidence_guard": false' in settings_path.read_text()
def test_tailor_requires_exactly_one_job_source(tmp_path: Path) -> None: def test_tailor_requires_exactly_one_job_source(tmp_path: Path) -> None:
profile_path = tmp_path / "profile.json" profile_path = tmp_path / "profile.json"
save_json(sample_profile(), profile_path) save_json(sample_profile(), profile_path)
@@ -113,6 +113,93 @@
</p> </p>
</div> </div>
<div class="space-y-3 rounded-lg border p-3">
<div>
<div class="font-medium">Tailoring configuration</div>
<p class="mt-1 text-xs text-muted-foreground">
Set the app-wide evidence guard, then choose the mode for this rewrite.
</p>
</div>
<button
class="flex w-full items-center gap-3 rounded-md border p-3 text-left"
:class="
globalEvidenceGuard
? 'border-emerald-600 bg-emerald-50'
: 'border-orange-500 bg-orange-50'
"
type="button"
:aria-pressed="globalEvidenceGuard"
@click="toggleGlobalEvidenceGuard"
>
<span
class="relative h-5 w-9 shrink-0 rounded-full transition-colors"
:class="globalEvidenceGuard ? 'bg-emerald-600' : 'bg-orange-500'"
>
<span
class="absolute left-0.5 top-0.5 size-4 rounded-full bg-white shadow transition-transform"
:class="globalEvidenceGuard ? 'translate-x-4' : ''"
/>
</span>
<span>
<strong class="block text-xs">
{{
globalEvidenceGuard
? "App-wide evidence guard on"
: "App-wide evidence guard off"
}}
</strong>
<small class="mt-0.5 block text-xs text-muted-foreground">
{{
globalEvidenceGuard
? "Claim-level evidence rules are available across Signal."
: "All tailoring and revision uses profile-based rules."
}}
</small>
</span>
</button>
<button
class="flex w-full items-center gap-3 rounded-md border bg-muted/40 p-3 text-left"
:class="evidenceMode === 'profile' ? 'border-orange-500 bg-orange-50' : ''"
type="button"
:disabled="!globalEvidenceGuard"
:aria-pressed="evidenceMode === 'profile'"
:aria-disabled="!globalEvidenceGuard"
@click="evidenceMode = evidenceMode === 'strict' ? 'profile' : 'strict'"
>
<span
class="relative h-5 w-9 shrink-0 rounded-full transition-colors"
:class="evidenceMode === 'profile' ? 'bg-orange-500' : 'bg-gray-400'"
>
<span
class="absolute left-0.5 top-0.5 size-4 rounded-full bg-white shadow transition-transform"
:class="evidenceMode === 'profile' ? 'translate-x-4' : ''"
/>
</span>
<span>
<strong class="block text-xs">
{{
evidenceMode === "profile"
? "Flexible profile-based rewrite"
: "Strict source evidence"
}}
</strong>
<small class="mt-0.5 block text-xs text-muted-foreground">
{{
evidenceMode === "profile"
? globalEvidenceGuard
? "Uses the full resume profile; claim-level IDs are optional."
: "Forced by the app-wide setting; claim-level IDs are optional."
: "Every claim must cite a supporting profile fact."
}}
</small>
</span>
</button>
<p class="text-xs text-muted-foreground">
Flexible mode still excludes employers, skills, dates, metrics, and
achievements that are absent from this resume or your added context.
</p>
</div>
<UiAlert v-if="errorMessage" variant="destructive"> <UiAlert v-if="errorMessage" variant="destructive">
<UiAlertTitle>Signal could not complete the request</UiAlertTitle> <UiAlertTitle>Signal could not complete the request</UiAlertTitle>
<UiAlertDescription>{{ errorMessage }}</UiAlertDescription> <UiAlertDescription>{{ errorMessage }}</UiAlertDescription>
@@ -185,6 +272,8 @@ const jobURL = ref("");
const jobText = ref(""); const jobText = ref("");
const about = ref(""); const about = ref("");
const tailoringStrength = ref(50); const tailoringStrength = ref(50);
const evidenceMode = ref<"strict" | "profile">("strict");
const globalEvidenceGuard = ref(true);
const busy = ref(false); const busy = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
@@ -209,10 +298,50 @@ const strengthDescription = computed(() => {
return "Fuller supported detail and stronger job-aligned wording without invention."; return "Fuller supported detail and stronger job-aligned wording without invention.";
}); });
onMounted(() => { onMounted(async () => {
hasBackup.value = Boolean(localStorage.getItem(backupKey.value)); hasBackup.value = Boolean(localStorage.getItem(backupKey.value));
try {
const response = await fetch(`${apiBase.value}/api/settings`);
if (!response.ok) return;
const settings = (await response.json()) as { evidence_guard: boolean };
globalEvidenceGuard.value = settings.evidence_guard;
if (!settings.evidence_guard) evidenceMode.value = "profile";
} catch {
// The API error will be surfaced if the user starts a tailoring request.
}
}); });
const toggleGlobalEvidenceGuard = async () => {
const previous = globalEvidenceGuard.value;
const next = !previous;
globalEvidenceGuard.value = next;
if (!next) evidenceMode.value = "profile";
errorMessage.value = "";
try {
const response = await fetch(`${apiBase.value}/api/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ evidence_guard: next })
});
const settings = (await response.json()) as {
evidence_guard?: boolean;
detail?: string;
};
if (!response.ok) {
throw new Error(settings.detail || "Could not update the Signal settings.");
}
globalEvidenceGuard.value = Boolean(settings.evidence_guard);
if (!globalEvidenceGuard.value) evidenceMode.value = "profile";
successMessage.value = globalEvidenceGuard.value
? "Evidence guard enabled across Signal."
: "Evidence guard disabled across Signal; profile truth rules remain active.";
} catch (error) {
globalEvidenceGuard.value = previous;
errorMessage.value = error instanceof Error ? error.message : "Unknown error.";
}
};
async function post<T>(path: string, payload: Record<string, unknown>): Promise<T> { async function post<T>(path: string, payload: Record<string, unknown>): Promise<T> {
const response = await fetch(`${apiBase.value}${path}`, { const response = await fetch(`${apiBase.value}${path}`, {
method: "POST", method: "POST",
@@ -300,6 +429,7 @@ const tailorResume = async () => {
markdown: data.markdown, markdown: data.markdown,
about: about.value, about: about.value,
tailoring_strength: tailoringStrength.value, tailoring_strength: tailoringStrength.value,
evidence_mode: evidenceMode.value,
...jobPayload() ...jobPayload()
}); });