from __future__ import annotations from copy import deepcopy import logging 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, 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 . import builder_conversation 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, ) -> 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() 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 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) 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 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")], ), ) 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") 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}"