feat(community): add CommunityPost frozen dataclass with element snapshot schema

This commit is contained in:
pyr0ball 2026-04-12 20:51:29 -07:00
parent 3082318e0d
commit 2e9e3fdc4b
4 changed files with 198 additions and 2 deletions

View file

@ -1,8 +1,16 @@
# circuitforge_core/community/__init__.py
# MIT License
from .db import CommunityDB
from .models import CommunityPost
from .store import SharedStore
try:
from .db import CommunityDB
except ImportError:
CommunityDB = None # type: ignore[assignment,misc]
try:
from .store import SharedStore
except ImportError:
SharedStore = None # type: ignore[assignment,misc]
__all__ = ["CommunityDB", "CommunityPost", "SharedStore"]

View file

@ -0,0 +1,94 @@
# circuitforge_core/community/models.py
# MIT License
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
PostType = Literal["plan", "recipe_success", "recipe_blooper"]
CreativityLevel = Literal[1, 2, 3, 4]
_VALID_POST_TYPES: frozenset[str] = frozenset(["plan", "recipe_success", "recipe_blooper"])
def _validate_score(name: str, value: float) -> float:
if not (0.0 <= value <= 1.0):
raise ValueError(f"{name} must be between 0.0 and 1.0, got {value!r}")
return value
@dataclass(frozen=True)
class CommunityPost:
"""Immutable snapshot of a published community post.
Lists (dietary_tags, allergen_flags, flavor_molecules, slots) are stored as
tuples to enforce immutability. Pass lists -- they are converted in __post_init__.
"""
# Identity
slug: str
pseudonym: str
post_type: PostType
published: datetime
title: str
# Optional content
description: str | None
photo_url: str | None
# Plan slots -- list[dict] for post_type="plan"
slots: tuple
# Recipe result fields -- for post_type="recipe_success" | "recipe_blooper"
recipe_id: int | None
recipe_name: str | None
level: CreativityLevel | None
outcome_notes: str | None
# Element snapshot
seasoning_score: float
richness_score: float
brightness_score: float
depth_score: float
aroma_score: float
structure_score: float
texture_profile: str
# Dietary/allergen/flavor
dietary_tags: tuple
allergen_flags: tuple
flavor_molecules: tuple
# USDA FDC macros (optional -- may not be available for all recipes)
fat_pct: float | None
protein_pct: float | None
moisture_pct: float | None
def __new__(cls, **kwargs):
# Convert lists to tuples before frozen dataclass assignment
for key in ("slots", "dietary_tags", "allergen_flags", "flavor_molecules"):
if key in kwargs and isinstance(kwargs[key], list):
kwargs[key] = tuple(kwargs[key])
return object.__new__(cls)
def __init__(self, **kwargs):
# Convert lists to tuples
for key in ("slots", "dietary_tags", "allergen_flags", "flavor_molecules"):
if key in kwargs and isinstance(kwargs[key], list):
kwargs[key] = tuple(kwargs[key])
for f in self.__dataclass_fields__:
object.__setattr__(self, f, kwargs[f])
self.__post_init__()
def __post_init__(self) -> None:
if self.post_type not in _VALID_POST_TYPES:
raise ValueError(
f"post_type must be one of {sorted(_VALID_POST_TYPES)}, got {self.post_type!r}"
)
for score_name in (
"seasoning_score", "richness_score", "brightness_score",
"depth_score", "aroma_score", "structure_score",
):
_validate_score(score_name, getattr(self, score_name))

View file

View file

@ -0,0 +1,94 @@
# tests/community/test_models.py
import pytest
from datetime import datetime, timezone
from circuitforge_core.community.models import CommunityPost
def make_post(**kwargs) -> CommunityPost:
defaults = dict(
slug="kiwi-plan-test-2026-04-12-pasta-week",
pseudonym="PastaWitch",
post_type="plan",
published=datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc),
title="Pasta Week",
description="Seven days of carbs",
photo_url=None,
slots=[{"day": 0, "meal_type": "dinner", "recipe_id": 1, "recipe_name": "Spaghetti"}],
recipe_id=None,
recipe_name=None,
level=None,
outcome_notes=None,
seasoning_score=0.7,
richness_score=0.6,
brightness_score=0.3,
depth_score=0.5,
aroma_score=0.4,
structure_score=0.8,
texture_profile="chewy",
dietary_tags=["vegetarian"],
allergen_flags=["gluten"],
flavor_molecules=[1234, 5678],
fat_pct=12.5,
protein_pct=10.0,
moisture_pct=55.0,
)
defaults.update(kwargs)
return CommunityPost(**defaults)
def test_community_post_immutable():
post = make_post()
with pytest.raises((AttributeError, TypeError)):
post.title = "changed" # type: ignore
def test_community_post_slug_uri_compatible():
post = make_post(slug="kiwi-plan-test-2026-04-12-pasta-week")
assert " " not in post.slug
assert post.slug == post.slug.lower()
def test_community_post_type_valid():
make_post(post_type="plan")
make_post(post_type="recipe_success")
make_post(post_type="recipe_blooper")
def test_community_post_type_invalid():
with pytest.raises(ValueError):
make_post(post_type="garbage")
def test_community_post_scores_range():
post = make_post(seasoning_score=1.0, richness_score=0.0)
assert 0.0 <= post.seasoning_score <= 1.0
assert 0.0 <= post.richness_score <= 1.0
def test_community_post_scores_out_of_range():
with pytest.raises(ValueError):
make_post(seasoning_score=1.5)
with pytest.raises(ValueError):
make_post(richness_score=-0.1)
def test_community_post_dietary_tags_immutable():
post = make_post(dietary_tags=["vegan"])
assert isinstance(post.dietary_tags, tuple)
def test_community_post_allergen_flags_immutable():
post = make_post(allergen_flags=["nuts", "dairy"])
assert isinstance(post.allergen_flags, tuple)
def test_community_post_flavor_molecules_immutable():
post = make_post(flavor_molecules=[1, 2, 3])
assert isinstance(post.flavor_molecules, tuple)
def test_community_post_optional_fields_none():
post = make_post(photo_url=None, recipe_id=None, fat_pct=None)
assert post.photo_url is None
assert post.recipe_id is None
assert post.fat_pct is None