@@ -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>" )
20012168def serve_web_files (filename ):
20022169 """Serve web UI files."""
0 commit comments