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
+1
View File
@@ -0,0 +1 @@
"""PostgreSQL persistence primitives for Resume Agent."""
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import Engine, func, insert, select, update
from sqlalchemy.exc import IntegrityError
from .schema import build_session_tables
class RevisionConflict(Exception):
"""The caller attempted to replace a stale resume document."""
def _now() -> datetime:
return datetime.now(UTC)
class PostgresSessionRepository:
"""Core PostgreSQL store, shaped for incremental replacement of SQLite."""
def __init__(self, engine: Engine, *, schema: str = "resume_agent") -> None:
self.engine = engine
self.schema = schema
self.metadata, self.tables = build_session_tables(schema)
def initialize(self) -> None:
with self.engine.begin() as connection:
connection.exec_driver_sql(f'CREATE SCHEMA IF NOT EXISTS "{self.schema}"')
self.metadata.create_all(connection)
def create_session(self, session_id: str, stage: str, profile: dict[str, Any]) -> dict[str, Any]:
now = _now()
values = {
"id": session_id,
"stage": stage,
"revision": 0,
"profile": profile,
"created_at": now,
"updated_at": now,
}
with self.engine.begin() as connection:
connection.execute(insert(self.tables["sessions"]).values(**values))
return self.get_session(session_id) or values
def get_session(self, session_id: str) -> dict[str, Any] | None:
with self.engine.connect() as connection:
row = connection.execute(
select(self.tables["sessions"]).where(self.tables["sessions"].c.id == session_id)
).mappings().first()
return dict(row) if row else None
def insert_turn(
self, session_id: str, *, role: str, content: str | None, composer_mode: str, blocks: list[dict[str, Any]]
) -> dict[str, Any]:
turns, block_table = self.tables["turns"], self.tables["blocks"]
now, turn_id = _now(), f"turn_{uuid4().hex}"
with self.engine.begin() as connection:
maximum = connection.execute(
select(func.coalesce(func.max(turns.c.sequence), 0)).where(turns.c.session_id == session_id)
).scalar_one()
sequence = maximum + 1
connection.execute(insert(turns).values(
id=turn_id, session_id=session_id, sequence=sequence, role=role,
content=content, composer_mode=composer_mode, created_at=now,
))
for index, block in enumerate(blocks):
connection.execute(insert(block_table).values(
id=block.get("id", f"block_{uuid4().hex}"), session_id=session_id,
turn_id=turn_id, block_index=index, type=block["type"],
lifecycle=block.get("lifecycle", "active"), data=block.get("data", {}),
version=1, created_at=now, updated_at=now,
))
return self._turn(turn_id)
def list_turns(self, session_id: str) -> list[dict[str, Any]]:
turns = self.tables["turns"]
with self.engine.connect() as connection:
ids = connection.execute(
select(turns.c.id).where(turns.c.session_id == session_id).order_by(turns.c.sequence)
).scalars().all()
return [self._turn(turn_id) for turn_id in ids]
def _turn(self, turn_id: str) -> dict[str, Any]:
turns, blocks = self.tables["turns"], self.tables["blocks"]
with self.engine.connect() as connection:
turn = connection.execute(select(turns).where(turns.c.id == turn_id)).mappings().one()
rows = connection.execute(
select(blocks).where(blocks.c.turn_id == turn_id).order_by(blocks.c.block_index)
).mappings().all()
result = dict(turn)
result["blocks"] = [dict(row) for row in rows]
return result
def create_resume(
self, session_id: str, resume_id: str, idempotency_key: str | None, content: dict[str, Any]
) -> dict[str, Any]:
existing = self.get_resume(session_id)
if existing:
return existing
now = _now()
try:
with self.engine.begin() as connection:
connection.execute(insert(self.tables["resumes"]).values(
id=resume_id, session_id=session_id, idempotency_key=idempotency_key,
revision=1, content=content, created_at=now, updated_at=now,
))
except IntegrityError:
existing = self.get_resume(session_id)
if existing:
return existing
raise
return self.get_resume(session_id) # type: ignore[return-value]
def get_resume(self, session_id: str) -> dict[str, Any] | None:
with self.engine.connect() as connection:
row = connection.execute(
select(self.tables["resumes"]).where(self.tables["resumes"].c.session_id == session_id)
).mappings().first()
return dict(row) if row else None
def update_resume(self, session_id: str, content: dict[str, Any], *, expected_revision: int) -> dict[str, Any]:
resumes = self.tables["resumes"]
with self.engine.begin() as connection:
result = connection.execute(update(resumes).where(
resumes.c.session_id == session_id, resumes.c.revision == expected_revision
).values(content=content, revision=expected_revision + 1, updated_at=_now()))
if result.rowcount != 1:
raise RevisionConflict(session_id)
return self.get_resume(session_id) # type: ignore[return-value]
def create_resume_import(
self,
session_id: str,
*,
import_id: str,
file_name: str,
mime_type: str,
size_bytes: int,
sha256: str,
object_key: str,
document: dict[str, Any] | None,
field_reviews: list[dict[str, Any]],
status: str = "awaiting_review",
error_code: str | None = None,
) -> dict[str, Any]:
existing = self.find_resume_import_by_sha256(session_id, sha256)
if existing:
return existing
now = _now()
values = {
"id": import_id,
"session_id": session_id,
"file_name": file_name,
"mime_type": mime_type,
"size_bytes": size_bytes,
"sha256": sha256,
"object_key": object_key,
"status": status,
"document": document,
"field_reviews": field_reviews,
"error_code": error_code,
"created_at": now,
"updated_at": now,
}
try:
with self.engine.begin() as connection:
connection.execute(insert(self.tables["resume_imports"]).values(**values))
except IntegrityError:
existing = self.find_resume_import_by_sha256(session_id, sha256)
if existing:
return existing
raise
return self.get_resume_import(session_id, import_id) # type: ignore[return-value]
def get_resume_import(self, session_id: str, import_id: str) -> dict[str, Any] | None:
imports = self.tables["resume_imports"]
with self.engine.connect() as connection:
row = connection.execute(
select(imports).where(
imports.c.id == import_id,
imports.c.session_id == session_id,
)
).mappings().first()
return dict(row) if row else None
def find_resume_import_by_sha256(self, session_id: str, sha256: str) -> dict[str, Any] | None:
imports = self.tables["resume_imports"]
with self.engine.connect() as connection:
row = connection.execute(
select(imports).where(
imports.c.session_id == session_id,
imports.c.sha256 == sha256,
)
).mappings().first()
return dict(row) if row else None
def update_resume_import_status(
self,
session_id: str,
import_id: str,
status: str,
*,
error_code: str | None = None,
) -> dict[str, Any]:
imports = self.tables["resume_imports"]
with self.engine.begin() as connection:
result = connection.execute(
update(imports)
.where(imports.c.id == import_id, imports.c.session_id == session_id)
.values(status=status, error_code=error_code, updated_at=_now())
)
if result.rowcount != 1:
raise KeyError(import_id)
return self.get_resume_import(session_id, import_id) # type: ignore[return-value]
def delete_session(self, session_id: str) -> bool:
with self.engine.begin() as connection:
result = connection.execute(
self.tables["sessions"].delete().where(self.tables["sessions"].c.id == session_id)
)
return result.rowcount == 1
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from sqlalchemy import (
CheckConstraint,
Column,
DateTime,
ForeignKey,
Index,
Integer,
MetaData,
String,
Table,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
def build_session_tables(schema: str) -> tuple[MetaData, dict[str, Table]]:
"""Build the core Resume Agent tables in the supplied PostgreSQL schema."""
metadata = MetaData(schema=schema)
sessions = Table(
"sessions",
metadata,
Column("id", String(128), primary_key=True),
Column("stage", String(64), nullable=False),
Column("revision", Integer, nullable=False, default=0),
Column("profile", JSONB, nullable=False),
Column("draft_id", String(128)),
Column("resume_id", String(128)),
Column("created_at", DateTime(timezone=True), nullable=False),
Column("updated_at", DateTime(timezone=True), nullable=False),
)
turns = Table(
"turns",
metadata,
Column("id", String(128), primary_key=True),
Column("session_id", String(128), ForeignKey(f"{schema}.sessions.id", ondelete="CASCADE"), nullable=False),
Column("sequence", Integer, nullable=False),
Column("role", String(32), nullable=False),
Column("content", String),
Column("composer_mode", String(32), nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False),
UniqueConstraint("session_id", "sequence", name="uq_turn_session_sequence"),
)
blocks = Table(
"blocks",
metadata,
Column("id", String(128), primary_key=True),
Column("session_id", String(128), ForeignKey(f"{schema}.sessions.id", ondelete="CASCADE"), nullable=False),
Column("turn_id", String(128), ForeignKey(f"{schema}.turns.id", ondelete="CASCADE"), nullable=False),
Column("block_index", Integer, nullable=False),
Column("type", String(32), nullable=False),
Column("lifecycle", String(32), nullable=False),
Column("data", JSONB, nullable=False),
Column("version", Integer, nullable=False, default=1),
Column("created_at", DateTime(timezone=True), nullable=False),
Column("updated_at", DateTime(timezone=True), nullable=False),
UniqueConstraint("turn_id", "block_index", name="uq_block_turn_index"),
)
resumes = Table(
"resumes",
metadata,
Column("id", String(128), primary_key=True),
Column("session_id", String(128), ForeignKey(f"{schema}.sessions.id", ondelete="CASCADE"), nullable=False, unique=True),
Column("idempotency_key", String(128)),
Column("revision", Integer, nullable=False, default=1),
Column("content", JSONB, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False),
Column("updated_at", DateTime(timezone=True), nullable=False),
CheckConstraint("revision >= 1", name="ck_resume_revision_positive"),
)
resume_imports = Table(
"resume_imports",
metadata,
Column("id", String(128), primary_key=True),
Column(
"session_id",
String(128),
ForeignKey(f"{schema}.sessions.id", ondelete="CASCADE"),
nullable=False,
),
Column("file_name", String(512), nullable=False),
Column("mime_type", String(128), nullable=False),
Column("size_bytes", Integer, nullable=False),
Column("sha256", String(64), nullable=False),
Column("object_key", String(512), nullable=False),
Column("status", String(32), nullable=False),
Column("document", JSONB),
Column("field_reviews", JSONB, nullable=False, default=list),
Column("error_code", String(128)),
Column("created_at", DateTime(timezone=True), nullable=False),
Column("updated_at", DateTime(timezone=True), nullable=False),
UniqueConstraint("session_id", "sha256", name="uq_resume_import_session_sha256"),
CheckConstraint("size_bytes >= 0", name="ck_resume_import_size_nonnegative"),
)
Index("ix_resume_imports_session_created", resume_imports.c.session_id, resume_imports.c.created_at)
optimization_runs = Table(
"optimization_runs", metadata,
Column("id", String(128), primary_key=True),
Column("session_id", String(128), ForeignKey(f"{schema}.sessions.id", ondelete="CASCADE"), nullable=False),
Column("entry_id", String(128), nullable=False),
Column("mode", String(32), nullable=False),
Column("status", String(32), nullable=False),
Column("source_revision", Integer, nullable=False),
Column("state", JSONB, nullable=False),
Column("proposal", JSONB),
Column("created_at", DateTime(timezone=True), nullable=False),
Column("updated_at", DateTime(timezone=True), nullable=False),
CheckConstraint("source_revision >= 1", name="ck_optimization_source_revision_positive"),
)
Index("ix_optimization_runs_active", optimization_runs.c.session_id, optimization_runs.c.entry_id, optimization_runs.c.status)
Index("ix_turns_session_sequence", turns.c.session_id, turns.c.sequence)
Index("ix_blocks_session_turn_index", blocks.c.session_id, blocks.c.turn_id, blocks.c.block_index)
return metadata, {
"sessions": sessions,
"turns": turns,
"blocks": blocks,
"resumes": resumes,
"resume_imports": resume_imports,
"optimization_runs": optimization_runs,
}
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import hashlib
import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from sqlalchemy import create_engine, select
from sqlalchemy.dialects.postgresql import insert as postgres_insert
from .schema import build_session_tables
TABLES = ("sessions", "turns", "blocks", "resumes", "resume_imports", "optimization_runs")
@dataclass(frozen=True, slots=True)
class MigrationReport:
source_counts: dict[str, int]
target_counts: dict[str, int]
source_checksum: str
target_checksum: str
def migrate_sqlite_to_postgres(
source_path: str | Path,
target_url: str,
*,
schema: str = "resume_agent",
dry_run: bool = False,
conflict_policy: str = "error",
) -> MigrationReport:
"""Copy SQLite rows into PostgreSQL without deleting target-only records."""
if conflict_policy not in {"error", "update"}:
raise ValueError("conflict_policy must be 'error' or 'update'")
source_rows = _read_source(Path(source_path))
source_counts = {name: len(rows) for name, rows in source_rows.items()}
source_checksum = _checksum(source_rows)
if dry_run:
return MigrationReport(source_counts, {}, source_checksum, "")
engine = create_engine(target_url)
metadata, tables = build_session_tables(schema)
try:
with engine.begin() as connection:
connection.exec_driver_sql(f'CREATE SCHEMA IF NOT EXISTS "{schema}"')
metadata.create_all(connection)
for name in TABLES:
rows = source_rows[name]
if not rows:
continue
table = tables[name]
statement = postgres_insert(table).values(rows)
if conflict_policy == "update":
statement = statement.on_conflict_do_update(
index_elements=[table.c.id],
set_={column.name: statement.excluded[column.name]
for column in table.columns if column.name != "id"},
)
connection.execute(statement)
target_rows = _read_target(connection, tables, source_rows)
target_checksum = _checksum(target_rows)
if target_checksum != source_checksum:
raise RuntimeError("PostgreSQL verification checksum does not match SQLite source")
finally:
engine.dispose()
target_counts = {name: len(rows) for name, rows in target_rows.items()}
target_checksum = _checksum(target_rows)
return MigrationReport(source_counts, target_counts, source_checksum, target_checksum)
def _read_source(path: Path) -> dict[str, list[dict[str, Any]]]:
connection = sqlite3.connect(f"file:{path.resolve().as_posix()}?mode=ro", uri=True)
connection.row_factory = sqlite3.Row
try:
rows: dict[str, list[dict[str, Any]]] = {}
existing = {row[0] for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
)}
for name in TABLES:
if name not in existing:
rows[name] = []
continue
result = [dict(row) for row in connection.execute(f"SELECT * FROM {name}")]
rows[name] = [_normalize_source(name, row) for row in result]
return rows
finally:
connection.close()
def _normalize_source(name: str, row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
if name == "sessions":
result["profile"] = json.loads(result.pop("profile_json"))
if result["profile"].get("job_type") == "other":
result["profile"]["job_type"] = "internship"
elif name == "blocks":
result["data"] = json.loads(result.pop("data_json"))
elif name == "resumes":
result["content"] = json.loads(result.pop("content_json"))
elif name == "resume_imports":
raw_document = result.pop("document_json")
result["document"] = json.loads(raw_document) if raw_document else None
result["field_reviews"] = json.loads(result.pop("field_reviews_json"))
elif name == "optimization_runs":
result["state"] = json.loads(result.pop("state_json"))
raw_proposal = result.pop("proposal_json")
result["proposal"] = json.loads(raw_proposal) if raw_proposal else None
return result
def _read_target(
connection: Any, tables: dict[str, Any], source_rows: dict[str, list[dict[str, Any]]]
) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = {}
for name in TABLES:
table = tables[name]
source_ids = [row["id"] for row in source_rows[name]]
if not source_ids:
result[name] = []
continue
rows = connection.execute(select(table).where(
table.c.id.in_(source_ids)
).order_by(table.c.id)).mappings().all()
result[name] = [dict(row) for row in rows]
return result
def _checksum(rows_by_table: dict[str, list[dict[str, Any]]]) -> str:
stable: dict[str, list[dict[str, Any]]] = {}
for name in TABLES:
rows = rows_by_table.get(name, [])
stable[name] = [_checksum_row(name, row) for row in rows]
stable[name].sort(key=lambda row: str(row["id"]))
serialized = json.dumps(stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _checksum_row(name: str, row: dict[str, Any]) -> dict[str, Any]:
keys = {
"sessions": ("id", "stage", "revision", "profile", "draft_id", "resume_id"),
"turns": ("id", "session_id", "sequence", "role", "content", "composer_mode"),
"blocks": ("id", "session_id", "turn_id", "block_index", "type", "lifecycle", "data", "version"),
"resumes": ("id", "session_id", "idempotency_key", "revision", "content"),
"resume_imports": (
"id", "session_id", "file_name", "mime_type", "size_bytes", "sha256",
"object_key", "status", "document", "field_reviews", "error_code",
),
"optimization_runs": (
"id", "session_id", "entry_id", "mode", "status", "source_revision",
"state", "proposal",
),
}[name]
return {key: row[key] for key in keys}