Compare commits

...

2 commits

Author SHA1 Message Date
95e76edaea feat(community): complete Layer A subcategory tagging (#118)
Some checks failed
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Has been cancelled
Release / release (push) Has been cancelled
- RecipeBrowserPanel: fix onTagSearchInput using '_all' domain slug
  (backend validates domain — was silently returning empty results)
- RecipeDetailPanel: fetch and display accepted community category tags
  on recipe open; accepted tags shown with accent chip + checkmark,
  pending tags shown in muted style
- browserAPI.listRecipeTags() was already in api.ts but not consumed —
  now wired into RecipeDetailPanel onMounted as a background fetch
2026-04-25 23:31:30 -07:00
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
10 changed files with 261 additions and 8 deletions

View file

@ -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")

View file

@ -17,6 +17,7 @@ from app.models.schemas.saved_recipe import (
SaveRecipeRequest, SaveRecipeRequest,
UpdateSavedRecipeRequest, UpdateSavedRecipeRequest,
) )
from app.services.magpie_hook import fire_recipe_signal
from app.tiers import can_use 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) row = store.save_recipe(req.recipe_id, req.notes, req.rating)
return _to_summary(row, store) 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) @router.delete("/{recipe_id}", status_code=204)
@ -87,7 +90,11 @@ async def update_saved_recipe(
) )
return _to_summary(row, store) 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]) @router.get("", response_model=list[SavedRecipeSummary])

View file

