feat(resume-optimizer): make proposed text editable in review modal and preview
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:
parent
77e49db4e9
commit
f4a524ba0b
6 changed files with 127 additions and 38 deletions
|
|
@ -667,7 +667,8 @@ def approve_resume(job_id: int, body: dict):
|
|||
raise HTTPException(400, "preview_struct is required")
|
||||
|
||||
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)
|
||||
_finalize(db_path=db_path, job_id=job_id, final_text=final_text)
|
||||
|
|
|
|||
|
|
@ -532,27 +532,37 @@ def apply_review_decisions(
|
|||
struct["skills"] = sorted(original_kept | approved_additions)
|
||||
break
|
||||
|
||||
# ── Summary: accept proposed or revert to original ──────────────────────
|
||||
if not decisions.get("summary", {}).get("accepted", True):
|
||||
# ── Summary: accept/reject + optional user-edited text ─────────────────
|
||||
summary_dec = decisions.get("summary", {})
|
||||
if not summary_dec.get("accepted", True):
|
||||
for sec in sections:
|
||||
if sec["section"] == "summary":
|
||||
struct["career_summary"] = sec.get("original", struct.get("career_summary", ""))
|
||||
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 ─────────────────────────────────
|
||||
exp_decisions: dict[str, bool] = {
|
||||
f"{ed.get('title', '')}|{ed.get('company', '')}": ed.get("accepted", True)
|
||||
# ── Experience: per-entry accept/reject + optional user-edited bullets ──
|
||||
exp_entry_map: dict[str, dict] = {
|
||||
f"{ed.get('title', '')}|{ed.get('company', '')}": ed
|
||||
for ed in (decisions.get("experience", {}).get("accepted_entries") or [])
|
||||
}
|
||||
for sec in sections:
|
||||
if sec["section"] == "experience":
|
||||
for entry_diff in (sec.get("entries") or []):
|
||||
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 []):
|
||||
if (exp_entry.get("title") == entry_diff["title"] and
|
||||
exp_entry.get("company") == entry_diff["company"]):
|
||||
if not accepted:
|
||||
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
|
||||
|
||||
return struct
|
||||
|
|
|
|||
|
|
@ -112,16 +112,15 @@
|
|||
<span class="rop__preview-badge">Preview — not yet saved</span>
|
||||
</div>
|
||||
<textarea
|
||||
:value="previewText"
|
||||
class="rop__textarea rop__textarea--preview"
|
||||
aria-label="Resume preview text"
|
||||
spellcheck="false"
|
||||
readonly
|
||||
v-model="previewText"
|
||||
class="rop__textarea"
|
||||
aria-label="Resume preview — editable before approving"
|
||||
spellcheck="true"
|
||||
/>
|
||||
<p class="rop__preview-hint">
|
||||
Review the assembled resume above. If it looks right, click
|
||||
<strong>Approve & Save</strong> to lock it in. You can also go back and adjust
|
||||
your review decisions.
|
||||
Review and edit the assembled resume above. Click
|
||||
<strong>Approve & Save</strong> to lock it in, or go back to adjust
|
||||
your section-level decisions.
|
||||
</p>
|
||||
<div class="rop__save-to-library">
|
||||
<label class="rop__save-toggle">
|
||||
|
|
@ -492,7 +491,10 @@ async function approveResume() {
|
|||
if (!previewStruct.value) return
|
||||
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) {
|
||||
body.save_to_library = true
|
||||
body.resume_name = savedResumeName.value.trim() || `Optimized for job ${props.jobId}`
|
||||
|
|
@ -1099,10 +1101,6 @@ onUnmounted(stopPolling)
|
|||
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 {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@
|
|||
<SummaryPage
|
||||
:section="summarySection!"
|
||||
:accepted="summaryAccepted"
|
||||
:edited-proposed="summaryEdited"
|
||||
@update:accepted="summaryAccepted = $event"
|
||||
@update:editedProposed="summaryEdited = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
@ -72,7 +74,9 @@
|
|||
<ExperiencePage
|
||||
:entry="currentEntry!"
|
||||
:accepted="expAccepted[currentPage.entryKey!] ?? true"
|
||||
:edited-bullets="expEdited[currentPage.entryKey!] ?? currentEntry!.proposed_bullets"
|
||||
@update:accepted="expAccepted[currentPage.entryKey!] = $event"
|
||||
@update:editedBullets="expEdited[currentPage.entryKey!] = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
@ -255,11 +259,17 @@ function goTo(idx: number) {
|
|||
const approvedSkills = ref<Set<string>>(new Set(skillsSection.value?.added ?? []))
|
||||
const skillFramings = ref<Map<string, GapFraming>>(new Map())
|
||||
const summaryAccepted = ref(true)
|
||||
const summaryEdited = ref<string>(summarySection.value?.proposed ?? '')
|
||||
const expAccepted = ref<Record<string, boolean>>(
|
||||
Object.fromEntries(
|
||||
(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) {
|
||||
interactedPages.value = new Set([...interactedPages.value, 'skills'])
|
||||
|
|
@ -322,15 +332,22 @@ function emitSubmit() {
|
|||
decisions.skills = { approved_additions: [...approvedSkills.value] }
|
||||
}
|
||||
if (summarySection.value) {
|
||||
decisions.summary = { accepted: summaryAccepted.value }
|
||||
decisions.summary = {
|
||||
accepted: summaryAccepted.value,
|
||||
edited_text: summaryEdited.value,
|
||||
}
|
||||
}
|
||||
if (expSection.value) {
|
||||
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,
|
||||
company: e.company,
|
||||
accepted: expAccepted.value[`${e.title}|${e.company}`] ?? true,
|
||||
})),
|
||||
accepted: expAccepted.value[key] ?? true,
|
||||
edited_bullets: expEdited.value[key] ?? e.proposed_bullets,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,25 @@
|
|||
<li v-for="b in entry.original_bullets" :key="b">{{ b }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rp__diff-col">
|
||||
<span class="rp__diff-label">Proposed</span>
|
||||
<ul class="rp__bullet-list">
|
||||
<li v-for="b in entry.proposed_bullets" :key="b">{{ b }}</li>
|
||||
</ul>
|
||||
<div class="rp__diff-col rp__diff-col--editable">
|
||||
<span class="rp__diff-label">Proposed — edit below</span>
|
||||
<div class="rp__bullet-edit-list" role="list" :aria-label="`Edit proposed bullets for ${entry.title}`">
|
||||
<div
|
||||
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>
|
||||
<label class="rp__accept-toggle">
|
||||
|
|
@ -28,7 +42,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
entry: {
|
||||
title: string
|
||||
company: string
|
||||
|
|
@ -36,11 +50,18 @@ defineProps<{
|
|||
proposed_bullets: string[]
|
||||
}
|
||||
accepted: boolean
|
||||
editedBullets: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -50,7 +71,25 @@ const emit = defineEmits<{
|
|||
.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; } }
|
||||
.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__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); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@
|
|||
<span class="rp__diff-label" aria-label="Original">Original</span>
|
||||
<p class="rp__diff-text">{{ section.original || '(empty)' }}</p>
|
||||
</div>
|
||||
<div class="rp__diff-col">
|
||||
<span class="rp__diff-label" aria-label="Proposed">Proposed</span>
|
||||
<p class="rp__diff-text">{{ section.proposed }}</p>
|
||||
<div class="rp__diff-col rp__diff-col--editable">
|
||||
<span class="rp__diff-label" aria-label="Proposed — editable">Proposed</span>
|
||||
<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>
|
||||
<label class="rp__accept-toggle">
|
||||
|
|
@ -28,10 +34,12 @@ import type { TextDiff } from '../ResumeReviewModal.vue'
|
|||
defineProps<{
|
||||
section: TextDiff
|
||||
accepted: boolean
|
||||
editedProposed: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:accepted': [v: boolean]
|
||||
'update:editedProposed': [v: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
|
@ -41,7 +49,23 @@ const emit = defineEmits<{
|
|||
.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; } }
|
||||
.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-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); }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue