From 12ab63e2fbed2df2ab227e2dc12ea17d3a381a9e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 25 Apr 2026 23:31:20 -0700 Subject: [PATCH] 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 --- app/api/endpoints/corrections.py | 5 ++ app/api/endpoints/saved_recipes.py | 11 ++- app/api/routes.py | 2 + app/core/config.py | 4 + app/db/migrations/040_corrections.sql | 21 +++++ app/db/session.py | 15 ++++ app/services/magpie_hook.py | 97 ++++++++++++++++++++++++ frontend/src/components/SettingsView.vue | 44 ++++++++++- 8 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 app/api/endpoints/corrections.py create mode 100644 app/db/migrations/040_corrections.sql create mode 100644 app/services/magpie_hook.py diff --git a/app/api/endpoints/corrections.py b/app/api/endpoints/corrections.py new file mode 100644 index 0000000..8e44824 --- /dev/null +++ b/app/api/endpoints/corrections.py @@ -0,0 +1,5 @@ +# app/api/endpoints/corrections.py — user corrections to LLM output for SFT training +from circuitforge_core.api import make_corrections_router +from app.db.session import get_db + +router = make_corrections_router(get_db=get_db, product="kiwi") diff --git a/app/api/endpoints/saved_recipes.py b/app/api/endpoints/saved_recipes.py index 76f3692..db9b2bc 100644 --- a/app/api/endpoints/saved_recipes.py +++ b/app/api/endpoints/saved_recipes.py @@ -17,6 +17,7 @@ from app.models.schemas.saved_recipe import ( SaveRecipeRequest, UpdateSavedRecipeRequest, ) +from app.services.magpie_hook import fire_recipe_signal from app.tiers import can_use @@ -60,7 +61,9 @@ async def save_recipe( row = store.save_recipe(req.recipe_id, req.notes, req.rating) return _to_summary(row, store) - return await asyncio.to_thread(_in_thread, session.db, _run) + result = await asyncio.to_thread(_in_thread, session.db, _run) + asyncio.create_task(fire_recipe_signal(session.db, req.recipe_id, req.rating, [])) + return result @router.delete("/{recipe_id}", status_code=204) @@ -87,7 +90,11 @@ async def update_saved_recipe( ) return _to_summary(row, store) - return await asyncio.to_thread(_in_thread, session.db, _run) + result = await asyncio.to_thread(_in_thread, session.db, _run) + asyncio.create_task( + fire_recipe_signal(session.db, recipe_id, req.rating, req.style_tags or []) + ) + return result @router.get("", response_model=list[SavedRecipeSummary]) diff --git a/app/api/routes.py b/app/api/routes.py index a426572..a204f84 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping from app.api.endpoints.community import router as community_router +from app.api.endpoints.corrections import router as corrections_router from app.api.endpoints.recipe_tags import router as recipe_tags_router api_router = APIRouter() @@ -24,3 +25,4 @@ api_router.include_router(orch_usage.router, prefix="/orch-usage", tags= api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"]) api_router.include_router(community_router) api_router.include_router(recipe_tags_router) +api_router.include_router(corrections_router, prefix="/corrections", tags=["corrections"]) diff --git a/app/core/config.py b/app/core/config.py index 42375e3..b611d2a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -43,6 +43,10 @@ class Settings: os.environ.get("BROWSE_COUNTS_PATH", str(DATA_DIR / "browse_counts.db")) ) + # Magpie data flywheel — ingest endpoint for anonymized recipe signals + # Set MAGPIE_INGEST_URL to enable; leave unset (or None) to disable silently. + MAGPIE_INGEST_URL: str | None = os.environ.get("MAGPIE_INGEST_URL") or None + # Community feature settings COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None COMMUNITY_PSEUDONYM_SALT: str = os.environ.get( diff --git a/app/db/migrations/040_corrections.sql b/app/db/migrations/040_corrections.sql new file mode 100644 index 0000000..c355e41 --- /dev/null +++ b/app/db/migrations/040_corrections.sql @@ -0,0 +1,21 @@ +-- 040_corrections.sql — corrections table for SFT training data +-- Schema from circuitforge_core.api.corrections.CORRECTIONS_MIGRATION_SQL +CREATE TABLE IF NOT EXISTS corrections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id TEXT NOT NULL DEFAULT '', + product TEXT NOT NULL, + correction_type TEXT NOT NULL, + input_text TEXT NOT NULL, + original_output TEXT NOT NULL, + corrected_output TEXT NOT NULL DEFAULT '', + rating TEXT NOT NULL DEFAULT 'down', + context TEXT NOT NULL DEFAULT '{}', + opted_in INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_corrections_product + ON corrections (product); + +CREATE INDEX IF NOT EXISTS idx_corrections_opted_in + ON corrections (opted_in); diff --git a/app/db/session.py b/app/db/session.py index ea70682..1a14d58 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -6,6 +6,8 @@ Cloud mode: opens a Store at the per-user DB path from the CloudUser session. """ from __future__ import annotations +import sqlite3 +from collections.abc import Iterator from typing import Generator from fastapi import Depends @@ -21,3 +23,16 @@ def get_store(session: CloudUser = Depends(get_session)) -> Generator[Store, Non yield store finally: store.close() + + +def get_db(session: CloudUser = Depends(get_session)) -> Iterator[sqlite3.Connection]: + """FastAPI dependency — yields the raw sqlite3.Connection for the current user. + + Used by make_corrections_router() from circuitforge-core, which expects a + dependency that yields a sqlite3.Connection directly. + """ + store = Store(session.db) + try: + yield store.conn + finally: + store.close() diff --git a/app/services/magpie_hook.py b/app/services/magpie_hook.py new file mode 100644 index 0000000..e85c519 --- /dev/null +++ b/app/services/magpie_hook.py @@ -0,0 +1,97 @@ +"""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) diff --git a/frontend/src/components/SettingsView.vue b/frontend/src/components/SettingsView.vue index 2173053..c4f947a 100644 --- a/frontend/src/components/SettingsView.vue +++ b/frontend/src/components/SettingsView.vue @@ -271,6 +271,24 @@ + +
+

