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
97 lines
3.2 KiB
Python
97 lines
3.2 KiB
Python
"""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 (0–5) 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)
|