from __future__ import annotations from copy import deepcopy from typing import Any 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, next_anchor_component, process_component_event, required_fields, text_block, ) from .models import ( ActionResponse, AnchorType, BusinessResume, ComposerMode, ComponentEventRequest, CreateResumeRequest, CreateResumeResponse, CreateSessionRequest, GateView, MessageRequest, Stage, TimelineResponse, ) from .services import ExperienceExtractor, ResumeRewriter class ResumeAgent: def __init__( self, database: Database, extractor: ExperienceExtractor, rewriter: ResumeRewriter, ) -> None: self.database = database self.extractor = extractor self.rewriter = rewriter def create_session(self, request: CreateSessionRequest) -> TimelineResponse: session_id = f"session_{uuid4().hex}" profile: dict[str, Any] = { "account_phone": request.account_phone, "metadata": request.metadata, "anchor": {}, "experiences": [], } 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._require_session(session_id) turns = self.database.list_turns(session_id) gate = self._gate(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"), missing_fields=gate.missing_fields, gate=gate, trace_id=self._trace_id(), ) 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.CONTENT_READY and block["data"].get( "confirmation_kind" ) == "rewrite": transition = process_rewrite_confirmation( session["profile"], request.action ) else: transition = process_component_event( stage=Stage(session["stage"]), profile=session["profile"], component_data=block["data"], action=request.action, payload=request.payload, ) 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 = 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 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 = self.database.update_resume(connection, session_id, 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) return self._action_response(updated, turn) 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) current_stage = Stage(session["stage"]) allowed = { Stage.ANCHOR_COLLECTING, Stage.CONTENT_READY, Stage.RESUME_ENRICHING, Stage.CONTENT_DISAMBIGUATION, } if current_stage not in allowed: raise FSMError( "message_not_allowed", "Free-text messages are not available in the current UI-only stage", missing_fields=missing_fields(session["profile"]), ) if session["profile"].get("pending_experience"): raise FSMError( "rewrite_confirmation_required", "Confirm or revise the proposed rewrite before sending more text", ) self.database.insert_turn( connection, session_id=session_id, role="user", content=request.content, composer_mode="chat", blocks=[ { "type": "text", "lifecycle": "submitted", "data": {"text": request.content}, } ], ) if current_stage == Stage.ANCHOR_COLLECTING: profile = deepcopy(session["profile"]) anchor_type = str(profile.get("anchor_type") or "") before = missing_fields(profile) patch = self.extractor.extract_anchor(request.content, anchor_type, before) profile.setdefault("anchor", {}).update(patch) profile.setdefault("anchor_source_messages", []).append(request.content) remaining = missing_fields(profile) self.database.supersede_active_components(connection, session_id) if remaining: turn_spec = assistant_turn( "我已记录这段描述。还需要补充一项结构信息。", [next_anchor_component(profile)], mode=ComposerMode.HYBRID, ) updated = self.database.update_session( connection, session_id, stage=Stage.ANCHOR_COLLECTING, profile=profile, ) else: turn_spec = assistant_turn( "我已经整理出第一段必要经历,请确认信息是否准确。", [ component( "ExperienceConfirmCard", anchor_type=profile["anchor_type"], value=profile["anchor"], ) ], mode=ComposerMode.UI_ONLY, ) updated = self.database.update_session( connection, session_id, stage=Stage.ANCHOR_CONFIRM, profile=profile, ) turn_id = self.database.insert_turn( connection, session_id=session_id, **turn_spec, ) turn = self.database.fetch_turn(connection, turn_id) return self._action_response(updated, turn) resume = self.database.fetch_resume(connection, session_id) if resume is None: raise FSMError("resume_not_created", "Create the resume before enriching it") profile = deepcopy(session["profile"]) pending = profile.pop("pending_message", None) source_text = f"{pending} {request.content}".strip() if pending else request.content extraction = self.extractor.extract(source_text) if len(source_text) < 8 or extraction.confidence <= 0.5: profile["pending_message"] = source_text turn_spec = assistant_turn( "请再补充一下所在组织、你的角色或可量化结果。", [ { "type": "status", "lifecycle": "active", "data": {"status": "needs_disambiguation"}, } ], mode="chat", ) updated = self.database.update_session( connection, session_id, stage=Stage.CONTENT_DISAMBIGUATION, profile=profile, ) self.database.supersede_active_components(connection, session_id) turn_id = self.database.insert_turn( connection, session_id=session_id, **turn_spec ) else: candidate_profile = deepcopy(profile) candidate_profile.setdefault("experiences", []).append(extraction.to_dict()) rewritten = self.rewriter.rewrite(candidate_profile) profile, turn_spec = prepare_rewrite_confirmation( profile, extraction, rewritten ) updated = self.database.update_session( connection, session_id, stage=Stage.CONTENT_READY, profile=profile, ) self.database.supersede_active_components(connection, session_id) turn_id = self.database.insert_turn( connection, session_id=session_id, **turn_spec ) turn = self.database.get_turn(turn_id) return self._action_response(updated, turn) 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_status], ), ) content = 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, ) updated = self.database.update_session( connection, session_id, stage=Stage.RESUME_ENRICHING, profile=creating["profile"], resume_id=resume_id, ) ready_turn = assistant_turn( "基础简历已创建。你可以现在退出,也可以继续补充经历内容。", [ { "type": "resume_patch", "lifecycle": "submitted", "data": { "resume_id": resume_id, "revision": 1, "operation": "replace", "value": content, }, }, component( "ContentReadyCard", resume_id=resume_id, formal_content_ready=False, actions=["continue_enriching", "finish_enrichment"], ), ], mode=ComposerMode.HYBRID, ) 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( "创建失败,请重试。", [component("CreateRetryCard", primary_action="create")], ), ) def delete_session(self, session_id: str) -> None: 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") return GateView( allowed=gate_allowed(profile), formal_content_ready=bool( session.get("resume_id") and profile.get("experiences") 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) 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"), missing_fields=gate.missing_fields, gate=gate, trace_id=self._trace_id(), ) 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}"