Data Sharing

+ +

+ When enabled, Kiwi sends the recipe source ID, your star rating, and + style tags to CircuitForge. No personal information or pantry contents + are included. +

+
+

Display

@@ -381,7 +399,7 @@ import { ref, computed, onMounted } from 'vue' import { useSettingsStore } from '../stores/settings' import { useRecipesStore } from '../stores/recipes' -import { householdAPI, type HouseholdStatus } from '../services/api' +import { householdAPI, settingsAPI, type HouseholdStatus } from '../services/api' import type { TextureTag, SmellLevel, NoiseLevel } from '../services/api' import type { TimeFirstLayout } from '../stores/settings' import { useOrchUsage } from '../composables/useOrchUsage' @@ -390,6 +408,23 @@ const settingsStore = useSettingsStore() const recipesStore = useRecipesStore() const { enabled: orchPillEnabled, setEnabled: setOrchPillEnabled } = useOrchUsage() +// Cloud mode — baked in at build time via VITE_CLOUD_MODE=true in cloud builds +const isCloudMode = import.meta.env.VITE_CLOUD_MODE === 'true' + +// Data sharing — magpie opt-in (cloud mode only) +const magpieOptIn = ref(false) + +async function loadMagpieOptIn(): Promise { + if (!isCloudMode) return + const value = await settingsAPI.getSetting('magpie_opt_in') + magpieOptIn.value = value === 'true' +} + +async function setMagpieOptIn(enabled: boolean): Promise { + magpieOptIn.value = enabled + await settingsAPI.setSetting('magpie_opt_in', enabled ? 'true' : 'false') +} + const timeFirstLayoutOptions: Array<{ value: TimeFirstLayout; label: string; description: string }> = [ { value: 'auto', label: 'Auto', description: 'Shows a time selector when recipes are available.' }, { value: 'time_first', label: 'Time First', description: 'Always show the time bucket selector at the top.' }, @@ -539,6 +574,7 @@ async function handleRemoveMember(userId: string) { onMounted(async () => { await settingsStore.load() await loadHouseholdStatus() + await loadMagpieOptIn() }) // ── Sensory taxonomy ─────────────────────────────────────────────────────── @@ -762,13 +798,15 @@ function getNoiseClass(_value: NoiseLevel, idx: number): string { color: var(--color-text-muted); } -.orch-pill-toggle { +.orch-pill-toggle, +.data-sharing-toggle { cursor: pointer; align-items: center; color: var(--color-text); } -.orch-pill-toggle input[type="checkbox"] { +.orch-pill-toggle input[type="checkbox"], +.data-sharing-toggle input[type="checkbox"] { accent-color: var(--color-primary); width: 1rem; height: 1rem;