feat: builder 简历生成 + 轻度优化 + 简历导入交付副本

自内部仓库剥离深度优化与 RAG 知识库后的交付版本:
- Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强)
- 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护
- 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏
- PostgreSQL 运行时 + Alembic 迁移链

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hyp
2026-08-05 11:08:30 +08:00
co-authored by Claude
commit ae2d9b128d
191 changed files with 27719 additions and 0 deletions
+371
View File
@@ -0,0 +1,371 @@
"""Persistent light/deep optimization operations for ResumeAgent."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from pydantic import ValidationError
from uuid import uuid4
from .claim_validator import validate_proposal
from .optimization_tiers import tier_config_for_session
from .fsm import FSMError
from .llm_services import LLMServiceError, log_ai_event
from .optimization_models import OptimizationRunView, OptimizationStartRequest
from .resume_document import (
DocumentError,
confirm_proposal,
entry_fingerprint,
find_entry,
reject_proposal,
set_pending_proposal,
)
from .resume_editing import _to_fsm
_OPTIMIZATION_EXCEPTIONS = (LLMServiceError, ValidationError, KeyError, TypeError, ValueError)
class OptimizationFlowMixin:
database: Any
experience_optimizer: Any
def set_target_position(self, session_id: str, target_position: str) -> dict[str, Any]:
"""Persist a user-confirmed target position from any recommendation source."""
normalized = target_position.strip()
if not normalized or len(normalized) > 32:
raise FSMError(
"invalid_target_position",
"Target position must be 1-32 characters",
status_code=422,
)
with self.database.transaction(immediate=True) as connection:
session = self._session_or_404(connection, session_id)
profile = dict(session["profile"])
profile["target_position"] = normalized
profile["target_position_confirmed"] = True
updated = self.database.update_session(
connection,
session_id,
stage=session["stage"],
profile=profile,
)
return {
"target_position": updated["profile"]["target_position"],
"target_position_confirmed": True,
}
def optimize_light(self, session_id: str, request: OptimizationStartRequest) -> OptimizationRunView:
with self.database.transaction(immediate=True) as connection:
session, resume, section, entry = self._entry(connection, session_id, request.entry_id)
context = self._context(session, section, request.instruction)
context["optimization_mode"] = "light"
facts = self._facts(entry)
try:
proposal = validate_proposal(
self.experience_optimizer.optimize(deepcopy(entry), context=context, facts=facts), facts
)
except _OPTIMIZATION_EXCEPTIONS as exc:
self._raise_optimization_ai_failed(exc, session_id, request.entry_id)
tier = tier_config_for_session(session)
gap_report: list[dict[str, Any]] | None = None
content = self._set_proposal(resume["content"], request.entry_id, proposal)
self.database.update_resume(connection, session_id, content)
run = self.database.create_optimization_run(
connection, run_id=f"opt_{uuid4().hex}", session_id=session_id,
entry_id=request.entry_id, mode="light", status="proposal_pending",
source_revision=resume["revision"],
state={
"facts": facts,
"star": proposal.get("star") or {},
"gap_report": gap_report,
"tier": tier.tier,
},
proposal=proposal,
)
return self._view(run, self._action_response(session, None))
def confirm_deep_optimization(self, session_id: str, run_id: str) -> OptimizationRunView:
with self.database.transaction(immediate=True) as connection:
session, resume, run = self._run(connection, session_id, run_id, "proposal_pending")
proposal = run.get("proposal") or {}
content = self._materialize_run_proposal(
resume["content"], run["entry_id"], proposal, run["source_revision"], resume["revision"]
)
try:
content = confirm_proposal(content, run["entry_id"])
except DocumentError as exc:
raise _to_fsm(exc) from exc
self.database.update_resume(connection, session_id, content)
run = self.database.update_optimization_run(
connection, session_id=session_id, run_id=run_id, status="confirmed",
state=run["state"], proposal=proposal,
)
return self._view(run, self._action_response(session, None))
def reject_optimization(self, session_id: str, run_id: str) -> OptimizationRunView:
with self.database.transaction(immediate=True) as connection:
session, resume, run = self._run(connection, session_id, run_id, "proposal_pending")
found = find_entry(resume["content"], run["entry_id"])
content = resume["content"]
if found is not None and "pending_proposal" in found[1]:
try:
content = reject_proposal(content, run["entry_id"])
except DocumentError as exc:
raise _to_fsm(exc) from exc
self.database.update_resume(connection, session_id, content)
run = self.database.update_optimization_run(
connection, session_id=session_id, run_id=run_id, status="rejected",
state=run["state"], proposal=run.get("proposal"),
)
return self._view(run, self._action_response(session, None))
def _materialize_run_proposal(
self,
content: dict[str, Any],
entry_id: str,
proposal: dict[str, Any],
source_revision: int,
current_revision: int,
) -> dict[str, Any]:
found = find_entry(content, entry_id)
if found is None:
raise FSMError("entry_not_found", "Entry not found in resume", status_code=404)
entry = found[1]
pending = entry.get("pending_proposal")
if isinstance(pending, dict):
if self._proposal_matches_run(pending, proposal, entry):
return content
raise FSMError(
"optimization_stale",
"Another proposal is pending for this entry; restart optimization",
status_code=409,
)
source_fingerprint = str(proposal.get("based_on") or "")
if source_fingerprint:
stale = source_fingerprint != entry_fingerprint(entry)
else:
stale = current_revision != source_revision
if stale:
raise FSMError(
"optimization_stale",
"Resume changed; restart optimization",
status_code=409,
)
materialized = self._set_proposal(content, entry_id, proposal)
refreshed = find_entry(materialized, entry_id)
if refreshed is None or refreshed[1]["pending_proposal"].get("based_on") != entry_fingerprint(entry):
raise FSMError("optimization_stale", "Resume changed; restart optimization", status_code=409)
return materialized
@staticmethod
def _proposal_value(proposal: dict[str, Any], key: str) -> Any:
"""Normalize optional proposal fields before checking an existing pending proposal."""
value = proposal.get(key)
if key in {"changes", "missing_facts", "unconfirmed_suggestions", "optional_enhancements", "validation_warnings", "omitted_fact_ids"}:
return list(value or [])
if key == "star":
return value or {}
return value
@classmethod
def _proposal_matches_run(
cls, pending: dict[str, Any], proposal: dict[str, Any], entry: dict[str, Any]
) -> bool:
source_fingerprint = str(proposal.get("based_on") or "")
if source_fingerprint and source_fingerprint != entry_fingerprint(entry):
return False
if pending.get("based_on") != (source_fingerprint or entry_fingerprint(entry)):
return False
return all(
cls._proposal_value(pending, key) == cls._proposal_value(proposal, key)
for key in (
"optimized_description",
"source",
"changes",
"missing_facts",
"unconfirmed_suggestions",
"optional_enhancements",
"validation_warnings",
"star",
"omitted_fact_ids",
)
)
def _optimization_failure_message(exc: Exception) -> str:
if not isinstance(exc, LLMServiceError):
reason_code = (
"structured_output_invalid"
if isinstance(exc, ValidationError)
else "optimization_state_invalid"
)
return f"AI \u4f18\u5316\u672a\u80fd\u751f\u6210\u53ef\u7528\u7ed3\u679c\uff08{reason_code}\uff09\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002"
detail = str(exc.safe_summary or exc.reason_code)
messages = {
"equivalent_result": "\u6a21\u578b\u8fd4\u56de\u7684\u6539\u5199\u4e0e\u539f\u63cf\u8ff0\u53d8\u5316\u8fc7\u5c0f\uff0c\u8bf7\u8865\u5145\u5177\u4f53\u884c\u52a8\u6216\u7ed3\u679c\u540e\u91cd\u8bd5\u3002",
"structured_fields_only": "AI \u53ea\u8fd4\u56de\u4e86\u8868\u5355\u5b57\u6bb5\uff0c\u6ca1\u6709\u5f62\u6210\u7b80\u5386\u5316\u7684\u7ecf\u5386\u53d9\u8ff0\u3002\u8bf7\u8865\u5145\u7ecf\u5386\u63cf\u8ff0\u540e\u91cd\u8bd5\u3002",
"grounding_rejected": "AI \u4f18\u5316\u7a3f\u5305\u542b\u65e0\u6cd5\u7531\u5df2\u586b\u5199\u4fe1\u606f\u9a8c\u8bc1\u7684\u5185\u5bb9\uff0c\u5df2\u88ab\u62e6\u622a\u3002\u8bf7\u8865\u5145\u53ef\u786e\u8ba4\u7684\u4e8b\u5b9e\u540e\u91cd\u8bd5\u3002",
"insufficient_facts": "\u5f53\u524d\u53ef\u786e\u8ba4\u4fe1\u606f\u4e0d\u8db3\uff0c\u65e0\u6cd5\u751f\u6210\u53ef\u9a8c\u8bc1\u7684\u4f18\u5316\u7a3f\u3002\u8bf7\u8865\u5145\u4f60\u505a\u4e86\u4ec0\u4e48\u3001\u5982\u4f55\u5b8c\u6210\u6216\u6709\u4ec0\u4e48\u7ed3\u679c\u3002",
"empty_result": "AI \u670d\u52a1\u672a\u8fd4\u56de\u53ef\u7528\u7684\u4f18\u5316\u7a3f\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002",
}
return messages.get(
detail,
f"AI \u4f18\u5316\u672a\u80fd\u751f\u6210\u53ef\u7528\u7ed3\u679c\uff08{exc.reason_code}\uff09\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002",
)
@staticmethod
def _raise_optimization_ai_failed(
exc: Exception,
session_id: str,
entry_id: str,
run_id: str | None = None,
) -> None:
if isinstance(exc, LLMServiceError):
reason_code = exc.reason_code
trace_id = exc.trace_id
stage = exc.stage
else:
reason_code = (
"structured_output_invalid"
if isinstance(exc, ValidationError)
else "optimization_state_invalid"
)
trace_id = None
stage = "optimization_flow"
log_ai_event(
"deep_optimization_request_failed",
session_id=session_id,
entry_id=entry_id,
run_id=run_id,
reason_code=reason_code,
trace_id=trace_id,
stage=stage,
exception=type(exc).__name__,
)
raise FSMError(
"optimization_ai_failed",
OptimizationFlowMixin._optimization_failure_message(exc),
status_code=502,
) from exc
def _entry(self, connection: Any, session_id: str, entry_id: str) -> tuple[Any, Any, Any, Any]:
session, resume = self._session_or_404(connection, session_id), self._resume_or_409(connection, session_id)
found = find_entry(resume["content"], entry_id)
if found is None:
raise FSMError("entry_not_found", "Entry not found in resume", status_code=404)
return session, resume, found[0], found[1]
def list_active_optimization_runs(self, session_id: str) -> list[OptimizationRunView]:
with self.database.transaction() as connection:
self._session_or_404(connection, session_id)
runs = self.database.list_active_optimization_runs(connection, session_id)
return [self._view(run, None) for run in runs]
def _run(self, connection: Any, session_id: str, run_id: str, expected_status: str) -> tuple[Any, Any, Any]:
session, resume = self._session_or_404(connection, session_id), self._resume_or_409(connection, session_id)
run = self.database.fetch_optimization_run(connection, session_id, run_id)
if run is None:
raise FSMError("optimization_not_found", "Optimization run not found", status_code=404)
if run["status"] != expected_status:
raise FSMError("optimization_not_ready", "Optimization run is not ready for this action", status_code=409)
if expected_status == "question_pending" and run["source_revision"] != resume["revision"]:
raise FSMError("optimization_stale", "Resume changed; restart optimization", status_code=409)
return session, resume, run
@staticmethod
def _facts(entry: dict[str, Any]) -> list[dict[str, str]]:
keys = (
"title", "organization", "role", "company", "position", "project_name",
"project_role", "school", "major", "degree", "start_date",
"end_date_or_present", "name", "award", "date", "description",
)
facts: list[dict[str, str]] = []
for key in keys:
text = str(entry.get(key) or "").strip()
if text:
facts.append({
"id": f"fact_{len(facts) + 1}",
"source": "user_form",
"field": key,
"text": text,
})
return facts
@staticmethod
def _context(session: dict[str, Any], section: dict[str, Any], instruction: str | None) -> dict[str, Any]:
profile = session["profile"]
return {"job_type": profile.get("job_type"), "target_position": profile.get("target_position"), "major": (profile.get("anchor") or {}).get("major"), "entry_type": section.get("kind"), "instruction": instruction}
@staticmethod
def _set_proposal(content: dict[str, Any], entry_id: str, proposal: dict[str, Any]) -> dict[str, Any]:
optimized = str(proposal.get("optimized_description") or "").strip()
if not optimized:
raise FSMError(
"optimization_not_enough_facts",
"Please add an experience description or at least one usable experience field before optimizing.",
status_code=422,
)
try:
return set_pending_proposal(
content,
entry_id,
optimized,
source=proposal.get("source", "rule_structured"),
changes=proposal.get("changes") or [],
generation_source=proposal.get("generation_source"),
fallback_reason=proposal.get("fallback_reason"),
missing_facts=proposal.get("missing_facts"),
unconfirmed_suggestions=proposal.get("unconfirmed_suggestions"),
optional_enhancements=proposal.get("optional_enhancements"),
validation_warnings=proposal.get("validation_warnings"),
star=proposal.get("star"),
omitted_fact_ids=proposal.get("omitted_fact_ids"),
)
except DocumentError as exc:
raise _to_fsm(exc) from exc
def _view(self, run: dict[str, Any], action: Any) -> OptimizationRunView:
state = run["state"]
gaps = state.get("missing_dimensions") or []
remaining_high_priority_gaps = [
str(item.get("dimension") or "").strip()
for item in gaps
if isinstance(item, dict)
and item.get("priority") == "high"
and str(item.get("dimension") or "").strip()
]
gap_analysis = state.get("gap_analysis") or []
if gap_analysis:
remaining_high_priority_gaps = [
str(item.get("dimension") or "").strip()
for item in gap_analysis
if isinstance(item, dict)
and int(item.get("severity") or 0) >= 4
and str(item.get("dimension") or "").strip()
]
question = state.get("question") if isinstance(state.get("question"), dict) else None
completion = state.get("completion_decision") or {}
decision_source = (
state.get("decision_source")
or (question or {}).get("decision_source")
or completion.get("decision_source")
)
return OptimizationRunView(
id=run["id"],
mode=run["mode"],
status=run["status"],
entry_id=run["entry_id"],
question_count=int(state.get("question_count") or 0),
question=question,
proposal=run.get("proposal"),
covered_dimensions=[
str(item) for item in state.get("covered_dimensions") or [] if str(item).strip()
],
remaining_high_priority_gaps=list(dict.fromkeys(remaining_high_priority_gaps)),
decision_source=str(decision_source) if decision_source else None,
error_code=str(state.get("error_code")) if state.get("error_code") else None,
action=action,
gap_report=[dict(item) for item in state.get("gap_report") or []] or None,
tier=str(state.get("tier")) if state.get("tier") else None,
)