Compare commits

...

7 commits

27 changed files with 2012 additions and 4 deletions

View file

@ -0,0 +1,339 @@
# app/api/endpoints/community.py
# MIT License
from __future__ import annotations
import asyncio
import logging
import re
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from app.cloud_session import CloudUser, get_session
from app.core.config import settings
from app.db.store import Store
from app.services.community.feed import posts_to_rss
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/community", tags=["community"])
# Module-level KiwiCommunityStore — None when COMMUNITY_DB_URL is not set.
# Browse endpoints degrade gracefully to empty; write endpoints return 503.
_community_store = None
def _get_community_store():
"""Return the module-level KiwiCommunityStore, or None if community DB is unavailable."""
return _community_store
def init_community_store(community_db_url: str | None) -> None:
"""Called from main.py lifespan when COMMUNITY_DB_URL is set."""
global _community_store
if not community_db_url:
logger.info(
"COMMUNITY_DB_URL not set — community write features disabled. "
"Browse still works via cloud fallback."
)
return
try:
from circuitforge_core.community import CommunityDB
from app.services.community.community_store import KiwiCommunityStore
db = CommunityDB(dsn=community_db_url)
db.run_migrations()
_community_store = KiwiCommunityStore(db)
logger.info("Community store initialized (PostgreSQL).")
except Exception as exc:
logger.warning("Community store init failed — community writes disabled: %s", exc)
# ── Browse (no auth required — Free tier) ────────────────────────────────────
@router.get("/posts")
async def list_posts(
post_type: str | None = None,
dietary_tags: str | None = None,
allergen_exclude: str | None = None,
page: int = 1,
page_size: int = 20,
):
"""Paginated community post list. Available on all tiers (read-only)."""
store = _get_community_store()
if store is None:
return {"posts": [], "total": 0, "note": "Community DB not available on this instance."}
dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None
allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None
offset = (page - 1) * min(page_size, 100)
posts = await asyncio.to_thread(
store.list_posts,
limit=min(page_size, 100),
offset=offset,
post_type=post_type,
dietary_tags=dietary,
allergen_exclude=allergen_ex,
)
return {"posts": [_post_to_dict(p) for p in posts], "page": page, "page_size": page_size}
@router.get("/posts/{slug}")
async def get_post(slug: str, request: Request):
"""Single post. Returns AP JSON-LD when Accept: application/activity+json."""
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available on this instance.")
post = await asyncio.to_thread(store.get_post_by_slug, slug)
if post is None:
raise HTTPException(status_code=404, detail="Post not found.")
accept = request.headers.get("accept", "")
if "application/activity+json" in accept:
from app.services.community.ap_compat import post_to_ap_json_ld
base_url = str(request.base_url).rstrip("/")
return post_to_ap_json_ld(_post_to_dict(post), base_url=base_url)
return _post_to_dict(post)
@router.get("/feed.rss")
async def get_rss_feed(request: Request):
"""RSS 2.0 feed of recent community posts."""
store = _get_community_store()
posts_data: list[dict] = []
if store is not None:
posts = await asyncio.to_thread(store.list_posts, limit=50)
posts_data = [_post_to_dict(p) for p in posts]
base_url = str(request.base_url).rstrip("/")
rss = posts_to_rss(posts_data, base_url=base_url)
return Response(content=rss, media_type="application/rss+xml; charset=utf-8")
@router.get("/local-feed")
async def local_feed():
"""LAN peer endpoint: last 50 posts from this instance. No auth required."""
store = _get_community_store()
if store is None:
return []
posts = await asyncio.to_thread(store.list_posts, limit=50)
return [_post_to_dict(p) for p in posts]
# ── Write endpoints (auth required) ──────────────────────────────────────────
@router.post("/posts", status_code=201)
async def publish_post(
body: dict,
session: CloudUser = Depends(get_session),
):
"""Publish a plan or outcome to the community feed. Requires Paid tier."""
from app.tiers import can_use
if not can_use("community_publish", session.tier, session.has_byok):
raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.")
store = _get_community_store()
if store is None:
raise HTTPException(
status_code=503,
detail="This Kiwi instance is not connected to a community database. "
"Publishing is only available on cloud instances.",
)
# Resolve pseudonym (first-time setup inline via publish modal)
from app.services.community.community_store import get_or_create_pseudonym
db_path = session.db
def _get_pseudonym():
s = Store(db_path)
try:
return get_or_create_pseudonym(
store=s,
directus_user_id=session.user_id,
requested_name=body.get("pseudonym_name"),
)
finally:
s.close()
pseudonym = await asyncio.to_thread(_get_pseudonym)
# Compute element snapshot from corpus recipes in the plan
recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")]
from app.services.community.element_snapshot import compute_snapshot
def _snapshot():
s = Store(db_path)
try:
return compute_snapshot(recipe_ids=recipe_ids, store=s)
finally:
s.close()
snapshot = await asyncio.to_thread(_snapshot)
# Build deterministic slug
slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-")
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
post_type = body.get("post_type", "plan")
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
try:
from circuitforge_core.community.models import CommunityPost
post = CommunityPost(
slug=slug,
pseudonym=pseudonym,
post_type=post_type,
published=datetime.now(timezone.utc),
title=body.get("title", "Untitled"),
description=body.get("description"),
photo_url=body.get("photo_url"),
slots=body.get("slots", []),
recipe_id=body.get("recipe_id"),
recipe_name=body.get("recipe_name"),
level=body.get("level"),
outcome_notes=body.get("outcome_notes"),
seasoning_score=snapshot.seasoning_score,
richness_score=snapshot.richness_score,
brightness_score=snapshot.brightness_score,
depth_score=snapshot.depth_score,
aroma_score=snapshot.aroma_score,
structure_score=snapshot.structure_score,
texture_profile=snapshot.texture_profile,
dietary_tags=list(snapshot.dietary_tags),
allergen_flags=list(snapshot.allergen_flags),
flavor_molecules=list(snapshot.flavor_molecules),
fat_pct=snapshot.fat_pct,
protein_pct=snapshot.protein_pct,
moisture_pct=snapshot.moisture_pct,
)
except ImportError:
raise HTTPException(status_code=503, detail="Community module not available.")
inserted = await asyncio.to_thread(store.insert_post, post)
return _post_to_dict(inserted)
@router.delete("/posts/{slug}", status_code=204)
async def delete_post(slug: str, session: CloudUser = Depends(get_session)):
"""Hard-delete a post. Only succeeds if the caller is the post author (pseudonym match)."""
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available.")
def _get_pseudonym():
s = Store(session.db)
try:
return s.get_current_pseudonym(session.user_id)
finally:
s.close()
pseudonym = await asyncio.to_thread(_get_pseudonym)
if not pseudonym:
raise HTTPException(status_code=400, detail="No pseudonym set. Cannot delete posts.")
deleted = await asyncio.to_thread(store.delete_post, slug=slug, pseudonym=pseudonym)
if not deleted:
raise HTTPException(status_code=404, detail="Post not found or you are not the author.")
@router.post("/posts/{slug}/fork", status_code=201)
async def fork_post(slug: str, session: CloudUser = Depends(get_session)):
"""Exact-copy fork: creates a new meal plan in the caller's DB with matching slots. Free."""
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available.")
post = await asyncio.to_thread(store.get_post_by_slug, slug)
if post is None:
raise HTTPException(status_code=404, detail="Post not found.")
if post.post_type != "plan":
raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.")
from datetime import date
def _create_plan():
s = Store(session.db)
try:
week_start = date.today().strftime("%Y-%m-%d")
meal_types = list({slot["meal_type"] for slot in post.slots}) or ["dinner"]
plan = s.create_meal_plan(week_start=week_start, meal_types=meal_types)
for slot in post.slots:
s.assign_recipe_to_slot(
plan_id=plan["id"],
day_of_week=slot["day"],
meal_type=slot["meal_type"],
recipe_id=slot["recipe_id"],
)
return plan
finally:
s.close()
plan = await asyncio.to_thread(_create_plan)
return {"plan_id": plan["id"], "week_start": plan["week_start"], "forked_from": slug}
@router.post("/posts/{slug}/fork-adapt", status_code=201)
async def fork_adapt_post(slug: str, session: CloudUser = Depends(get_session)):
"""Fork with LLM pantry adaptation. Paid/BYOK. Returns suggestions for user review."""
from app.tiers import can_use
if not can_use("community_fork_adapt", session.tier, session.has_byok):
raise HTTPException(status_code=402, detail="Fork with adaptation requires Paid tier or BYOK.")
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available.")
post = await asyncio.to_thread(store.get_post_by_slug, slug)
if post is None:
raise HTTPException(status_code=404, detail="Post not found.")
# BSL 1.1 call site — LLM adaptation
from app.services.meal_plan.llm_planner import adapt_plan_to_pantry
suggestions = await adapt_plan_to_pantry(
slots=list(post.slots),
db_path=session.db,
tier=session.tier,
has_byok=session.has_byok,
)
return {"suggestions": suggestions, "forked_from": slug}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _post_to_dict(post) -> dict:
"""Convert a CommunityPost (frozen dataclass) to a JSON-serializable dict."""
return {
"slug": post.slug,
"pseudonym": post.pseudonym,
"post_type": post.post_type,
"published": post.published.isoformat() if hasattr(post.published, "isoformat") else str(post.published),
"title": post.title,
"description": post.description,
"photo_url": post.photo_url,
"slots": list(post.slots),
"recipe_id": post.recipe_id,
"recipe_name": post.recipe_name,
"level": post.level,
"outcome_notes": post.outcome_notes,
"element_profiles": {
"seasoning_score": post.seasoning_score,
"richness_score": post.richness_score,
"brightness_score": post.brightness_score,
"depth_score": post.depth_score,
"aroma_score": post.aroma_score,
"structure_score": post.structure_score,
"texture_profile": post.texture_profile,
},
"dietary_tags": list(post.dietary_tags),
"allergen_flags": list(post.allergen_flags),
"flavor_molecules": list(post.flavor_molecules),
"fat_pct": post.fat_pct,
"protein_pct": post.protein_pct,
"moisture_pct": post.moisture_pct,
}
def _post_type_prefix(post_type: str) -> str:
return {"plan": "plan", "recipe_success": "success", "recipe_blooper": "blooper"}.get(post_type, "post")

