- A1: fire the hard filter (Apify is precise); embedding vibe-ranker (embed.py) blends with the floor-free white-box (rank.py) to sift ~90 -> top 18 - A2: Opus curator (curate.py) reads the 18 -> honest <=count + Gemini-voiced report cards; graceful fallback to the white-box + templated cards - LLM via opencode.ai/zen gateway (llm.py): Opus chat + direct-OpenAI embeddings - Run LLM work off the event loop (asyncio.to_thread) so the Redis response publishes - C1: dedicated Postgres (app/db/) persists the per-user feed; get_scout_feed replays it so matches survive navigation/refresh - match contract + report-card fields (schema.py); skills.py; tests/
79 lines
2.8 KiB
Python
79 lines
2.8 KiB
Python
"""Feed repository — upsert + read the per-user cached feed. All calls degrade to no-op/None
|
|
when the DB is off or unreachable (persistence is optional; the engine must never crash on it)."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
|
|
from app.db.models import Base, UserFeed
|
|
from app.db.session import engine, session_factory
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def init_db() -> None:
|
|
"""Create tables if the DB is configured (best-effort; logged, never fatal)."""
|
|
eng = engine()
|
|
if eng is None:
|
|
logger.info("DATABASE_URL unset — feed persistence disabled")
|
|
return
|
|
try:
|
|
async with eng.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
logger.info("feed store ready")
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("feed store init failed (persistence off): %s", e)
|
|
|
|
|
|
async def save_feed(user_id: str, prefs: dict, result: dict) -> None:
|
|
"""Upsert the user's last completed search. No-op if the DB is off."""
|
|
factory = session_factory()
|
|
if factory is None or not user_id:
|
|
return
|
|
row = {
|
|
"user_id": user_id,
|
|
"prefs": prefs or {},
|
|
"opportunities": result.get("opportunities") or [],
|
|
"sources": result.get("sources") or {},
|
|
"engine": result.get("engine") or "",
|
|
"scanned": int(result.get("scanned") or 0),
|
|
}
|
|
try:
|
|
async with factory() as s:
|
|
stmt = pg_insert(UserFeed).values(**row)
|
|
stmt = stmt.on_conflict_do_update(
|
|
index_elements=[UserFeed.user_id],
|
|
set_={k: row[k] for k in ("prefs", "opportunities", "sources", "engine", "scanned")},
|
|
)
|
|
await s.execute(stmt)
|
|
await s.commit()
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("save_feed failed (non-fatal): %s", e)
|
|
|
|
|
|
async def get_feed(user_id: str) -> dict | None:
|
|
"""The user's last cached feed as a search_complete-shaped dict, or None."""
|
|
factory = session_factory()
|
|
if factory is None or not user_id:
|
|
return None
|
|
try:
|
|
async with factory() as s:
|
|
row = (await s.execute(select(UserFeed).where(UserFeed.user_id == user_id))).scalar_one_or_none()
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("get_feed failed (non-fatal): %s", e)
|
|
return None
|
|
if not row:
|
|
return None
|
|
return {
|
|
"opportunities": row.opportunities or [],
|
|
"sources": row.sources or {},
|
|
"prefs": row.prefs or {},
|
|
"engine": row.engine,
|
|
"scanned": row.scanned,
|
|
"shortlisted": len(row.opportunities or []),
|
|
"cached": True,
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
}
|