Files
resume-agent/backend/app/agent.py
T

1511 lines
62 KiB
Python

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}"