View file

@ -1,5 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, meal_plans from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, meal_plans
from app.api.endpoints.community import router as community_router
api_router = APIRouter() api_router = APIRouter()
@ -15,3 +16,4 @@ api_router.include_router(feedback.router, prefix="/feedback", tags=["f
api_router.include_router(household.router, prefix="/household", tags=["household"]) api_router.include_router(household.router, prefix="/household", tags=["household"])
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"]) api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"]) api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
api_router.include_router(community_router)

View file

@ -53,6 +53,17 @@ class Settings:
# Feature flags # Feature flags
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes") ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
# Community feature
# COMMUNITY_DB_URL: unset = community writes disabled (local/offline mode, fail soft)
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
"COMMUNITY_PSEUDONYM_SALT", "kiwi-default-salt-change-in-prod"
)
COMMUNITY_CLOUD_FEED_URL: str = os.environ.get(
"COMMUNITY_CLOUD_FEED_URL",
"https://menagerie.circuitforge.tech/kiwi/api/v1/community/posts",
)
# Runtime # Runtime
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes") DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "false").lower() in ("1", "true", "yes") CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "false").lower() in ("1", "true", "yes")

View file

@ -0,0 +1,21 @@
-- 026_community_pseudonyms.sql
-- Per-user pseudonym store: maps the user's chosen community display name
-- to their Directus user ID. This table lives in per-user kiwi.db only.
-- It is NEVER replicated to the community PostgreSQL — pseudonym isolation is by design.
--
-- A user may have one active pseudonym. Old pseudonyms are retained for reference
-- (posts published under them keep their pseudonym attribution) but only one is
-- flagged as current (is_current = 1).
CREATE TABLE IF NOT EXISTS community_pseudonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pseudonym TEXT NOT NULL,
directus_user_id TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Only one pseudonym can be current at a time per user
CREATE UNIQUE INDEX IF NOT EXISTS idx_community_pseudonyms_current
ON community_pseudonyms (directus_user_id)
WHERE is_current = 1;

View file

@ -1128,3 +1128,31 @@ class Store:
) )
self.conn.commit() self.conn.commit()
return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,))
# ── Community pseudonyms ──────────────────────────────────────────────────
def get_current_pseudonym(self, directus_user_id: str) -> str | None:
"""Return the current community pseudonym for this user, or None if not set."""
cur = self.conn.execute(
"SELECT pseudonym FROM community_pseudonyms "
"WHERE directus_user_id = ? AND is_current = 1 LIMIT 1",
(directus_user_id,),
)
row = cur.fetchone()
return row["pseudonym"] if row else None
def set_pseudonym(self, directus_user_id: str, pseudonym: str) -> None:
"""Set the current community pseudonym for this user.
Marks any previous pseudonym as non-current (retains history for attribution).
"""
self.conn.execute(
"UPDATE community_pseudonyms SET is_current = 0 WHERE directus_user_id = ?",
(directus_user_id,),
)
self.conn.execute(
"INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) "
"VALUES (?, ?, 1)",
(pseudonym, directus_user_id),
)
self.conn.commit()

View file

@ -20,6 +20,10 @@ async def lifespan(app: FastAPI):
settings.ensure_dirs() settings.ensure_dirs()
register_kiwi_programs() register_kiwi_programs()
# Initialize community store (fail-soft if COMMUNITY_DB_URL not set)
from app.api.endpoints.community import init_community_store
init_community_store(settings.COMMUNITY_DB_URL)
# Start LLM background task scheduler # Start LLM background task scheduler
from app.tasks.scheduler import get_scheduler from app.tasks.scheduler import get_scheduler
get_scheduler(settings.DB_PATH) get_scheduler(settings.DB_PATH)

View file

View file

@ -0,0 +1,44 @@
# app/services/community/ap_compat.py
# MIT License — AP (ActivityPub) scaffold only (no actor, inbox, outbox)
from __future__ import annotations
from datetime import datetime, timezone
def post_to_ap_json_ld(post: dict, base_url: str) -> dict:
"""Serialize a community post dict to an ActivityPub-compatible JSON-LD Note.
This is a read-only scaffold. No AP actor, inbox, or outbox is implemented.
The slug URI is stable so a future full AP implementation can envelope posts
without a database migration.
"""
slug = post["slug"]
published = post.get("published")
if isinstance(published, datetime):
published_str = published.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
else:
published_str = str(published)
dietary_tags: list[str] = post.get("dietary_tags") or []
tags = [{"type": "Hashtag", "name": "#kiwi"}]
for tag in dietary_tags:
tags.append({"type": "Hashtag", "name": f"#{tag.replace('-', '').replace(' ', '')}"})
return {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"id": f"{base_url}/api/v1/community/posts/{slug}",
"attributedTo": post.get("pseudonym", "anonymous"),
"content": _build_content(post),
"published": published_str,
"tag": tags,
}
def _build_content(post: dict) -> str:
title = post.get("title") or "Untitled"
desc = post.get("description")
if desc:
return f"{title}{desc}"
return title

View file

