kiwi/app/services/magpie_hook.py
pyr0ball 12ab63e2fb feat: corrections router (#73) + Magpie flywheel hook (#28)
Corrections router (kiwi#73):
- Wire make_corrections_router() from cf-core at /api/v1/corrections
- Add get_db() dependency in session.py yielding store.conn (raw
  sqlite3.Connection as cf-core expects); cloud-aware via get_session
- Migration 040: corrections table + indexes (copied from cf-core DDL)
- Feeds Avocet SFT training pipeline via GET /corrections/export JSONL

Magpie flywheel hook (kiwi#28):
- app/services/magpie_hook.py: async fire_recipe_signal() that reads
  magpie_opt_in setting, checks external_id, POSTs anonymized payload
  to MAGPIE_INGEST_URL; stubs gracefully when URL unset or Magpie
  unreachable (DEBUG log, never raises)
- Hooks into save_recipe and update_saved_recipe as background tasks
- MAGPIE_INGEST_URL config key added to Settings
- SettingsView: "Data Sharing" toggle for magpie_opt_in, cloud-only
  (v-if VITE_CLOUD_MODE), plain-language consent label
2026-04-25 23:31:20 -07:00

97 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Magpie data-flywheel hook.
Fires anonymized recipe-signal events to the Magpie ingest endpoint when a
user saves or rates a recipe. This is the Kiwi side of the flywheel — Magpie
does not have a receiver endpoint yet, so the hook stubs out gracefully: if
``MAGPIE_INGEST_URL`` is unset, or the request fails for any reason, it logs
at DEBUG level and returns without raising.
"""
from __future__ import annotations
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
_INGEST_PATH = "/api/v1/ingest/recipe-signal"
async def fire_recipe_signal(
db_path: Path,
recipe_id: int,
rating: int | None,
style_tags: list[str],
) -> None:
"""Post an anonymized recipe signal to Magpie if the user has opted in.
Args:
db_path: Path to the user's SQLite database.
recipe_id: Internal Kiwi recipe ID being rated/saved.
rating: Star rating (05) or None if not yet rated.
style_tags: Style tags applied to the saved recipe.
"""
from app.core.config import settings
if not settings.MAGPIE_INGEST_URL:
return
# Check per-user opt-in via a short-lived Store (own connection, own thread
# context is fine — this runs in the async event loop as a background task
# so we open and close the connection immediately).
from app.db.store import Store
try:
store = Store(db_path)
try:
opt_in = store.get_setting("magpie_opt_in")
finally:
store.close()
except Exception as exc: # noqa: BLE001
logger.debug("magpie_hook: could not read magpie_opt_in setting: %s", exc)
return
if opt_in != "true":
return
# Fetch the recipe to get its external_id (source URL slug / corpus key).
try:
store = Store(db_path)
try:
recipe = store.get_recipe(recipe_id)
finally:
store.close()
except Exception as exc: # noqa: BLE001
logger.debug("magpie_hook: could not fetch recipe %d: %s", recipe_id, exc)
return
if recipe is None:
logger.debug("magpie_hook: recipe %d not found, skipping", recipe_id)
return
external_id: str | None = recipe.get("external_id") if isinstance(recipe, dict) else getattr(recipe, "external_id", None)
if not external_id:
# Corpus recipe not yet enriched with a source identifier — skip quietly.
logger.debug("magpie_hook: recipe %d has no external_id, skipping", recipe_id)
return
payload = {
"product": "kiwi",
"signal": "recipe_rating",
"external_id": external_id,
"rating": rating,
"style_tags": style_tags,
}
url = settings.MAGPIE_INGEST_URL.rstrip("/") + _INGEST_PATH
try:
import httpx
async with httpx.AsyncClient(timeout=3.0) as client:
response = await client.post(url, json=payload)
logger.debug(
"magpie_hook: POST %s%d", url, response.status_code
)
except Exception as exc: # noqa: BLE001
# Magpie may not have a receiver yet — log and swallow.
logger.debug("magpie_hook: ingest request failed (stub): %s", exc)