generated from kgod/ai-review-template
1511 lines
62 KiB
Python
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}"
|