@ -0,0 +1,108 @@
# app/services/community/community_store.py
# MIT License
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
def get_or_create_pseudonym(
store,
directus_user_id: str,
requested_name: str | None,
) -> str:
"""Return the user's current pseudonym, creating it if it doesn't exist.
If the user has an existing pseudonym, return it (ignore requested_name).
If not, create using requested_name (must be provided for first-time setup).
Raises ValueError if no existing pseudonym and requested_name is None or blank.
"""
existing = store.get_current_pseudonym(directus_user_id)
if existing:
return existing
if not requested_name or not requested_name.strip():
raise ValueError(
"A pseudonym is required for first publish. "
"Pass requested_name with your chosen display name."
)
name = requested_name.strip()
if "@" in name:
raise ValueError(
"Pseudonym must not contain '@' — use a display name, not an email address."
)
store.set_pseudonym(directus_user_id, name)
return name
try:
from circuitforge_core.community import SharedStore, CommunityPost
class KiwiCommunityStore(SharedStore):
"""Kiwi-specific community store: adds kiwi-domain query methods on top of SharedStore."""
def list_meal_plans(
self,
limit: int = 20,
offset: int = 0,
dietary_tags: list[str] | None = None,
allergen_exclude: list[str] | None = None,
) -> list[CommunityPost]:
return self.list_posts(
limit=limit,
offset=offset,
post_type="plan",
dietary_tags=dietary_tags,
allergen_exclude=allergen_exclude,
source_product="kiwi",
)
def list_outcomes(
self,
limit: int = 20,
offset: int = 0,
post_type: str | None = None,
) -> list[CommunityPost]:
if post_type in ("recipe_success", "recipe_blooper"):
return self.list_posts(
limit=limit, offset=offset,
post_type=post_type, source_product="kiwi",
)
# Fetch both types and merge by published date
success = self.list_posts(
limit=limit, offset=0, post_type="recipe_success", source_product="kiwi",
)
bloopers = self.list_posts(
limit=limit, offset=0, post_type="recipe_blooper", source_product="kiwi",
)
merged = sorted(success + bloopers, key=lambda p: p.published, reverse=True)
return merged[:limit]
except ImportError:
# cf-core community module not yet merged — stub for local dev without community DB
class KiwiCommunityStore: # type: ignore[no-redef]
def __init__(self, *args, **kwargs):
pass
def list_meal_plans(self, **kwargs):
return []
def list_outcomes(self, **kwargs):
return []
def list_posts(self, **kwargs):
return []
def get_post_by_slug(self, slug):
return None
def insert_post(self, post):
return post
def delete_post(self, slug, pseudonym):
return False

View file

@ -0,0 +1,127 @@
# app/services/community/element_snapshot.py
# MIT License
from __future__ import annotations
from dataclasses import dataclass
# Ingredient name substrings → allergen flag
_ALLERGEN_MAP: dict[str, str] = {
"milk": "dairy", "cream": "dairy", "cheese": "dairy", "butter": "dairy",
"yogurt": "dairy", "whey": "dairy",
"egg": "eggs",
"wheat": "gluten", "pasta": "gluten", "flour": "gluten", "bread": "gluten",
"barley": "gluten", "rye": "gluten",
"peanut": "nuts", "almond": "nuts", "cashew": "nuts", "walnut": "nuts",
"pecan": "nuts", "hazelnut": "nuts", "pistachio": "nuts", "macadamia": "nuts",
"soy": "soy", "tofu": "soy", "edamame": "soy", "miso": "soy", "tempeh": "soy",
"shrimp": "shellfish", "crab": "shellfish", "lobster": "shellfish",
"clam": "shellfish", "mussel": "shellfish", "scallop": "shellfish",
"fish": "fish", "salmon": "fish", "tuna": "fish", "cod": "fish",
"tilapia": "fish", "halibut": "fish",
"sesame": "sesame",
}
_MEAT_KEYWORDS = frozenset([
"chicken", "beef", "pork", "lamb", "turkey", "bacon", "ham", "sausage",
"salami", "prosciutto", "guanciale", "pancetta", "steak", "ground meat",
"mince", "veal", "duck", "venison", "bison", "lard",
])
_SEAFOOD_KEYWORDS = frozenset([
"fish", "shrimp", "crab", "lobster", "tuna", "salmon", "clam", "mussel",
"scallop", "anchovy", "sardine", "cod", "tilapia",
])
_ANIMAL_PRODUCT_KEYWORDS = frozenset([
"milk", "cream", "cheese", "butter", "egg", "honey", "yogurt", "whey",
])
def _detect_allergens(ingredient_names: list[str]) -> list[str]:
found: set[str] = set()
for ingredient in (n.lower() for n in ingredient_names):
for keyword, flag in _ALLERGEN_MAP.items():
if keyword in ingredient:
found.add(flag)
return sorted(found)
def _detect_dietary_tags(ingredient_names: list[str]) -> list[str]:
all_text = " ".join(n.lower() for n in ingredient_names)
has_meat = any(k in all_text for k in _MEAT_KEYWORDS)
has_seafood = any(k in all_text for k in _SEAFOOD_KEYWORDS)
has_animal_products = any(k in all_text for k in _ANIMAL_PRODUCT_KEYWORDS)
tags: list[str] = []
if not has_meat and not has_seafood:
tags.append("vegetarian")
if not has_meat and not has_seafood and not has_animal_products:
tags.append("vegan")
return tags
@dataclass(frozen=True)
class ElementSnapshot:
seasoning_score: float
richness_score: float
brightness_score: float
depth_score: float
aroma_score: float
structure_score: float
texture_profile: str
dietary_tags: tuple
allergen_flags: tuple
flavor_molecules: tuple
fat_pct: float | None
protein_pct: float | None
moisture_pct: float | None
def compute_snapshot(recipe_ids: list[int], store) -> ElementSnapshot:
"""Compute an element snapshot from a list of recipe IDs.
Pulls SFAH scores, ingredient lists, and USDA FDC macros from the corpus.
Averages numeric scores across all recipes. Unions allergen flags and dietary tags.
Call at publish time only snapshot is stored denormalized in community_posts.
"""
_empty = ElementSnapshot(
seasoning_score=0.0, richness_score=0.0, brightness_score=0.0,
depth_score=0.0, aroma_score=0.0, structure_score=0.0,
texture_profile="", dietary_tags=(), allergen_flags=(),
flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None,
)
if not recipe_ids:
return _empty
rows = store.get_recipes_by_ids(recipe_ids)
if not rows:
return _empty
def _avg(field: str) -> float:
vals = [r.get(field) or 0.0 for r in rows]
return sum(vals) / len(vals)
all_ingredients: list[str] = []
for r in rows:
names = r.get("ingredient_names") or []
if isinstance(names, list):
all_ingredients.extend(names)
fat_vals = [r["fat"] for r in rows if r.get("fat") is not None]
prot_vals = [r["protein"] for r in rows if r.get("protein") is not None]
moist_vals = [r["moisture"] for r in rows if r.get("moisture") is not None]
return ElementSnapshot(
seasoning_score=_avg("seasoning_score"),
richness_score=_avg("richness_score"),
brightness_score=_avg("brightness_score"),
depth_score=_avg("depth_score"),
aroma_score=_avg("aroma_score"),
structure_score=_avg("structure_score"),
texture_profile=rows[0].get("texture_profile") or "",
dietary_tags=tuple(_detect_dietary_tags(all_ingredients)),
allergen_flags=tuple(_detect_allergens(all_ingredients)),
flavor_molecules=(), # deferred — FlavorGraph ticket
fat_pct=(sum(fat_vals) / len(fat_vals)) if fat_vals else None,
protein_pct=(sum(prot_vals) / len(prot_vals)) if prot_vals else None,
moisture_pct=(sum(moist_vals) / len(moist_vals)) if moist_vals else None,
)

View file

