Skip to content

Commit c0214b2

Browse files
feat(ui+api): support Markdown paste for Surgical Update + tests
- UI: add Markdown textarea in Surgical Update; JSON optional fallback\n- API: accept 'markdown' field, parse to experiences + section updates\n- Parser: handle headings, bullets, and '*Tech:*' lines; tags at experience level\n- CLI: --markdown-file support in surgical_resume_update.py\n- Tests: parser + API E2E (all tests passing)
1 parent a8384f2 commit c0214b2

7 files changed

Lines changed: 709 additions & 18 deletions

File tree

scripts/surgical_resume_update.py

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ def main():
227227
"--experiences-file",
228228
help="Path to JSON file with new experiences",
229229
)
230+
parser.add_argument(
231+
"--markdown-file",
232+
help="Path to Markdown file with summary/core competencies/experiences",
233+
)
230234
parser.add_argument(
231235
"--replace-employers",
232236
nargs="+",
@@ -246,11 +250,8 @@ def main():
246250
if not args.resume and not args.resume_id:
247251
parser.error("Either --resume or --resume-id must be provided")
248252

249-
if not args.experiences_file and not args.updates_file:
250-
parser.error("Either --experiences-file or --updates-file must be provided")
251-
252-
if args.experiences_file and not args.replace_employers:
253-
parser.error("--experiences-file requires --replace-employers to specify which employers to replace")
253+
if not (args.experiences_file or args.updates_file or args.markdown_file):
254+
parser.error("Provide at least one of --experiences-file, --updates-file, or --markdown-file")
254255

255256
try:
256257
data_dir = Path(args.data_dir)
@@ -278,31 +279,66 @@ def main():
278279
print(f"\nLoading resume...")
279280
resume_data = load_resume(data_dir, resume_id)
280281

281-
# Replace experiences surgically
282-
if args.experiences_file and args.replace_employers:
282+
# Gather inputs
283+
new_experiences: List[Dict[str, Any]] = []
284+
updates: Dict[str, Any] = {}
285+
derived_employers: List[str] = []
286+
287+
# From JSON experiences file
288+
if args.experiences_file:
283289
print(f"\nParsing new experiences from: {args.experiences_file}")
284290
new_experiences = parse_json_experiences(Path(args.experiences_file))
285291
print(f"Found {len(new_experiences)} new experience entries")
286292

293+
# From Markdown file
294+
if args.markdown_file:
295+
md_path = Path(args.markdown_file)
296+
if not md_path.exists():
297+
raise FileNotFoundError(f"Markdown file not found: {md_path}")
298+
print(f"\nParsing markdown from: {md_path}")
299+
from src.utils.markdown_resume_parser import parse_surgical_markdown
300+
md_text = md_path.read_text(encoding="utf-8")
301+
parsed = parse_surgical_markdown(md_text)
302+
md_exps = parsed.get("experiences") or []
303+
md_updates = parsed.get("updates") or {}
304+
if not new_experiences and md_exps:
305+
new_experiences = md_exps
306+
print(f"Derived {len(new_experiences)} experiences from markdown")
307+
# Merge updates (markdown provides defaults; explicit updates file will override)
308+
updates.update(md_updates)
309+
if not derived_employers:
310+
derived_employers = [e.get("employer", "") for e in md_exps if e.get("employer")]
311+
312+
# From updates JSON file (overrides markdown updates where overlapping)
313+
if args.updates_file:
314+
print(f"\nLoading section updates from: {args.updates_file}")
315+
upd_file_data = load_updates(Path(args.updates_file))
316+
updates.update(upd_file_data)
317+
318+
# Determine employers to replace
319+
employers_to_replace = args.replace_employers or derived_employers
320+
if new_experiences and not employers_to_replace:
321+
employers_to_replace = [e.get("employer", "") for e in new_experiences if e.get("employer")]
322+
323+
# Replace experiences surgically if applicable
324+
if new_experiences and employers_to_replace:
287325
print(f"\nReplacing experiences for:")
288-
for employer in args.replace_employers:
326+
for employer in employers_to_replace:
289327
print(f" • {employer}")
290-
291328
resume_data = replace_experiences_surgically(
292-
resume_data, new_experiences, args.replace_employers
329+
resume_data, new_experiences, employers_to_replace
293330
)
294-
print(f"✓ Experiences replaced surgically")
331+
print("✓ Experiences replaced surgically")
332+
elif new_experiences and not employers_to_replace:
333+
print("Warning: No employers to replace were provided or derived; experiences not applied", file=sys.stderr)
295334

296335
# Update other sections
297-
if args.updates_file:
298-
print(f"\nLoading section updates from: {args.updates_file}")
299-
updates = load_updates(Path(args.updates_file))
300-
print(f"Updating {len(updates)} sections:")
336+
if updates:
337+
print(f"\nUpdating {len(updates)} sections:")
301338
for section in updates.keys():
302339
print(f" • {section}")
303-
304340
resume_data = update_sections(resume_data, updates)
305-
print(f"✓ Sections updated")
341+
print("✓ Sections updated")
306342

307343
# Save resume
308344
print(f"\nSaving resume...")

src/api/app.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1997,6 +1997,173 @@ def rag_index():
19971997
)
19981998

