Compare commits

..

No commits in common. "d24f87a47618bdb264d4c590590fdb768312a507" and "192ecc7078e0ca650d85cefbe506af7230402610" have entirely different histories.

32 changed files with 19 additions and 2680 deletions

View file

@ -83,10 +83,3 @@ DEMO_MODE=false
# INSTACART_AFFILIATE_ID=circuitforge
# Walmart Impact network affiliate ID (inline, path-based redirect)
# WALMART_AFFILIATE_ID=
# Community PostgreSQL — shared across CF products (cloud only; leave unset for local dev)
# Points at cf-orch's cf-community-postgres container (port 5434 on the orch host).
# When unset, community write paths fail soft with a plain-language message.
# COMMUNITY_DB_URL=postgresql://cf_community:changeme@cf-orch-host:5434/cf_community
# COMMUNITY_PSEUDONYM_SALT=change-this-to-a-random-32-char-string

View file

@ -10,8 +10,6 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea
**Status:** Beta · CircuitForge LLC
**[Documentation](https://docs.circuitforge.tech/kiwi/)** · [circuitforge.tech](https://circuitforge.tech)
---
## What it does
@ -23,7 +21,7 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable)
- **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable)
- **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable)
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Free, 5/day; unlimited at Paid+)
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier)
- **LLM backend config** — configure inference via `circuitforge-core` env-var system; BYOK unlocks Paid AI features at any tier
- **Feedback FAB** — in-app feedback button; status probed on load, hidden if CF feedback endpoint unreachable
@ -70,7 +68,7 @@ cp .env.example .env
| LLM style auto-classifier | — | BYOK | ✓ |
| Meal planning | — | ✓ | ✓ |
| Multi-household | — | — | ✓ |
| Leftover mode (5/day) | ✓ | ✓ | ✓ |
| Leftover mode | — | — | ✓ |
BYOK = bring your own LLM backend (configure `~/.config/circuitforge/llm.yaml`)

View file

@ -1,336 +0,0 @@
# app/api/endpoints/community.py
# MIT License
from __future__ import annotations
import asyncio
import logging
import re
import sqlite3
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"])
_community_store = None
def _get_community_store():
return _community_store
def init_community_store(community_db_url: str | None) -> None:
global _community_store
if not community_db_url:
logger.info(
"COMMUNITY_DB_URL not set — community write features disabled. "
"Browse still works via cloud feed."
)
return
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.")
def _visible(post, session=None) -> bool:
"""Return False for premium-tier posts when the session is not paid/premium."""
tier = getattr(post, "tier", None)
if tier == "premium":
if session is None or getattr(session, "tier", None) not in ("paid", "premium"):
return False
return True
@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,
):
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 if _visible(p)], "page": page, "page_size": page_size}
@router.get("/posts/{slug}")
async def get_post(slug: str, request: Request):
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 or "application/ld+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):
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():
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]
_VALID_POST_TYPES = {"plan", "recipe_success", "recipe_blooper"}
_MAX_TITLE_LEN = 200
_MAX_TEXT_LEN = 2000
def _validate_publish_body(body: dict) -> None:
"""Raise HTTPException(422) for any invalid fields in a publish request."""
post_type = body.get("post_type", "plan")
if post_type not in _VALID_POST_TYPES:
raise HTTPException(
status_code=422,
detail=f"post_type must be one of: {', '.join(sorted(_VALID_POST_TYPES))}",
)
title = body.get("title") or ""
if len(title) > _MAX_TITLE_LEN:
raise HTTPException(status_code=422, detail=f"title exceeds {_MAX_TITLE_LEN} character limit.")
for field in ("description", "outcome_notes", "recipe_name"):
value = body.get(field)
if value and len(str(value)) > _MAX_TEXT_LEN:
raise HTTPException(status_code=422, detail=f"{field} exceeds {_MAX_TEXT_LEN} character limit.")
photo_url = body.get("photo_url")
if photo_url and not str(photo_url).startswith("https://"):
raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.")
@router.post("/posts", status_code=201)
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
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.")
_validate_publish_body(body)
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.",
)
from app.services.community.community_store import get_or_create_pseudonym
def _get_pseudonym():
s = Store(session.db)
try:
return get_or_create_pseudonym(
store=s,
directus_user_id=session.user_id,
requested_name=body.get("pseudonym_name"),
)
finally:
s.close()
try:
pseudonym = await asyncio.to_thread(_get_pseudonym)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
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(session.db)
try:
return compute_snapshot(recipe_ids=recipe_ids, store=s)
finally:
s.close()
snapshot = await asyncio.to_thread(_snapshot)
post_type = body.get("post_type", "plan")
slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-")
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
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") or "Untitled")[:_MAX_TITLE_LEN],
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,
)
try:
inserted = await asyncio.to_thread(store.insert_post, post)
except sqlite3.IntegrityError as exc:
raise HTTPException(
status_code=409,
detail="A post with this title already exists today. Try a different title.",
) from exc
return _post_to_dict(inserted)
@router.delete("/posts/{slug}", status_code=204)
async def delete_post(slug: str, session: CloudUser = Depends(get_session)):
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)):
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.")
required_slot_keys = {"day", "meal_type", "recipe_id"}
if any(not required_slot_keys.issubset(slot) for slot in post.slots):
raise HTTPException(status_code=400, detail="Post contains malformed slots and cannot be forked.")
from datetime import date
week_start = date.today().strftime("%Y-%m-%d")
def _create_plan():
s = Store(session.db)
try:
meal_types = list({slot["meal_type"] for slot in post.slots})
plan = s.create_meal_plan(week_start=week_start, meal_types=meal_types or ["dinner"])
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)):
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.")
# Stub: full LLM adaptation deferred
raise HTTPException(status_code=501, detail="Fork-adapt not yet implemented.")
def _post_to_dict(post) -> 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,6 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate
from app.api.endpoints.community import router as community_router
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes
api_router = APIRouter()
@ -15,5 +14,3 @@ api_router.include_router(staples.router, prefix="/staples", tags=
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(imitate.router, prefix="/imitate", tags=["imitate"])
api_router.include_router(community_router)

View file

@ -35,16 +35,6 @@ class Settings:
# Database
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
# Community feature settings
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",
)
# Processing
MAX_CONCURRENT_JOBS: int = int(os.environ.get("MAX_CONCURRENT_JOBS", "4"))
USE_GPU: bool = os.environ.get("USE_GPU", "true").lower() in ("1", "true", "yes")

