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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 11:08:30 +08:00

282 lines
11 KiB
Python

"""Light entry expansion: pure LLM expander, fallback composition, and factory.
The RAG knowledge base was removed (it only ever served the deep-optimization track).
Expansion is the model rewriting the user's own confirmed facts; every candidate still
passes through claim validation so unconfirmed additions never silently enter a resume.
"""
from __future__ import annotations
import logging
from typing import Any
from pydantic import Field
from .claim_validator import partition_entry_text, quantified_fact_contexts
from .entry_expander import EntryExpander, RuleBasedEntryExpander
from .experience_optimizer import (
_fact_text_is_preserved,
normalize_fact_ledger,
required_material_fact_ids,
)
from .llm_services import (
LLMServiceError,
OpenAICompatibleStructuredClient,
StrictSchema,
log_ai_event,
)
from .resume_expansion_prompts import (
_EDUCATION_PROMPT,
_EXPANSION_REPAIR_PROMPT,
_repair_prompt,
_system_prompt,
)
from .settings import Settings
__all__ = [
"EntryExpansionOutput",
"OpenAIEntryExpander",
"FallbackEntryExpander",
"build_expander",
"_EDUCATION_PROMPT",
"_EXPANSION_REPAIR_PROMPT",
"_system_prompt",
"_entry_fact_ledger",
"_entry_facts",
]
class EntryExpansionOutput(StrictSchema):
optimized_description: str
changes: list[str] = Field(max_length=5)
exemplar_titles: list[str] = Field(max_length=3)
class OpenAIEntryExpander:
"""LLM expander over user-confirmed facts only (no retrieval)."""
def __init__(self, completion: Any) -> None:
self.completion = completion
def expand(self, entry: dict[str, Any], *, context: dict[str, Any]) -> dict[str, Any]:
facts_text = _entry_facts(entry)
fact_ledger = _entry_fact_ledger(entry)
entry_type = str(context.get("entry_type") or "")
primary_description = str(entry.get("description") or "").strip()
output: EntryExpansionOutput = self.completion.complete(
schema=EntryExpansionOutput,
schema_name="entry_expansion",
system_prompt=_system_prompt(entry_type),
payload={
"entry_facts": facts_text,
"primary_description": primary_description,
"entry_type": entry_type or None,
"target_position": context.get("target_position"),
"instruction": context.get("instruction"),
"protected_quantity_facts": quantified_fact_contexts(facts_text),
},
)
candidate = output.optimized_description.strip()
repair_reason: str | None = None
if not candidate and primary_description:
repair_reason = "empty_result"
log_ai_event(
"entry_expansion_repair_started",
entry_type=entry_type,
reason_code=repair_reason,
)
repaired: EntryExpansionOutput = self.completion.complete(
schema=EntryExpansionOutput,
schema_name="entry_expansion_repair",
system_prompt=_repair_prompt(entry_type),
payload={
"entry_facts": facts_text,
"primary_description": primary_description,
"entry_type": entry_type or None,
"target_position": context.get("target_position"),
"instruction": context.get("instruction"),
"protected_quantity_facts": quantified_fact_contexts(facts_text),
"rejected_candidate": "",
"rejected_reason": repair_reason,
},
)
output = repaired
candidate = repaired.optimized_description.strip()
optimized, suggestions, warnings = partition_entry_text(candidate, fact_ledger)
if not optimized and primary_description:
# A model result composed only of unconfirmed additions must not become a failed
# card operation. Preserve the user's confirmed text and surface the additions.
optimized = primary_description
warnings.append("candidate_contains_unconfirmed_additions")
if optimized:
missing = _missing_material_facts(fact_ledger, optimized)
if missing:
optimized, extra_suggestions, extra_warnings = self._repair_material_omissions(
optimized,
missing,
fact_ledger,
facts_text=facts_text,
primary_description=primary_description,
entry_type=entry_type,
context=context,
)
suggestions.extend(extra_suggestions)
warnings.extend(extra_warnings)
if not optimized:
fallback_reason = "repair_failed" if repair_reason else "insufficient_facts"
log_ai_event(
"entry_expansion_rejected",
level=logging.WARNING,
entry_type=entry_type,
reason_code=fallback_reason,
)
return {
"optimized_description": "",
"changes": [],
"unconfirmed_suggestions": suggestions,
"validation_warnings": list(dict.fromkeys(warnings)),
"source": "ai_expanded",
"generation_source": "llm",
"fallback_reason": fallback_reason,
}
return {
"optimized_description": optimized,
"changes": [item.strip() for item in output.changes if item.strip()][:5],
"unconfirmed_suggestions": suggestions[:6],
"validation_warnings": list(dict.fromkeys(warnings)),
"source": "ai_expanded",
"generation_source": "llm",
}
def _repair_material_omissions(
self,
optimized: str,
missing: list[str],
fact_ledger: list[dict[str, str]],
*,
facts_text: str,
primary_description: str,
entry_type: str,
context: dict[str, Any],
) -> tuple[str, list[str], list[str]]:
"""One repair pass for candidates that dropped confirmed material facts.
Feature lists, product intros, and outcomes must not vanish while the
tech stack survives. The pre-repair candidate is kept when the repair
call fails or partitions to nothing: an omission never vetoes the draft.
"""
try:
repaired: EntryExpansionOutput = self.completion.complete(
schema=EntryExpansionOutput,
schema_name="entry_expansion_repair",
system_prompt=_repair_prompt(entry_type),
payload={
"entry_facts": facts_text,
"primary_description": primary_description,
"entry_type": entry_type or None,
"target_position": context.get("target_position"),
"instruction": context.get("instruction"),
"protected_quantity_facts": quantified_fact_contexts(facts_text),
"rejected_candidate": optimized,
"rejected_reason": "material_fact_omitted",
"omitted_facts": missing,
},
)
except Exception as exc:
log_ai_event(
"entry_expansion_coverage_repair_failed",
level=logging.WARNING,
entry_type=entry_type,
reason_code=getattr(exc, "reason_code", type(exc).__name__),
)
return optimized, [], ["material_fact_omitted"]
repaired_text, extra_suggestions, _ = partition_entry_text(
repaired.optimized_description.strip(), fact_ledger
)
if not repaired_text:
return optimized, [], ["material_fact_omitted"]
if _missing_material_facts(fact_ledger, repaired_text):
return repaired_text, extra_suggestions, ["material_fact_omitted_after_repair"]
return repaired_text, extra_suggestions, []
def _missing_material_facts(facts: list[dict[str, str]], narrative: str) -> list[str]:
ledger = normalize_fact_ledger(facts)
required = set(required_material_fact_ids(ledger))
return [
fact["text"]
for fact in ledger
if fact["id"] in required and not _fact_text_is_preserved(fact["id"], ledger, narrative)
]
class FallbackEntryExpander:
def __init__(self, primary: EntryExpander, fallback: EntryExpander) -> None:
self.primary = primary
self.fallback = fallback
def expand(self, entry: dict[str, Any], *, context: dict[str, Any]) -> dict[str, Any]:
try:
return self.primary.expand(entry, context=context)
except Exception as exc:
reason = exc.reason_code if isinstance(exc, LLMServiceError) else type(exc).__name__.lower()[:48]
log_ai_event(
"entry_expansion_failed",
level=logging.ERROR,
entry_type=str(context.get("entry_type") or ""),
reason_code=reason,
trace_id=getattr(exc, "trace_id", None),
exception=type(exc).__name__,
)
fallback = self.fallback.expand(entry, context=context)
optimized = str(fallback.get("optimized_description") or "").strip()
if optimized:
return {
**fallback,
"source": str(fallback.get("source") or "rule_polish"),
"generation_source": "rule_fallback",
"fallback_reason": reason,
}
return {
"optimized_description": "",
"changes": [],
"unconfirmed_suggestions": [],
"source": "rule_polish",
"generation_source": "unavailable",
"fallback_reason": reason,
}
def _entry_facts(entry: dict[str, Any]) -> str:
return "\n".join(item["text"] for item in _entry_fact_ledger(entry))
def _entry_fact_ledger(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",
)
ledger: list[dict[str, str]] = []
for key in keys:
value = str(entry.get(key) or "").strip()
if value:
ledger.append({"id": f"entry_{key}", "field": key, "text": value})
for index, value in enumerate(entry.get("highlights") or [], start=1):
clean = str(value).strip()
if clean:
ledger.append({"id": f"entry_highlight_{index}", "field": "highlight", "text": clean})
return ledger
def build_expander(settings: Settings, client: Any | None = None) -> EntryExpander:
rules = RuleBasedEntryExpander()
if not settings.use_openai:
return rules
completion = OpenAICompatibleStructuredClient(settings, client)
return FallbackEntryExpander(OpenAIEntryExpander(completion), rules)