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;