@ -0,0 +1,43 @@
# app/services/community/feed.py
# MIT License
from __future__ import annotations
from datetime import datetime, timezone
from email.utils import format_datetime
from xml.etree.ElementTree import Element, SubElement, tostring
def posts_to_rss(posts: list[dict], base_url: str) -> str:
"""Generate an RSS 2.0 feed from a list of community post dicts.
base_url: the root URL of this Kiwi instance (no trailing slash).
Returns UTF-8 XML string.
"""
rss = Element("rss", version="2.0")
channel = SubElement(rss, "channel")
_sub(channel, "title", "Kiwi Community Feed")
_sub(channel, "link", f"{base_url}/community")
_sub(channel, "description", "Meal plans and recipe outcomes from the Kiwi community")
_sub(channel, "language", "en")
_sub(channel, "lastBuildDate", format_datetime(datetime.now(timezone.utc)))
for post in posts:
item = SubElement(channel, "item")
_sub(item, "title", post.get("title") or "Untitled")
_sub(item, "link", f"{base_url}/api/v1/community/posts/{post['slug']}")
_sub(item, "guid", f"{base_url}/api/v1/community/posts/{post['slug']}")
if post.get("description"):
_sub(item, "description", post["description"])
published = post.get("published")
if isinstance(published, datetime):
_sub(item, "pubDate", format_datetime(published))
return '<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(rss, encoding="unicode")
def _sub(parent: Element, tag: str, text: str) -> Element:
el = SubElement(parent, tag)
el.text = text
return el

View file

