generated from kgod/ai-review-template
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
643 lines
28 KiB
Python
643 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
import logging
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from .database import Database
|
|
from .enrichment import prepare_rewrite_confirmation, process_rewrite_confirmation
|
|
from .fsm import (
|
|
FSMError,
|
|
assistant_turn,
|
|
component,
|
|
gate_allowed,
|
|
initial_turn,
|
|
missing_fields,
|
|
process_component_event,
|
|
required_fields,
|
|
)
|
|
from .llm_services import LLMServiceError, log_ai_event
|
|
from .models import (
|
|
ActionResponse,
|
|
AnchorType,
|
|
BusinessResume,
|
|
ComposerMode,
|
|
ComponentEventRequest,
|
|
CreateResumeRequest,
|
|
CreateResumeResponse,
|
|
CreateSessionRequest,
|
|
GateView,
|
|
MessageRequest,
|
|
Stage,
|
|
TimelineResponse,
|
|
)
|
|
from .resume_document import merge_ids, merge_profile_refresh, set_generated_profile_summary
|
|
from .resume_editing import ResumeEditingMixin
|
|
from .optimization_flow import OptimizationFlowMixin
|
|
from .target_position_suggester import TargetPositionSuggester
|
|
from .experience_optimizer import ExperienceOptimizer, RuleStructuredExperienceOptimizer
|
|
from .services import EntryExpander, ExperienceExtractor, ResumeRewriter
|
|
from .profile_summary import ProfileSummaryGenerator, RuleBasedProfileSummaryGenerator
|
|
from .skill_suggester import SkillSuggester
|
|
from .resume_skill_advisor import recommend_skill_candidates
|
|
from . import builder_conversation
|
|
|
|
|
|
class ResumeAgent(ResumeEditingMixin, OptimizationFlowMixin):
|
|
def __init__(
|
|
self,
|
|
database: Database,
|
|
extractor: ExperienceExtractor,
|
|
rewriter: ResumeRewriter,
|
|
expander: EntryExpander,
|
|
skill_suggester: SkillSuggester,
|
|
experience_optimizer: ExperienceOptimizer | None = None,
|
|
target_position_suggester: TargetPositionSuggester | None = None,
|
|
profile_summary_generator: ProfileSummaryGenerator | None = None,
|
|
) -> None:
|
|
self.database = database
|
|
self.extractor = extractor
|
|
self.rewriter = rewriter
|
|
self.expander = expander
|
|
self.skill_suggester = skill_suggester
|
|
self.experience_optimizer = experience_optimizer or RuleStructuredExperienceOptimizer()
|
|
self.target_position_suggester = target_position_suggester
|
|
self.profile_summary_generator = profile_summary_generator or RuleBasedProfileSummaryGenerator()
|
|
|
|
def recommend_skills(self, session_id: str, question: str) -> list[dict[str, Any]]:
|
|
with self.database.transaction() as connection:
|
|
session = self._session_or_404(connection, session_id)
|
|
resume = self._resume_or_409(connection, session_id)
|
|
existing = [
|
|
str(skill).strip()
|
|
for group in resume["content"].get("skill_groups") or []
|
|
for skill in group.get("skills") or []
|
|
if str(skill).strip()
|
|
]
|
|
return recommend_skill_candidates(session["profile"], existing, question, self.skill_suggester)
|
|
def create_session(self, request: CreateSessionRequest) -> TimelineResponse:
|
|
session_id = f"session_{uuid4().hex}"
|
|
profile: dict[str, Any] = {
|
|
"account_phone": request.account_phone,
|
|
"metadata": request.metadata,
|
|
"anchor": {},
|
|
"experiences": [],
|
|
}
|
|
self.database.create_session(
|
|
session_id,
|
|
Stage.PRIVACY_CONSENT,
|
|
profile,
|
|
initial_turn(),
|
|
)
|
|
return self.timeline(session_id)
|
|
|
|
def timeline(self, session_id: str) -> TimelineResponse:
|
|
session = self._require_session(session_id)
|
|
turns = self.database.list_turns(session_id)
|
|
gate = self._gate(session)
|
|
resume = self._resume_view(session)
|
|
return TimelineResponse(
|
|
session_id=session_id,
|
|
session=self.database.session_view(session),
|
|
turns=turns,
|
|
stage=session["stage"],
|
|
revision=session["revision"],
|
|
draft_id=session.get("draft_id"),
|
|
resume_id=session.get("resume_id"),
|
|
resume=resume,
|
|
missing_fields=gate.missing_fields,
|
|
gate=gate,
|
|
trace_id=self._trace_id(),
|
|
)
|
|
|
|
def component_event(
|
|
self, session_id: str, request: ComponentEventRequest
|
|
) -> ActionResponse:
|
|
with self.database.transaction(immediate=True) as connection:
|
|
session = self.database.fetch_session(connection, session_id)
|
|
if session is None:
|
|
raise FSMError("session_not_found", "Session not found", status_code=404)
|
|
block = self.database.fetch_block(connection, session_id, request.component_id)
|
|
if block is None:
|
|
raise FSMError("component_not_found", "Component not found", status_code=404)
|
|
if block["type"] != "component":
|
|
raise FSMError("invalid_component", "Events can only target component blocks", status_code=422)
|
|
if block["lifecycle"] != "active":
|
|
raise FSMError("component_not_active", "Component was already handled")
|
|
if request.action == "create" and block["data"].get("component_name") in {
|
|
"CreateResumeCard",
|
|
"CreateRetryCard",
|
|
}:
|
|
raise FSMError(
|
|
"use_create_endpoint",
|
|
"Use POST /sessions/{session_id}/create for resume creation",
|
|
status_code=422,
|
|
)
|
|
if Stage(session["stage"]) == Stage.BUILDER_CONVERSATION:
|
|
resume = self.database.fetch_resume(connection, session_id)
|
|
if resume is None:
|
|
raise FSMError("resume_not_created", "Create the resume before using Builder cards")
|
|
transition = builder_conversation.process_component_event(
|
|
session["profile"],
|
|
block["data"],
|
|
request.action,
|
|
request.payload,
|
|
resume["content"],
|
|
self.skill_suggester,
|
|
)
|
|
elif Stage(session["stage"]) == Stage.CONTENT_READY and block["data"].get(
|
|
"confirmation_kind"
|
|
) == "rewrite":
|
|
transition = process_rewrite_confirmation(
|
|
session["profile"], request.action, request.payload
|
|
)
|
|
else:
|
|
transition = process_component_event(
|
|
stage=Stage(session["stage"]),
|
|
profile=session["profile"],
|
|
component_data=block["data"],
|
|
action=request.action,
|
|
payload=request.payload,
|
|
)
|
|
if getattr(transition, "polish_description", False):
|
|
self._polish_module_entry(transition)
|
|
if getattr(transition, "propose_anchor_optimization", False):
|
|
self._propose_anchor_optimization(transition)
|
|
if getattr(transition, "suggest_skills", False):
|
|
self._suggest_skills(transition)
|
|
if getattr(transition, "suggest_target_positions", False):
|
|
self._suggest_target_positions(transition)
|
|
anchor_proposal = transition.profile.get("anchor_proposal")
|
|
if transition.stage == Stage.MINIMUM_READY:
|
|
transition.profile.pop("anchor_proposal", None)
|
|
if (
|
|
isinstance(anchor_proposal, dict)
|
|
and isinstance(transition.profile.get("anchor"), dict)
|
|
and request.payload.get("use_optimized") is True
|
|
):
|
|
transition.profile["anchor"]["description"] = anchor_proposal[
|
|
"optimized_description"
|
|
]
|
|
transition.profile["anchor"]["provenance"] = anchor_proposal["source"]
|
|
elif transition.stage == Stage.ANCHOR_COLLECTING:
|
|
transition.profile.pop("anchor_proposal", None)
|
|
self.database.update_block(
|
|
connection,
|
|
block["id"],
|
|
lifecycle=transition.lifecycle,
|
|
)
|
|
draft_id = session.get("draft_id")
|
|
if transition.create_draft:
|
|
draft_id = draft_id or f"draft_{uuid4().hex}"
|
|
preview = merge_ids(None, self.rewriter.rewrite(transition.profile))
|
|
transition.turn["blocks"].insert(
|
|
-1,
|
|
{
|
|
"type": "resume_patch",
|
|
"lifecycle": "submitted",
|
|
"data": {"draft_id": draft_id, "operation": "replace", "value": preview},
|
|
},
|
|
)
|
|
resume_content = getattr(transition, "resume_content", None)
|
|
if resume_content is None and getattr(transition, "refresh_resume", False):
|
|
resume_content = self.rewriter.rewrite(transition.profile)
|
|
transition.resume_content = resume_content
|
|
resume = None
|
|
if resume_content is not None:
|
|
resume = self.database.fetch_resume(connection, session_id)
|
|
if resume is None:
|
|
raise FSMError("resume_not_created", "Create the resume before confirming content")
|
|
resume_content = (
|
|
merge_profile_refresh(resume["content"], resume_content)
|
|
if getattr(transition, "refresh_resume", False)
|
|
else merge_ids(resume["content"], resume_content)
|
|
)
|
|
if getattr(transition, "generate_profile_summary", False):
|
|
resume = resume or self.database.fetch_resume(connection, session_id)
|
|
if resume is None:
|
|
raise FSMError("resume_not_created", "Create the resume before finishing content")
|
|
base_content = resume_content if resume_content is not None else resume["content"]
|
|
summary = base_content.get("profile_summary")
|
|
should_generate_summary = not isinstance(summary, dict) or summary.get("stale") is True
|
|
if should_generate_summary:
|
|
try:
|
|
summary_text = self.profile_summary_generator.generate(base_content)
|
|
resume_content = set_generated_profile_summary(
|
|
base_content, summary_text, replace_stale=True
|
|
)
|
|
|
|
except Exception as exc:
|
|
log_ai_event(
|
|
"profile_summary_generation_failed",
|
|
level=logging.WARNING,
|
|
reason_code=getattr(exc, "reason_code", type(exc).__name__),
|
|
exception=type(exc).__name__,
|
|
)
|
|
if resume_content is not None:
|
|
assert resume is not None
|
|
resume = self.database.update_resume(connection, session_id, resume_content)
|
|
if transition.stage == Stage.BUILDER_CONVERSATION:
|
|
builder_conversation.reconcile_last_confirmed_entry(
|
|
transition.profile, resume["content"]
|
|
)
|
|
transition.turn["blocks"].insert(
|
|
-1,
|
|
{
|
|
"type": "resume_patch",
|
|
"lifecycle": "confirmed",
|
|
"data": {
|
|
"resume_id": resume["id"],
|
|
"revision": resume["revision"],
|
|
"operation": "replace",
|
|
"value": resume_content,
|
|
},
|
|
},
|
|
)
|
|
updated = self.database.update_session(
|
|
connection,
|
|
session_id,
|
|
stage=transition.stage,
|
|
profile=transition.profile,
|
|
draft_id=draft_id,
|
|
)
|
|
turn_id = self.database.insert_turn(
|
|
connection,
|
|
session_id=session_id,
|
|
**transition.turn,
|
|
)
|
|
turn = self.database.get_turn(turn_id)
|
|
response = self._action_response(updated, turn)
|
|
response.builder_stream_phases = list(
|
|
((transition.profile.get("builder") or {}).get("last_stream_phases") or [])
|
|
)
|
|
return response
|
|
|
|
def add_message(self, session_id: str, request: MessageRequest) -> ActionResponse:
|
|
with self.database.transaction(immediate=True) as connection:
|
|
session = self.database.fetch_session(connection, session_id)
|
|
if session is None:
|
|
raise FSMError("session_not_found", "Session not found", status_code=404)
|
|
if Stage(session["stage"]) != Stage.BUILDER_CONVERSATION:
|
|
raise FSMError(
|
|
"message_not_allowed",
|
|
"Free-text messages are available after the resume is created or imported",
|
|
status_code=422,
|
|
missing_fields=missing_fields(session["profile"]),
|
|
)
|
|
resume = self.database.fetch_resume(connection, session_id)
|
|
if resume is None:
|
|
raise FSMError("resume_not_created", "Create the resume before using Builder chat")
|
|
self.database.insert_turn(
|
|
connection,
|
|
session_id=session_id,
|
|
role="user",
|
|
content=request.content,
|
|
composer_mode=ComposerMode.CHAT,
|
|
blocks=[{"type": "text", "lifecycle": "submitted", "data": {"text": request.content}}],
|
|
)
|
|
transition = builder_conversation.process_message(self, session["profile"], request.content, resume["content"])
|
|
self.database.supersede_active_components(connection, session_id)
|
|
updated = self.database.update_session(
|
|
connection,
|
|
session_id,
|
|
stage=transition.stage,
|
|
profile=transition.profile,
|
|
)
|
|
turn_id = self.database.insert_turn(connection, session_id=session_id, **transition.turn)
|
|
response = self._action_response(updated, self.database.get_turn(turn_id))
|
|
response.builder_stream_phases = list(
|
|
((transition.profile.get("builder") or {}).get("last_stream_phases") or [])
|
|
)
|
|
return response
|
|
def _polish_module_entry(self, transition: Any) -> None:
|
|
"""Generate a proposal without mutating the user's original description."""
|
|
draft = (transition.profile.get("enrichment") or {}).get("module_draft") or {}
|
|
entry = draft.get("entry")
|
|
if not isinstance(entry, dict):
|
|
return
|
|
description = str(entry.get("description") or "").strip()
|
|
if description:
|
|
extraction = self.extractor.extract(description)
|
|
if extraction.highlights:
|
|
entry["highlights"] = extraction.highlights
|
|
if extraction.metrics:
|
|
entry["metrics"] = extraction.metrics
|
|
context = {
|
|
"job_type": transition.profile.get("job_type"),
|
|
"target_position": transition.profile.get("target_position"),
|
|
"instruction": None,
|
|
"entry_type": entry.get("record_type"),
|
|
}
|
|
try:
|
|
proposal = self.expander.expand(deepcopy(entry), context=context)
|
|
except Exception as exc:
|
|
self._log_expansion_failure("module_entry_expansion_failed", exc, context)
|
|
proposal = {}
|
|
optimized = str(proposal.get("optimized_description") or "").strip()
|
|
saved = None
|
|
if optimized and optimized != description:
|
|
saved = self._entry_proposal_payload(proposal, optimized)
|
|
entry["pending_proposal"] = saved
|
|
unavailable = proposal.get("generation_source") == "unavailable"
|
|
for block in transition.turn.get("blocks", []):
|
|
data = block.get("data") or {}
|
|
if block.get("type") == "component" and data.get("confirmation_kind") == "module_entry":
|
|
data["ai_proposal"] = saved
|
|
if unavailable:
|
|
data["optimization_unavailable"] = True
|
|
data["optimization_retryable"] = True
|
|
data["optimization_reason"] = proposal.get("fallback_reason")
|
|
|
|
def _suggest_target_positions(self, transition: Any) -> None:
|
|
"""Populate exploratory roles without treating them as user-confirmed facts."""
|
|
if self.target_position_suggester is None:
|
|
return
|
|
try:
|
|
suggestions = self.target_position_suggester.suggest(
|
|
major=str(transition.profile.get("target_position_major") or ""),
|
|
job_type=str(transition.profile.get("job_type") or "") or None,
|
|
interests=transition.profile.get("target_position_interests"),
|
|
)
|
|
except Exception:
|
|
return
|
|
transition.profile["target_position_suggestions"] = suggestions
|
|
from .fsm_basics import target_position_recommendation_transition
|
|
|
|
transition.turn = target_position_recommendation_transition(transition.profile).turn
|
|
def _suggest_skills(self, transition: Any) -> None:
|
|
"""Refresh the skills card with real-model suggestions when configured."""
|
|
try:
|
|
suggestions = self.skill_suggester.suggest(transition.profile)
|
|
except Exception:
|
|
return
|
|
for block in transition.turn.get("blocks", []):
|
|
data = block.get("data") or {}
|
|
if (
|
|
block.get("type") == "component"
|
|
and data.get("component_name") == "TagsInput"
|
|
and data.get("field") == "skills"
|
|
):
|
|
data["suggestions"] = suggestions
|
|
|
|
def _propose_anchor_optimization(self, transition: Any) -> None:
|
|
"""Add an optional expansion proposal to an anchor confirmation card."""
|
|
anchor = transition.profile.get("anchor") or {}
|
|
if not anchor:
|
|
return
|
|
context = {
|
|
"job_type": transition.profile.get("job_type"),
|
|
"target_position": transition.profile.get("target_position"),
|
|
"instruction": None,
|
|
"entry_type": transition.profile.get("anchor_type"),
|
|
}
|
|
try:
|
|
proposal = self.expander.expand(deepcopy(anchor), context=context)
|
|
except Exception as exc:
|
|
self._log_expansion_failure("anchor_expansion_failed", exc, context)
|
|
return
|
|
optimized = str(proposal.get("optimized_description") or "").strip()
|
|
if not optimized or optimized == str(anchor.get("description") or "").strip():
|
|
return
|
|
saved = self._entry_proposal_payload(proposal, optimized)
|
|
transition.profile["anchor_proposal"] = saved
|
|
for block in transition.turn.get("blocks", []):
|
|
data = block.get("data") or {}
|
|
if (
|
|
block.get("type") == "component"
|
|
and data.get("component_name") == "ExperienceConfirmCard"
|
|
):
|
|
data["ai_proposal"] = saved
|
|
|
|
@staticmethod
|
|
def _entry_proposal_payload(
|
|
proposal: dict[str, Any], optimized_description: str
|
|
) -> dict[str, Any]:
|
|
saved: dict[str, Any] = {
|
|
"optimized_description": optimized_description,
|
|
"changes": proposal.get("changes") or [],
|
|
"source": proposal.get("source", "ai_expanded"),
|
|
}
|
|
for key in ("generation_source", "fallback_reason"):
|
|
if proposal.get(key):
|
|
saved[key] = proposal[key]
|
|
return saved
|
|
|
|
@staticmethod
|
|
def _log_expansion_failure(
|
|
event: str, exc: Exception, context: dict[str, Any]
|
|
) -> None:
|
|
reason = (
|
|
exc.reason_code
|
|
if isinstance(exc, LLMServiceError)
|
|
else type(exc).__name__.casefold()[:48]
|
|
)
|
|
log_ai_event(
|
|
event,
|
|
level=logging.ERROR,
|
|
entry_type=str(context.get("entry_type") or ""),
|
|
reason_code=reason,
|
|
trace_id=getattr(exc, "trace_id", None),
|
|
stage=getattr(exc, "stage", "entry_expansion"),
|
|
exception=type(exc).__name__,
|
|
)
|
|
|
|
def create_resume(
|
|
self, session_id: str, request: CreateResumeRequest
|
|
) -> CreateResumeResponse:
|
|
try:
|
|
return self._create_resume_transaction(session_id, request)
|
|
except FSMError:
|
|
raise
|
|
except Exception as exc:
|
|
self._record_creation_failure(session_id)
|
|
raise FSMError(
|
|
"resume_creation_failed",
|
|
"Resume creation failed; retry is available",
|
|
status_code=503,
|
|
) from exc
|
|
|
|
def _create_resume_transaction(
|
|
self, session_id: str, request: CreateResumeRequest
|
|
) -> CreateResumeResponse:
|
|
with self.database.transaction(immediate=True) as connection:
|
|
session = self.database.fetch_session(connection, session_id)
|
|
if session is None:
|
|
raise FSMError("session_not_found", "Session not found", status_code=404)
|
|
existing = self.database.fetch_resume(connection, session_id)
|
|
if existing is not None:
|
|
turn = self._last_turn(session_id)
|
|
return self._create_response(session, existing, turn, created=False)
|
|
if Stage(session["stage"]) not in {Stage.MINIMUM_READY, Stage.CREATE_FAILED}:
|
|
raise FSMError(
|
|
"resume_not_ready",
|
|
"Confirm a complete first anchor before creating the resume",
|
|
missing_fields=missing_fields(session["profile"]),
|
|
)
|
|
if not gate_allowed(session["profile"]):
|
|
raise FSMError(
|
|
"anchor_incomplete",
|
|
"The first-anchor gate is not satisfied",
|
|
missing_fields=missing_fields(session["profile"]),
|
|
)
|
|
creating = self.database.update_session(
|
|
connection,
|
|
session_id,
|
|
stage=Stage.RESUME_CREATING,
|
|
profile=session["profile"],
|
|
)
|
|
self.database.supersede_active_components(connection, session_id)
|
|
creating_status = component("CreatingStatusCard", status="creating")
|
|
creating_status["lifecycle"] = "submitted"
|
|
self.database.insert_turn(
|
|
connection,
|
|
session_id=session_id,
|
|
**assistant_turn(
|
|
"Creating your resume.",
|
|
[creating_status],
|
|
),
|
|
)
|
|
content = merge_ids(None, self.rewriter.rewrite(creating["profile"]))
|
|
resume_id = f"resume_{uuid4().hex}"
|
|
resume = self.database.insert_resume(
|
|
connection,
|
|
resume_id=resume_id,
|
|
session_id=session_id,
|
|
idempotency_key=request.idempotency_key,
|
|
content=content,
|
|
)
|
|
profile, ready_turn = builder_conversation.welcome_turn(
|
|
deepcopy(creating["profile"]), resume_id, resume_content=content
|
|
)
|
|
updated = self.database.update_session(
|
|
connection,
|
|
session_id,
|
|
stage=Stage.BUILDER_CONVERSATION,
|
|
profile=profile,
|
|
resume_id=resume_id,
|
|
)
|
|
ready_turn["blocks"].insert(
|
|
1,
|
|
{
|
|
"type": "resume_patch",
|
|
"lifecycle": "submitted",
|
|
"data": {
|
|
"resume_id": resume_id,
|
|
"revision": 1,
|
|
"operation": "replace",
|
|
"value": content,
|
|
},
|
|
},
|
|
)
|
|
turn_id = self.database.insert_turn(
|
|
connection,
|
|
session_id=session_id,
|
|
**ready_turn,
|
|
)
|
|
turn = self.database.get_turn(turn_id)
|
|
return self._create_response(updated, resume, turn, created=True)
|
|
|
|
def _record_creation_failure(self, session_id: str) -> None:
|
|
with self.database.transaction(immediate=True) as connection:
|
|
session = self.database.fetch_session(connection, session_id)
|
|
if session is None or self.database.fetch_resume(connection, session_id):
|
|
return
|
|
self.database.update_session(
|
|
connection,
|
|
session_id,
|
|
stage=Stage.CREATE_FAILED,
|
|
profile=session["profile"],
|
|
)
|
|
self.database.insert_turn(
|
|
connection,
|
|
session_id=session_id,
|
|
**assistant_turn(
|
|
"Resume creation failed. Please try again.",
|
|
[component("CreateRetryCard", primary_action="create")],
|
|
),
|
|
)
|
|
|
|
def delete_session(self, session_id: str) -> None:
|
|
if not self.database.delete_session(session_id):
|
|
raise FSMError("session_not_found", "Session not found", status_code=404)
|
|
|
|
def _require_session(self, session_id: str) -> dict[str, Any]:
|
|
session = self.database.get_session(session_id)
|
|
if session is None:
|
|
raise FSMError("session_not_found", "Session not found", status_code=404)
|
|
return session
|
|
|
|
def _gate(self, session: dict[str, Any]) -> GateView:
|
|
profile = session["profile"]
|
|
anchor = profile.get("anchor_type")
|
|
records = profile.get("records") or {}
|
|
has_confirmed_content = bool(profile.get("experiences")) or any(
|
|
records.get(kind) for kind in records
|
|
)
|
|
return GateView(
|
|
allowed=gate_allowed(profile),
|
|
formal_content_ready=bool(
|
|
session.get("resume_id")
|
|
and has_confirmed_content
|
|
and profile.get("ai_rewrites_confirmed")
|
|
),
|
|
anchor_type=AnchorType(anchor) if anchor else None,
|
|
required_fields=required_fields(profile),
|
|
missing_fields=missing_fields(profile),
|
|
)
|
|
|
|
def _action_response(self, session: dict[str, Any], turn: Any) -> ActionResponse:
|
|
gate = self._gate(session)
|
|
resume = self._resume_view(session)
|
|
return ActionResponse(
|
|
session_id=session["id"],
|
|
stage=session["stage"],
|
|
revision=session["revision"],
|
|
turn=turn,
|
|
draft_id=session.get("draft_id"),
|
|
resume_id=session.get("resume_id"),
|
|
resume=resume,
|
|
missing_fields=gate.missing_fields,
|
|
gate=gate,
|
|
trace_id=self._trace_id(),
|
|
)
|
|
|
|
def _resume_view(self, session: dict[str, Any]) -> BusinessResume | None:
|
|
if not session.get("resume_id"):
|
|
return None
|
|
with self.database.transaction() as connection:
|
|
resume = self.database.fetch_resume(connection, session["id"])
|
|
return self.database.resume_view(resume) if resume else None
|
|
|
|
def _create_response(
|
|
self,
|
|
session: dict[str, Any],
|
|
resume: dict[str, Any],
|
|
turn: Any,
|
|
*,
|
|
created: bool,
|
|
) -> CreateResumeResponse:
|
|
gate = self._gate(session)
|
|
return CreateResumeResponse(
|
|
session_id=session["id"],
|
|
stage=session["stage"],
|
|
revision=session["revision"],
|
|
turn=turn,
|
|
draft_id=session.get("draft_id"),
|
|
resume_id=resume["id"],
|
|
missing_fields=gate.missing_fields,
|
|
gate=gate,
|
|
trace_id=self._trace_id(),
|
|
created=created,
|
|
resume=self.database.resume_view(resume),
|
|
)
|
|
|
|
def _last_turn(self, session_id: str) -> Any:
|
|
turns = self.database.list_turns(session_id)
|
|
return turns[-1] if turns else None
|
|
|
|
@staticmethod
|
|
def _trace_id() -> str:
|
|
return f"trace_{uuid4().hex}"
|
|
|