"""Validated resume document edits and proposal lifecycle operations.""" from __future__ import annotations import re from copy import deepcopy from datetime import UTC, datetime from typing import Any from .profile_summary import generated_summary from .skill_classifier import classify_skills from .validators import mask_phone from .resume_document_core import ( DocumentError, entry_fingerprint, find_bullet, find_entry, require_entry, ) WRITABLE_ENTRY_FIELDS = { "school", "major", "degree", "company", "position", "project_name", "project_role", "start_date", "end_date_or_present", "description", "name", "title", "organization", "role", "award", "date", "value", } WRITABLE_BASICS_FIELDS = {"name", "phone", "email", "city", "portfolio_url"} _MONTH = re.compile(r"^(?:19|20)\d{2}-(?:0[1-9]|1[0-2])$") _PHONE = re.compile(r"^1[3-9]\d{9}$") def _validate_entry_fields(fields: dict[str, Any]) -> None: for key, value in fields.items(): if key not in WRITABLE_ENTRY_FIELDS: raise DocumentError("field_not_writable", f"Field '{key}' is not writable") if key == "start_date" and value is not None and not _MONTH.fullmatch(str(value)): raise DocumentError("invalid_field", "start_date must use YYYY-MM") if ( key == "end_date_or_present" and value is not None and value != "present" and not _MONTH.fullmatch(str(value)) ): raise DocumentError("invalid_field", "end_date_or_present must use YYYY-MM or present") def _validate_basics_fields(fields: dict[str, Any]) -> None: for key in fields: if key not in WRITABLE_BASICS_FIELDS: raise DocumentError("field_not_writable", f"Basics field '{key}' is not writable") name = fields.get("name") if name is not None and not (0 < len(str(name).strip()) <= 64): raise DocumentError("invalid_field", "name must contain 1 to 64 characters") phone = fields.get("phone") if phone is not None and not _PHONE.fullmatch(str(phone)): raise DocumentError("invalid_field", "phone must be a valid mainland China mobile number") def apply_update_basics(content: dict[str, Any], fields: dict[str, Any]) -> dict[str, Any]: _validate_basics_fields(fields) result = deepcopy(content) basics = result.setdefault("basics", {}) for key, value in fields.items(): if key == "phone": basics.pop("phone", None) basics["masked_phone"] = mask_phone(str(value)) continue basics[key] = value.strip() if isinstance(value, str) else value return mark_profile_summary_stale(result) def apply_update_skill_groups(content: dict[str, Any], skills: list[Any]) -> dict[str, Any]: result = deepcopy(content) clean: list[str] = [] seen: set[str] = set() for value in skills: skill = str(value or "").strip() key = skill.casefold() if not skill or len(skill) > 48 or key in seen: continue seen.add(key) clean.append(skill) result["skill_groups"] = classify_skills(clean) return mark_profile_summary_stale(result) def apply_update_entry(content: dict[str, Any], entry_id: str, fields: dict[str, Any]) -> dict[str, Any]: _validate_entry_fields(fields) result = deepcopy(content) entry = require_entry(result, entry_id) for key, value in fields.items(): if value is None: entry.pop(key, None) else: entry[key] = value.strip() if isinstance(value, str) else value entry["provenance"] = "user_edited" return mark_profile_summary_stale(result) def apply_update_bullet( content: dict[str, Any], entry_id: str, bullet_id: str, text: str ) -> dict[str, Any]: clean = text.strip() if not (0 < len(clean) <= 200): raise DocumentError("invalid_field", "bullet must contain 1 to 200 characters") result = deepcopy(content) entry = require_entry(result, entry_id) bullet = find_bullet(entry, bullet_id) if bullet is None: raise DocumentError("bullet_not_found", "Bullet not found in entry") bullet["text"] = clean entry["provenance"] = "user_edited" return mark_profile_summary_stale(result) def apply_delete_entry(content: dict[str, Any], entry_id: str) -> dict[str, Any]: result = deepcopy(content) found = find_entry(result, entry_id) if found is None: raise DocumentError("entry_not_found", "Entry not found in resume") section, _ = found section["items"] = [item for item in section["items"] if item.get("id") != entry_id] if not section["items"]: result["sections"] = [item for item in result["sections"] if item.get("id") != section.get("id")] return mark_profile_summary_stale(result) def apply_delete_bullet(content: dict[str, Any], entry_id: str, bullet_id: str) -> dict[str, Any]: result = deepcopy(content) entry = require_entry(result, entry_id) if find_bullet(entry, bullet_id) is None: raise DocumentError("bullet_not_found", "Bullet not found in entry") entry["resume_bullets"] = [ bullet for bullet in entry.get("resume_bullets") or [] if bullet.get("id") != bullet_id ] entry["provenance"] = "user_edited" return mark_profile_summary_stale(result) def set_pending_proposal( content: dict[str, Any], entry_id: str, optimized_description: str, *, source: str, changes: list[str] | None = None, generation_source: str | None = None, fallback_reason: str | None = None, missing_facts: list[str] | None = None, unconfirmed_suggestions: list[str] | None = None, optional_enhancements: list[str] | None = None, validation_warnings: list[str] | None = None, star: dict[str, Any] | None = None, omitted_fact_ids: list[str] | None = None, ) -> dict[str, Any]: clean = str(optimized_description).strip() if not clean: raise DocumentError("nothing_to_expand", "Expander produced no optimized description") result = deepcopy(content) entry = require_entry(result, entry_id) proposal = { "optimized_description": clean, "changes": [str(item).strip() for item in changes or [] if str(item).strip()][:5], "source": source, "based_on": entry_fingerprint(entry), "created_at": datetime.now(UTC).isoformat(), } if generation_source: proposal["generation_source"] = generation_source if fallback_reason: proposal["fallback_reason"] = fallback_reason for key, values in ( ("missing_facts", missing_facts), ("unconfirmed_suggestions", unconfirmed_suggestions), ("optional_enhancements", optional_enhancements), ("validation_warnings", validation_warnings), ): cleaned = [str(item).strip() for item in values or [] if str(item).strip()] if cleaned: proposal[key] = list(dict.fromkeys(cleaned))[:8] if isinstance(star, dict) and star: proposal["star"] = deepcopy(star) if omitted_fact_ids: proposal["omitted_fact_ids"] = [ str(item).strip() for item in omitted_fact_ids if str(item).strip() ][:24] entry["pending_proposal"] = proposal return result def confirm_proposal(content: dict[str, Any], entry_id: str) -> dict[str, Any]: result = deepcopy(content) entry = require_entry(result, entry_id) proposal = entry.get("pending_proposal") if not isinstance(proposal, dict): raise DocumentError("optimize_not_pending", "No pending proposal for entry") if proposal.get("based_on") != entry_fingerprint(entry): raise DocumentError("proposal_stale", "Entry changed after proposal was created") entry["previous_version"] = { "description": entry.get("description"), "provenance": entry.get("provenance", "user_provided"), } entry["description"] = str(proposal["optimized_description"]).strip() entry["provenance"] = proposal.get("source", "ai_expanded") entry.pop("pending_proposal", None) return mark_profile_summary_stale(result) def reject_proposal(content: dict[str, Any], entry_id: str) -> dict[str, Any]: result = deepcopy(content) entry = require_entry(result, entry_id) if "pending_proposal" not in entry: raise DocumentError("optimize_not_pending", "No pending proposal for entry") entry.pop("pending_proposal", None) return result def undo_entry(content: dict[str, Any], entry_id: str) -> dict[str, Any]: result = deepcopy(content) entry = require_entry(result, entry_id) previous = entry.get("previous_version") if not isinstance(previous, dict): raise DocumentError("nothing_to_undo", "No previous version stored for entry") if previous.get("description") is None: entry.pop("description", None) else: entry["description"] = previous["description"] entry["provenance"] = previous.get("provenance", "user_edited") entry.pop("previous_version", None) return mark_profile_summary_stale(result) def _summary(content: dict[str, Any]) -> dict[str, Any] | None: value = content.get("profile_summary") return value if isinstance(value, dict) else None def mark_profile_summary_stale(content: dict[str, Any]) -> dict[str, Any]: result = deepcopy(content) summary = _summary(result) if summary and str(summary.get("content") or "").strip(): summary["stale"] = True return result def set_generated_profile_summary( content: dict[str, Any], summary_text: str, *, replace_stale: bool = False ) -> dict[str, Any]: result = deepcopy(content) summary = _summary(result) if summary and not (replace_stale and summary.get("stale") is True): return result result["profile_summary"] = generated_summary(summary_text) return result def set_profile_summary_proposal(content: dict[str, Any], summary_text: str) -> dict[str, Any]: result = deepcopy(content) summary = _summary(result) if summary is None: summary = {"content": "", "source": "ai_generated", "generated_at": None, "stale": False} result["profile_summary"] = summary proposal = generated_summary(summary_text) summary["pending_proposal"] = { "content": proposal["content"], "source": "ai_generated", "generated_at": proposal["generated_at"], } return result def confirm_profile_summary_proposal(content: dict[str, Any]) -> dict[str, Any]: result = deepcopy(content) summary = _summary(result) proposal = summary.get("pending_proposal") if summary else None if not isinstance(proposal, dict) or not str(proposal.get("content") or "").strip(): raise DocumentError("profile_summary_not_pending", "No pending profile summary proposal") summary.update(generated_summary(str(proposal["content"]))) summary.pop("pending_proposal", None) return result def reject_profile_summary_proposal(content: dict[str, Any]) -> dict[str, Any]: result = deepcopy(content) summary = _summary(result) if not summary or "pending_proposal" not in summary: raise DocumentError("profile_summary_not_pending", "No pending profile summary proposal") summary.pop("pending_proposal", None) return result def apply_update_profile_summary(content: dict[str, Any], summary_text: str) -> dict[str, Any]: result = deepcopy(content) proposal = generated_summary(summary_text) result["profile_summary"] = { "content": proposal["content"], "source": "user_edited", "generated_at": proposal["generated_at"], "stale": False, } return result def set_entry_gap_report( content: dict[str, Any], entry_id: str, gaps: list[dict[str, Any]] ) -> dict[str, Any]: """Persist the latest gap analysis so the conversion panel survives other run changes.""" result = deepcopy(content) entry = require_entry(result, entry_id) entry["gap_report"] = { "gaps": [dict(gap) for gap in gaps], "based_on": entry_fingerprint(entry), } return result