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")
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 & Save</strong> to lock it in. You can also go back and adjust
|
<strong>Approve & 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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue