from __future__ import annotations from copy import deepcopy from dataclasses import dataclass from functools import wraps import logging from threading import Lock from typing import Any, Callable from uuid import uuid4 from .database import Database from .enrichment import prepare_rewrite_confirmation, process_rewrite_confirmation from .fsm import ( FSMError, assistant_turn, component, gate_allowed, initial_turn, missing_fields, new_resume_transition, process_component_event, required_fields, ) from .llm_services import LLMServiceError, log_ai_event from .models import ( ActionResponse, AnchorType, BusinessResume, ComposerMode, ComponentEventRequest, CreateResumeRequest, CreateResumeResponse, CreateSessionRequest, GateView, MessageRequest, Stage, TimelineResponse, ) from .resume_document import merge_ids, merge_profile_refresh, set_generated_profile_summary from .resume_editing import ResumeEditingMixin from .optimization_flow import OptimizationFlowMixin from .target_position_suggester import TargetPositionSuggester from .experience_optimizer import ExperienceOptimizer, RuleStructuredExperienceOptimizer from .services import EntryExpander, ExperienceExtractor, ResumeRewriter from .profile_summary import ProfileSummaryGenerator, RuleBasedProfileSummaryGenerator from .skill_suggester import SkillSuggester from .resume_skill_advisor import recommend_skill_candidates from .offerpai_auth import ( OfferPaiAuthError, OfferPaiIdentity, OfferPaiIdentityProvider, ) from .offerpai_resume import ( SUPPORTED_SECTION_KINDS, OfferPaiResumeError, OfferPaiResumeProvider, build_offerpai_resume_payload, merge_offerpai_resume_snapshot, offerpai_payload_hash, offerpai_update_marker, ) from . import builder_conversation @dataclass(frozen=True) class OfferPaiReconcileResult: remote_id: str | None action: str def _serialize_offerpai_session(method: Any) -> Any: """Serialize OfferPai writes for one session inside the running process.""" @wraps(method) def wrapped( self: "ResumeAgent", session_id: str, *args: Any, **kwargs: Any ) -> Any: locks = self._offerpai_session_locks with locks[hash(session_id) % len(locks)]: return method(self, session_id, *args, **kwargs) return wrapped class ResumeAgent(ResumeEditingMixin, OptimizationFlowMixin): def __init__( self, database: Database, extractor: ExperienceExtractor, rewriter: ResumeRewriter, expander: EntryExpander, skill_suggester: SkillSuggester, experience_optimizer: ExperienceOptimizer | None = None, target_position_suggester: TargetPositionSuggester | None = None, profile_summary_generator: ProfileSummaryGenerator | None = None, offerpai_identity_provider: OfferPaiIdentityProvider | None = None, offerpai_resume_provider: OfferPaiResumeProvider | None = None, ) -> None: self.database = database self.extractor = extractor self.rewriter = rewriter self.expander = expander self.skill_suggester = skill_suggester self.experience_optimizer = experience_optimizer or RuleStructuredExperienceOptimizer() self.target_position_suggester = target_position_suggester self.profile_summary_generator = profile_summary_generator or RuleBasedProfileSummaryGenerator() self.offerpai_identity_provider = offerpai_identity_provider self.offerpai_resume_provider = offerpai_resume_provider # The documented pilot deployment is a single process. Striped locks # prevent duplicate creates and stale-over-new writes from concurrent # requests without retaining one lock object per session forever. self._offerpai_session_locks = tuple(Lock() for _ in range(64)) def recommend_skills(self, session_id: str, question: str) -> list[dict[str, Any]]: with self.database.transaction() as connection: session = self._session_or_404(connection, session_id) resume = self._resume_or_409(connection, session_id) existing = [ str(skill).strip() for group in resume["content"].get("skill_groups") or [] for skill in group.get("skills") or [] if str(skill).strip() ] return recommend_skill_candidates(session["profile"], existing, question, self.skill_suggester) def authenticate_external_token(self, external_token: str) -> OfferPaiIdentity: if self.offerpai_identity_provider is None: raise FSMError( "external_auth_unavailable", "OfferPai 账号服务尚未配置。", status_code=503, ) try: return self.offerpai_identity_provider.authenticate(external_token) except OfferPaiAuthError as exc: raise FSMError( exc.code, exc.public_message, status_code=exc.status_code, ) from exc def authorize_session( self, session_id: str, external_token: str ) -> OfferPaiIdentity: identity = self.authenticate_external_token(external_token) session = self.database.get_session(session_id) if session is None: raise FSMError("session_not_found", "Session not found", status_code=404) external_account = session["profile"].get("external_account") if not isinstance(external_account, dict) or ( external_account.get("provider") != "offerpai" or str(external_account.get("user_id") or "").strip() != identity.user_id ): raise FSMError( "external_auth_forbidden", "当前 OfferPai 账号无权访问该简历会话。", status_code=403, ) return identity def sync_resume_to_offerpai( self, session_id: str, external_token: str, *, raise_on_error: bool = True, ) -> str | None: result = self.reconcile_resume_with_offerpai( session_id, external_token, raise_on_error=raise_on_error, ) return result.remote_id @_serialize_offerpai_session def reconcile_resume_with_offerpai( self, session_id: str, external_token: str, *, raise_on_error: bool = True, ) -> OfferPaiReconcileResult: return self._reconcile_resume_with_offerpai_unlocked( session_id, external_token, raise_on_error=raise_on_error, ) @_serialize_offerpai_session def mutate_with_offerpai_sync( self, session_id: str, external_token: str, mutation: Callable[[], Any], *, allow_pulled_resume: bool = False, ) -> Any: """Hold the per-session sync lock across preflight, mutation and push.""" result = self._reconcile_resume_with_offerpai_unlocked( session_id, external_token, raise_on_error=True, ) if result.action == "pulled" and not allow_pulled_resume: raise FSMError( "offerpai_resume_changed", "The resume changed in OfferPai. Refresh before continuing.", status_code=409, ) response = mutation() post_result = self._reconcile_resume_with_offerpai_unlocked( session_id, external_token, raise_on_error=True, ) if post_result.action == "pulled": raise FSMError( "offerpai_resume_changed", "The resume changed in OfferPai. Refresh before continuing.", status_code=409, ) return response def _reconcile_resume_with_offerpai_unlocked( self, session_id: str, external_token: str, *, raise_on_error: bool, ) -> OfferPaiReconcileResult: provider = self.offerpai_resume_provider if provider is None: if raise_on_error: raise FSMError( "offerpai_resume_unavailable", "OfferPai resume storage is not configured.", status_code=503, ) return OfferPaiReconcileResult(None, "error") with self.database.transaction() as connection: session = self.database.fetch_session(connection, session_id) if session is None: raise FSMError("session_not_found", "Session not found", status_code=404) resume = self.database.fetch_resume(connection, session_id) if resume is None: return OfferPaiReconcileResult(None, "none") profile = deepcopy(session["profile"]) sync_state = ( deepcopy(profile.get("external_resume")) if isinstance(profile.get("external_resume"), dict) else {} ) resume_name = str(sync_state.get("resume_name") or "").strip() or ( self._offerpai_resume_name(session_id, profile) ) remote_id = str(sync_state.get("id") or "").strip() or None main_payload, section_payloads, unsupported = build_offerpai_resume_payload( resume["content"], profile, resume_name=resume_name, resume_id=remote_id, ) local_hash = offerpai_payload_hash(main_payload, section_payloads) try: listed_resumes = provider.list_resumes(external_token) listed_remote: dict[str, Any] | None = None if remote_id is not None: listed_remote = next( ( item for item in listed_resumes if str(item.get("id") or "").strip() == remote_id ), None, ) if listed_remote is None: raise OfferPaiResumeError( "offerpai_resume_not_found", "The OfferPai resume was deleted or is no longer accessible.", status_code=404, ) else: if sync_state.get("status") == "remote_deleted" or ( sync_state.get("last_error_code") == "offerpai_resume_not_found" and sync_state.get("resume_name") ): raise OfferPaiResumeError( "offerpai_resume_not_found", "The OfferPai resume was deleted or is no longer accessible.", status_code=404, ) matches = [ item for item in listed_resumes if str(item.get("resumeName") or "").strip() == resume_name and str(item.get("id") or "").strip() ] if len(matches) > 1: raise OfferPaiResumeError( "offerpai_resume_conflict", "Multiple OfferPai resumes have the same generated name.", status_code=409, ) if matches: listed_remote = matches[0] remote_id = str(listed_remote["id"]) sync_state = { **sync_state, "provider": "offerpai", "id": remote_id, "resume_name": resume_name, } else: return self._push_resume_to_offerpai_unlocked( session_id=session_id, external_token=external_token, resume=resume, sync_state=sync_state, resume_name=resume_name, remote_id=None, main_payload=main_payload, section_payloads=section_payloads, unsupported=unsupported, payload_hash=local_hash, ) marker = offerpai_update_marker(listed_remote or {}) baseline_hash = ( str(sync_state.get("payload_hash") or "").strip() or None if sync_state.get("payload_hash_version") == 2 else None ) if ( baseline_hash is not None and marker is not None and marker == sync_state.get("update_marker") and sync_state.get("status") in {"synced", "partial"} ): if local_hash != baseline_hash: return self._push_resume_to_offerpai_unlocked( session_id=session_id, external_token=external_token, resume=resume, sync_state=sync_state, resume_name=resume_name, remote_id=remote_id, main_payload=main_payload, section_payloads=section_payloads, unsupported=unsupported, payload_hash=local_hash, ) refreshed_state = { **sync_state, "provider": "offerpai", "id": remote_id, "resume_name": resume_name, "status": "partial" if unsupported else "synced", "synced_revision": resume["revision"], "payload_hash": baseline_hash, "payload_hash_version": 2, "update_marker": marker, "unsupported_sections": list(unsupported), "last_error_code": None, } if refreshed_state != sync_state: self._update_external_resume_state(session_id, refreshed_state) return OfferPaiReconcileResult(remote_id, "unchanged") remote_main, remote_sections, marker = ( self._read_stable_offerpai_snapshot_unlocked( external_token, remote_id, initial_marker=marker, ) ) remote_hash = offerpai_payload_hash(remote_main, remote_sections) remote_resume_name = ( str(remote_main.get("resumeName") or "").strip() or resume_name ) if local_hash == remote_hash: converged_state = { **sync_state, "provider": "offerpai", "id": remote_id, "resume_name": remote_resume_name, "status": "partial" if unsupported else "synced", "synced_revision": resume["revision"], "payload_hash": remote_hash, "payload_hash_version": 2, "update_marker": marker, "unsupported_sections": list(unsupported), "last_error_code": None, } self._update_external_resume_state(session_id, converged_state) return OfferPaiReconcileResult(remote_id, "converged") if baseline_hash is None: legacy_local_unchanged = ( sync_state.get("status") in {"synced", "partial"} and sync_state.get("synced_revision") == resume["revision"] ) if legacy_local_unchanged: return self._pull_offerpai_resume_unlocked( session_id=session_id, source_session=session, source_resume=resume, sync_state=sync_state, remote_id=remote_id, remote_main=remote_main, remote_sections=remote_sections, remote_hash=remote_hash, update_marker=marker, ) recoverable_uncertain_create = ( sync_state.get("status") in {"failed", "syncing"} and sync_state.get("last_error_code") in { None, "offerpai_resume_timeout", "offerpai_resume_unavailable", "offerpai_resume_invalid_response", } ) if recoverable_uncertain_create: return self._push_resume_to_offerpai_unlocked( session_id=session_id, external_token=external_token, resume=resume, sync_state=sync_state, resume_name=resume_name, remote_id=remote_id, main_payload=main_payload, section_payloads=section_payloads, unsupported=unsupported, payload_hash=local_hash, ) raise OfferPaiResumeError( "offerpai_resume_conflict", "Local and OfferPai resumes differ and have no shared sync baseline.", status_code=409, ) if local_hash == baseline_hash and remote_hash != baseline_hash: return self._pull_offerpai_resume_unlocked( session_id=session_id, source_session=session, source_resume=resume, sync_state=sync_state, remote_id=remote_id, remote_main=remote_main, remote_sections=remote_sections, remote_hash=remote_hash, update_marker=marker, ) if remote_hash == baseline_hash and local_hash != baseline_hash: return self._push_resume_to_offerpai_unlocked( session_id=session_id, external_token=external_token, resume=resume, sync_state=sync_state, resume_name=resume_name, remote_id=remote_id, main_payload=main_payload, section_payloads=section_payloads, unsupported=unsupported, payload_hash=local_hash, ) raise OfferPaiResumeError( "offerpai_resume_conflict", "The resume changed both locally and in OfferPai. Refresh and resolve the conflict.", status_code=409, ) except OfferPaiResumeError as exc: self._record_offerpai_failure( session_id, sync_state=sync_state, resume_name=resume_name, remote_id=remote_id, error=exc, ) if raise_on_error: raise FSMError( exc.code, exc.public_message, status_code=exc.status_code, ) from exc return OfferPaiReconcileResult( remote_id, "conflict" if exc.code == "offerpai_resume_conflict" else "error", ) def _push_resume_to_offerpai_unlocked( self, *, session_id: str, external_token: str, resume: dict[str, Any], sync_state: dict[str, Any], resume_name: str, remote_id: str | None, main_payload: dict[str, Any], section_payloads: dict[str, dict[str, Any]], unsupported: tuple[str, ...], payload_hash: str, ) -> OfferPaiReconcileResult: provider = self.offerpai_resume_provider assert provider is not None created_now = remote_id is None if created_now: if not provider.can_create(external_token): raise OfferPaiResumeError( "offerpai_resume_limit_reached", "The OfferPai resume limit has been reached.", status_code=409, ) remote_id = provider.save_main(external_token, main_payload) sync_state = { **sync_state, "provider": "offerpai", "id": remote_id, "resume_name": resume_name, "status": "syncing", "last_error_code": None, } self._update_external_resume_state(session_id, sync_state) else: main_payload = {**main_payload, "resumeId": remote_id} provider.save_main(external_token, main_payload) for kind in SUPPORTED_SECTION_KINDS: provider.replace_section( external_token, kind, resume_id=remote_id, items=section_payloads[kind].get("items") or [], ) listed = provider.list_resumes(external_token) remote_listing = next( ( item for item in listed if str(item.get("id") or "").strip() == remote_id ), None, ) if remote_listing is None: raise OfferPaiResumeError( "offerpai_resume_not_found", "The OfferPai resume disappeared while it was being saved.", status_code=404, ) update_marker = offerpai_update_marker(remote_listing) verified_main, verified_sections, update_marker = ( self._read_stable_offerpai_snapshot_unlocked( external_token, remote_id, initial_marker=update_marker, ) ) verified_hash = offerpai_payload_hash(verified_main, verified_sections) if verified_hash != payload_hash: raise OfferPaiResumeError( "offerpai_resume_conflict", "The OfferPai resume changed while it was being saved; please retry.", status_code=409, ) final_state = { **sync_state, "provider": "offerpai", "id": remote_id, "resume_name": resume_name, "status": "partial" if unsupported else "synced", "synced_revision": resume["revision"], "payload_hash": payload_hash, "payload_hash_version": 2, "update_marker": update_marker, "unsupported_sections": list(unsupported), "last_error_code": None, } self._update_external_resume_state(session_id, final_state) return OfferPaiReconcileResult( remote_id, "created" if created_now else "pushed", ) def _read_stable_offerpai_snapshot_unlocked( self, external_token: str, remote_id: str, *, initial_marker: str | None, ) -> tuple[ dict[str, Any], dict[str, list[dict[str, Any]]], str | None, ]: """Read the six remote resources and reject a torn multi-request snapshot. OfferPai does not expose a conditional version for these endpoints. The list endpoint's ``updateTime`` is therefore used as a best-effort read-version: when it changes during the six reads, retry once and then surface a conflict instead of merging fields from different versions. """ provider = self.offerpai_resume_provider assert provider is not None marker_before = initial_marker for attempt in range(2): remote_main = provider.get_main(external_token, remote_id) remote_sections = { kind: provider.list_section( external_token, kind, resume_id=remote_id, ) for kind in SUPPORTED_SECTION_KINDS } listed = provider.list_resumes(external_token) remote_listing = next( ( item for item in listed if str(item.get("id") or "").strip() == remote_id ), None, ) if remote_listing is None: raise OfferPaiResumeError( "offerpai_resume_not_found", "The OfferPai resume was deleted or is no longer accessible.", status_code=404, ) marker_after = offerpai_update_marker(remote_listing) if marker_before is not None and marker_after != marker_before: marker_before = marker_after continue if marker_before is None and marker_after is not None and attempt == 0: # There was no trusted marker before the read. Take one more # snapshot so two equal post-read markers establish stability. marker_before = marker_after continue return remote_main, remote_sections, marker_after raise OfferPaiResumeError( "offerpai_resume_conflict", "The OfferPai resume changed while it was being read; please retry.", status_code=409, ) def _pull_offerpai_resume_unlocked( self, *, session_id: str, source_session: dict[str, Any], source_resume: dict[str, Any], sync_state: dict[str, Any], remote_id: str, remote_main: dict[str, Any], remote_sections: dict[str, list[dict[str, Any]]], remote_hash: str, update_marker: str | None, ) -> OfferPaiReconcileResult: merged_content, merged_profile, unsupported = merge_offerpai_resume_snapshot( source_resume["content"], source_session["profile"], remote_main, remote_sections, ) self._invalidate_external_pull_state(merged_profile) remote_resume_name = ( str(remote_main.get("resumeName") or "").strip() or str(sync_state.get("resume_name") or "").strip() or self._offerpai_resume_name(session_id, merged_profile) ) with self.database.transaction(immediate=True) as connection: current_session = self.database.fetch_session(connection, session_id) current_resume = self.database.fetch_resume(connection, session_id) if current_session is None: raise FSMError("session_not_found", "Session not found", status_code=404) if current_resume is None: raise FSMError("resume_not_created", "Create the resume before editing it") if ( current_resume["revision"] != source_resume["revision"] or current_session["revision"] != source_session["revision"] ): raise FSMError( "offerpai_sync_race", "The local resume changed while OfferPai was being read. Retry.", status_code=409, ) updated_resume = self.database.update_resume( connection, session_id, merged_content, ) for run in self.database.list_active_optimization_runs( connection, session_id ): state = deepcopy(run["state"]) state["stale_reason"] = "offerpai_external_change" self.database.update_optimization_run( connection, session_id=session_id, run_id=run["id"], status="stale", state=state, proposal=run.get("proposal"), ) self.database.supersede_active_components(connection, session_id) merged_profile["external_resume"] = { **sync_state, "provider": "offerpai", "id": remote_id, "resume_name": remote_resume_name, "status": "partial" if unsupported else "synced", "synced_revision": updated_resume["revision"], "payload_hash": remote_hash, "payload_hash_version": 2, "update_marker": update_marker, "unsupported_sections": list(unsupported), "last_error_code": None, } self.database.update_session( connection, session_id, stage=current_session["stage"], profile=merged_profile, ) return OfferPaiReconcileResult(remote_id, "pulled") @staticmethod def _invalidate_external_pull_state(profile: dict[str, Any]) -> None: profile.pop("anchor_proposal", None) profile.pop("pending_experience", None) builder = profile.get("builder") if not isinstance(builder, dict): return builder["identity_draft"] = {} builder["pending_entry"] = None builder["editing_entry_id"] = None builder["editing_base_entry"] = None builder["selection_candidates"] = [] builder["revision_mode"] = False builder["last_confirmed_entry"] = None builder["pending_skill_candidates"] = [] builder.pop("pending_summary_proposal", None) def _record_offerpai_failure( self, session_id: str, *, sync_state: dict[str, Any], resume_name: str, remote_id: str | None, error: OfferPaiResumeError, ) -> None: current = self.database.get_session(session_id) current_state = ( deepcopy(current["profile"].get("external_resume")) if current is not None and isinstance(current["profile"].get("external_resume"), dict) else {} ) failure_state = { **sync_state, **current_state, "provider": "offerpai", "resume_name": resume_name, "status": ( "remote_deleted" if error.code == "offerpai_resume_not_found" and (remote_id or current_state.get("id")) else "conflict" if error.code == "offerpai_resume_conflict" else "failed" ), "last_error_code": error.code, } if remote_id: failure_state["id"] = remote_id self._update_external_resume_state(session_id, failure_state) def _update_external_resume_state( self, session_id: str, external_resume: dict[str, Any] ) -> None: with self.database.transaction(immediate=True) as connection: session = self.database.fetch_session(connection, session_id) if session is None: raise FSMError("session_not_found", "Session not found", status_code=404) profile = deepcopy(session["profile"]) profile["external_resume"] = external_resume self.database.update_session( connection, session_id, stage=session["stage"], profile=profile, increment_revision=False, ) @staticmethod def _offerpai_resume_name(session_id: str, profile: dict[str, Any]) -> str: name = str(profile.get("name") or "我的").strip()[:16] or "我的" suffix = session_id.rsplit("_", 1)[-1][-8:] return f"AI简历-{name}-{suffix}" def create_session( self, request: CreateSessionRequest, *, external_token: str | None = None ) -> TimelineResponse: account_phone = request.account_phone external_account: dict[str, Any] | None = None if external_token: identity = self.authenticate_external_token(external_token) account_phone = identity.mobile_number external_account = identity.profile_value() existing = self.database.find_latest_session_by_external_user_id( identity.user_id ) if existing is not None: with self.database.transaction(immediate=True) as connection: current = self.database.fetch_session(connection, existing["id"]) if current is None: raise FSMError( "session_not_found", "Session not found", status_code=404, ) profile = deepcopy(current["profile"]) profile["external_account"] = external_account profile["account_phone"] = account_phone if profile.get("phone_source") == "account": profile["phone"] = account_phone self.database.update_session( connection, current["id"], stage=current["stage"], profile=profile, increment_revision=False, ) return self.timeline(existing["id"]) session_id = f"session_{uuid4().hex}" profile: dict[str, Any] = { "account_phone": account_phone, "metadata": request.metadata, "anchor": {}, "experiences": [], } if external_account is not None: profile["external_account"] = external_account self.database.create_session( session_id, Stage.PRIVACY_CONSENT, profile, initial_turn(), ) return self.timeline(session_id) def timeline(self, session_id: str) -> TimelineResponse: session = self._advance_legacy_resume_source_stage(session_id) turns = self.database.list_turns(session_id) gate = self._gate(session) resume = self._resume_view(session) return TimelineResponse( session_id=session_id, session=self.database.session_view(session), turns=turns, stage=session["stage"], revision=session["revision"], draft_id=session.get("draft_id"), resume_id=session.get("resume_id"), resume=resume, missing_fields=gate.missing_fields, gate=gate, trace_id=self._trace_id(), ) def _advance_legacy_resume_source_stage(self, session_id: str) -> dict[str, Any]: """Move sessions parked on removed source/import screens into new-resume setup.""" session = self._require_session(session_id) removed_stages = {Stage.RESUME_SOURCE_SELECT, Stage.RESUME_IMPORT_UPLOAD} if Stage(session["stage"]) not in removed_stages or session.get("resume_id"): return session with self.database.transaction(immediate=True) as connection: current = self.database.fetch_session(connection, session_id, for_update=True) if current is None: raise FSMError("session_not_found", "Session not found", status_code=404) if Stage(current["stage"]) not in removed_stages or current.get("resume_id"): return current transition = new_resume_transition(current["profile"]) self.database.supersede_active_components(connection, session_id) updated = self.database.update_session( connection, session_id, stage=transition.stage, profile=transition.profile, ) self.database.insert_turn(connection, session_id=session_id, **transition.turn) return updated def component_event( self, session_id: str, request: ComponentEventRequest ) -> ActionResponse: with self.database.transaction(immediate=True) as connection: session = self.database.fetch_session(connection, session_id) if session is None: raise FSMError("session_not_found", "Session not found", status_code=404) block = self.database.fetch_block(connection, session_id, request.component_id) if block is None: raise FSMError("component_not_found", "Component not found", status_code=404) if block["type"] != "component": raise FSMError("invalid_component", "Events can only target component blocks", status_code=422) if block["lifecycle"] != "active": raise FSMError("component_not_active", "Component was already handled") if request.action == "create" and block["data"].get("component_name") in { "CreateResumeCard", "CreateRetryCard", }: raise FSMError( "use_create_endpoint", "Use POST /sessions/{session_id}/create for resume creation", status_code=422, ) if Stage(session["stage"]) == Stage.BUILDER_CONVERSATION: resume = self.database.fetch_resume(connection, session_id) if resume is None: raise FSMError("resume_not_created", "Create the resume before using Builder cards") transition = builder_conversation.process_component_event( session["profile"], block["data"], request.action, request.payload, resume["content"], self.skill_suggester, ) elif Stage(session["stage"]) == Stage.CONTENT_READY and block["data"].get( "confirmation_kind" ) == "rewrite": transition = process_rewrite_confirmation( session["profile"], request.action, request.payload ) else: transition = process_component_event( stage=Stage(session["stage"]), profile=session["profile"], component_data=block["data"], action=request.action, payload=request.payload, ) if getattr(transition, "polish_description", False): self._polish_module_entry(transition) if getattr(transition, "propose_anchor_optimization", False): self._propose_anchor_optimization(transition) if getattr(transition, "suggest_skills", False): self._suggest_skills(transition) if getattr(transition, "suggest_target_positions", False): self._suggest_target_positions(transition) anchor_proposal = transition.profile.get("anchor_proposal") if transition.stage == Stage.MINIMUM_READY: transition.profile.pop("anchor_proposal", None) if ( isinstance(anchor_proposal, dict) and isinstance(transition.profile.get("anchor"), dict) and request.payload.get("use_optimized") is True ): transition.profile["anchor"]["description"] = anchor_proposal[ "optimized_description" ] transition.profile["anchor"]["provenance"] = anchor_proposal["source"] elif transition.stage == Stage.ANCHOR_COLLECTING: transition.profile.pop("anchor_proposal", None) self.database.update_block( connection, block["id"], lifecycle=transition.lifecycle, ) draft_id = session.get("draft_id") if transition.create_draft: draft_id = draft_id or f"draft_{uuid4().hex}" preview = merge_ids(None, self.rewriter.rewrite(transition.profile)) transition.turn["blocks"].insert( -1, { "type": "resume_patch", "lifecycle": "submitted", "data": {"draft_id": draft_id, "operation": "replace", "value": preview}, }, ) resume_content = getattr(transition, "resume_content", None) if resume_content is None and getattr(transition, "refresh_resume", False): resume_content = self.rewriter.rewrite(transition.profile) transition.resume_content = resume_content resume = None if resume_content is not None: resume = self.database.fetch_resume(connection, session_id) if resume is None: raise FSMError("resume_not_created", "Create the resume before confirming content") resume_content = ( merge_profile_refresh(resume["content"], resume_content) if getattr(transition, "refresh_resume", False) else merge_ids(resume["content"], resume_content) ) if getattr(transition, "generate_profile_summary", False): resume = resume or self.database.fetch_resume(connection, session_id) if resume is None: raise FSMError("resume_not_created", "Create the resume before finishing content") base_content = resume_content if resume_content is not None else resume["content"] summary = base_content.get("profile_summary") should_generate_summary = not isinstance(summary, dict) or summary.get("stale") is True if should_generate_summary: try: summary_text = self.profile_summary_generator.generate(base_content) resume_content = set_generated_profile_summary( base_content, summary_text, replace_stale=True ) except Exception as exc: log_ai_event( "profile_summary_generation_failed", level=logging.WARNING, reason_code=getattr(exc, "reason_code", type(exc).__name__), exception=type(exc).__name__, ) if resume_content is not None: assert resume is not None resume = self.database.update_resume(connection, session_id, resume_content) if transition.stage == Stage.BUILDER_CONVERSATION: builder_conversation.reconcile_last_confirmed_entry( transition.profile, resume["content"] ) transition.turn["blocks"].insert( -1, { "type": "resume_patch", "lifecycle": "confirmed", "data": { "resume_id": resume["id"], "revision": resume["revision"], "operation": "replace", "value": resume_content, }, }, ) updated = self.database.update_session( connection, session_id, stage=transition.stage, profile=transition.profile, draft_id=draft_id, ) turn_id = self.database.insert_turn( connection, session_id=session_id, **transition.turn, ) turn = self.database.get_turn(turn_id) response = self._action_response(updated, turn) response.builder_stream_phases = list( ((transition.profile.get("builder") or {}).get("last_stream_phases") or []) ) return response def add_message(self, session_id: str, request: MessageRequest) -> ActionResponse: with self.database.transaction(immediate=True) as connection: session = self.database.fetch_session(connection, session_id) if session is None: raise FSMError("session_not_found", "Session not found", status_code=404) if Stage(session["stage"]) != Stage.BUILDER_CONVERSATION: raise FSMError( "message_not_allowed", "Free-text messages are available after the resume is created or imported", status_code=422, missing_fields=missing_fields(session["profile"]), ) resume = self.database.fetch_resume(connection, session_id) if resume is None: raise FSMError("resume_not_created", "Create the resume before using Builder chat") self.database.insert_turn( connection, session_id=session_id, role="user", content=request.content, composer_mode=ComposerMode.CHAT, blocks=[{"type": "text", "lifecycle": "submitted", "data": {"text": request.content}}], ) transition = builder_conversation.process_message(self, session["profile"], request.content, resume["content"]) self.database.supersede_active_components(connection, session_id) updated = self.database.update_session( connection, session_id, stage=transition.stage, profile=transition.profile, ) turn_id = self.database.insert_turn(connection, session_id=session_id, **transition.turn) response = self._action_response(updated, self.database.get_turn(turn_id)) response.builder_stream_phases = list( ((transition.profile.get("builder") or {}).get("last_stream_phases") or []) ) return response def _polish_module_entry(self, transition: Any) -> None: """Generate a proposal without mutating the user's original description.""" draft = (transition.profile.get("enrichment") or {}).get("module_draft") or {} entry = draft.get("entry") if not isinstance(entry, dict): return description = str(entry.get("description") or "").strip() if description: extraction = self.extractor.extract(description) if extraction.highlights: entry["highlights"] = extraction.highlights if extraction.metrics: entry["metrics"] = extraction.metrics context = { "job_type": transition.profile.get("job_type"), "target_position": transition.profile.get("target_position"), "instruction": None, "entry_type": entry.get("record_type"), } try: proposal = self.expander.expand(deepcopy(entry), context=context) except Exception as exc: self._log_expansion_failure("module_entry_expansion_failed", exc, context) proposal = {} optimized = str(proposal.get("optimized_description") or "").strip() saved = None if optimized and optimized != description: saved = self._entry_proposal_payload(proposal, optimized) entry["pending_proposal"] = saved unavailable = proposal.get("generation_source") == "unavailable" for block in transition.turn.get("blocks", []): data = block.get("data") or {} if block.get("type") == "component" and data.get("confirmation_kind") == "module_entry": data["ai_proposal"] = saved if unavailable: data["optimization_unavailable"] = True data["optimization_retryable"] = True data["optimization_reason"] = proposal.get("fallback_reason") def _suggest_target_positions(self, transition: Any) -> None: """Populate exploratory roles without treating them as user-confirmed facts.""" if self.target_position_suggester is None: return try: suggestions = self.target_position_suggester.suggest( major=str(transition.profile.get("target_position_major") or ""), job_type=str(transition.profile.get("job_type") or "") or None, interests=transition.profile.get("target_position_interests"), ) except Exception: return transition.profile["target_position_suggestions"] = suggestions from .fsm_basics import target_position_recommendation_transition transition.turn = target_position_recommendation_transition(transition.profile).turn def _suggest_skills(self, transition: Any) -> None: """Refresh the skills card with real-model suggestions when configured.""" try: suggestions = self.skill_suggester.suggest(transition.profile) except Exception: return for block in transition.turn.get("blocks", []): data = block.get("data") or {} if ( block.get("type") == "component" and data.get("component_name") == "TagsInput" and data.get("field") == "skills" ): data["suggestions"] = suggestions def _propose_anchor_optimization(self, transition: Any) -> None: """Add an optional expansion proposal to an anchor confirmation card.""" anchor = transition.profile.get("anchor") or {} if not anchor: return context = { "job_type": transition.profile.get("job_type"), "target_position": transition.profile.get("target_position"), "instruction": None, "entry_type": transition.profile.get("anchor_type"), } try: proposal = self.expander.expand(deepcopy(anchor), context=context) except Exception as exc: self._log_expansion_failure("anchor_expansion_failed", exc, context) return optimized = str(proposal.get("optimized_description") or "").strip() if not optimized or optimized == str(anchor.get("description") or "").strip(): return saved = self._entry_proposal_payload(proposal, optimized) transition.profile["anchor_proposal"] = saved for block in transition.turn.get("blocks", []): data = block.get("data") or {} if ( block.get("type") == "component" and data.get("component_name") == "ExperienceConfirmCard" ): data["ai_proposal"] = saved @staticmethod def _entry_proposal_payload( proposal: dict[str, Any], optimized_description: str ) -> dict[str, Any]: saved: dict[str, Any] = { "optimized_description": optimized_description, "changes": proposal.get("changes") or [], "source": proposal.get("source", "ai_expanded"), } for key in ("generation_source", "fallback_reason"): if proposal.get(key): saved[key] = proposal[key] return saved @staticmethod def _log_expansion_failure( event: str, exc: Exception, context: dict[str, Any] ) -> None: reason = ( exc.reason_code if isinstance(exc, LLMServiceError) else type(exc).__name__.casefold()[:48] ) log_ai_event( event, level=logging.ERROR, entry_type=str(context.get("entry_type") or ""), reason_code=reason, trace_id=getattr(exc, "trace_id", None), stage=getattr(exc, "stage", "entry_expansion"), exception=type(exc).__name__, ) def create_resume( self, session_id: str, request: CreateResumeRequest ) -> CreateResumeResponse: try: return self._create_resume_transaction(session_id, request) except FSMError: raise except Exception as exc: self._record_creation_failure(session_id) raise FSMError( "resume_creation_failed", "Resume creation failed; retry is available", status_code=503, ) from exc def _create_resume_transaction( self, session_id: str, request: CreateResumeRequest ) -> CreateResumeResponse: with self.database.transaction(immediate=True) as connection: session = self.database.fetch_session(connection, session_id) if session is None: raise FSMError("session_not_found", "Session not found", status_code=404) existing = self.database.fetch_resume(connection, session_id) if existing is not None: turn = self._last_turn(session_id) return self._create_response(session, existing, turn, created=False) if Stage(session["stage"]) not in {Stage.MINIMUM_READY, Stage.CREATE_FAILED}: raise FSMError( "resume_not_ready", "Confirm a complete first anchor before creating the resume", missing_fields=missing_fields(session["profile"]), ) if not gate_allowed(session["profile"]): raise FSMError( "anchor_incomplete", "The first-anchor gate is not satisfied", missing_fields=missing_fields(session["profile"]), ) creating = self.database.update_session( connection, session_id, stage=Stage.RESUME_CREATING, profile=session["profile"], ) self.database.supersede_active_components(connection, session_id) creating_status = component("CreatingStatusCard", status="creating") creating_status["lifecycle"] = "submitted" self.database.insert_turn( connection, session_id=session_id, **assistant_turn( "Creating your resume.", [creating_status], ), ) content = merge_ids(None, self.rewriter.rewrite(creating["profile"])) resume_id = f"resume_{uuid4().hex}" resume = self.database.insert_resume( connection, resume_id=resume_id, session_id=session_id, idempotency_key=request.idempotency_key, content=content, ) profile, ready_turn = builder_conversation.welcome_turn( deepcopy(creating["profile"]), resume_id, resume_content=content ) updated = self.database.update_session( connection, session_id, stage=Stage.BUILDER_CONVERSATION, profile=profile, resume_id=resume_id, ) ready_turn["blocks"].insert( 1, { "type": "resume_patch", "lifecycle": "submitted", "data": { "resume_id": resume_id, "revision": 1, "operation": "replace", "value": content, }, }, ) turn_id = self.database.insert_turn( connection, session_id=session_id, **ready_turn, ) turn = self.database.get_turn(turn_id) return self._create_response(updated, resume, turn, created=True) def _record_creation_failure(self, session_id: str) -> None: with self.database.transaction(immediate=True) as connection: session = self.database.fetch_session(connection, session_id) if session is None or self.database.fetch_resume(connection, session_id): return self.database.update_session( connection, session_id, stage=Stage.CREATE_FAILED, profile=session["profile"], ) self.database.insert_turn( connection, session_id=session_id, **assistant_turn( "Resume creation failed. Please try again.", [component("CreateRetryCard", primary_action="create")], ), ) @_serialize_offerpai_session def delete_session( self, session_id: str, *, external_token: str | None = None ) -> None: session = self.database.get_session(session_id) if session is None: raise FSMError("session_not_found", "Session not found", status_code=404) external_resume = session["profile"].get("external_resume") remote_id = ( str(external_resume.get("id") or "").strip() if isinstance(external_resume, dict) else "" ) resume_name = ( str(external_resume.get("resume_name") or "").strip() if isinstance(external_resume, dict) else "" ) if external_token and not remote_id and resume_name: if self.offerpai_resume_provider is None: raise FSMError( "offerpai_resume_unavailable", "OfferPai 简历保存服务尚未配置。", status_code=503, ) try: matches = [ item for item in self.offerpai_resume_provider.list_resumes( external_token ) if str(item.get("resumeName") or "").strip() == resume_name and str(item.get("id") or "").strip() ] except OfferPaiResumeError as exc: raise FSMError( exc.code, exc.public_message, status_code=exc.status_code, ) from exc if len(matches) > 1: raise FSMError( "offerpai_resume_conflict", "检测到多份同名 OfferPai 简历,无法安全自动删除。", status_code=409, ) if matches: remote_id = str(matches[0]["id"]) if remote_id and external_token: if self.offerpai_resume_provider is None: raise FSMError( "offerpai_resume_unavailable", "OfferPai 简历保存服务尚未配置。", status_code=503, ) try: self.offerpai_resume_provider.delete_resume( external_token, remote_id ) except OfferPaiResumeError as exc: if exc.code == "offerpai_resume_not_found": # Delete is idempotent: a missing remote resume already # satisfies the requested final state. pass else: raise FSMError( exc.code, exc.public_message, status_code=exc.status_code, ) from exc if not self.database.delete_session(session_id): raise FSMError("session_not_found", "Session not found", status_code=404) def _require_session(self, session_id: str) -> dict[str, Any]: session = self.database.get_session(session_id) if session is None: raise FSMError("session_not_found", "Session not found", status_code=404) return session def _gate(self, session: dict[str, Any]) -> GateView: profile = session["profile"] anchor = profile.get("anchor_type") records = profile.get("records") or {} has_confirmed_content = bool(profile.get("experiences")) or any( records.get(kind) for kind in records ) return GateView( allowed=gate_allowed(profile), formal_content_ready=bool( session.get("resume_id") and has_confirmed_content and profile.get("ai_rewrites_confirmed") ), anchor_type=AnchorType(anchor) if anchor else None, required_fields=required_fields(profile), missing_fields=missing_fields(profile), ) def _action_response(self, session: dict[str, Any], turn: Any) -> ActionResponse: gate = self._gate(session) resume = self._resume_view(session) return ActionResponse( session_id=session["id"], stage=session["stage"], revision=session["revision"], turn=turn, draft_id=session.get("draft_id"), resume_id=session.get("resume_id"), resume=resume, missing_fields=gate.missing_fields, gate=gate, trace_id=self._trace_id(), ) def _resume_view(self, session: dict[str, Any]) -> BusinessResume | None: if not session.get("resume_id"): return None with self.database.transaction() as connection: resume = self.database.fetch_resume(connection, session["id"]) return self.database.resume_view(resume) if resume else None def _create_response( self, session: dict[str, Any], resume: dict[str, Any], turn: Any, *, created: bool, ) -> CreateResumeResponse: gate = self._gate(session) return CreateResumeResponse( session_id=session["id"], stage=session["stage"], revision=session["revision"], turn=turn, draft_id=session.get("draft_id"), resume_id=resume["id"], missing_fields=gate.missing_fields, gate=gate, trace_id=self._trace_id(), created=created, resume=self.database.resume_view(resume), ) def _last_turn(self, session_id: str) -> Any: turns = self.database.list_turns(session_id) return turns[-1] if turns else None @staticmethod def _trace_id() -> str: return f"trace_{uuid4().hex}"