@ -1,6 +1,7 @@
from fastapi import APIRouter 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 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.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 from app.api.endpoints.recipe_tags import router as recipe_tags_router
api_router = APIRouter() 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(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(community_router) api_router.include_router(community_router)
api_router.include_router(recipe_tags_router) api_router.include_router(recipe_tags_router)
api_router.include_router(corrections_router, prefix="/corrections", tags=["corrections"])

View file

@ -43,6 +43,10 @@ class Settings:
os.environ.get("BROWSE_COUNTS_PATH", str(DATA_DIR / "browse_counts.db")) 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 feature settings
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get( COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(

View file

@ -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);

View file

@ -6,6 +6,8 @@ Cloud mode: opens a Store at the per-user DB path from the CloudUser session.
""" """
from __future__ import annotations from __future__ import annotations
import sqlite3
from collections.abc import Iterator
from typing import Generator from typing import Generator
from fastapi import Depends from fastapi import Depends
@ -21,3 +23,16 @@ def get_store(session: CloudUser = Depends(get_session)) -> Generator[Store, Non
yield store yield store
finally: finally:
store.close() 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()

View file

@ -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 (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)

View file

@ -527,8 +527,10 @@ function onTagSearchInput() {
tagSearchDebounce = setTimeout(async () => { tagSearchDebounce = setTimeout(async () => {
tagModal.value.searching = true tagModal.value.searching = true
try { try {
// Re-use the browser API: browse all recipes filtered by title substring // Use the first available domain with category=_all to search all recipes by title.
const res = await browserAPI.browse('_all', '_all', { page: 1, q }) // Domain must be a real domain slug '_all' is not valid at the browse endpoint.
const searchDomain = domains.value[0]?.id ?? 'cuisine'
const res = await browserAPI.browse(searchDomain, '_all', { page: 1, q })
tagModal.value.results = (res.recipes ?? []).slice(0, 8).map( tagModal.value.results = (res.recipes ?? []).slice(0, 8).map(
(r: { id: number; title: string }) => ({ id: r.id, title: r.title }) (r: { id: number; title: string }) => ({ id: r.id, title: r.title })
) )

View file

@ -225,6 +225,23 @@
</ol> </ol>
</details> </details>
<!-- Community tags accepted location tags from other users -->
<div v-if="communityTags.length > 0" class="detail-section community-tags-section">
<h3 class="section-label">Community categories</h3>
<div class="community-tags-list">
<span
v-for="tag in communityTags"
:key="tag.id"
class="community-tag-chip"
:class="{ 'community-tag-chip--accepted': tag.accepted }"
:title="tag.accepted ? 'Confirmed by the community' : 'Pending confirmation'"
>
{{ tag.domain }} {{ tag.category }}<template v-if="tag.subcategory"> {{ tag.subcategory }}</template>
<span v-if="tag.accepted" class="community-tag-check" aria-label="Confirmed"></span>
</span>
</div>
</div>
<!-- Bottom padding so last step isn't hidden behind sticky footer --> <!-- Bottom padding so last step isn't hidden behind sticky footer -->
<div style="height: var(--spacing-xl)" /> <div style="height: var(--spacing-xl)" />
</div> </div>
@ -354,7 +371,7 @@
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRecipesStore } from '../stores/recipes' import { useRecipesStore } from '../stores/recipes'
import { useSavedRecipesStore } from '../stores/savedRecipes' import { useSavedRecipesStore } from '../stores/savedRecipes'
import { inventoryAPI, recipesAPI } from '../services/api' import { inventoryAPI, recipesAPI, browserAPI } from '../services/api'
import type { RecipeSuggestion, GroceryLink, StepAnalysis } from '../services/api' import type { RecipeSuggestion, GroceryLink, StepAnalysis } from '../services/api'
import SaveRecipeModal from './SaveRecipeModal.vue' import SaveRecipeModal from './SaveRecipeModal.vue'
@ -386,6 +403,12 @@ onMounted(() => {
) )
;(focusable ?? dialogRef.value)?.focus() ;(focusable ?? dialogRef.value)?.focus()
}) })
// Load community tags in the background non-critical, silently skip on error
browserAPI.listRecipeTags(props.recipe.id).then((tags) => {
communityTags.value = tags
}).catch(() => {
// Community tags are supplemental; silently skip on error
})
}) })
onUnmounted(() => { onUnmounted(() => {
@ -411,6 +434,10 @@ const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
const cookDone = ref(false) const cookDone = ref(false)
// Community tags
type CommunityTag = { id: number; domain: string; category: string; subcategory: string | null; pseudonym: string; upvotes: number; accepted: boolean }
const communityTags = ref<CommunityTag[]>([])
// Leftover shelf-life // Leftover shelf-life
type LeftoversData = { fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string } type LeftoversData = { fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string }
const leftovers = ref<LeftoversData | null>(null) const leftovers = ref<LeftoversData | null>(null)
@ -1628,4 +1655,39 @@ details[open].steps-collapsible .steps-collapsible-summary::before {
font-size: 1rem; font-size: 1rem;
line-height: 1; line-height: 1;
} }
/* ── Community tags section ──────────────────────────────── */
.community-tags-section {
padding-top: var(--spacing-sm);
}
.community-tags-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.community-tag-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 2px var(--spacing-sm);
border-radius: var(--radius-pill, 999px);
font-size: var(--font-size-xs, 0.72rem);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
white-space: nowrap;
}
.community-tag-chip--accepted {
background: rgba(124, 111, 205, 0.12);
color: var(--color-accent, #7c6fcd);
border-color: rgba(124, 111, 205, 0.3);
}
.community-tag-check {
font-size: 0.65rem;
opacity: 0.8;
}
</style> </style>

View file

@ -271,6 +271,24 @@
</div> </div>
</section> </section>
<!-- Data Sharing (cloud only) -->
<section v-if="isCloudMode" class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Data Sharing</h3>
<label class="data-sharing-toggle flex-start gap-sm text-sm">
<input
type="checkbox"
:checked="magpieOptIn"
@change="setMagpieOptIn(($event.target as HTMLInputElement).checked)"
/>
Share anonymized recipe ratings to help improve suggestions
</label>
<p class="text-xs text-muted mt-xs">
When enabled, Kiwi sends the recipe source ID, your star rating, and
style tags to CircuitForge. No personal information or pantry contents
are included.
</p>
</section>
<!-- Display Preferences --> <!-- Display Preferences -->
<section class="mt-md"> <section class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Display</h3> <h3 class="text-lg font-semibold mb-xs">Display</h3>
@ -381,7 +399,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { useRecipesStore } from '../stores/recipes' 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 { TextureTag, SmellLevel, NoiseLevel } from '../services/api'
import type { TimeFirstLayout } from '../stores/settings' import type { TimeFirstLayout } from '../stores/settings'
import { useOrchUsage } from '../composables/useOrchUsage' import { useOrchUsage } from '../composables/useOrchUsage'
@ -390,6 +408,23 @@ const settingsStore = useSettingsStore()
const recipesStore = useRecipesStore() const recipesStore = useRecipesStore()
const { enabled: orchPillEnabled, setEnabled: setOrchPillEnabled } = useOrchUsage() 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<void> {
if (!isCloudMode) return
const value = await settingsAPI.getSetting('magpie_opt_in')
magpieOptIn.value = value === 'true'
}
async function setMagpieOptIn(enabled: boolean): Promise<void> {
magpieOptIn.value = enabled
await settingsAPI.setSetting('magpie_opt_in', enabled ? 'true' : 'false')
}
const timeFirstLayoutOptions: Array<{ value: TimeFirstLayout; label: string; description: string }> = [ const timeFirstLayoutOptions: Array<{ value: TimeFirstLayout; label: string; description: string }> = [
{ value: 'auto', label: 'Auto', description: 'Shows a time selector when recipes are available.' }, { 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.' }, { 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 () => { onMounted(async () => {
await settingsStore.load() await settingsStore.load()
await loadHouseholdStatus() await loadHouseholdStatus()
await loadMagpieOptIn()
}) })
// Sensory taxonomy // Sensory taxonomy
@ -762,13 +798,15 @@ function getNoiseClass(_value: NoiseLevel, idx: number): string {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.orch-pill-toggle { .orch-pill-toggle,
.data-sharing-toggle {
cursor: pointer; cursor: pointer;
align-items: center; align-items: center;
color: var(--color-text); 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); accent-color: var(--color-primary);
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;