diff --git a/README.md b/README.md index 9ea9ad9..6389293 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/resume_agent/agent.py b/src/resume_agent/agent.py index cd82591..55e34db 100644 --- a/src/resume_agent/agent.py +++ b/src/resume_agent/agent.py @@ -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: diff --git a/src/resume_agent/cli.py b/src/resume_agent/cli.py index 93c16bf..ff5541e 100644 --- a/src/resume_agent/cli.py +++ b/src/resume_agent/cli.py @@ -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") diff --git a/src/resume_agent/prompts.py b/src/resume_agent/prompts.py index e0c0c61..eb2a25b 100644 --- a/src/resume_agent/prompts.py +++ b/src/resume_agent/prompts.py @@ -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. diff --git a/src/resume_agent/render.py b/src/resume_agent/render.py index 4a16608..76525ab 100644 --- a/src/resume_agent/render.py +++ b/src/resume_agent/render.py @@ -55,14 +55,20 @@ def render_resume(package: TailoringPackage, include_evidence: bool = False) -> lines.extend(["", "## Professional Summary", ""]) for item in resume.summary: - suffix = f" " if include_evidence else "" + suffix = ( + f" " + 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" " if include_evidence else "" + f" " + 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" ") return "\n".join(lines).strip() + "\n" diff --git a/src/resume_agent/static/app.js b/src/resume_agent/static/app.js index 26703af..7268d89 100644 --- a/src/resume_agent/static/app.js +++ b/src/resume_agent/static/app.js @@ -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."; }; + 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); - 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([ diff --git a/src/resume_agent/static/index.html b/src/resume_agent/static/index.html index 4a345a3..f7004a9 100644 --- a/src/resume_agent/static/index.html +++ b/src/resume_agent/static/index.html @@ -19,6 +19,16 @@ Signal
++ Flexible mode removes per-claim evidence requirements and rewrites from + your complete profile and original resume. It still excludes information + you did not provide. +
+Revision assistant
+
Ask for changes to tone, detail, ordering, emphasis, or length. Every revision is checked against your evidence profile.