generated from kgod/ai-review-template
feat: builder 简历生成 + 轻度优化 + 简历导入交付副本
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""PostgreSQL persistence primitives for Resume Agent."""
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user