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;
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
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,
job_text: str,
tailoring_strength: int = 50,
evidence_mode: str = "strict",
) -> TailoringPackage:
if not 0 <= tailoring_strength <= 100:
raise ValueError("Tailoring strength must be between 0 and 100.")
_validate_evidence_mode(evidence_mode)
payload = {
"canonical_profile": profile.model_dump(mode="json"),
"job_post": job_text,
"tailoring_strength": tailoring_strength,
"evidence_mode": evidence_mode,
}
draft = llm.parse(
TailoringPackage,
_tailoring_prompt(tailoring_strength),
_tailoring_prompt(tailoring_strength, evidence_mode),
json.dumps(payload, ensure_ascii=False),
)
validate_package(profile, draft)
validate_package(profile, draft, require_evidence=evidence_mode == "strict")
audit_payload = {
"canonical_profile": profile.model_dump(mode="json"),
"proposed_package": draft.model_dump(mode="json"),
"tailoring_strength": tailoring_strength,
"evidence_mode": evidence_mode,
}
audited = llm.parse(
TailoringPackage,
AUDIT_PROMPT,
_audit_prompt(evidence_mode),
json.dumps(audit_payload, ensure_ascii=False),
)
validate_package(profile, audited)
validate_package(profile, audited, require_evidence=evidence_mode == "strict")
return audited
def _tailoring_prompt(strength: int) -> str:
def _tailoring_prompt(strength: int, evidence_mode: str) -> str:
if strength <= 20:
guidance = (
"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"Tailoring strength: {strength}/100.\n"
f"{guidance}\n"
f"{_evidence_guidance(evidence_mode)}\n"
"This setting changes editing intensity only. It never permits fabricated, "
"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(
llm: StructuredLLM,
profile: CareerProfile,
current: TailoringPackage,
instruction: str,
evidence_mode: str = "strict",
) -> TailoringPackage:
instruction = instruction.strip()
if len(instruction) < 3:
raise ValueError("Describe how you want the tailored resume revised.")
_validate_evidence_mode(evidence_mode)
revision_payload = {
"canonical_profile": profile.model_dump(mode="json"),
"current_tailored_package": current.model_dump(mode="json"),
"candidate_request": instruction,
"evidence_mode": evidence_mode,
}
revised = llm.parse(
TailoringPackage,
REVISION_PROMPT,
f"{REVISION_PROMPT}\n\n{_evidence_guidance(evidence_mode)}",
json.dumps(revision_payload, ensure_ascii=False),
)
_validate_revision(profile, current, revised)
_validate_revision(profile, current, revised, evidence_mode)
audit_payload = {
"canonical_profile": profile.model_dump(mode="json"),
"original_job_analysis": current.job.model_dump(mode="json"),
"candidate_request": instruction,
"proposed_package": revised.model_dump(mode="json"),
"evidence_mode": evidence_mode,
}
audited = llm.parse(
TailoringPackage,
AUDIT_PROMPT,
_audit_prompt(evidence_mode),
json.dumps(audit_payload, ensure_ascii=False),
)
_validate_revision(profile, current, audited)
_validate_revision(profile, current, audited, evidence_mode)
return audited
@@ -162,8 +205,9 @@ def _validate_revision(
profile: CareerProfile,
current: TailoringPackage,
revised: TailoringPackage,
evidence_mode: str,
) -> None:
validate_package(profile, revised)
validate_package(profile, revised, require_evidence=evidence_mode == "strict")
if revised.job != current.job:
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.")
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}
backed_items = list(package.resume.summary)
for section in package.resume.sections:
@@ -187,7 +236,7 @@ def validate_package(profile: CareerProfile, package: TailoringPackage) -> None:
if not backed_items:
raise ValueError("The tailored resume contains no evidence-backed content.")
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}")
unknown = set(item.evidence_ids) - valid_ids
if unknown:
+13 -1
View File
@@ -136,10 +136,22 @@ def tailor(
help="Truthful tailoring strength: 0 preserves wording; 100 maximizes supported fit.",
),
] = 50,
evidence_mode: Annotated[
str,
typer.Option(
help="Evidence mode: strict claim-level IDs or profile-based flexible rewriting.",
),
] = "strict",
) -> None:
"""Tailor the resume to a job and run a second factuality audit."""
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)
save_json(package, out_dir / "tailoring.json")
(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:
- The canonical profile is the only source of candidate facts.
- 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.
- Use job-post terminology only when the profile proves the corresponding capability.
- Preserve the candidate's contact information exactly.
@@ -59,9 +55,7 @@ AUDIT_PROMPT = """
Audit the proposed tailored resume against the canonical profile.
Return a corrected TailoringPackage.
- Remove or rewrite every claim not directly supported by its cited fact IDs.
- 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.
- Remove or rewrite claims not supported by the supplied candidate information.
- Do not add new candidate facts.
- Preserve useful tailoring when it is truthful.
- 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.
- Never invent, exaggerate, infer, or strengthen experience, dates, titles, metrics,
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
gap, and explain the limitation in warnings or questions_for_candidate.
- 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", ""])
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}")
for section in resume.sections:
lines.extend(["", f"## {section.title}", ""])
for item in section.items:
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}")
@@ -125,7 +131,7 @@ def render_ohmycv_resume(
if not clean_text:
continue
lines.append(f"- {clean_text}")
if include_evidence:
if include_evidence and item.evidence_ids:
lines.append(f" <!-- Signal evidence: {', '.join(item.evidence_ids)} -->")
return "\n".join(lines).strip() + "\n"
+80 -2
View File
@@ -4,6 +4,8 @@ const state = {
jobMode: "url",
resumeView: "clean",
editorAvailable: false,
evidenceMode: "strict",
evidenceGuard: true,
};
const $ = (selector) => document.querySelector(selector);
@@ -17,6 +19,7 @@ document.addEventListener("DOMContentLoaded", async () => {
bindResultTabs();
bindOhMyCvImport();
bindRevisionChat();
bindGlobalEvidenceGuard();
await loadStatus();
});
@@ -76,6 +79,9 @@ async function loadStatus() {
pill.classList.add(status.configured ? "ready" : "warning");
$("#tailorButton").disabled = !status.configured;
state.editorAvailable = status.editor_available;
state.evidenceGuard = status.evidence_guard;
renderGlobalEvidenceGuard();
document.dispatchEvent(new Event("evidenceguardchange"));
$("#cvEditorLink").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() {
$$(".step").forEach((button) => {
button.addEventListener("click", () => {
@@ -254,6 +302,7 @@ function bindJobForm() {
});
const strength = $("#tailoringStrength");
const evidenceModeButton = $("#evidenceModeButton");
const updateStrength = () => {
const value = Number(strength.value);
$("#strengthValue").textContent = value;
@@ -261,11 +310,34 @@ function bindJobForm() {
value <= 20
? "Minimal edits that stay very close to your current wording and structure."
: 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.";
};
strength.addEventListener("input", updateStrength);
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);
updateEvidenceMode();
$("#tailorForm").addEventListener("submit", async (event) => {
event.preventDefault();
@@ -281,11 +353,13 @@ function bindJobForm() {
job_url: $("#jobUrl").value.trim(),
job_text: null,
tailoring_strength: Number(strength.value),
evidence_mode: state.evidenceMode,
}
: {
job_url: null,
job_text: $("#jobText").value.trim(),
tailoring_strength: Number(strength.value),
evidence_mode: state.evidenceMode,
};
if (!(body.job_url || body.job_text)) {
@@ -427,6 +501,10 @@ function showResults(packageData, shouldScroll = true) {
$("#targetCompany").textContent = packageData.job.company || "Company";
$("#strongMatchCount").textContent = packageData.match.strong_matches.length;
$("#gapCount").textContent = packageData.match.genuine_gaps.length;
$("#auditSealText").textContent =
state.evidenceMode === "strict"
? "Factuality audit passed"
: "Profile-based review passed";
const worthDiscussing = [
...new Set([
+40 -4
View File
@@ -19,6 +19,16 @@
<span>Signal</span>
</a>
<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/">
Open CV editor
<span aria-hidden="true"></span>
@@ -260,6 +270,32 @@
</p>
</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>
<strong>Two-pass tailoring</strong>
@@ -313,7 +349,7 @@
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 12.5l2.5 2.5L16 9.5" />
</svg>
Factuality audit passed
<span id="auditSealText">Factuality audit passed</span>
</span>
</div>
@@ -359,14 +395,14 @@
<div>
<p class="kicker">Revision assistant</p>
<h3 id="revisionChatTitle">Refine this tailored resume</h3>
<p>
<p id="revisionGuardDescription">
Ask for changes to tone, detail, ordering, emphasis, or length. Every
revision is checked against your evidence profile.
</p>
</div>
<span class="chat-status">
<span class="chat-status" id="revisionGuardStatus">
<span></span>
Evidence guard on
<span id="revisionGuardStatusText">Evidence guard on</span>
</span>
</div>
+146 -1
View File
@@ -119,6 +119,45 @@ button {
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 {
padding: 9px 13px;
border-radius: 999px;
@@ -855,6 +894,104 @@ input::placeholder {
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 {
margin-top: 24px;
padding-top: 22px;
@@ -1196,7 +1333,7 @@ input::placeholder {
font-weight: 750;
}
.chat-status span {
.chat-status > span:first-child {
width: 7px;
height: 7px;
border-radius: 50%;
@@ -1506,6 +1643,14 @@ footer {
display: none;
}
.guard-toggle {
padding: 9px;
}
.guard-toggle span:last-child {
display: none;
}
.step {
min-width: 0;
display: flex;
+70 -4
View File
@@ -52,6 +52,7 @@ class TailorRequest(BaseModel):
job_url: str | None = None
job_text: str | None = Field(default=None, max_length=200_000)
tailoring_strength: int = Field(default=50, ge=0, le=100)
evidence_mode: Literal["strict", "profile"] = "strict"
class MarkdownProfileRequest(BaseModel):
@@ -65,6 +66,7 @@ class MarkdownTailorRequest(MarkdownProfileRequest):
job_url: str | None = None
job_text: str | None = Field(default=None, max_length=200_000)
tailoring_strength: int = Field(default=50, ge=0, le=100)
evidence_mode: Literal["strict", "profile"] = "strict"
class MarkdownTailorResponse(BaseModel):
@@ -102,6 +104,12 @@ class TaskStatusResponse(BaseModel):
error: str | None = None
class AppSettings(BaseModel):
model_config = ConfigDict(extra="forbid")
evidence_guard: bool = True
class SPAStaticFiles(StaticFiles):
"""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"
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)
save_json(package, output_dir / "tailoring.json")
(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, ""),
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(
@@ -144,12 +180,14 @@ def create_app(
output_dir: Path = DEFAULT_OUTPUT,
llm_factory: Callable[[], OpenAILLM] = OpenAILLM,
cv_dist: Path = DEFAULT_CV_DIST,
settings_path: Path | None = None,
) -> FastAPI:
web_app = FastAPI(title="Resume Agent", version="0.1.0")
web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
editor_available = cv_dist.is_dir()
if editor_available:
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] = {}
active_tasks: set[asyncio.Task[None]] = set()
@@ -234,8 +272,22 @@ def create_app(
"api_style": os.getenv("RESUME_AGENT_API_STYLE", "auto"),
"profile_exists": profile_path.is_file(),
"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")
async def models() -> dict[str, list[str]]:
try:
@@ -313,6 +365,10 @@ def create_app(
detail="Provide exactly one job URL or pasted job description.",
)
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)
if request.job_url:
job_text = await run_in_threadpool(fetch_job_page, request.job_url)
@@ -326,8 +382,9 @@ def create_app(
profile,
job_text,
request.tailoring_strength,
evidence_mode,
)
_save_outputs(package, output_dir)
_save_outputs(package, output_dir, evidence_mode)
return package
except Exception as exc:
raise _http_error(exc) from exc
@@ -344,6 +401,10 @@ def create_app(
detail="Provide exactly one job URL or pasted job description.",
)
try:
app_settings = _load_app_settings(resolved_settings_path)
evidence_mode = (
request.evidence_mode if app_settings.evidence_guard else "profile"
)
llm = llm_factory()
profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about)
save_json(profile, profile_path)
@@ -361,8 +422,9 @@ def create_app(
profile,
job_text,
request.tailoring_strength,
evidence_mode,
)
_save_outputs(package, output_dir)
_save_outputs(package, output_dir, evidence_mode)
markdown = render_ohmycv_resume(package, request.markdown)
(output_dir / "resume-ohmycv.md").write_text(markdown, encoding="utf-8")
return MarkdownTailorResponse(
@@ -392,14 +454,18 @@ def create_app(
current = TailoringPackage.model_validate_json(
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(
revise_tailored_resume,
llm_factory(),
profile,
current,
request.message,
evidence_mode,
)
_save_outputs(package, output_dir)
_save_outputs(package, output_dir, evidence_mode)
reply = _revision_reply(package)
return RevisionResponse(package=package, reply=reply)
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)
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:
current = 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()
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:
profile_path = tmp_path / "profile.json"
save_json(sample_profile(), profile_path)
@@ -113,6 +113,93 @@
</p>
</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">
<UiAlertTitle>Signal could not complete the request</UiAlertTitle>
<UiAlertDescription>{{ errorMessage }}</UiAlertDescription>
@@ -185,6 +272,8 @@ const jobURL = ref("");
const jobText = ref("");
const about = ref("");
const tailoringStrength = ref(50);
const evidenceMode = ref<"strict" | "profile">("strict");
const globalEvidenceGuard = ref(true);
const busy = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
@@ -209,10 +298,50 @@ const strengthDescription = computed(() => {
return "Fuller supported detail and stronger job-aligned wording without invention.";
});
onMounted(() => {
onMounted(async () => {
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> {
const response = await fetch(`${apiBase.value}${path}`, {
method: "POST",
@@ -300,6 +429,7 @@ const tailorResume = async () => {
markdown: data.markdown,
about: about.value,
tailoring_strength: tailoringStrength.value,
evidence_mode: evidenceMode.value,
...jobPayload()
});