@ -0,0 +1,111 @@
# app/services/community/mdns.py
# MIT License
# mDNS advertisement for Kiwi instances on the local network.
# Advertises _kiwi._tcp.local so other Kiwi instances (and discovery apps)
# can find this one without manual configuration.
#
# Opt-in only: enabled=False by default. Users are prompted on first community
# tab access. Never advertised without explicit consent (a11y requirement).
from __future__ import annotations
import logging
import socket
from typing import Any
logger = logging.getLogger(__name__)
# Deferred import — avoid hard failure when zeroconf is not installed.
try:
from zeroconf import ServiceInfo, Zeroconf
_ZEROCONF_AVAILABLE = True
except ImportError: # pragma: no cover
_ZEROCONF_AVAILABLE = False
class KiwiMDNS:
"""Context manager that advertises this Kiwi instance via mDNS (_kiwi._tcp.local).
Defaults to disabled. User must explicitly opt in via Settings.
feed_url is broadcast in the TXT record so peer instances know where to fetch posts.
Usage:
mdns = KiwiMDNS(
enabled=settings.MDNS_ENABLED,
port=8512,
feed_url="http://10.0.0.5:8512/api/v1/community/local-feed",
)
mdns.start() # in lifespan startup
mdns.stop() # in lifespan shutdown
"""
SERVICE_TYPE = "_kiwi._tcp.local."
def __init__(
self,
port: int = 8512,
name: str | None = None,
feed_url: str = "",
enabled: bool = False,
) -> None:
self._port = port
self._name = name or f"kiwi-{socket.gethostname()}"
self._feed_url = feed_url
self._enabled = enabled
self._zc: Any = None
self._info: Any = None
def start(self) -> None:
if not self._enabled:
logger.info("mDNS advertisement disabled (user opt-in required)")
return
try:
local_ip = _get_local_ip()
props = {b"product": b"kiwi", b"version": b"1"}
if self._feed_url:
props[b"feed"] = self._feed_url.encode()
self._info = ServiceInfo(
type_=self.SERVICE_TYPE,
name=f"{self._name}.{self.SERVICE_TYPE}",
addresses=[socket.inet_aton(local_ip)],
port=self._port,
properties=props,
server=f"{socket.gethostname()}.local.",
)
self._zc = Zeroconf()
self._zc.register_service(self._info)
logger.info("mDNS: advertising %s on %s:%d", self._name, local_ip, self._port)
except Exception as exc:
logger.warning("mDNS advertisement failed (non-fatal): %s", exc)
self._zc = None
self._info = None
def stop(self) -> None:
if self._zc and self._info:
try:
self._zc.unregister_service(self._info)
self._zc.close()
logger.info("mDNS: unregistered %s", self._name)
except Exception as exc:
logger.warning("mDNS unregister failed (non-fatal): %s", exc)
finally:
self._zc = None
self._info = None
def __enter__(self) -> "KiwiMDNS":
self.start()
return self
def __exit__(self, *_: object) -> None:
self.stop()
def _get_local_ip() -> str:
"""Return the primary non-loopback IPv4 address of this host."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"

View file

@ -18,6 +18,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"style_classifier", "style_classifier",
"meal_plan_llm", "meal_plan_llm",
"meal_plan_llm_timing", "meal_plan_llm_timing",
"community_fork_adapt", # Fork a community plan with LLM pantry adaptation
}) })
# Feature → minimum tier required # Feature → minimum tier required
@ -44,6 +45,11 @@ KIWI_FEATURES: dict[str, str] = {
"recipe_collections": "paid", "recipe_collections": "paid",
"style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable "style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
# Community (free to browse, paid to publish/fork)
"community_browse": "free", # Read-only feed access
"community_publish": "paid", # Publish plans/outcomes to community feed
"community_fork_adapt": "paid", # Fork a plan with LLM pantry adaptation; BYOK-unlockable
# Premium tier # Premium tier
"multi_household": "premium", "multi_household": "premium",
"background_monitoring": "premium", "background_monitoring": "premium",

View file

@ -0,0 +1,231 @@
<!-- frontend/src/components/CommunityFeedPanel.vue -->
<template>
<div class="community-feed-panel">
<!-- Filter bar -->
<div class="filter-bar" role="toolbar" aria-label="Filter community posts">
<button
v-for="f in FILTERS"
:key="f.value ?? 'all'"
class="filter-btn"
:class="{ active: activeFilter === f.value }"
:aria-pressed="activeFilter === f.value"
@click="setFilter(f.value)"
>{{ f.label }}</button>
</div>
<!-- Results count (aria-live so screen readers announce changes) -->
<p
class="results-summary"
aria-live="polite"
aria-atomic="true"
>
<template v-if="!loading">
{{ posts.length }} post{{ posts.length !== 1 ? 's' : '' }}
<template v-if="activeFilter"> · {{ activeFilterLabel }}</template>
</template>
</p>
<!-- Publish button (visible when plan is active) -->
<div class="publish-row" v-if="activePlanId">
<button class="publish-btn" @click="showPublish = true">
Share this week's plan
</button>
</div>
<!-- Error state -->
<div v-if="error" class="feed-error" role="alert">
<p>{{ error }}</p>
<button class="retry-btn" @click="communityStore.clearError(); communityStore.loadPosts(true)">
Try again
</button>
</div>
<!-- Feed -->
<div v-else-if="posts.length" class="feed-list">
<CommunityPostCard
v-for="post in posts"
:key="post.slug"
:post="post"
:forking="forkingSlug === post.slug"
@fork="onFork"
/>
<button
v-if="hasMore && !loading"
class="load-more-btn"
@click="communityStore.loadPosts()"
>
Load more
</button>
</div>
<!-- Loading skeleton -->
<div v-else-if="loading" class="feed-loading" aria-busy="true" aria-label="Loading posts">
<div v-for="i in 3" :key="i" class="skeleton-card"></div>
</div>
<!-- Empty state -->
<div v-else-if="isEmpty" class="feed-empty">
<p>No community posts yet.</p>
<p class="feed-empty-hint">Be the first to share a meal plan!</p>
</div>
<!-- Fork success toast -->
<div
v-if="forkSuccess"
class="fork-toast"
role="status"
aria-live="polite"
>
Plan forked into your week starting {{ forkSuccess.week_start }}
</div>
<!-- Publish modal -->
<PublishPlanModal
v-if="showPublish"
:plan-id="activePlanId"
@close="showPublish = false"
@published="onPublished"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useCommunityStore } from '../stores/community'
import CommunityPostCard from './CommunityPostCard.vue'
import PublishPlanModal from './PublishPlanModal.vue'
const props = defineProps<{
activePlanId?: number | null
}>()
const communityStore = useCommunityStore()
const { posts, loading, error, hasMore, isEmpty } = storeToRefs(communityStore)
const activeFilter = ref<'plan' | 'recipe_success' | 'recipe_blooper' | null>(null)
const showPublish = ref(false)
const forkingSlug = ref<string | null>(null)
const forkSuccess = ref<{ plan_id: number; week_start: string } | null>(null)
const FILTERS = [
{ label: 'All', value: null },
{ label: 'Plans', value: 'plan' as const },
{ label: 'Wins', value: 'recipe_success' as const },
{ label: 'Bloopers', value: 'recipe_blooper' as const },
] as const
const activeFilterLabel = computed(
() => FILTERS.find(f => f.value === activeFilter.value)?.label ?? ''
)
onMounted(() => communityStore.loadPosts(true))
function setFilter(value: typeof activeFilter.value) {
activeFilter.value = value
communityStore.setFilter(value)
}
async function onFork(slug: string) {
forkingSlug.value = slug
forkSuccess.value = null
const result = await communityStore.forkPost(slug)
forkingSlug.value = null
if (result) {
forkSuccess.value = result
setTimeout(() => { forkSuccess.value = null }, 4000)
}
}
function onPublished() {
showPublish.value = false
communityStore.loadPosts(true)
}
</script>
<style scoped>
.community-feed-panel { display: flex; flex-direction: column; gap: 0.75rem; }
.filter-bar { display: flex; gap: 6px; flex-wrap: wrap; }
.filter-btn {
font-size: 0.78rem;
padding: 0.25rem 0.75rem;
border-radius: 16px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.filter-btn.active, .filter-btn:hover {
background: var(--color-accent-subtle);
color: var(--color-accent);
border-color: var(--color-accent);
}
.filter-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.results-summary { font-size: 0.75rem; color: var(--color-text-secondary); min-height: 1.1em; margin: 0; }
.publish-row { display: flex; }
.publish-btn {
font-size: 0.82rem;
padding: 0.4rem 1.1rem;
border-radius: 20px;
background: var(--color-accent);
color: white;
border: none;
cursor: pointer;
transition: opacity 0.15s;
}
.publish-btn:hover { opacity: 0.88; }
.publish-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.feed-list { display: flex; flex-direction: column; gap: 0.75rem; }
.load-more-btn {
align-self: center;
font-size: 0.8rem;
padding: 0.4rem 1.2rem;
border-radius: 16px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
}
.load-more-btn:hover { border-color: var(--color-accent); color: var(--color-accent); }
.feed-loading { display: flex; flex-direction: column; gap: 0.75rem; }
.skeleton-card {
height: 110px;
border-radius: 10px;
background: linear-gradient(90deg, var(--color-surface) 25%, var(--color-border) 50%, var(--color-surface) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@media (prefers-reduced-motion: reduce) { .skeleton-card { animation: none; background: var(--color-surface); } }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.feed-empty { text-align: center; padding: 2rem 0; color: var(--color-text-secondary); }
.feed-empty p { margin: 0.25rem 0; }
.feed-empty-hint { font-size: 0.82rem; opacity: 0.7; }
.feed-error { padding: 0.75rem 1rem; border-radius: 8px; background: color-mix(in srgb, red 8%, transparent); border: 1px solid color-mix(in srgb, red 20%, transparent); }
.feed-error p { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--color-text); }
.retry-btn { font-size: 0.78rem; padding: 0.3rem 0.8rem; border-radius: 14px; border: 1px solid currentColor; background: none; cursor: pointer; color: var(--color-accent); }
.fork-toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
background: var(--color-accent);
color: white;
padding: 0.6rem 1.2rem;
border-radius: 20px;
font-size: 0.85rem;
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
z-index: 100;
pointer-events: none;
}
</style>

View file

@ -0,0 +1,171 @@
<!-- frontend/src/components/CommunityPostCard.vue -->
<template>
<article class="post-card" :class="`post-type-${post.post_type}`">
<header class="post-header">
<span class="post-type-badge" :aria-label="`Post type: ${typeLabel}`">{{ typeLabel }}</span>
<time class="post-date" :datetime="post.published">{{ formattedDate }}</time>
</header>
<h3 class="post-title">{{ post.title }}</h3>
<p v-if="post.description" class="post-description">{{ post.description }}</p>
<div v-if="post.dietary_tags.length || post.allergen_flags.length" class="post-tags">
<span
v-for="tag in post.dietary_tags"
:key="tag"
class="tag tag-dietary"
:aria-label="`Dietary: ${tag}`"
>{{ tag }}</span>
<span
v-for="flag in post.allergen_flags"
:key="flag"
class="tag tag-allergen"
:aria-label="`Contains: ${flag}`"
>{{ flag }}</span>
</div>
<div v-if="post.post_type === 'plan' && post.slots.length" class="post-slots">
<span class="slots-summary">
{{ post.slots.length }} meal{{ post.slots.length !== 1 ? 's' : '' }} planned
</span>
</div>
<div v-if="post.outcome_notes" class="outcome-notes">
<p class="outcome-label">Notes</p>
<p class="outcome-text">{{ post.outcome_notes }}</p>
</div>
<footer class="post-footer">
<span class="post-author">by {{ post.pseudonym }}</span>
<div class="post-actions">
<button
v-if="post.post_type === 'plan'"
class="action-btn fork-btn"
:disabled="forking"
:aria-busy="forking"
@click="$emit('fork', post.slug)"
>
{{ forking ? 'Forking…' : 'Fork plan' }}
</button>
</div>
</footer>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { CommunityPost } from '../stores/community'
const props = defineProps<{
post: CommunityPost
forking?: boolean
}>()
defineEmits<{
fork: [slug: string]
}>()
const typeLabel = computed(() => ({
plan: 'Meal Plan',
recipe_success: 'Recipe Win',
recipe_blooper: 'Recipe Blooper',
}[props.post.post_type] ?? props.post.post_type))
const formattedDate = computed(() => {
try {
return new Date(props.post.published).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
})
} catch {
return props.post.published
}
})
</script>
<style scoped>
.post-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
transition: box-shadow 0.15s;
}
.post-card:hover { box-shadow: 0 2px 8px color-mix(in srgb, var(--color-accent) 12%, transparent); }
.post-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.post-type-badge {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.5rem;
border-radius: 20px;
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.post-type-recipe_blooper .post-type-badge { background: color-mix(in srgb, orange 15%, transparent); color: #b36000; }
.post-type-recipe_success .post-type-badge { background: color-mix(in srgb, green 12%, transparent); color: #2a7a2a; }
.post-date { font-size: 0.75rem; color: var(--color-text-secondary); }
.post-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.3;
}
.post-description { margin: 0; font-size: 0.82rem; color: var(--color-text-secondary); line-height: 1.5; }
.post-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
font-size: 0.68rem;
padding: 0.1rem 0.4rem;
border-radius: 10px;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.tag-dietary { border-color: color-mix(in srgb, green 30%, transparent); color: #2a7a2a; }
.tag-allergen { border-color: color-mix(in srgb, orange 30%, transparent); color: #b36000; }
.slots-summary { font-size: 0.78rem; color: var(--color-text-secondary); }
.outcome-notes { background: var(--color-bg); border-radius: 6px; padding: 0.5rem 0.7rem; }
.outcome-label { margin: 0 0 0.2rem; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; color: var(--color-text-secondary); }
.outcome-text { margin: 0; font-size: 0.82rem; color: var(--color-text); line-height: 1.5; }
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.25rem;
flex-wrap: wrap;
gap: 0.4rem;
}
.post-author { font-size: 0.75rem; color: var(--color-text-secondary); font-style: italic; }
.action-btn {
font-size: 0.78rem;
padding: 0.3rem 0.8rem;
border-radius: 16px;
border: 1px solid var(--color-accent);
background: var(--color-accent-subtle);
color: var(--color-accent);
cursor: pointer;
transition: background 0.15s;
}
.action-btn:hover:not(:disabled) { background: var(--color-accent); color: white; }
.action-btn:disabled { opacity: 0.5; cursor: default; }
.action-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
</style>

View file

@ -26,7 +26,7 @@
@add-meal-type="onAddMealType" @add-meal-type="onAddMealType"
/> />
<!-- Panel tabs: Shopping List | Prep Schedule --> <!-- Panel tabs: Shopping List | Prep Schedule | Community -->
<div class="panel-tabs" role="tablist" aria-label="Plan outputs"> <div class="panel-tabs" role="tablist" aria-label="Plan outputs">
<button <button
v-for="tab in TABS" v-for="tab in TABS"
@ -60,6 +60,16 @@
> >
<PrepSessionView @load="store.loadPrepSession()" /> <PrepSessionView @load="store.loadPrepSession()" />
</div> </div>
<div
v-show="activeTab === 'community'"
id="tabpanel-community"
role="tabpanel"
aria-labelledby="tab-community"
class="tab-panel"
>
<CommunityFeedPanel :active-plan-id="activePlan?.id ?? null" />
</div>
</template> </template>
<div v-else-if="!loading" class="empty-plan-state"> <div v-else-if="!loading" class="empty-plan-state">
@ -76,10 +86,12 @@ import { useMealPlanStore } from '../stores/mealPlan'
import MealPlanGrid from './MealPlanGrid.vue' import MealPlanGrid from './MealPlanGrid.vue'
import ShoppingListPanel from './ShoppingListPanel.vue' import ShoppingListPanel from './ShoppingListPanel.vue'
import PrepSessionView from './PrepSessionView.vue' import PrepSessionView from './PrepSessionView.vue'
import CommunityFeedPanel from './CommunityFeedPanel.vue'
const TABS = [ const TABS = [
{ id: 'shopping', label: 'Shopping List' }, { id: 'shopping', label: 'Shopping List' },
{ id: 'prep', label: 'Prep Schedule' }, { id: 'prep', label: 'Prep Schedule' },
{ id: 'community', label: 'Community' },
] as const ] as const
type TabId = typeof TABS[number]['id'] type TabId = typeof TABS[number]['id']

View file

@ -0,0 +1,230 @@
<!-- frontend/src/components/PublishPlanModal.vue -->
<!-- Publish a meal plan to the community feed.
Pseudonym setup is inline (pre-populated, feels like invitation).
Focus-trapped per a11y audit. No countdown on delete-undo. -->
<template>
<div
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="publish-modal-title"
@keydown.esc="$emit('close')"
ref="backdropEl"
@click.self="$emit('close')"
>
<div class="modal-card" ref="cardEl">
<header class="modal-header">
<h2 id="publish-modal-title" class="modal-title">Share this week's plan</h2>
<button class="close-btn" aria-label="Close" @click="$emit('close')">×</button>
</header>
<div class="modal-body">
<div class="field">
<label for="plan-title" class="field-label">Title</label>
<input
id="plan-title"
v-model="title"
class="field-input"
type="text"
placeholder="e.g. Pasta Week"
maxlength="120"
required
ref="firstFocusEl"
/>
</div>
<div class="field">
<label for="plan-description" class="field-label">Description <span class="optional">(optional)</span></label>
<textarea
id="plan-description"
v-model="description"
class="field-input field-textarea"
placeholder="What makes this week special?"
maxlength="400"
rows="3"
/>
</div>
<div class="field">
<label for="pseudonym" class="field-label">
Your community name
<span class="field-hint">How you appear on posts not your real name or email</span>
</label>
<input
id="pseudonym"
v-model="pseudonymName"
class="field-input"
type="text"
placeholder="e.g. PastaWitch"
maxlength="40"
/>
<p v-if="pseudonymError" class="field-error" role="alert">{{ pseudonymError }}</p>
</div>
<p v-if="error" class="submit-error" role="alert">{{ error }}</p>
</div>
<footer class="modal-footer">
<button class="cancel-btn" @click="$emit('close')">Cancel</button>
<button
class="publish-btn"
:disabled="submitting || !title.trim()"
:aria-busy="submitting"
@click="onSubmit"
>
{{ submitting ? 'Publishing…' : 'Publish' }}
</button>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useCommunityStore } from '../stores/community'
const props = defineProps<{
planId?: number | null
}>()
const emit = defineEmits<{
close: []
published: []
}>()
const communityStore = useCommunityStore()
const title = ref('')
const description = ref('')
const pseudonymName = ref('')
const pseudonymError = ref('')
const submitting = ref(false)
const error = ref('')
const backdropEl = ref<HTMLElement | null>(null)
const cardEl = ref<HTMLElement | null>(null)
const firstFocusEl = ref<HTMLInputElement | null>(null)
onMounted(() => {
firstFocusEl.value?.focus()
document.addEventListener('keydown', trapFocus)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', trapFocus)
})
function trapFocus(e: KeyboardEvent) {
if (e.key !== 'Tab' || !cardEl.value) return
const focusables = cardEl.value.querySelectorAll<HTMLElement>(
'button, input, textarea, [tabindex]:not([tabindex="-1"])'
)
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
async function onSubmit() {
pseudonymError.value = ''
error.value = ''
if (pseudonymName.value && pseudonymName.value.includes('@')) {
pseudonymError.value = 'Community name must not contain "@" — use a display name, not an email.'
return
}
submitting.value = true
const result = await communityStore.publishPost({
post_type: 'plan',
title: title.value.trim(),
description: description.value.trim() || undefined,
pseudonym_name: pseudonymName.value.trim() || undefined,
})
submitting.value = false
if (result) {
emit('published')
} else {
error.value = communityStore.error ?? 'Publish failed. Please try again.'
}
}
</script>
<style scoped>
.modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 200;
display: flex; align-items: center; justify-content: center; padding: 1rem;
}
.modal-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
width: 100%;
max-width: 440px;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.22);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 1rem 1.2rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.modal-title { margin: 0; font-size: 1rem; font-weight: 600; }
.close-btn {
background: none; border: none; font-size: 1.4rem; cursor: pointer;
color: var(--color-text-secondary); line-height: 1; padding: 0 0.2rem;
}
.close-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.modal-body { padding: 1rem 1.2rem; display: flex; flex-direction: column; gap: 0.9rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; }
.field-label { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
.optional { font-weight: 400; color: var(--color-text-secondary); }
.field-hint { display: block; font-size: 0.72rem; color: var(--color-text-secondary); font-weight: 400; margin-top: 0.1rem; }
.field-input {
padding: 0.45rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 7px;
background: var(--color-bg);
color: var(--color-text);
font-size: 0.88rem;
width: 100%;
box-sizing: border-box;
}
.field-input:focus { outline: 2px solid var(--color-accent); border-color: transparent; }
.field-textarea { resize: vertical; font-family: inherit; min-height: 70px; }
.field-error { margin: 0; font-size: 0.78rem; color: #c0392b; }
.submit-error { margin: 0; font-size: 0.82rem; color: #c0392b; }
.modal-footer {
padding: 0.85rem 1.2rem;
border-top: 1px solid var(--color-border);
display: flex;
gap: 0.6rem;
justify-content: flex-end;
}
.cancel-btn {
padding: 0.4rem 1rem; border-radius: 8px; border: 1px solid var(--color-border);
background: none; color: var(--color-text-secondary); cursor: pointer; font-size: 0.85rem;
}
.cancel-btn:hover { border-color: var(--color-text-secondary); }
.publish-btn {
padding: 0.4rem 1.2rem; border-radius: 8px; border: none;
background: var(--color-accent); color: white; cursor: pointer; font-size: 0.85rem;
font-weight: 600;
}
.publish-btn:hover:not(:disabled) { opacity: 0.88; }
.publish-btn:disabled { opacity: 0.5; cursor: default; }
.publish-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
</style>

View file

@ -0,0 +1,123 @@
// frontend/src/stores/community.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { api } from '../services/api'
export interface CommunityPost {
slug: string
pseudonym: string
post_type: 'plan' | 'recipe_success' | 'recipe_blooper'
published: string
title: string
description: string | null
photo_url: string | null
slots: Array<{ day: string; meal_type: string; recipe_id: number; recipe_name: string }>
recipe_id: number | null
recipe_name: string | null
level: number | null
outcome_notes: string | null
element_profiles: {
seasoning_score: number
richness_score: number
brightness_score: number
depth_score: number
aroma_score: number
structure_score: number
texture_profile: string
}
dietary_tags: string[]
allergen_flags: string[]
fat_pct: number | null
protein_pct: number | null
moisture_pct: number | null
}
export const useCommunityStore = defineStore('community', () => {
const posts = ref<CommunityPost[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const page = ref(1)
const hasMore = ref(true)
const pseudonym = ref<string | null>(null)
// Filters
const filterDietaryTags = ref<string[]>([])
const filterPostType = ref<'plan' | 'recipe_success' | 'recipe_blooper' | null>(null)
const isEmpty = computed(() => !loading.value && posts.value.length === 0)
async function loadPosts(reset = false) {
if (loading.value) return
if (reset) {
posts.value = []
page.value = 1
hasMore.value = true
}
if (!hasMore.value) return
loading.value = true
error.value = null
try {
const params: Record<string, string | number> = {
page: page.value,
page_size: 20,
}
if (filterPostType.value) params.post_type = filterPostType.value
if (filterDietaryTags.value.length) params.dietary_tags = filterDietaryTags.value.join(',')
const res = await api.get('/community/posts', { params })
const newPosts: CommunityPost[] = res.data.posts ?? []
posts.value = reset ? newPosts : [...posts.value, ...newPosts]
hasMore.value = newPosts.length === 20
page.value += 1
} catch (err: any) {
error.value = err?.response?.data?.detail ?? 'Could not load community posts.'
} finally {
loading.value = false
}
}
async function forkPost(slug: string): Promise<{ plan_id: number; week_start: string } | null> {
try {
const res = await api.post(`/community/posts/${slug}/fork`)
return res.data
} catch (err: any) {
error.value = err?.response?.data?.detail ?? 'Fork failed.'
return null
}
}
async function publishPost(payload: {
post_type: string
title: string
description?: string
pseudonym_name?: string
slots?: unknown[]
recipe_id?: number
recipe_name?: string
outcome_notes?: string
}): Promise<CommunityPost | null> {
try {
const res = await api.post('/community/posts', payload)
return res.data
} catch (err: any) {
error.value = err?.response?.data?.detail ?? 'Publish failed.'
return null
}
}
function setFilter(type: typeof filterPostType.value) {
filterPostType.value = type
loadPosts(true)
}
function clearError() {
error.value = null
}
return {
posts, loading, error, page, hasMore, pseudonym,
filterDietaryTags, filterPostType, isEmpty,
loadPosts, forkPost, publishPost, setFilter, clearError,
}
})

View file

@ -24,6 +24,8 @@ dependencies = [
"requests>=2.31", "requests>=2.31",
# CircuitForge shared scaffold # CircuitForge shared scaffold
"circuitforge-core>=0.8.0", "circuitforge-core>=0.8.0",
# mDNS advertisement (opt-in: _kiwi._tcp.local discovery)
"zeroconf>=0.131",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View file

@ -0,0 +1,68 @@
# tests/api/test_community_endpoints.py
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_get_community_posts_no_db_returns_empty():
"""When COMMUNITY_DB_URL is not set, GET /community/posts returns empty list (no 500)."""
with patch("app.api.endpoints.community._community_store", None):
response = client.get("/api/v1/community/posts")
assert response.status_code == 200
data = response.json()
assert "posts" in data
assert isinstance(data["posts"], list)
def test_get_community_post_not_found():
"""GET /community/posts/{slug} returns 404 when slug doesn't exist."""
mock_store = MagicMock()
mock_store.get_post_by_slug.return_value = None
with patch("app.api.endpoints.community._community_store", mock_store):
response = client.get("/api/v1/community/posts/nonexistent-slug")
assert response.status_code == 404
def test_get_community_rss():
"""GET /community/feed.rss returns XML content-type."""
with patch("app.api.endpoints.community._community_store", None):
response = client.get("/api/v1/community/feed.rss")
assert response.status_code == 200
assert "xml" in response.headers.get("content-type", "")
def test_post_community_no_store_returns_503():
"""POST /community/posts returns 503 when community DB is not configured.
In local/dev mode the session auth is bypassed (local session). The endpoint
should then fail-soft with 503, not 500. Production cloud mode enforces auth
before the store check tested in integration tests.
"""
with patch("app.api.endpoints.community._community_store", None):
response = client.post("/api/v1/community/posts", json={"title": "Test"})
# 503 = no community store; 402 = tier gate fired first; both are acceptable
assert response.status_code in (402, 503)
def test_delete_post_no_store_returns_503():
"""DELETE /community/posts/{slug} returns 503 when community DB is not configured."""
with patch("app.api.endpoints.community._community_store", None):
response = client.delete("/api/v1/community/posts/some-slug")
assert response.status_code in (400, 503)
def test_fork_post_route_exists():
"""POST /community/posts/{slug}/fork route must exist (not 404)."""
response = client.post("/api/v1/community/posts/some-slug/fork")
assert response.status_code != 404
def test_local_feed_returns_json():
"""GET /community/local-feed returns JSON list for LAN peers."""
with patch("app.api.endpoints.community._community_store", None):
response = client.get("/api/v1/community/local-feed")
assert response.status_code == 200
assert isinstance(response.json(), list)

