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;
|
||||
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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user