feat(resume-optimizer): make proposed text editable in review modal and preview
Some checks failed
CI / Backend (Python) (push) Failing after 1m45s
CI / Frontend (Vue) (push) Failing after 22s
Mirror / mirror (push) Failing after 11s

Summary and experience bullet fields in the review modal are now
editable textareas. Edited values flow through decisions to
apply_review_decisions(), which uses edited_text/edited_bullets when
the section is accepted. Clearing unwanted LLM-added bullets (empty
lines filtered server-side) addresses the extra-bullets issue.

The preview textarea in the apply workspace is also now editable;
approveResume() passes preview_text_override so manual edits survive
the approve step without re-rendering from struct.
This commit is contained in:
pyr0ball 2026-05-05 13:35:01 -07:00
parent 77e49db4e9
commit f4a524ba0b
6 changed files with 127 additions and 38 deletions

View file

@ -667,7 +667,8 @@ def approve_resume(job_id: int, body: dict):
raise HTTPException(400, "preview_struct is required") raise HTTPException(400, "preview_struct is required")
from scripts.resume_optimizer import render_resume_text from scripts.resume_optimizer import render_resume_text
final_text = render_resume_text(struct) override = (body.get("preview_text_override") or "").strip()
final_text = override if override else render_resume_text(struct)
# Persist plain text + struct (struct enables YAML export later) # Persist plain text + struct (struct enables YAML export later)
_finalize(db_path=db_path, job_id=job_id, final_text=final_text) _finalize(db_path=db_path, job_id=job_id, final_text=final_text)

View file