View file

@ -0,0 +1,49 @@
# tests/db/test_migration_026.py
import pytest
from pathlib import Path
from app.db.store import Store
def test_migration_026_adds_community_pseudonyms(tmp_path):
"""Migration 026 adds community_pseudonyms table to per-user kiwi.db."""
store = Store(tmp_path / "test.db")
cur = store.conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='community_pseudonyms'"
)
assert cur.fetchone() is not None, "community_pseudonyms table must exist after migrations"
store.close()
def test_migration_026_unique_index_enforces_single_current_pseudonym(tmp_path):
"""Only one is_current=1 pseudonym per directus_user_id is allowed."""
store = Store(tmp_path / "test.db")
store.conn.execute(
"INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) VALUES (?, ?, 1)",
("PastaWitch", "user-abc-123"),
)
store.conn.commit()
import sqlite3
with pytest.raises(sqlite3.IntegrityError):
store.conn.execute(
"INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) VALUES (?, ?, 1)",
("NoodleNinja", "user-abc-123"),
)
store.conn.commit()
store.close()
def test_migration_026_allows_historical_pseudonyms(tmp_path):
"""Multiple is_current=0 pseudonyms are allowed for the same user."""
store = Store(tmp_path / "test.db")
store.conn.executemany(
"INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) VALUES (?, ?, 0)",
[("OldName1", "user-abc-123"), ("OldName2", "user-abc-123")],
)
store.conn.commit()
cur = store.conn.execute(
"SELECT COUNT(*) FROM community_pseudonyms WHERE directus_user_id='user-abc-123'"
)
assert cur.fetchone()[0] == 2
store.close()

