generated from kgod/ai-review-template
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""Light entry expansion: pure LLM expander, fallback composition, and factory."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
from .claim_validator import partition_entry_text, quantified_fact_contexts
|
|
from .entry_expander import EntryExpander, RuleBasedEntryExpander, normalize_bullet_description
|
|
from .fact_coverage import (
|
|
FactRequirement,
|
|
classify_fact_requirements,
|
|
hard_fact_is_preserved,
|
|
missing_hard_facts,
|
|
)
|
|
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
|
|
|
|
|
|
class OpenAIEntryExpander:
|
|
"""LLM expander over user-confirmed facts only (no retrieval)."""
|
|
|
|
_MIN_REPAIR_SECONDS = 6.0
|
|
|
|
def __init__(self, completion: Any, *, timeout_seconds: float | None = None) -> None:
|
|
self.completion = completion
|
|
settings = getattr(completion, "settings", None)
|
|
configured_timeout = getattr(settings, "light_entry_timeout_seconds", None)
|
|
self.timeout_seconds = timeout_seconds or configured_timeout
|
|
|
|
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)
|
|
hard_required_facts, _ = classify_fact_requirements(fact_ledger)
|
|
entry_type = str(context.get("entry_type") or "")
|
|
primary_description = str(entry.get("description") or "").strip()
|
|
started_at = time.perf_counter()
|
|
output: EntryExpansionOutput = self._complete(
|
|
schema_name="entry_expansion",
|
|
system_prompt=_system_prompt(entry_type),
|
|
payload=self._base_payload(
|
|
facts_text=facts_text,
|
|
primary_description=primary_description,
|
|
entry_type=entry_type,
|
|
context=context,
|
|
hard_required_facts=hard_required_facts,
|
|
),
|
|
remaining_seconds=self._remaining_seconds(started_at),
|
|
)
|
|
|
|
candidate = _normalize_candidate(output.optimized_description, entry_type)
|
|
repair_reason: str | None = None
|
|
if not candidate and primary_description:
|
|
repair_reason = "empty_result"
|
|
remaining_seconds = self._remaining_seconds(started_at)
|
|
if remaining_seconds is None or remaining_seconds >= self._MIN_REPAIR_SECONDS:
|
|
log_ai_event("entry_expansion_repair_started", entry_type=entry_type, reason_code=repair_reason)
|
|
output = self._complete_repair(
|
|
facts_text=facts_text,
|
|
primary_description=primary_description,
|
|
entry_type=entry_type,
|
|
context=context,
|
|
hard_required_facts=hard_required_facts,
|
|
optimized="",
|
|
reason=repair_reason,
|
|
missing_hard=[],
|
|
started_at=started_at,
|
|
)
|
|
candidate = _normalize_candidate(output.optimized_description, entry_type)
|
|
|
|
optimized, suggestions, warnings = partition_entry_text(candidate, fact_ledger)
|
|
if not optimized and primary_description:
|
|
optimized = primary_description
|
|
warnings.append("candidate_contains_unconfirmed_additions")
|
|
|
|
missing_hard = missing_hard_facts(hard_required_facts, optimized) if optimized else []
|
|
if optimized and missing_hard:
|
|
repair_reason = "hard_fact_omitted"
|
|
log_ai_event(
|
|
"entry_expansion_repair_started",
|
|
entry_type=entry_type,
|
|
reason_code=repair_reason,
|
|
hard_fact_count=len(hard_required_facts),
|
|
omitted_fact_count=len(missing_hard),
|
|
)
|
|
optimized, extra_suggestions, extra_warnings, output = self._repair_material_omissions(
|
|
optimized,
|
|
missing_hard,
|
|
fact_ledger,
|
|
facts_text=facts_text,
|
|
primary_description=primary_description,
|
|
entry_type=entry_type,
|
|
context=context,
|
|
hard_required_facts=hard_required_facts,
|
|
reason=repair_reason,
|
|
previous_output=output,
|
|
started_at=started_at,
|
|
)
|
|
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,
|
|
}
|
|
|
|
remaining_hard = missing_hard_facts(hard_required_facts, optimized)
|
|
if remaining_hard:
|
|
warnings.append("hard_fact_omitted_after_repair")
|
|
return {
|
|
"optimized_description": optimized,
|
|
"changes": [],
|
|
"unconfirmed_suggestions": suggestions[:6],
|
|
"validation_warnings": list(dict.fromkeys(warnings)),
|
|
"uncovered_facts": remaining_hard[:8],
|
|
"source": "ai_expanded",
|
|
"generation_source": "llm",
|
|
}
|
|
|
|
def _base_payload(
|
|
self,
|
|
*,
|
|
facts_text: str,
|
|
primary_description: str,
|
|
entry_type: str,
|
|
context: dict[str, Any],
|
|
hard_required_facts: list[FactRequirement],
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"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),
|
|
"hard_required_facts": hard_required_facts,
|
|
}
|
|
|
|
def _complete_repair(
|
|
self,
|
|
*,
|
|
facts_text: str,
|
|
primary_description: str,
|
|
entry_type: str,
|
|
context: dict[str, Any],
|
|
hard_required_facts: list[FactRequirement],
|
|
optimized: str,
|
|
reason: str,
|
|
missing_hard: list[str],
|
|
started_at: float,
|
|
) -> EntryExpansionOutput:
|
|
payload = self._base_payload(
|
|
facts_text=facts_text,
|
|
primary_description=primary_description,
|
|
entry_type=entry_type,
|
|
context=context,
|
|
hard_required_facts=hard_required_facts,
|
|
)
|
|
payload.update({
|
|
"rejected_candidate": optimized,
|
|
"rejected_reason": reason,
|
|
"omitted_facts": missing_hard,
|
|
})
|
|
return self._complete(
|
|
schema_name="entry_expansion_repair",
|
|
system_prompt=_repair_prompt(entry_type),
|
|
payload=payload,
|
|
remaining_seconds=self._remaining_seconds(started_at),
|
|
)
|
|
|
|
def _repair_material_omissions(
|
|
self,
|
|
optimized: str,
|
|
missing_hard: list[str],
|
|
fact_ledger: list[dict[str, str]],
|
|
*,
|
|
facts_text: str,
|
|
primary_description: str,
|
|
entry_type: str,
|
|
context: dict[str, Any],
|
|
hard_required_facts: list[FactRequirement],
|
|
reason: str,
|
|
previous_output: EntryExpansionOutput,
|
|
started_at: float,
|
|
) -> tuple[str, list[str], list[str], EntryExpansionOutput]:
|
|
"""Run at most one repair pass; semantic source text is never raw-appended."""
|
|
remaining_seconds = self._remaining_seconds(started_at)
|
|
if remaining_seconds is not None and remaining_seconds < self._MIN_REPAIR_SECONDS:
|
|
return optimized, [], ["repair_skipped_budget"], previous_output
|
|
try:
|
|
repaired = self._complete_repair(
|
|
facts_text=facts_text,
|
|
primary_description=primary_description,
|
|
entry_type=entry_type,
|
|
context=context,
|
|
hard_required_facts=hard_required_facts,
|
|
optimized=optimized,
|
|
reason=reason,
|
|
missing_hard=missing_hard,
|
|
started_at=started_at,
|
|
)
|
|
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, [], ["repair_failed"], EntryExpansionOutput(
|
|
optimized_description=optimized,
|
|
)
|
|
repaired_text, extra_suggestions, repair_warnings = partition_entry_text(
|
|
_normalize_candidate(repaired.optimized_description, entry_type), fact_ledger
|
|
)
|
|
if not repaired_text:
|
|
return optimized, [], ["repair_failed"], previous_output
|
|
preserved_initial = [
|
|
fact for fact in hard_required_facts if hard_fact_is_preserved(fact, optimized)
|
|
]
|
|
repaired_missing = missing_hard_facts(hard_required_facts, repaired_text)
|
|
if (
|
|
len(repaired_missing) >= len(missing_hard)
|
|
or any(not hard_fact_is_preserved(fact, repaired_text) for fact in preserved_initial)
|
|
or _repair_regresses_structure(optimized, repaired_text)
|
|
):
|
|
return optimized, [], ["repair_rejected_quality_regression"], previous_output
|
|
return repaired_text, extra_suggestions, repair_warnings, repaired
|
|
|
|
def _remaining_seconds(self, started_at: float) -> float | None:
|
|
if self.timeout_seconds is None:
|
|
return None
|
|
return max(0.1, self.timeout_seconds - (time.perf_counter() - started_at))
|
|
|
|
def _complete(
|
|
self, *, schema_name: str, system_prompt: str, payload: dict[str, Any], remaining_seconds: float | None
|
|
) -> EntryExpansionOutput:
|
|
kwargs: dict[str, Any] = {
|
|
"schema": EntryExpansionOutput,
|
|
"schema_name": schema_name,
|
|
"system_prompt": system_prompt,
|
|
"payload": payload,
|
|
}
|
|
if remaining_seconds is not None:
|
|
kwargs.update(timeout_seconds=remaining_seconds, max_attempts=1)
|
|
return self.completion.complete(**kwargs)
|
|
|
|
|
|
def _repair_regresses_structure(original: str, repaired: str) -> bool:
|
|
original_lines = [line for line in original.splitlines() if line.strip()]
|
|
repaired_lines = [line for line in repaired.splitlines() if line.strip()]
|
|
if len(original_lines) >= 2 and len(repaired_lines) < len(original_lines):
|
|
return True
|
|
return len(original) >= 120 and len(repaired) < len(original) * 0.65
|
|
|
|
def _normalize_candidate(candidate: str, entry_type: str) -> str:
|
|
text = candidate.strip()
|
|
if not text or entry_type == "education":
|
|
return text
|
|
return normalize_bullet_description(text)
|
|
|
|
|
|
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)
|
|
primary = OpenAIEntryExpander(completion)
|
|
return FallbackEntryExpander(primary, rules) if settings.fallback_to_rules else primary |