Files
srbhr--resume-matcher/apps/backend/app/database.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

778 lines
30 KiB
Python

"""SQLAlchemy (SQLite) data layer for Resume Matcher.
This is a behavior-preserving replacement for the original TinyDB wrapper. The
``Database`` facade keeps the same method names/signatures and returns **plain
dicts** (never ORM rows), so the ~50 call sites only needed ``await`` added.
Two engines back one SQLite file:
- an **async** engine (``aiosqlite``) for the document tables and applications;
- a **sync** engine for the encrypted ``api_keys`` table, which is read on the
synchronous LLM hot path (``get_llm_config`` → ``resolve_api_key``).
"""
import asyncio
import logging
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from app.config import settings
from app.db_engine import init_models_sync, make_async_engine, make_sync_engine
from app.models import ApiKey, Application, Improvement, Job, Resume
logger = logging.getLogger(__name__)
# Columns that are first-class on the jobs table; everything else the pipeline
# attaches dynamically is stored in ``metadata_json`` (see Job model).
_JOB_CORE_FIELDS = frozenset({"job_id", "content", "resume_id", "created_at"})
# Application status columns (stable keys, decoupled from i18n labels).
APPLICATION_STATUSES: tuple[str, ...] = (
"saved",
"applied",
"no_response",
"response",
"interview",
"accepted",
"rejected",
)
def _now() -> str:
"""Current UTC time as an ISO-8601 string (TinyDB-era format)."""
return datetime.now(timezone.utc).isoformat()
class Database:
"""Async SQLAlchemy facade for resume matcher data."""
# Serializes concurrent master-resume promotion. Stays the *primary*
# mechanism for the single-master invariant (the partial unique index is a
# storage-level backstop).
_master_resume_lock = asyncio.Lock()
def __init__(self, db_path: Path | None = None):
self.db_path = db_path or settings.sqlite_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._async_engine = None
self._async_session_factory: async_sessionmaker[AsyncSession] | None = None
self._sync_engine = None
self._sync_session_factory: sessionmaker[Session] | None = None
self._initialized = False
# -- engine / session plumbing ------------------------------------------
def _ensure_initialized(self) -> None:
"""Create engines and tables once (idempotent).
Tables are created via the **sync** engine so both the sync (api_keys)
and async (docs) paths see them immediately, without needing an event
loop. Both engines point at the same file.
"""
if self._initialized:
return
self._sync_engine = make_sync_engine(self.db_path)
self._sync_session_factory = sessionmaker(self._sync_engine, expire_on_commit=False)
init_models_sync(self._sync_engine)
self._async_engine = make_async_engine(self.db_path)
self._async_session_factory = async_sessionmaker(
self._async_engine, expire_on_commit=False
)
self._initialized = True
@property
def _session(self) -> async_sessionmaker[AsyncSession]:
self._ensure_initialized()
assert self._async_session_factory is not None
return self._async_session_factory
@property
def _sync(self) -> sessionmaker[Session]:
self._ensure_initialized()
assert self._sync_session_factory is not None
return self._sync_session_factory
async def close(self) -> None:
"""Dispose engines and release file handles."""
if self._async_engine is not None:
await self._async_engine.dispose()
self._async_engine = None
self._async_session_factory = None
if self._sync_engine is not None:
self._sync_engine.dispose()
self._sync_engine = None
self._sync_session_factory = None
self._initialized = False
# -- row -> dict converters ---------------------------------------------
@staticmethod
def _resume_to_dict(row: Resume) -> dict[str, Any]:
doc: dict[str, Any] = {
"resume_id": row.resume_id,
"content": row.content,
"content_type": row.content_type,
"filename": row.filename,
"is_master": row.is_master,
"parent_id": row.parent_id,
"processed_data": row.processed_data,
"processing_status": row.processing_status,
"cover_letter": row.cover_letter,
"outreach_message": row.outreach_message,
"interview_prep": row.interview_prep,
"title": row.title,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
# Preserve TinyDB absence semantics: omit the key entirely when None.
if row.original_markdown is not None:
doc["original_markdown"] = row.original_markdown
return doc
@staticmethod
def _job_to_dict(row: Job) -> dict[str, Any]:
doc: dict[str, Any] = {
"job_id": row.job_id,
"content": row.content,
"resume_id": row.resume_id,
"created_at": row.created_at,
}
meta = row.metadata_json or {}
if isinstance(meta, dict):
doc.update(meta) # flatten dynamic fields to top level
return doc
@staticmethod
def _improvement_to_dict(row: Improvement) -> dict[str, Any]:
return {
"request_id": row.request_id,
"original_resume_id": row.original_resume_id,
"tailored_resume_id": row.tailored_resume_id,
"job_id": row.job_id,
"improvements": row.improvements,
"created_at": row.created_at,
}
@staticmethod
def _application_to_dict(row: Application) -> dict[str, Any]:
return {
"application_id": row.application_id,
"job_id": row.job_id,
"resume_id": row.resume_id,
"master_resume_id": row.master_resume_id,
"status": row.status,
"company": row.company,
"role": row.role,
"applied_at": row.applied_at,
"notes": row.notes,
"position": row.position,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
# -- Resume operations --------------------------------------------------
async def create_resume(
self,
content: str,
content_type: str = "md",
filename: str | None = None,
is_master: bool = False,
parent_id: str | None = None,
processed_data: dict[str, Any] | None = None,
processing_status: str = "pending",
cover_letter: str | None = None,
outreach_message: str | None = None,
title: str | None = None,
original_markdown: str | None = None,
interview_prep: str | None = None,
) -> dict[str, Any]:
"""Create a new resume entry.
processing_status: "pending", "processing", "ready", "failed"
"""
resume_id = str(uuid4())
now = _now()
async with self._session() as session:
session.add(
Resume(
resume_id=resume_id,
content=content,
content_type=content_type,
filename=filename,
is_master=is_master,
parent_id=parent_id,
processed_data=processed_data,
processing_status=processing_status,
cover_letter=cover_letter,
outreach_message=outreach_message,
interview_prep=interview_prep,
title=title,
original_markdown=original_markdown,
created_at=now,
updated_at=now,
)
)
await session.commit()
doc: dict[str, Any] = {
"resume_id": resume_id,
"content": content,
"content_type": content_type,
"filename": filename,
"is_master": is_master,
"parent_id": parent_id,
"processed_data": processed_data,
"processing_status": processing_status,
"cover_letter": cover_letter,
"outreach_message": outreach_message,
"interview_prep": interview_prep,
"title": title,
"created_at": now,
"updated_at": now,
}
if original_markdown is not None:
doc["original_markdown"] = original_markdown
return doc
async def create_resume_atomic_master(
self,
content: str,
content_type: str = "md",
filename: str | None = None,
processed_data: dict[str, Any] | None = None,
processing_status: str = "pending",
cover_letter: str | None = None,
outreach_message: str | None = None,
original_markdown: str | None = None,
title: str | None = None,
interview_prep: str | None = None,
) -> dict[str, Any]:
"""Create a new resume with atomic master assignment.
Uses an asyncio.Lock to prevent race conditions when multiple uploads
happen concurrently and both try to become master.
"""
async with self._master_resume_lock:
current_master = await self.get_master_resume()
is_master = current_master is None
# Recovery: if the current master is stuck failed/processing, demote
# it so this upload can become the new master.
if current_master and current_master.get("processing_status") in (
"failed",
"processing",
):
async with self._session() as session:
row = await session.get(Resume, current_master["resume_id"])
if row is not None:
row.is_master = False
await session.commit()
is_master = True
return await self.create_resume(
content=content,
content_type=content_type,
filename=filename,
is_master=is_master,
processed_data=processed_data,
processing_status=processing_status,
cover_letter=cover_letter,
outreach_message=outreach_message,
interview_prep=interview_prep,
original_markdown=original_markdown,
title=title,
)
async def get_resume(self, resume_id: str) -> dict[str, Any] | None:
"""Get resume by ID."""
async with self._session() as session:
row = await session.get(Resume, resume_id)
return self._resume_to_dict(row) if row else None
async def get_master_resume(self) -> dict[str, Any] | None:
"""Get the master resume if exists."""
async with self._session() as session:
result = await session.execute(
select(Resume).where(Resume.is_master.is_(True))
)
row = result.scalars().first()
return self._resume_to_dict(row) if row else None
async def update_resume(self, resume_id: str, updates: dict[str, Any]) -> dict[str, Any]:
"""Update resume by ID.
Raises:
ValueError: If resume not found.
"""
async with self._session() as session:
row = await session.get(Resume, resume_id)
if row is None:
raise ValueError(f"Resume not found: {resume_id}")
for key, value in updates.items():
if hasattr(row, key):
setattr(row, key, value)
else:
logger.warning("Ignoring unknown resume field on update: %s", key)
row.updated_at = _now()
await session.commit()
return self._resume_to_dict(row)
async def delete_resume(self, resume_id: str) -> bool:
"""Delete resume by ID."""
async with self._session() as session:
row = await session.get(Resume, resume_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def list_resumes(self) -> list[dict[str, Any]]:
"""List all resumes."""
async with self._session() as session:
result = await session.execute(select(Resume).order_by(Resume.created_at))
return [self._resume_to_dict(row) for row in result.scalars().all()]
async def set_master_resume(self, resume_id: str) -> bool:
"""Set a resume as the master, unsetting any existing master.
Returns False if the resume doesn't exist. Demote-then-promote happens
in a single transaction so the partial unique index is never violated.
"""
async with self._session() as session:
target = await session.get(Resume, resume_id)
if target is None:
logger.warning("Cannot set master: resume %s not found", resume_id)
return False
current = await session.execute(
select(Resume).where(Resume.is_master.is_(True))
)
for row in current.scalars().all():
if row.resume_id != resume_id:
row.is_master = False
# Flush the demotions before promoting to satisfy the unique index.
await session.flush()
target.is_master = True
await session.commit()
return True
# -- Job operations -----------------------------------------------------
async def create_job(self, content: str, resume_id: str | None = None) -> dict[str, Any]:
"""Create a new job description entry."""
job_id = str(uuid4())
now = _now()
async with self._session() as session:
session.add(
Job(job_id=job_id, content=content, resume_id=resume_id, created_at=now, metadata_json={})
)
await session.commit()
return {
"job_id": job_id,
"content": content,
"resume_id": resume_id,
"created_at": now,
}
async def get_job(self, job_id: str) -> dict[str, Any] | None:
"""Get job by ID (dynamic fields flattened to top level)."""
async with self._session() as session:
row = await session.get(Job, job_id)
return self._job_to_dict(row) if row else None
async def update_job(
self, job_id: str, updates: dict[str, Any]
) -> dict[str, Any] | None:
"""Update a job by ID.
Core columns are set directly; every other key is merged into
``metadata_json`` so dynamic pipeline fields (``preview_hash``,
``job_keywords``, ``company``/``role``, …) round-trip through
``get_job`` as top-level keys.
"""
async with self._session() as session:
row = await session.get(Job, job_id)
if row is None:
return None
meta = dict(row.metadata_json or {})
for key, value in updates.items():
if key in _JOB_CORE_FIELDS:
setattr(row, key, value)
else:
meta[key] = value
# Reassign so SQLAlchemy detects the JSON mutation.
row.metadata_json = meta
await session.commit()
return self._job_to_dict(row)
async def delete_job(self, job_id: str) -> bool:
"""Delete a job by ID (used to clean up an orphaned manual-add job)."""
async with self._session() as session:
row = await session.get(Job, job_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
# -- Improvement operations ---------------------------------------------
async def create_improvement(
self,
original_resume_id: str,
tailored_resume_id: str,
job_id: str,
improvements: list[dict[str, Any]],
) -> dict[str, Any]:
"""Create an improvement result entry."""
request_id = str(uuid4())
now = _now()
async with self._session() as session:
session.add(
Improvement(
request_id=request_id,
original_resume_id=original_resume_id,
tailored_resume_id=tailored_resume_id,
job_id=job_id,
improvements=improvements,
created_at=now,
)
)
await session.commit()
return {
"request_id": request_id,
"original_resume_id": original_resume_id,
"tailored_resume_id": tailored_resume_id,
"job_id": job_id,
"improvements": improvements,
"created_at": now,
}
async def get_improvement_by_tailored_resume(
self, tailored_resume_id: str
) -> dict[str, Any] | None:
"""Get improvement record by tailored resume ID."""
async with self._session() as session:
result = await session.execute(
select(Improvement).where(
Improvement.tailored_resume_id == tailored_resume_id
)
)
row = result.scalars().first()
return self._improvement_to_dict(row) if row else None
# -- Application (tracker) operations -----------------------------------
async def _next_position(self, session: AsyncSession, status: str) -> int:
result = await session.execute(
select(func.count())
.select_from(Application)
.where(Application.status == status)
)
return int(result.scalar() or 0)
async def _renumber(self, session: AsyncSession, status: str) -> None:
"""Renumber a column's positions to a contiguous 0..n-1 sequence."""
result = await session.execute(
select(Application)
.where(Application.status == status)
.order_by(Application.position, Application.created_at)
)
for index, row in enumerate(result.scalars().all()):
if row.position != index:
row.position = index
async def create_application(
self,
job_id: str,
resume_id: str,
master_resume_id: str | None = None,
status: str = "applied",
company: str | None = None,
role: str | None = None,
applied_at: str | None = None,
notes: str | None = None,
) -> dict[str, Any]:
"""Create a tracker card, deduped on (job_id, resume_id).
If a card for the same job+resume already exists it is returned as-is
(survives double-submit / retried confirms).
"""
async with self._session() as session:
existing = await session.execute(
select(Application).where(
Application.job_id == job_id, Application.resume_id == resume_id
)
)
found = existing.scalars().first()
if found is not None:
return self._application_to_dict(found)
now = _now()
if applied_at is None and status != "saved":
applied_at = now
position = await self._next_position(session, status)
row = Application(
application_id=str(uuid4()),
job_id=job_id,
resume_id=resume_id,
master_resume_id=master_resume_id,
status=status,
company=company,
role=role,
applied_at=applied_at,
notes=notes,
position=position,
created_at=now,
updated_at=now,
)
session.add(row)
try:
await session.commit()
except IntegrityError:
# A concurrent create won the (job_id, resume_id) unique
# constraint — return the existing card instead of duplicating.
await session.rollback()
dup = await session.execute(
select(Application).where(
Application.job_id == job_id,
Application.resume_id == resume_id,
)
)
found = dup.scalars().first()
if found is not None:
logger.debug(
"Deduped concurrent application create for job=%s resume=%s",
job_id,
resume_id,
)
return self._application_to_dict(found)
raise
return self._application_to_dict(row)
async def list_applications(self, status: str | None = None) -> list[dict[str, Any]]:
"""List applications ordered by (status, position)."""
async with self._session() as session:
stmt = select(Application)
if status is not None:
stmt = stmt.where(Application.status == status)
stmt = stmt.order_by(Application.status, Application.position)
result = await session.execute(stmt)
return [self._application_to_dict(row) for row in result.scalars().all()]
async def get_application(self, application_id: str) -> dict[str, Any] | None:
"""Get an application by ID."""
async with self._session() as session:
row = await session.get(Application, application_id)
return self._application_to_dict(row) if row else None
async def update_application(
self, application_id: str, updates: dict[str, Any]
) -> dict[str, Any] | None:
"""Update an application; renumber columns when status/position change.
``position`` is interpreted as the desired index within the (possibly
new) ``status`` column; siblings are renumbered server-side so the
column stays a contiguous 0..n-1 sequence.
"""
async with self._session() as session:
row = await session.get(Application, application_id)
if row is None:
return None
old_status = row.status
new_status = updates.get("status", old_status)
target_position = updates.get("position", None)
for key in ("company", "role", "applied_at", "notes"):
if key in updates:
setattr(row, key, updates[key])
moved = "status" in updates or "position" in updates
if moved:
row.status = new_status
# Park it out of the way, renumber both columns, then reinsert.
row.position = 10_000_000
await session.flush()
if old_status != new_status:
await self._renumber(session, old_status)
# Renumber the target column excluding this row, then splice in.
siblings = await session.execute(
select(Application)
.where(
Application.status == new_status,
Application.application_id != application_id,
)
.order_by(Application.position, Application.created_at)
)
ordered = list(siblings.scalars().all())
if target_position is None or target_position > len(ordered):
target_position = len(ordered)
if target_position < 0:
target_position = 0
ordered.insert(target_position, row)
for index, item in enumerate(ordered):
item.position = index
row.updated_at = _now()
await session.commit()
return self._application_to_dict(row)
async def bulk_update_applications(
self, application_ids: list[str], status: str
) -> int:
"""Move many applications to the end of ``status``. Returns count moved."""
moved = 0
async with self._session() as session:
affected_old: set[str] = set()
for application_id in application_ids:
row = await session.get(Application, application_id)
if row is None:
continue
affected_old.add(row.status)
row.status = status
row.position = 20_000_000 + moved # provisional, renumbered below
row.updated_at = _now()
moved += 1
await session.flush()
for old_status in affected_old - {status}:
await self._renumber(session, old_status)
await self._renumber(session, status)
await session.commit()
return moved
async def delete_application(self, application_id: str) -> bool:
"""Delete an application; renumber its column."""
async with self._session() as session:
row = await session.get(Application, application_id)
if row is None:
return False
status = row.status
await session.delete(row)
await session.flush()
await self._renumber(session, status)
await session.commit()
return True
async def bulk_delete_applications(self, application_ids: list[str]) -> int:
"""Delete many applications; renumber affected columns. Returns count."""
deleted = 0
async with self._session() as session:
affected: set[str] = set()
for application_id in application_ids:
row = await session.get(Application, application_id)
if row is None:
continue
affected.add(row.status)
await session.delete(row)
deleted += 1
await session.flush()
for status in affected:
await self._renumber(session, status)
await session.commit()
return deleted
# -- Encrypted API key store (sync; read on the LLM hot path) -----------
def get_api_key_ciphertexts(self) -> dict[str, str]:
"""Return ``{provider: ciphertext}`` for all stored keys (sync)."""
with self._sync() as session:
rows = session.execute(select(ApiKey)).scalars().all()
return {row.provider: row.ciphertext for row in rows}
def set_api_key_ciphertext(self, provider: str, ciphertext: str) -> None:
"""Upsert one provider's ciphertext (sync)."""
with self._sync() as session:
row = session.get(ApiKey, provider)
if row is None:
session.add(
ApiKey(provider=provider, ciphertext=ciphertext, updated_at=_now())
)
else:
row.ciphertext = ciphertext
row.updated_at = _now()
session.commit()
def delete_api_key(self, provider: str) -> None:
"""Delete one provider's key (sync)."""
with self._sync() as session:
row = session.get(ApiKey, provider)
if row is not None:
session.delete(row)
session.commit()
def clear_api_keys(self) -> None:
"""Delete all stored keys (sync)."""
with self._sync() as session:
session.execute(delete(ApiKey))
session.commit()
def replace_api_keys(self, ciphertexts: dict[str, str]) -> None:
"""Atomically replace the whole key store (clear + insert in one txn).
A single transaction means a failure mid-write can't leave the store
half-cleared and wipe a user's previously saved keys.
"""
with self._sync() as session:
session.execute(delete(ApiKey))
now = _now()
for provider, ciphertext in ciphertexts.items():
if ciphertext:
session.add(
ApiKey(provider=provider, ciphertext=ciphertext, updated_at=now)
)
session.commit()
# -- Stats / maintenance ------------------------------------------------
async def get_stats(self) -> dict[str, Any]:
"""Get database statistics."""
async with self._session() as session:
resumes = await session.scalar(select(func.count()).select_from(Resume))
jobs = await session.scalar(select(func.count()).select_from(Job))
improvements = await session.scalar(
select(func.count()).select_from(Improvement)
)
master = await session.execute(
select(Resume.resume_id).where(Resume.is_master.is_(True)).limit(1)
)
return {
"total_resumes": int(resumes or 0),
"total_jobs": int(jobs or 0),
"total_improvements": int(improvements or 0),
"has_master_resume": master.first() is not None,
}
async def reset_database(self) -> None:
"""Reset by truncating user-document tables and clearing uploads.
Clears resumes/jobs/improvements **and** tracker applications (leaving
orphaned cards after a full data reset would be a bug). Encrypted
``api_keys`` are preserved — matching the pre-existing behavior where a
reset never wiped the user's stored credentials.
"""
async with self._session() as session:
await session.execute(delete(Application))
await session.execute(delete(Improvement))
await session.execute(delete(Job))
await session.execute(delete(Resume))
await session.commit()
uploads_dir = settings.data_dir / "uploads"
if uploads_dir.exists():
shutil.rmtree(uploads_dir)
uploads_dir.mkdir(parents=True, exist_ok=True)
# Global database instance
db = Database()