{{ post.title }}
+ +{{ post.description }}
+ + + +Notes
+{{ post.outcome_notes }}
+diff --git a/app/api/endpoints/community.py b/app/api/endpoints/community.py
new file mode 100644
index 0000000..31df5bf
--- /dev/null
+++ b/app/api/endpoints/community.py
@@ -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")
diff --git a/app/api/routes.py b/app/api/routes.py
index e0ea172..0e7c9d4 100644
--- a/app/api/routes.py
+++ b/app/api/routes.py
@@ -1,5 +1,6 @@
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.community import router as community_router
api_router = APIRouter()
@@ -14,4 +15,5 @@ api_router.include_router(staples.router, prefix="/staples", tags=["s
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
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(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
\ No newline at end of file
+api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
+api_router.include_router(community_router)
\ No newline at end of file
diff --git a/app/core/config.py b/app/core/config.py
index 091b574..f4bb75a 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -53,6 +53,17 @@ class Settings:
# Feature flags
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
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")
diff --git a/app/db/migrations/026_community_pseudonyms.sql b/app/db/migrations/026_community_pseudonyms.sql
new file mode 100644
index 0000000..ed6ac3f
--- /dev/null
+++ b/app/db/migrations/026_community_pseudonyms.sql
@@ -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;
diff --git a/app/db/store.py b/app/db/store.py
index 576ac55..3d30b9b 100644
--- a/app/db/store.py
+++ b/app/db/store.py
@@ -1128,3 +1128,31 @@ class Store:
)
self.conn.commit()
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()
diff --git a/app/main.py b/app/main.py
index c5ccec3..e1d06e6 100644
--- a/app/main.py
+++ b/app/main.py
@@ -20,6 +20,10 @@ async def lifespan(app: FastAPI):
settings.ensure_dirs()
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
from app.tasks.scheduler import get_scheduler
get_scheduler(settings.DB_PATH)
diff --git a/app/services/community/__init__.py b/app/services/community/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/services/community/ap_compat.py b/app/services/community/ap_compat.py
new file mode 100644
index 0000000..c3ac181
--- /dev/null
+++ b/app/services/community/ap_compat.py
@@ -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
diff --git a/app/services/community/community_store.py b/app/services/community/community_store.py
new file mode 100644
index 0000000..f1af73f
--- /dev/null
+++ b/app/services/community/community_store.py
@@ -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
diff --git a/app/services/community/element_snapshot.py b/app/services/community/element_snapshot.py
new file mode 100644
index 0000000..ad78697
--- /dev/null
+++ b/app/services/community/element_snapshot.py
@@ -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,
+ )
diff --git a/app/services/community/feed.py b/app/services/community/feed.py
new file mode 100644
index 0000000..36000b9
--- /dev/null
+++ b/app/services/community/feed.py
@@ -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 '\n' + tostring(rss, encoding="unicode")
+
+
+def _sub(parent: Element, tag: str, text: str) -> Element:
+ el = SubElement(parent, tag)
+ el.text = text
+ return el
diff --git a/app/services/community/mdns.py b/app/services/community/mdns.py
new file mode 100644
index 0000000..efc3ca9
--- /dev/null
+++ b/app/services/community/mdns.py
@@ -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"
diff --git a/app/tiers.py b/app/tiers.py
index c3257ce..05193b2 100644
--- a/app/tiers.py
+++ b/app/tiers.py
@@ -18,6 +18,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"style_classifier",
"meal_plan_llm",
"meal_plan_llm_timing",
+ "community_fork_adapt", # Fork a community plan with LLM pantry adaptation
})
# Feature → minimum tier required
@@ -44,6 +45,11 @@ KIWI_FEATURES: dict[str, str] = {
"recipe_collections": "paid",
"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
"multi_household": "premium",
"background_monitoring": "premium",
diff --git a/frontend/src/components/CommunityFeedPanel.vue b/frontend/src/components/CommunityFeedPanel.vue
new file mode 100644
index 0000000..915106a
--- /dev/null
+++ b/frontend/src/components/CommunityFeedPanel.vue
@@ -0,0 +1,231 @@
+
+
+
+
+ {{ posts.length }} post{{ posts.length !== 1 ? 's' : '' }}
+ · {{ activeFilterLabel }}
+
+ {{ error }} No community posts yet. Be the first to share a meal plan! {{ post.description }} Notes {{ post.outcome_notes }}{{ post.title }}
+
+
{{ pseudonymError }}
+{{ error }}
+