19991999

2000+
2001+
2002+
@app.route("/api/resumes/<resume_id>/surgical-update", methods=["POST"])
2003+
def surgical_update_resume(resume_id: str):
2004+
"""
2005+
Surgically update a resume by replacing only specified employers' experiences and/or updating sections.
2006+
2007+
Request body accepts any of:
2008+
- markdown: Raw Markdown text containing PROFESSIONAL SUMMARY, CORE COMPETENCIES, and RELEVANT EXPERIENCE sections
2009+
- experiences: Array or {"experiences": [...]} (used if provided; otherwise derived from markdown)
2010+
- replace_employers: Optional list of employer names to replace. If omitted, derived from experiences[].employer
2011+
- updates: Optional dict of section updates (e.g., {"summary": str, "core_competencies": list|str}); merged with markdown-derived updates
2012+
- dry_run: Optional bool (default False) to preview without saving
2013+
2014+
Returns JSON with success, optional meta (replaced/missing employers, counts), and optionally updated data on dry_run.
2015+
"""
2016+
try:
2017+
body = request.get_json(force=True) or {}
2018+
experiences_input = body.get("experiences")
2019+
replace_employers = body.get("replace_employers") or []
2020+
updates = body.get("updates") or {}
2021+
dry_run = bool(body.get("dry_run", False))
2022+
2023+
# Optionally parse Markdown to derive experiences and section updates
2024+
markdown_input = body.get("markdown") or body.get("markdown_input")
2025+
if markdown_input:
2026+
try:
2027+
from utils.markdown_resume_parser import parse_surgical_markdown
2028+
parsed = parse_surgical_markdown(str(markdown_input))
2029+
md_exps = parsed.get("experiences") or []
2030+
md_updates = parsed.get("updates") or {}
2031+
if experiences_input is None and md_exps:
2032+
experiences_input = md_exps
2033+
# Merge updates, preferring explicitly provided values
2034+
if md_updates and not updates:
2035+
updates = md_updates
2036+
elif md_updates and updates:
2037+
merged = dict(md_updates)
2038+
merged.update(updates)
2039+
updates = merged
2040+
if not replace_employers and md_exps:
2041+
replace_employers = [e.get("employer", "") for e in md_exps if e.get("employer")]
2042+
except Exception as _e:
2043+
return jsonify({"error": f"Failed to parse markdown: {str(_e)}"}), 400
2044+
2045+
2046+
# Helpers (scoped here to avoid global pollution)
2047+
def _normalize_employer_name(name: str) -> str:
2048+
n = (name or "").lower()
2049+
return n.replace("–", "-").replace("—", "-").replace("−", "-")
2050+
2051+
def _ensure_experience_level_tags(exp: Dict[str, Any]) -> Dict[str, Any]:
2052+
promoted = []
2053+
for b in (exp.get("bullets") or []):
2054+
tags = b.get("tags")
2055+
if isinstance(tags, list):
2056+
promoted.extend([str(t) for t in tags])
2057+
existing = (exp.get("tags") or [])
2058+
merged, seen = [], set()
2059+
for t in list(existing) + promoted:
2060+
if t not in seen:
2061+
merged.append(t)
2062+
seen.add(t)
2063+
if merged:
2064+
exp["tags"] = merged
2065+
return exp
2066+
2067+
def _parse_experiences(inp: Any) -> List[Dict[str, Any]]:
2068+
if inp is None:
2069+
return []
2070+
if isinstance(inp, str):
2071+
import json as _json
2072+
parsed = _json.loads(inp)
2073+
else:
2074+
parsed = inp
2075+
if isinstance(parsed, list):
2076+
return [ _ensure_experience_level_tags(dict(e)) for e in parsed ]
2077+
if isinstance(parsed, dict) and isinstance(parsed.get("experiences"), list):
2078+
return [ _ensure_experience_level_tags(dict(e)) for e in parsed["experiences"] ]
2079+
raise ValueError("'experiences' must be an array or an object containing an 'experiences' array")
2080+
2081+
def _select_matching(new_exps: List[Dict[str, Any]], employers: List[str]) -> Tuple[List[Dict[str, Any]], List[str]]:
2082+
by_emp = { _normalize_employer_name(e.get("employer", "")): e for e in new_exps }
2083+
selected, missing = [], []
2084+
for emp in employers:
2085+
key = _normalize_employer_name(emp)
2086+
if key in by_emp:
2087+
selected.append(_ensure_experience_level_tags(by_emp[key]))
2088+
else:
2089+
missing.append(emp)
2090+
return selected, missing
2091+
2092+
def _replace_surgically(resume: Dict[str, Any], new_exps: List[Dict[str, Any]], employers: List[str]) -> Dict[str, Any]:
2093+
if "experience" not in resume:
2094+
resume["experience"] = []
2095+
targets = { _normalize_employer_name(emp) for emp in employers }
2096+
kept = [e for e in (resume.get("experience") or []) if _normalize_employer_name(e.get("employer", "")) not in targets]
2097+
selected, missing = _select_matching(new_exps, employers)
2098+
resume["experience"] = selected + kept
2099+
return resume, selected, missing
2100+
2101+
def _update_sections(resume: Dict[str, Any], updates_dict: Dict[str, Any]) -> Dict[str, Any]:
2102+
# Coerce core_competencies to list if a newline string is provided
2103+
upd = dict(updates_dict or {})
2104+
core = upd.get("core_competencies")
2105+
if isinstance(core, str):
2106+
upd["core_competencies"] = [line.strip() for line in core.splitlines() if line.strip()]
2107+
for section, val in upd.items():
2108+
resume[section] = val
2109+
return resume
2110+
2111+
# Load target resume
2112+
data = resume_model.get(resume_id)
2113+
if not data:
2114+
return jsonify({"error": "Resume not found"}), 404
2115+
2116+
before_count = len(data.get("experience", []) or [])
2117+
2118+
# Apply experiences replacement if provided
2119+
replaced_employers_effective: List[str] = []
2120+
missing_employers: List[str] = []
2121+
if experiences_input is not None:
2122+
new_exps = _parse_experiences(experiences_input)
2123+
# Derive employers if not provided
2124+
if not replace_employers:
2125+
replace_employers = [e.get("employer", "") for e in new_exps if e.get("employer")]
2126+
# Deduplicate while preserving order
2127+
seen = set()
2128+
employers_clean = []
2129+
for emp in replace_employers:
2130+
key = _normalize_employer_name(emp)
2131+
if key and key not in seen:
2132+
employers_clean.append(emp)
2133+
seen.add(key)
2134+
data, selected, missing = _replace_surgically(data, new_exps, employers_clean)
2135+
replaced_employers_effective = [e.get("employer", "") for e in selected]
2136+
missing_employers = missing
2137+
2138+
# Apply section updates if provided
2139+
if updates:
2140+
data = _update_sections(data, updates)
2141+
2142+
after_count = len(data.get("experience", []) or [])
2143+
2144+
meta = {
2145+
"replaced_employers": replaced_employers_effective,
2146+
"missing_employers": missing_employers,
2147+
"total_experience_before": before_count,
2148+
"total_experience_after": after_count,
2149+
}
2150+
2151+
if dry_run:
2152+
return jsonify({"success": True, "dry_run": True, "meta": meta, "updated_preview": data})
2153+
2154+
# Persist changes
2155+
ok = resume_model.update(resume_id, data)
2156+
if not ok:
2157+
return jsonify({"error": "Failed to save updated resume"}), 500
2158+
2159+
return jsonify({"success": True, "message": "Surgical update applied", "meta": meta})
2160+
2161+
except ValueError as e:
2162+
return jsonify({"error": str(e)}), 400
2163+
except Exception as e:
2164+
import traceback
2165+
return jsonify({"error": f"Failed to apply surgical update: {str(e)}", "traceback": traceback.format_exc()}), 500
2166+
20002167
@app.route("/src/web/<path:filename>")
20012168
def serve_web_files(filename):
20022169
"""Serve web UI files."""

0 commit comments

Comments
 (0)