@ -532,27 +532,37 @@ def apply_review_decisions(
struct["skills"] = sorted(original_kept | approved_additions) struct["skills"] = sorted(original_kept | approved_additions)
break break
# ── Summary: accept proposed or revert to original ────────────────────── # ── Summary: accept/reject + optional user-edited text ─────────────────
if not decisions.get("summary", {}).get("accepted", True): summary_dec = decisions.get("summary", {})
if not summary_dec.get("accepted", True):
for sec in sections: for sec in sections:
if sec["section"] == "summary": if sec["section"] == "summary":
struct["career_summary"] = sec.get("original", struct.get("career_summary", "")) struct["career_summary"] = sec.get("original", struct.get("career_summary", ""))
break break
else:
edited_text = summary_dec.get("edited_text")
if edited_text is not None:
struct["career_summary"] = edited_text.strip()
# ── Experience: per-entry accept/reject ───────────────────────────────── # ── Experience: per-entry accept/reject + optional user-edited bullets ──
exp_decisions: dict[str, bool] = { exp_entry_map: dict[str, dict] = {
f"{ed.get('title', '')}|{ed.get('company', '')}": ed.get("accepted", True) f"{ed.get('title', '')}|{ed.get('company', '')}": ed
for ed in (decisions.get("experience", {}).get("accepted_entries") or []) for ed in (decisions.get("experience", {}).get("accepted_entries") or [])
} }
for sec in sections: for sec in sections:
if sec["section"] == "experience": if sec["section"] == "experience":
for entry_diff in (sec.get("entries") or []): for entry_diff in (sec.get("entries") or []):
key = f"{entry_diff['title']}|{entry_diff['company']}" key = f"{entry_diff['title']}|{entry_diff['company']}"
if not exp_decisions.get(key, True): entry_dec = exp_entry_map.get(key, {})
accepted = entry_dec.get("accepted", True)
edited_bullets = entry_dec.get("edited_bullets")
for exp_entry in (struct.get("experience") or []): for exp_entry in (struct.get("experience") or []):
if (exp_entry.get("title") == entry_diff["title"] and if (exp_entry.get("title") == entry_diff["title"] and
exp_entry.get("company") == entry_diff["company"]): exp_entry.get("company") == entry_diff["company"]):
if not accepted:
exp_entry["bullets"] = entry_diff["original_bullets"] exp_entry["bullets"] = entry_diff["original_bullets"]
elif edited_bullets is not None:
exp_entry["bullets"] = [b for b in edited_bullets if b.strip()]
break break
return struct return struct

View file

@ -112,16 +112,15 @@
<span class="rop__preview-badge">Preview not yet saved</span> <span class="rop__preview-badge">Preview not yet saved</span>
</div> </div>
<textarea <textarea
:value="previewText" v-model="previewText"
class="rop__textarea rop__textarea--preview" class="rop__textarea"
aria-label="Resume preview text" aria-label="Resume preview — editable before approving"
spellcheck="false" spellcheck="true"
readonly
/> />
<p class="rop__preview-hint"> <p class="rop__preview-hint">
Review the assembled resume above. If it looks right, click Review and edit the assembled resume above. Click
<strong>Approve &amp; Save</strong> to lock it in. You can also go back and adjust <strong>Approve &amp; Save</strong> to lock it in, or go back to adjust
your review decisions. your section-level decisions.
</p> </p>
<div class="rop__save-to-library"> <div class="rop__save-to-library">
<label class="rop__save-toggle"> <label class="rop__save-toggle">
@ -492,7 +491,10 @@ async function approveResume() {
if (!previewStruct.value) return if (!previewStruct.value) return
approvingResume.value = true approvingResume.value = true
const body: Record<string, unknown> = { preview_struct: previewStruct.value } const body: Record<string, unknown> = {
preview_struct: previewStruct.value,
preview_text_override: previewText.value,
}
if (saveToLibrary.value) { if (saveToLibrary.value) {
body.save_to_library = true body.save_to_library = true
body.resume_name = savedResumeName.value.trim() || `Optimized for job ${props.jobId}` body.resume_name = savedResumeName.value.trim() || `Optimized for job ${props.jobId}`
@ -1099,10 +1101,6 @@ onUnmounted(stopPolling)
border-radius: var(--radius-full, 9999px); border-radius: var(--radius-full, 9999px);
} }
.rop__textarea--preview {
background: color-mix(in srgb, var(--app-accent, #6366f1) 3%, var(--app-surface, #fff));
cursor: default;
}
.rop__preview-hint { .rop__preview-hint {
font-size: var(--font-sm, 0.875rem); font-size: var(--font-sm, 0.875rem);

View file

@ -63,7 +63,9 @@
<SummaryPage <SummaryPage
:section="summarySection!" :section="summarySection!"
:accepted="summaryAccepted" :accepted="summaryAccepted"
:edited-proposed="summaryEdited"
@update:accepted="summaryAccepted = $event" @update:accepted="summaryAccepted = $event"
@update:editedProposed="summaryEdited = $event"
/> />
</template> </template>
@ -72,7 +74,9 @@
<ExperiencePage <ExperiencePage
:entry="currentEntry!" :entry="currentEntry!"
:accepted="expAccepted[currentPage.entryKey!] ?? true" :accepted="expAccepted[currentPage.entryKey!] ?? true"
:edited-bullets="expEdited[currentPage.entryKey!] ?? currentEntry!.proposed_bullets"
@update:accepted="expAccepted[currentPage.entryKey!] = $event" @update:accepted="expAccepted[currentPage.entryKey!] = $event"
@update:editedBullets="expEdited[currentPage.entryKey!] = $event"
/> />
</template> </template>
@ -255,11 +259,17 @@ function goTo(idx: number) {
const approvedSkills = ref<Set<string>>(new Set(skillsSection.value?.added ?? [])) const approvedSkills = ref<Set<string>>(new Set(skillsSection.value?.added ?? []))
const skillFramings = ref<Map<string, GapFraming>>(new Map()) const skillFramings = ref<Map<string, GapFraming>>(new Map())
const summaryAccepted = ref(true) const summaryAccepted = ref(true)
const summaryEdited = ref<string>(summarySection.value?.proposed ?? '')
const expAccepted = ref<Record<string, boolean>>( const expAccepted = ref<Record<string, boolean>>(
Object.fromEntries( Object.fromEntries(
(expSection.value?.entries ?? []).map(e => [`${e.title}|${e.company}`, true]) (expSection.value?.entries ?? []).map(e => [`${e.title}|${e.company}`, true])
) )
) )
const expEdited = ref<Record<string, string[]>>(
Object.fromEntries(
(expSection.value?.entries ?? []).map(e => [`${e.title}|${e.company}`, [...e.proposed_bullets]])
)
)
function toggleSkill(skill: string) { function toggleSkill(skill: string) {
interactedPages.value = new Set([...interactedPages.value, 'skills']) interactedPages.value = new Set([...interactedPages.value, 'skills'])
@ -322,15 +332,22 @@ function emitSubmit() {
decisions.skills = { approved_additions: [...approvedSkills.value] } decisions.skills = { approved_additions: [...approvedSkills.value] }
} }
if (summarySection.value) { if (summarySection.value) {
decisions.summary = { accepted: summaryAccepted.value } decisions.summary = {
accepted: summaryAccepted.value,
edited_text: summaryEdited.value,
}
} }
if (expSection.value) { if (expSection.value) {
decisions.experience = { decisions.experience = {
accepted_entries: expSection.value.entries.map(e => ({ accepted_entries: expSection.value.entries.map(e => {
const key = `${e.title}|${e.company}`
return {
title: e.title, title: e.title,
company: e.company, company: e.company,
accepted: expAccepted.value[`${e.title}|${e.company}`] ?? true, accepted: expAccepted.value[key] ?? true,
})), edited_bullets: expEdited.value[key] ?? e.proposed_bullets,
}
}),
} }
} }

View file

@ -9,11 +9,25 @@
<li v-for="b in entry.original_bullets" :key="b">{{ b }}</li> <li v-for="b in entry.original_bullets" :key="b">{{ b }}</li>
</ul> </ul>
</div> </div>
<div class="rp__diff-col"> <div class="rp__diff-col rp__diff-col--editable">
<span class="rp__diff-label">Proposed</span> <span class="rp__diff-label">Proposed edit below</span>
<ul class="rp__bullet-list"> <div class="rp__bullet-edit-list" role="list" :aria-label="`Edit proposed bullets for ${entry.title}`">
<li v-for="b in entry.proposed_bullets" :key="b">{{ b }}</li> <div
</ul> v-for="(bullet, idx) in editedBullets"
:key="idx"
class="rp__bullet-edit-row"
role="listitem"
>
<textarea
class="rp__bullet-textarea"
:value="bullet"
:aria-label="`Bullet ${idx + 1}`"
rows="2"
spellcheck="true"
@input="updateBullet(idx, ($event.target as HTMLTextAreaElement).value)"
/>
</div>
</div>
</div> </div>
</div> </div>
<label class="rp__accept-toggle"> <label class="rp__accept-toggle">
@ -28,7 +42,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ const props = defineProps<{
entry: { entry: {
title: string title: string
company: string company: string
@ -36,11 +50,18 @@ defineProps<{
proposed_bullets: string[] proposed_bullets: string[]
} }
accepted: boolean accepted: boolean
editedBullets: string[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:accepted': [v: boolean] 'update:accepted': [v: boolean]
'update:editedBullets': [v: string[]]
}>() }>()
function updateBullet(idx: number, value: string) {
const next = props.editedBullets.map((b, i) => (i === idx ? value : b))
emit('update:editedBullets', next)
}
</script> </script>
<style scoped> <style scoped>
@ -50,7 +71,25 @@ const emit = defineEmits<{
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); } .rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } } @media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); } .rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__diff-col--editable { gap: var(--space-2, 0.5rem); }
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); } .rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
.rp__bullet-list { margin: 0; padding-left: var(--space-4, 1rem); font-size: var(--font-sm, 0.875rem); line-height: 1.6; background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-6, 1.5rem); } .rp__bullet-list { margin: 0; padding-left: var(--space-4, 1rem); font-size: var(--font-sm, 0.875rem); line-height: 1.6; background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-6, 1.5rem); }
.rp__bullet-edit-list { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__bullet-edit-row { display: flex; align-items: flex-start; gap: var(--space-1, 0.25rem); }
.rp__bullet-textarea {
flex: 1;
font-size: var(--font-sm, 0.875rem);
line-height: 1.5;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
background: var(--color-surface, #eaeff8);
border: 1.5px solid var(--color-accent, #c4732a);
border-radius: var(--radius-sm, 0.25rem);
color: var(--color-text, #1a2338);
resize: vertical;
width: 100%;
box-sizing: border-box;
font-family: inherit;
}
.rp__bullet-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); } .rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
</style> </style>

View file

@ -6,9 +6,15 @@
<span class="rp__diff-label" aria-label="Original">Original</span> <span class="rp__diff-label" aria-label="Original">Original</span>
<p class="rp__diff-text">{{ section.original || '(empty)' }}</p> <p class="rp__diff-text">{{ section.original || '(empty)' }}</p>
</div> </div>
<div class="rp__diff-col"> <div class="rp__diff-col rp__diff-col--editable">
<span class="rp__diff-label" aria-label="Proposed">Proposed</span> <span class="rp__diff-label" aria-label="Proposed editable">Proposed</span>
<p class="rp__diff-text">{{ section.proposed }}</p> <textarea
class="rp__edit-textarea"
:value="editedProposed"
:aria-label="`Edit proposed summary`"
spellcheck="true"
@input="emit('update:editedProposed', ($event.target as HTMLTextAreaElement).value)"
/>
</div> </div>
</div> </div>
<label class="rp__accept-toggle"> <label class="rp__accept-toggle">
@ -28,10 +34,12 @@ import type { TextDiff } from '../ResumeReviewModal.vue'
defineProps<{ defineProps<{
section: TextDiff section: TextDiff
accepted: boolean accepted: boolean
editedProposed: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:accepted': [v: boolean] 'update:accepted': [v: boolean]
'update:editedProposed': [v: string]
}>() }>()
</script> </script>
@ -41,7 +49,23 @@ const emit = defineEmits<{
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); } .rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } } @media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); } .rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__diff-col--editable { gap: var(--space-2, 0.5rem); }
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); } .rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
.rp__diff-text { font-size: var(--font-sm, 0.875rem); line-height: 1.6; padding: var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); margin: 0; } .rp__diff-text { font-size: var(--font-sm, 0.875rem); line-height: 1.6; padding: var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); margin: 0; }
.rp__edit-textarea {
font-size: var(--font-sm, 0.875rem);
line-height: 1.6;
padding: var(--space-3, 0.75rem);
background: var(--color-surface, #eaeff8);
border: 1.5px solid var(--color-accent, #c4732a);
border-radius: var(--radius-sm, 0.25rem);
color: var(--color-text, #1a2338);
resize: vertical;
min-height: 7rem;
width: 100%;
box-sizing: border-box;
font-family: inherit;
}
.rp__edit-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); } .rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
</style> </style>