View file

View file

@ -0,0 +1,47 @@
# tests/services/community/test_ap_compat.py
import pytest
from datetime import datetime, timezone
from app.services.community.ap_compat import post_to_ap_json_ld
POST = {
"slug": "kiwi-plan-test-pasta-week",
"title": "Pasta Week",
"description": "Seven days of carbs",
"published": datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc),
"pseudonym": "PastaWitch",
"dietary_tags": ["vegetarian"],
}
def test_ap_json_ld_context():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
assert doc["@context"] == "https://www.w3.org/ns/activitystreams"
def test_ap_json_ld_type():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
assert doc["type"] == "Note"
def test_ap_json_ld_id_is_uri():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
assert doc["id"].startswith("https://")
assert POST["slug"] in doc["id"]
def test_ap_json_ld_published_is_iso8601():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
from datetime import datetime
datetime.fromisoformat(doc["published"].replace("Z", "+00:00"))
def test_ap_json_ld_attributed_to_pseudonym():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
assert doc["attributedTo"] == "PastaWitch"
def test_ap_json_ld_tags_include_kiwi():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
tag_names = [t["name"] for t in doc.get("tag", [])]
assert "#kiwi" in tag_names

View file

@ -0,0 +1,64 @@
# tests/services/community/test_community_store.py
import pytest
from unittest.mock import MagicMock, patch
from app.services.community.community_store import KiwiCommunityStore, get_or_create_pseudonym
def test_get_or_create_pseudonym_new_user():
"""First-time publish: creates a new pseudonym in per-user SQLite."""
mock_store = MagicMock()
mock_store.get_current_pseudonym.return_value = None
result = get_or_create_pseudonym(
store=mock_store,
directus_user_id="user-123",
requested_name="PastaWitch",
)
mock_store.set_pseudonym.assert_called_once_with("user-123", "PastaWitch")
assert result == "PastaWitch"
def test_get_or_create_pseudonym_existing():
"""If user already has a pseudonym, return it without creating a new one."""
mock_store = MagicMock()
mock_store.get_current_pseudonym.return_value = "PastaWitch"
result = get_or_create_pseudonym(
store=mock_store,
directus_user_id="user-123",
requested_name=None,
)
mock_store.set_pseudonym.assert_not_called()
assert result == "PastaWitch"
def test_get_or_create_pseudonym_email_rejected():
"""Pseudonyms with @ are rejected to prevent PII leakage."""
mock_store = MagicMock()
mock_store.get_current_pseudonym.return_value = None
with pytest.raises(ValueError, match="@"):
get_or_create_pseudonym(
store=mock_store,
directus_user_id="user-123",
requested_name="user@example.com",
)
def test_get_or_create_pseudonym_blank_name_raises():
"""Empty requested_name raises when no existing pseudonym."""
mock_store = MagicMock()
mock_store.get_current_pseudonym.return_value = None
with pytest.raises(ValueError):
get_or_create_pseudonym(
store=mock_store,
directus_user_id="user-123",
requested_name="",
)
def test_kiwi_community_store_list_meal_plans_filters_by_plan_type():
"""KiwiCommunityStore.list_meal_plans calls list_posts with post_type='plan'."""
store = KiwiCommunityStore.__new__(KiwiCommunityStore)
with patch.object(store, "list_posts", return_value=[]) as mock_list:
result = store.list_meal_plans(limit=10)
mock_list.assert_called_once()
call_kwargs = mock_list.call_args.kwargs
assert call_kwargs.get("post_type") == "plan"