View file

@ -1,21 +0,0 @@
-- 028_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

@ -1011,29 +1011,3 @@ class Store:
(domain, category, page, result_count),
)
self.conn.commit()
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

@ -23,10 +23,6 @@ async def lifespan(app: FastAPI):
get_scheduler(settings.DB_PATH)
logger.info("Task scheduler started.")
# Initialize community store (no-op if COMMUNITY_DB_URL is not set)
from app.api.endpoints.community import init_community_store
init_community_store(settings.COMMUNITY_DB_URL)
yield
# Graceful scheduler shutdown

View file

@ -1,44 +0,0 @@
# app/services/community/ap_compat.py
# MIT License — AP 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.
The slug URI is stable so a future full AP implementation can reuse posts
without a DB 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

@ -1,90 +0,0 @@
# app/services/community/community_store.py
# MIT License
from __future__ import annotations
import logging
from circuitforge_core.community import CommunityPost, SharedStore
logger = logging.getLogger(__name__)
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",
)
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]
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 the user's 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

View file

@ -1,138 +0,0 @@
# 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()
lowered = [n.lower() for n in ingredient_names]
for ingredient in lowered:
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]:
lowered = [n.lower() for n in ingredient_names]
all_text = " ".join(lowered)
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.
"""
if not recipe_ids:
return 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,
)
rows = store.get_recipes_by_ids(recipe_ids)
if not rows:
return 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,
)
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 []
all_ingredients.extend(names if isinstance(names, list) else [])
allergens = _detect_allergens(all_ingredients)
dietary = _detect_dietary_tags(all_ingredients)
texture = rows[0].get("texture_profile") or ""
fat_vals = [r.get("fat") for r in rows if r.get("fat") is not None]
prot_vals = [r.get("protein") for r in rows if r.get("protein") is not None]
moist_vals = [r.get("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=texture,
dietary_tags=tuple(dietary),
allergen_flags=tuple(allergens),
flavor_molecules=(),
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

@ -1,43 +0,0 @@
# 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

@ -1,72 +0,0 @@
# app/services/community/mdns.py
# MIT License
from __future__ import annotations
import logging
import socket
logger = logging.getLogger(__name__)
# Import deferred to avoid hard failure when zeroconf is not installed
try:
from zeroconf import ServiceInfo, Zeroconf
_ZEROCONF_AVAILABLE = True
except ImportError:
_ZEROCONF_AVAILABLE = False
class KiwiMDNS:
"""Advertise this Kiwi instance on the LAN via mDNS (_kiwi._tcp.local).
Defaults to disabled (enabled=False). User must explicitly opt in via the
Settings page. This matches the CF a11y requirement: no surprise broadcasting.
Usage:
mdns = KiwiMDNS(enabled=settings.MDNS_ENABLED, port=settings.PORT,
feed_url=f"http://{hostname}:{settings.PORT}/api/v1/community/local-feed")
mdns.start() # in lifespan startup
mdns.stop() # in lifespan shutdown
"""
SERVICE_TYPE = "_kiwi._tcp.local."
def __init__(self, enabled: bool, port: int, feed_url: str) -> None:
self._enabled = enabled
self._port = port
self._feed_url = feed_url
self._zc: "Zeroconf | None" = None
self._info: "ServiceInfo | None" = None
def start(self) -> None:
if not self._enabled:
logger.debug("mDNS advertisement disabled (user has not opted in)")
return
if not _ZEROCONF_AVAILABLE:
logger.warning("zeroconf package not installed — mDNS advertisement unavailable")
return
hostname = socket.gethostname()
service_name = f"kiwi-{hostname}.{self.SERVICE_TYPE}"
self._info = ServiceInfo(
type_=self.SERVICE_TYPE,
name=service_name,
port=self._port,
properties={
b"feed_url": self._feed_url.encode(),
b"version": b"1",
},
addresses=[socket.inet_aton("127.0.0.1")],
)
self._zc = Zeroconf()
self._zc.register_service(self._info)
logger.info("mDNS: advertising %s on port %d", service_name, self._port)
def stop(self) -> None:
if self._zc is None or self._info is None:
return
self._zc.unregister_service(self._info)
self._zc.close()
self._zc = None
self._info = None
logger.info("mDNS: advertisement stopped")

View file

@ -16,7 +16,6 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"expiry_llm_matching",
"receipt_ocr",
"style_classifier",
"community_fork_adapt",
})
# Feature → minimum tier required
@ -39,8 +38,6 @@ KIWI_FEATURES: dict[str, str] = {
"style_picker": "paid",
"recipe_collections": "paid",
"style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
"community_publish": "paid", # Publish plans/outcomes to community feed
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
# Premium tier
"multi_household": "premium",

View file

@ -20,9 +20,6 @@ services:
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
# cf-orch: route LLM calls through the coordinator for managed GPU inference
CF_ORCH_URL: http://host.docker.internal:7700
# Community PostgreSQL — shared across CF products; unset = community features unavailable (fail soft)
COMMUNITY_DB_URL: ${COMMUNITY_DB_URL:-}
COMMUNITY_PSEUDONYM_SALT: ${COMMUNITY_PSEUDONYM_SALT:-}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:

View file

@ -1,337 +0,0 @@
<template>
<div class="community-feed-panel">
<!-- Filter tabs: All / Plans / Successes / Bloopers -->
<div role="tablist" aria-label="Community post filters" class="filter-bar flex gap-xs mb-md">
<button
v-for="f in filters"
:key="f.id"
role="tab"
:aria-selected="activeFilter === f.id"
:tabindex="activeFilter === f.id ? 0 : -1"
:class="['btn', 'tab-btn', activeFilter === f.id ? 'btn-primary' : 'btn-secondary']"
@click="setFilter(f.id)"
@keydown="onFilterKeydown"
@pointerdown="f.id === 'recipe_blooper' ? onBlooperPointerDown($event) : undefined"
@pointerup="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
@pointerleave="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
>{{ f.label }}</button>
</div>
<!-- Share a plan action row -->
<div class="action-row flex-between mb-sm">
<button
class="btn btn-secondary btn-sm share-plan-btn"
aria-haspopup="dialog"
@click="showPublishPlan = true"
>
Share a plan
</button>
</div>
<!-- Loading skeletons -->
<div
v-if="store.loading"
class="skeleton-list flex-col gap-sm"
aria-busy="true"
aria-label="Loading posts"
>
<div v-for="n in 3" :key="n" class="skeleton-card">
<div class="skeleton-line skeleton-line-short"></div>
<div class="skeleton-line skeleton-line-long mt-xs"></div>
<div class="skeleton-line skeleton-line-med mt-xs"></div>
</div>
</div>
<!-- Error state -->
<div
v-else-if="store.error"
class="error-state card"
role="alert"
>
<p class="text-sm text-secondary mb-sm">{{ store.error }}</p>
<button class="btn btn-secondary btn-sm" @click="retry">
Try again
</button>
</div>
<!-- Empty state -->
<div
v-else-if="store.posts.length === 0"
class="empty-state card text-center"
>
<p class="text-secondary mb-xs">No posts yet</p>
<p class="text-sm text-muted">Be the first to share a meal plan or recipe story.</p>
</div>
<!-- Post list -->
<div v-else class="post-list flex-col gap-sm">
<CommunityPostCard
v-for="post in store.posts"
:key="post.slug"
:post="post"
@fork="handleFork"
/>
</div>
<!-- Fork success toast -->
<Transition name="toast-fade">
<div
v-if="forkFeedback"
class="fork-toast status-badge status-success"
role="status"
aria-live="polite"
>
{{ forkFeedback }}
</div>
</Transition>
<!-- Fork error toast -->
<Transition name="toast-fade">
<div
v-if="forkError"
class="fork-toast status-badge status-error"
role="alert"
aria-live="assertive"
>
{{ forkError }}
</div>
</Transition>
<!-- Publish plan modal -->
<PublishPlanModal
v-if="showPublishPlan"
:plan="null"
@close="showPublishPlan = false"
@published="onPlanPublished"
/>
<!-- Hall of Chaos easter egg: hold Bloopers tab for 800ms -->
<HallOfChaosView
v-if="showHallOfChaos"
@close="showHallOfChaos = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { ForkResult } from '../stores/community'
import CommunityPostCard from './CommunityPostCard.vue'
import PublishPlanModal from './PublishPlanModal.vue'
import HallOfChaosView from './HallOfChaosView.vue'
const emit = defineEmits<{
'plan-forked': [payload: ForkResult]
}>()
const store = useCommunityStore()
const activeFilter = ref('all')
const showPublishPlan = ref(false)
const showHallOfChaos = ref(false)
let blooperHoldTimer: ReturnType<typeof setTimeout> | null = null
function onBlooperPointerDown(_e: PointerEvent) {
blooperHoldTimer = setTimeout(() => {
showHallOfChaos.value = true
blooperHoldTimer = null
}, 800)
}
function onBlooperPointerCancel() {
if (blooperHoldTimer !== null) {
clearTimeout(blooperHoldTimer)
blooperHoldTimer = null
}
}
const filters = [
{ id: 'all', label: 'All' },
{ id: 'plan', label: 'Plans' },
{ id: 'recipe_success', label: 'Successes' },
{ id: 'recipe_blooper', label: 'Bloopers' },
]
const filterIds = filters.map((f) => f.id)
function onFilterKeydown(e: KeyboardEvent) {
const current = filterIds.indexOf(activeFilter.value)
let next = current
if (e.key === 'ArrowRight') {
e.preventDefault()
next = (current + 1) % filterIds.length
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
next = (current - 1 + filterIds.length) % filterIds.length
} else {
return
}
setFilter(filterIds[next]!)
// Move DOM focus to the newly active tab per ARIA tablist pattern
const bar = (e.currentTarget as HTMLElement).closest('[role="tablist"]')
const buttons = bar?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons?.[next]?.focus()
}
async function setFilter(filterId: string) {
activeFilter.value = filterId
await store.fetchPosts(filterId === 'all' ? undefined : filterId)
}
async function retry() {
await store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
}
const forkFeedback = ref<string | null>(null)
const forkError = ref<string | null>(null)
function showToast(msg: string, type: 'success' | 'error') {
if (type === 'success') {
forkFeedback.value = msg
setTimeout(() => { forkFeedback.value = null }, 3000)
} else {
forkError.value = msg
setTimeout(() => { forkError.value = null }, 4000)
}
}
async function handleFork(slug: string) {
try {
const result = await store.forkPost(slug)
showToast('Plan added to your week.', 'success')
emit('plan-forked', result)
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : 'Could not fork this plan.', 'error')
}
}
function onPlanPublished(_payload: { slug: string }) {
showPublishPlan.value = false
store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
}
onMounted(async () => {
if (store.posts.length === 0) {
await store.fetchPosts()
}
})
onUnmounted(() => {
if (blooperHoldTimer !== null) {
clearTimeout(blooperHoldTimer)
blooperHoldTimer = null
}
})
</script>
<style scoped>
.community-feed-panel {
position: relative;
}
.filter-bar {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-sm);
}
.tab-btn {
border-radius: var(--radius-md) var(--radius-md) 0 0;
border-bottom: none;
}
.action-row {
padding: var(--spacing-xs) 0;
}
.share-plan-btn {
font-size: var(--font-size-xs);
}
/* Loading skeletons */
.skeleton-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
overflow: hidden;
}
.skeleton-line {
height: 12px;
border-radius: var(--radius-sm);
background: var(--color-bg-elevated);
animation: shimmer 1.4s ease-in-out infinite;
}
.skeleton-line-short { width: 35%; }
.skeleton-line-med { width: 60%; }
.skeleton-line-long { width: 90%; }
@keyframes shimmer {
0% { opacity: 0.6; }
50% { opacity: 1.0; }
100% { opacity: 0.6; }
}
/* Empty / error states */
.empty-state {
padding: var(--spacing-xl) var(--spacing-lg);
}
.error-state {
padding: var(--spacing-md);
}
/* Post list */
.post-list {
padding-top: var(--spacing-sm);
}
/* Toast */
.fork-toast {
position: fixed;
bottom: calc(72px + var(--spacing-md));
left: 50%;
transform: translateX(-50%);
z-index: 300;
white-space: nowrap;
box-shadow: var(--shadow-lg);
}
@media (min-width: 769px) {
.fork-toast {
bottom: var(--spacing-lg);
}
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
@media (prefers-reduced-motion: reduce) {
.skeleton-line {
animation: none;
opacity: 0.7;
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: none;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
transform: translateX(-50%);
}
}
</style>

View file

@ -1,178 +0,0 @@
<template>
<article class="community-post-card" :class="`post-type-${post.post_type}`">
<!-- Header row: type badge + date -->
<div class="card-header flex-between gap-sm mb-xs">
<span
class="post-type-badge status-badge"
:class="typeBadgeClass"
:aria-label="`Post type: ${typeLabel}`"
>{{ typeLabel }}</span>
<time
class="post-date text-xs text-muted"
:datetime="post.published"
:title="fullDate"
>{{ shortDate }}</time>
</div>
<!-- Title -->
<h3 class="post-title text-base font-semibold mb-xs">{{ post.title }}</h3>
<!-- Author -->
<p class="post-author text-xs text-muted mb-xs">
by {{ post.pseudonym }}
</p>
<!-- Description (if present) -->
<p v-if="post.description" class="post-description text-sm text-secondary mb-sm">
{{ post.description }}
</p>
<!-- Dietary tag pills -->
<div
v-if="post.dietary_tags.length > 0"
class="tag-row flex flex-wrap gap-xs mb-sm"
role="list"
aria-label="Dietary tags"
>
<span
v-for="tag in post.dietary_tags"
:key="tag"
class="status-badge status-success tag-pill"
role="listitem"
>{{ tag }}</span>
</div>
<!-- Fork button (plan posts only) -->
<div v-if="post.post_type === 'plan'" class="card-actions mt-sm">
<button
class="btn btn-primary btn-sm btn-fork"
:aria-label="`Fork ${post.title} to my meal plan`"
@click="$emit('fork', post.slug)"
>
Fork to my plan
</button>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { CommunityPost } from '../stores/community'
const props = defineProps<{
post: CommunityPost
}>()
defineEmits<{
fork: [slug: string]
}>()
const typeLabel = computed(() => {
switch (props.post.post_type) {
case 'plan': return 'Meal Plan'
case 'recipe_success': return 'Success'
case 'recipe_blooper': return 'Blooper'
default: return props.post.post_type
}
})
const typeBadgeClass = computed(() => {
switch (props.post.post_type) {
case 'plan': return 'status-info'
case 'recipe_success': return 'status-success'
case 'recipe_blooper': return 'status-warning'
default: return 'status-info'
}
})
const shortDate = computed(() => {
try {
return new Date(props.post.published).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
} catch {
return ''
}
})
const fullDate = computed(() => {
try {
return new Date(props.post.published).toLocaleString()
} catch {
return props.post.published
}
})
</script>
<style scoped>
.community-post-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
transition: box-shadow 0.18s ease;
}
.community-post-card:hover {
box-shadow: var(--shadow-md);
}
.post-type-plan { border-left: 3px solid var(--color-info); }
.post-type-recipe_success { border-left: 3px solid var(--color-success); }
.post-type-recipe_blooper { border-left: 3px solid var(--color-warning); }
.card-header {
align-items: center;
}
.post-type-badge,
.post-date {
flex-shrink: 0;
}
.post-title {
margin: 0;
color: var(--color-text-primary);
line-height: 1.3;
}
.post-author,
.post-description {
margin: 0;
}
.post-description {
line-height: 1.5;
}
.tag-pill {
text-transform: lowercase;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.btn-fork {
min-width: 120px;
}
@media (max-width: 480px) {
.community-post-card {
padding: var(--spacing-sm);
border-radius: var(--radius-md);
}
.btn-fork {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.community-post-card {
transition: none;
}
}
</style>

View file

@ -1,182 +0,0 @@
<template>
<div class="hall-of-chaos-overlay" role="dialog" aria-modal="true" aria-label="Hall of Chaos">
<!-- Header -->
<div class="chaos-header">
<h2 class="chaos-title">HALL OF CHAOS</h2>
<p class="chaos-subtitle text-sm">
Chaos Level: <span class="chaos-level">{{ chaosLevel }}</span>
</p>
<button
class="btn btn-secondary chaos-exit-btn"
aria-label="Exit Hall of Chaos"
@click="$emit('close')"
>
Escape the chaos
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="chaos-loading text-center text-secondary" aria-live="polite" aria-busy="true">
Assembling the chaos...
</div>
<!-- Error -->
<div v-else-if="error" class="chaos-empty text-center text-secondary" role="alert">
The chaos is temporarily indisposed.
</div>
<!-- Empty -->
<div v-else-if="posts.length === 0" class="chaos-empty text-center text-secondary">
<p>No bloopers yet. Be the first to make a glorious mistake.</p>
</div>
<!-- Blooper cards -->
<div v-else class="chaos-grid" aria-label="Blooper posts">
<article
v-for="(post, index) in posts"
:key="post.slug"
class="chaos-card"
:class="`chaos-card--tilt-${(index % 5) + 1}`"
:style="{ '--chaos-border-color': borderColors[index % borderColors.length] }"
>
<p class="chaos-card-author text-xs text-muted">{{ post.pseudonym }}</p>
<h3 class="chaos-card-title text-base font-semibold">{{ post.title }}</h3>
<p v-if="post.outcome_notes" class="chaos-card-notes text-sm text-secondary">
{{ post.outcome_notes }}
</p>
<p v-if="post.recipe_name" class="chaos-card-recipe text-xs text-muted mt-xs">
Recipe: {{ post.recipe_name }}
</p>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import api from '../services/api'
import type { CommunityPost } from '../stores/community'
defineEmits<{ close: [] }>()
const posts = ref<CommunityPost[]>([])
const chaosLevel = ref(0)
const loading = ref(true)
const error = ref(false)
// CSS custom property strings -- no hardcoded hex
const borderColors = [
'var(--color-warning)',
'var(--color-info)',
'var(--color-success)',
'var(--color-error)',
'var(--color-warning)',
]
onMounted(async () => {
try {
const response = await api.get<{ posts: CommunityPost[]; chaos_level: number }>(
'/community/hall-of-chaos'
)
posts.value = response.data.posts
chaosLevel.value = response.data.chaos_level
} catch {
error.value = true
} finally {
loading.value = false
}
})
</script>
<style scoped>
.hall-of-chaos-overlay {
position: absolute;
inset: 0;
z-index: 200;
background: var(--color-bg-primary);
overflow-y: auto;
padding: var(--spacing-md);
border-radius: var(--radius-lg);
}
.chaos-header {
text-align: center;
margin-bottom: var(--spacing-lg);
}
.chaos-title {
font-size: 2rem;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--color-warning);
margin: 0 0 var(--spacing-xs);
text-transform: uppercase;
}
.chaos-subtitle {
color: var(--color-text-secondary);
margin: 0 0 var(--spacing-sm);
}
.chaos-level {
font-weight: 700;
color: var(--color-warning);
}
.chaos-exit-btn {
font-size: var(--font-size-xs);
}
.chaos-loading,
.chaos-empty {
padding: var(--spacing-xl);
}
.chaos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--spacing-md);
padding-bottom: var(--spacing-lg);
}
/* Static tilts applied once at render -- not animations, no reduced-motion concern */
.chaos-card {
background: var(--color-bg-card);
border: 2px solid var(--chaos-border-color, var(--color-border));
border-radius: var(--radius-lg);
padding: var(--spacing-md);
}
.chaos-card--tilt-1 { transform: rotate(-3deg); }
.chaos-card--tilt-2 { transform: rotate(2deg); }
.chaos-card--tilt-3 { transform: rotate(-1.5deg); }
.chaos-card--tilt-4 { transform: rotate(4deg); }
.chaos-card--tilt-5 { transform: rotate(-4.5deg); }
.chaos-card-title {
margin: var(--spacing-xs) 0;
color: var(--color-text-primary);
}
.chaos-card-author,
.chaos-card-notes,
.chaos-card-recipe {
margin: 0;
}
@media (max-width: 480px) {
.chaos-grid {
grid-template-columns: 1fr;
}
.chaos-card--tilt-1,
.chaos-card--tilt-2,
.chaos-card--tilt-3,
.chaos-card--tilt-4,
.chaos-card--tilt-5 {
transform: none;
}
}
</style>

View file

@ -1,365 +0,0 @@
<template>
<Teleport to="body">
<div
class="modal-overlay"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
class="modal-panel card"
role="dialog"
aria-modal="true"
aria-labelledby="publish-outcome-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex-between mb-md">
<h2 id="publish-outcome-title" class="section-title">
Share a recipe story
<span v-if="recipeName" class="recipe-name-hint text-sm text-muted">
-- {{ recipeName }}
</span>
</h2>
<button
class="btn-close"
aria-label="Close"
@click="$emit('close')"
>&#x2715;</button>
</div>
<!-- Post type selector -->
<div class="form-group">
<fieldset class="type-fieldset">
<legend class="form-label">What kind of story is this?</legend>
<div class="type-toggle flex gap-sm">
<button
ref="firstFocusRef"
:class="['btn', 'type-btn', postType === 'recipe_success' ? 'type-btn-active' : 'btn-secondary']"
:aria-pressed="postType === 'recipe_success'"
@click="postType = 'recipe_success'"
>
Success
</button>
<button
:class="['btn', 'type-btn', postType === 'recipe_blooper' ? 'type-btn-active type-btn-blooper' : 'btn-secondary']"
:aria-pressed="postType === 'recipe_blooper'"
@click="postType = 'recipe_blooper'"
>
Blooper
</button>
</div>
</fieldset>
</div>
<!-- Title field -->
<div class="form-group">
<label class="form-label" for="outcome-title">
Title <span class="required-mark" aria-hidden="true">*</span>
</label>
<input
id="outcome-title"
v-model="title"
class="form-input"
type="text"
maxlength="200"
placeholder="e.g. Perfect crust on the first try"
autocomplete="off"
required
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ title.length }}/200
</span>
</div>
<!-- Outcome notes field -->
<div class="form-group">
<label class="form-label" for="outcome-notes">
What happened? <span class="optional-mark">(optional)</span>
</label>
<textarea
id="outcome-notes"
v-model="outcomeNotes"
class="form-input form-textarea"
maxlength="2000"
rows="4"
placeholder="Describe what you tried, what worked, or what went sideways."
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ outcomeNotes.length }}/2000
</span>
</div>
<!-- Pseudonym field -->
<div class="form-group">
<label class="form-label" for="outcome-pseudonym">
Community name <span class="optional-mark">(optional)</span>
</label>
<input
id="outcome-pseudonym"
v-model="pseudonymName"
class="form-input"
type="text"
maxlength="40"
placeholder="Leave blank to use your existing handle"
autocomplete="nickname"
/>
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
</div>
<!-- Submission feedback (aria-live region, always rendered) -->
<div
class="feedback-region"
aria-live="polite"
aria-atomic="true"
>
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
</div>
<!-- Footer actions -->
<div class="modal-footer flex gap-sm">
<button
class="btn btn-primary"
:disabled="submitting || !title.trim()"
:aria-busy="submitting"
@click="onSubmit"
>
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
{{ submitting ? 'Publishing...' : 'Publish' }}
</button>
<button class="btn btn-secondary" @click="$emit('close')">
Cancel
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { PublishPayload } from '../stores/community'
const props = defineProps<{
recipeId: number | null
recipeName: string | null
visible?: boolean
}>()
const emit = defineEmits<{
close: []
published: [payload: { slug: string }]
}>()
const store = useCommunityStore()
const postType = ref<'recipe_success' | 'recipe_blooper'>('recipe_success')
const title = ref('')
const outcomeNotes = ref('')
const pseudonymName = ref('')
const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref<string | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const firstFocusRef = ref<HTMLButtonElement | null>(null)
let previousFocus: HTMLElement | null = null
function getFocusables(): HTMLElement[] {
if (!dialogRef.value) return []
return Array.from(
dialogRef.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (e.key !== 'Tab') return
// Only intercept Tab when focus is inside this dialog
if (!dialogRef.value?.contains(document.activeElement)) return
const focusables = getFocusables()
if (focusables.length === 0) return
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()
}
}
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
(firstFocusRef.value ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
async function onSubmit() {
submitError.value = null
submitSuccess.value = null
if (!title.value.trim()) return
const payload: PublishPayload = {
post_type: postType.value,
title: title.value.trim(),
}
if (outcomeNotes.value.trim()) payload.outcome_notes = outcomeNotes.value.trim()
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
if (props.recipeId != null) payload.recipe_id = props.recipeId
submitting.value = true
try {
const result = await store.publishPost(payload)
submitSuccess.value = 'Your story has been posted.'
nextTick(() => {
emit('published', { slug: result.slug })
})
} catch (err: unknown) {
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 400;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.btn-close {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-secondary);
padding: var(--spacing-xs);
line-height: 1;
border-radius: var(--radius-sm);
}
.btn-close:hover {
color: var(--color-text-primary);
}
.recipe-name-hint {
font-style: italic;
}
.required-mark {
color: var(--color-error);
margin-left: 2px;
}
.optional-mark {
font-weight: 400;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.type-fieldset {
border: none;
padding: 0;
margin: 0;
}
.type-toggle {
flex-wrap: wrap;
}
.type-btn {
min-width: 100px;
}
.type-btn-active {
background: var(--color-success);
color: white;
border-color: var(--color-success);
font-weight: 700;
}
.type-btn-active.type-btn-blooper {
background: var(--color-warning);
border-color: var(--color-warning);
color: var(--color-text-primary);
}
.char-counter {
text-align: right;
display: block;
margin-top: var(--spacing-xs);
}
.feedback-region {
min-height: 1.4rem;
margin-bottom: var(--spacing-xs);
}
.feedback-error {
color: var(--color-error);
margin: 0;
}
.feedback-success {
color: var(--color-success);
margin: 0;
}
.modal-footer {
justify-content: flex-start;
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-md);
flex-wrap: wrap;
}
@media (max-width: 480px) {
.modal-panel {
max-height: 95vh;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}
</style>

View file

@ -1,311 +0,0 @@
<template>
<Teleport to="body">
<div
class="modal-overlay"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
class="modal-panel card"
role="dialog"
aria-modal="true"
aria-labelledby="publish-plan-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex-between mb-md">
<h2 id="publish-plan-title" class="section-title">Share this week's plan</h2>
<button
class="btn-close"
aria-label="Close"
@click="$emit('close')"
>&#x2715;</button>
</div>
<!-- Title field -->
<div class="form-group">
<label class="form-label" for="plan-pub-title">
Title <span class="required-mark" aria-hidden="true">*</span>
</label>
<input
id="plan-pub-title"
ref="firstFocusRef"
v-model="title"
class="form-input"
type="text"
maxlength="200"
placeholder="e.g. Mediterranean Week"
autocomplete="off"
required
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ title.length }}/200
</span>
</div>
<!-- Description field -->
<div class="form-group">
<label class="form-label" for="plan-pub-desc">
Description <span class="optional-mark">(optional)</span>
</label>
<textarea
id="plan-pub-desc"
v-model="description"
class="form-input form-textarea"
maxlength="2000"
rows="3"
placeholder="What makes this week worth sharing?"
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ description.length }}/2000
</span>
</div>
<!-- Pseudonym field -->
<div class="form-group">
<label class="form-label" for="plan-pub-pseudonym">
Community name <span class="optional-mark">(optional)</span>
</label>
<input
id="plan-pub-pseudonym"
v-model="pseudonymName"
class="form-input"
type="text"
maxlength="40"
placeholder="Leave blank to use your existing handle"
autocomplete="nickname"
/>
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
</div>
<!-- Submission feedback (aria-live region, always rendered) -->
<div
class="feedback-region"
aria-live="polite"
aria-atomic="true"
>
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
</div>
<!-- Footer actions -->
<div class="modal-footer flex gap-sm">
<button
class="btn btn-primary"
:disabled="submitting || !title.trim()"
:aria-busy="submitting"
@click="onSubmit"
>
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
{{ submitting ? 'Publishing...' : 'Publish' }}
</button>
<button class="btn btn-secondary" @click="$emit('close')">
Cancel
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { PublishPayload } from '../stores/community'
const props = defineProps<{
plan?: {
id: number
week_start: string
slots: Array<{ day: string; meal_type: string; recipe_id: number; recipe_name: string }>
} | null
visible?: boolean
}>()
const emit = defineEmits<{
close: []
published: [payload: { slug: string }]
}>()
const store = useCommunityStore()
const title = ref('')
const description = ref('')
const pseudonymName = ref('')
const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref<string | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const firstFocusRef = ref<HTMLInputElement | null>(null)
let previousFocus: HTMLElement | null = null
function getFocusables(): HTMLElement[] {
if (!dialogRef.value) return []
return Array.from(
dialogRef.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (e.key !== 'Tab') return
// Only intercept Tab when focus is inside this dialog
if (!dialogRef.value?.contains(document.activeElement)) return
const focusables = getFocusables()
if (focusables.length === 0) return
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()
}
}
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
(firstFocusRef.value ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
async function onSubmit() {
submitError.value = null
submitSuccess.value = null
if (!title.value.trim()) return
const payload: PublishPayload = {
post_type: 'plan',
title: title.value.trim(),
}
if (description.value.trim()) payload.description = description.value.trim()
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
if (props.plan?.id != null) payload.plan_id = props.plan.id
if (props.plan?.slots?.length) {
payload.slots = props.plan.slots.map(({ day, meal_type, recipe_id }) => ({ day, meal_type, recipe_id }))
}
submitting.value = true
try {
const result = await store.publishPost(payload)
submitSuccess.value = 'Plan published to the community feed.'
nextTick(() => {
emit('published', { slug: result.slug })
})
} catch (err: unknown) {
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 400;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.btn-close {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-secondary);
padding: var(--spacing-xs);
line-height: 1;
border-radius: var(--radius-sm);
}
.btn-close:hover {
color: var(--color-text-primary);
}
.required-mark {
color: var(--color-error);
margin-left: 2px;
}
.optional-mark {
font-weight: 400;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.char-counter {
text-align: right;
display: block;
margin-top: var(--spacing-xs);
}
.feedback-region {
min-height: 1.4rem;
margin-bottom: var(--spacing-xs);
}
.feedback-error {
color: var(--color-error);
margin: 0;
}
.feedback-success {
color: var(--color-success);
margin: 0;
}
.modal-footer {
justify-content: flex-start;
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-md);
flex-wrap: wrap;
}
@media (max-width: 480px) {
.modal-panel {
max-height: 95vh;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}
</style>

View file

@ -32,14 +32,6 @@
@open-recipe="openRecipeById"
/>
<!-- Community tab -->
<CommunityFeedPanel
v-else-if="activeTab === 'community'"
role="tabpanel"
aria-labelledby="tab-community"
@plan-forked="onPlanForked"
/>
<!-- Find tab (existing search UI) -->
<div v-else role="tabpanel" aria-labelledby="tab-find">
<!-- Controls Panel -->
@ -562,8 +554,6 @@ import { useInventoryStore } from '../stores/inventory'
import RecipeDetailPanel from './RecipeDetailPanel.vue'
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
import SavedRecipesPanel from './SavedRecipesPanel.vue'
import CommunityFeedPanel from './CommunityFeedPanel.vue'
import type { ForkResult } from '../stores/community'
import type { RecipeSuggestion, GroceryLink } from '../services/api'
import { recipesAPI } from '../services/api'
@ -571,17 +561,16 @@ const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore()
// Tab state
type TabId = 'find' | 'browse' | 'saved' | 'community'
type TabId = 'find' | 'browse' | 'saved'
const tabs: Array<{ id: TabId; label: string }> = [
{ id: 'find', label: 'Find' },
{ id: 'browse', label: 'Browse' },
{ id: 'saved', label: 'Saved' },
{ id: 'community', label: 'Community' },
]
const activeTab = ref<TabId>('find')
function onTabKeydown(e: KeyboardEvent) {
const tabIds: TabId[] = ['find', 'browse', 'saved', 'community']
const tabIds: TabId[] = ['find', 'browse', 'saved']
const current = tabIds.indexOf(activeTab.value)
if (e.key === 'ArrowRight') {
e.preventDefault()
@ -592,11 +581,6 @@ function onTabKeydown(e: KeyboardEvent) {
}
}
// Community tab: navigate to Find tab after a plan fork (full plan view deferred to Task 9)
function onPlanForked(_payload: ForkResult) {
activeTab.value = 'find'
}
// Browser/saved tab recipe detail panel (fetches full recipe from API)
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)

View file

@ -1,119 +0,0 @@
/**
* Community Store
*
* Manages community post feed state and fork actions using Pinia.
* Follows the composition store pattern established in recipes.ts.
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../services/api'
// ========== Types ==========
export interface CommunityPostSlot {
day: string
meal_type: string
recipe_id: number
}
export interface ElementProfiles {
seasoning_score: number | null
richness_score: number | null
brightness_score: number | null
depth_score: number | null
aroma_score: number | null
structure_score: number | null
texture_profile: string | null
}
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: CommunityPostSlot[]
recipe_id: number | null
recipe_name: string | null
level: number | null
outcome_notes: string | null
element_profiles: ElementProfiles
dietary_tags: string[]
allergen_flags: string[]
flavor_molecules: string[]
fat_pct: number | null
protein_pct: number | null
moisture_pct: number | null
}
export interface ForkResult {
plan_id: number
week_start: string
forked_from: string
}
export interface PublishPayload {
post_type: 'plan' | 'recipe_success' | 'recipe_blooper'
title: string
description?: string
pseudonym_name?: string
plan_id?: number
recipe_id?: number
outcome_notes?: string
slots?: CommunityPostSlot[]
}
export interface PublishResult {
slug: string
}
// ========== Store ==========
export const useCommunityStore = defineStore('community', () => {
const posts = ref<CommunityPost[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const currentFilter = ref<string | null>(null)
async function fetchPosts(postType?: string) {
loading.value = true
error.value = null
currentFilter.value = postType ?? null
try {
const params: Record<string, string | number> = { page: 1, page_size: 40 }
if (postType) {
params.post_type = postType
}
const response = await api.get<{ posts: CommunityPost[] }>('/community/posts', { params })
posts.value = response.data.posts
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Could not load community posts.'
} finally {
loading.value = false
}
}
async function forkPost(slug: string): Promise<ForkResult> {
const response = await api.post<ForkResult>(`/community/posts/${slug}/fork`)
return response.data
}
async function publishPost(payload: PublishPayload): Promise<PublishResult> {
const response = await api.post<PublishResult>('/community/posts', payload)
return response.data
}
return {
posts,
loading,
error,
currentFilter,
fetchPosts,
forkPost,
publishPost,
}
})

View file

@ -22,8 +22,6 @@ dependencies = [
# HTTP clients
"httpx>=0.27",
"requests>=2.31",
# mDNS advertisement (optional; user must opt in)
"zeroconf>=0.131",
# CircuitForge shared scaffold
"circuitforge-core>=0.8.0",
]

View file

@ -1,72 +0,0 @@
# 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 with content-type application/rss+xml."""
mock_store = MagicMock()
mock_store.list_posts.return_value = []
with patch("app.api.endpoints.community._community_store", mock_store):
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_requires_auth():
"""POST /community/posts requires authentication (401/403/422) or community store (503).
In local/dev mode get_session bypasses JWT auth and returns a privileged user,
so the next gate is the community store check (503 when COMMUNITY_DB_URL is not set).
In cloud mode the endpoint requires a valid session (401/403).
"""
response = client.post("/api/v1/community/posts", json={"title": "Test"})
assert response.status_code in (401, 403, 422, 503)
def test_delete_post_requires_auth():
"""DELETE /community/posts/{slug} requires authentication (401/403) or community store (503).
Same local-mode caveat as test_post_community_requires_auth.
"""
response = client.delete("/api/v1/community/posts/some-slug")
assert response.status_code in (401, 403, 422, 503)
def test_fork_post_route_exists():
"""POST /community/posts/{slug}/fork route exists (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."""
mock_store = MagicMock()
mock_store.list_posts.return_value = []
with patch("app.api.endpoints.community._community_store", mock_store):
response = client.get("/api/v1/community/local-feed")
assert response.status_code == 200
assert isinstance(response.json(), list)

View file

@ -1,18 +0,0 @@
import pytest
from pathlib import Path
from app.db.store import Store
@pytest.fixture
def tmp_db(tmp_path: Path) -> Path:
return tmp_path / "test.db"
def test_migration_028_adds_community_pseudonyms(tmp_db):
"""Migration 028 adds community_pseudonyms table to per-user kiwi.db."""
store = Store(tmp_db)
cur = store.conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='community_pseudonyms'"
)
assert cur.fetchone() is not None
store.close()

View file

@ -1,48 +0,0 @@
# tests/services/community/test_ap_compat.py
import pytest
import json
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

@ -1,43 +0,0 @@
# tests/services/community/test_community_store.py
# MIT License
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_kiwi_community_store_list_meal_plans():
"""KiwiCommunityStore.list_meal_plans filters by post_type='plan'."""
mock_db = MagicMock()
store = KiwiCommunityStore(mock_db)
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

@ -1,78 +0,0 @@
# 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:
"""Return a mock Store whose get_recipes_by_ids returns the given rows."""
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

@ -1,51 +0,0 @@
# 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

@ -1,39 +0,0 @@
# 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