Add configurable evidence guard for resume tailoring
This commit is contained in:
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user