View file

@ -0,0 +1,77 @@
# tests/services/community/test_element_snapshot.py
import pytest
from unittest.mock import MagicMock
from app.services.community.element_snapshot import compute_snapshot, ElementSnapshot
def make_mock_store(recipe_rows: list[dict]) -> MagicMock:
store = MagicMock()
store.get_recipes_by_ids.return_value = recipe_rows
return store
RECIPE_ROW = {
"id": 1,
"name": "Spaghetti Carbonara",
"ingredient_names": ["pasta", "eggs", "guanciale", "pecorino"],
"keywords": ["italian", "quick", "dinner"],
"category": "dinner",
"fat": 22.0,
"protein": 18.0,
"moisture": 45.0,
"seasoning_score": 0.7,
"richness_score": 0.8,
"brightness_score": 0.2,
"depth_score": 0.6,
"aroma_score": 0.5,
"structure_score": 0.9,
"texture_profile": "creamy",
}
def test_compute_snapshot_basic():
store = make_mock_store([RECIPE_ROW])
snap = compute_snapshot(recipe_ids=[1], store=store)
assert isinstance(snap, ElementSnapshot)
assert 0.0 <= snap.seasoning_score <= 1.0
assert snap.texture_profile == "creamy"
def test_compute_snapshot_averages_multiple_recipes():
row2 = {**RECIPE_ROW, "id": 2, "seasoning_score": 0.3, "richness_score": 0.2}
store = make_mock_store([RECIPE_ROW, row2])
snap = compute_snapshot(recipe_ids=[1, 2], store=store)
# seasoning: average of 0.7 and 0.3 = 0.5
assert abs(snap.seasoning_score - 0.5) < 0.01
def test_compute_snapshot_allergen_flags_detected():
row = {**RECIPE_ROW, "ingredient_names": ["pasta", "eggs", "milk", "shrimp", "peanuts"]}
store = make_mock_store([row])
snap = compute_snapshot(recipe_ids=[1], store=store)
assert "gluten" in snap.allergen_flags # pasta
assert "dairy" in snap.allergen_flags # milk
assert "shellfish" in snap.allergen_flags # shrimp
assert "nuts" in snap.allergen_flags # peanuts
def test_compute_snapshot_dietary_tags_vegetarian():
row = {**RECIPE_ROW, "ingredient_names": ["pasta", "eggs", "tomato", "basil"]}
store = make_mock_store([row])
snap = compute_snapshot(recipe_ids=[1], store=store)
assert "vegetarian" in snap.dietary_tags
def test_compute_snapshot_no_recipes_returns_defaults():
store = make_mock_store([])
snap = compute_snapshot(recipe_ids=[], store=store)
assert snap.seasoning_score == 0.0
assert snap.dietary_tags == ()
assert snap.allergen_flags == ()
def test_element_snapshot_immutable():
store = make_mock_store([RECIPE_ROW])
snap = compute_snapshot(recipe_ids=[1], store=store)
with pytest.raises((AttributeError, TypeError)):
snap.seasoning_score = 0.0 # type: ignore

View file

@ -0,0 +1,51 @@
# tests/services/community/test_feed.py
import pytest
from datetime import datetime, timezone
from app.services.community.feed import posts_to_rss
def make_post_dict(**kwargs):
defaults = dict(
slug="kiwi-plan-test-pasta-week",
title="Pasta Week",
description="Seven days of carbs",
published=datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc),
post_type="plan",
pseudonym="PastaWitch",
)
defaults.update(kwargs)
return defaults
def test_rss_is_valid_xml():
import xml.etree.ElementTree as ET
rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi")
root = ET.fromstring(rss)
assert root.tag == "rss"
assert root.attrib.get("version") == "2.0"
def test_rss_contains_item():
import xml.etree.ElementTree as ET
rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi")
root = ET.fromstring(rss)
items = root.findall(".//item")
assert len(items) == 1
def test_rss_item_has_required_fields():
import xml.etree.ElementTree as ET
rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi")
root = ET.fromstring(rss)
item = root.find(".//item")
assert item.find("title") is not None
assert item.find("link") is not None
assert item.find("pubDate") is not None
def test_rss_empty_posts():
import xml.etree.ElementTree as ET
rss = posts_to_rss([], base_url="https://menagerie.circuitforge.tech/kiwi")
root = ET.fromstring(rss)
items = root.findall(".//item")
assert len(items) == 0

View file

@ -0,0 +1,39 @@
# tests/services/community/test_mdns.py
import pytest
from unittest.mock import MagicMock, patch
from app.services.community.mdns import KiwiMDNS
def test_mdns_does_not_advertise_when_disabled():
"""When enabled=False, KiwiMDNS does not register any zeroconf service."""
with patch("app.services.community.mdns.Zeroconf") as mock_zc:
mdns = KiwiMDNS(enabled=False, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed")
mdns.start()
mock_zc.assert_not_called()
def test_mdns_advertises_when_enabled():
with patch("app.services.community.mdns.Zeroconf") as mock_zc_cls:
with patch("app.services.community.mdns.ServiceInfo") as mock_si:
mock_zc = MagicMock()
mock_zc_cls.return_value = mock_zc
mdns = KiwiMDNS(enabled=True, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed")
mdns.start()
mock_zc.register_service.assert_called_once()
def test_mdns_stop_unregisters_when_enabled():
with patch("app.services.community.mdns.Zeroconf") as mock_zc_cls:
with patch("app.services.community.mdns.ServiceInfo"):
mock_zc = MagicMock()
mock_zc_cls.return_value = mock_zc
mdns = KiwiMDNS(enabled=True, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed")
mdns.start()
mdns.stop()
mock_zc.unregister_service.assert_called_once()
mock_zc.close.assert_called_once()
def test_mdns_stop_is_noop_when_not_started():
mdns = KiwiMDNS(enabled=False, port=8512, feed_url="http://localhost/feed")
mdns.stop() # must not raise