feat(community): recipe_tags + tag vote tables and store methods
Adds community subcategory tagging for corpus recipes (kiwi#118). Any product with a recipe corpus can use this to let users tag recipes into browse taxonomy locations that FTS missed. - 005_recipe_tags.sql: recipe_tags (per-recipe taxonomy tag with upvote counter) + recipe_tag_votes (dedup table; submitter self-vote at insert) - store.py: submit_recipe_tag(), upvote_recipe_tag(), get_recipe_tag_by_id(), list_tags_for_recipe(), get_accepted_recipe_ids_for_subcategory() Acceptance threshold: upvotes >= 2 (submitter counts as 1, one more needed). Tags keyed as recipe_source='corpus' for future community-recipe extension.
This commit is contained in:
parent
82f0b4c3d0
commit
f2ae43696b
2 changed files with 209 additions and 0 deletions
42
circuitforge_core/community/migrations/005_recipe_tags.sql
Normal file
42
circuitforge_core/community/migrations/005_recipe_tags.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
-- 005_recipe_tags.sql
|
||||
-- Community-contributed recipe subcategory tags.
|
||||
--
|
||||
-- Users can tag corpus recipes (from a product's local recipe dataset) with a
|
||||
-- domain/category/subcategory from that product's browse taxonomy. Tags are
|
||||
-- keyed by (recipe_source, recipe_ref) so a single table serves all CF products
|
||||
-- that have a recipe corpus (currently: kiwi).
|
||||
--
|
||||
-- Acceptance threshold: upvotes >= 2 (submitter's implicit vote counts as 1,
|
||||
-- so one additional voter is enough to publish). Browse counts caches merge
|
||||
-- accepted tags into subcategory totals on each nightly refresh.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipe_tags (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
recipe_source TEXT NOT NULL CHECK (recipe_source IN ('corpus')),
|
||||
recipe_ref TEXT NOT NULL, -- corpus integer recipe ID stored as text
|
||||
domain TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT, -- NULL = category-level tag (no subcategory)
|
||||
pseudonym TEXT NOT NULL,
|
||||
upvotes INTEGER NOT NULL DEFAULT 1, -- starts at 1 (submitter's own vote)
|
||||
source_product TEXT NOT NULL DEFAULT 'kiwi',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- one tag per (recipe, location, user) — prevents submitting the same tag twice
|
||||
UNIQUE (recipe_source, recipe_ref, domain, category, subcategory, pseudonym)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipe_tags_lookup
|
||||
ON recipe_tags (source_product, domain, category, subcategory)
|
||||
WHERE upvotes >= 2;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipe_tags_recipe
|
||||
ON recipe_tags (recipe_source, recipe_ref);
|
||||
|
||||
-- Tracks who voted on which tag to prevent double-voting.
|
||||
-- The submitter's self-vote is inserted here at submission time.
|
||||
CREATE TABLE IF NOT EXISTS recipe_tag_votes (
|
||||
tag_id BIGINT NOT NULL REFERENCES recipe_tags(id) ON DELETE CASCADE,
|
||||
pseudonym TEXT NOT NULL,
|
||||
voted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tag_id, pseudonym)
|
||||
);
|
||||
|
|
@ -207,3 +207,170 @@ class SharedStore:
|
|||
raise
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
# ── Recipe tags ───────────────────────────────────────────────────────────
|
||||
|
||||
def submit_recipe_tag(
|
||||
self,
|
||||
recipe_id: int,
|
||||
domain: str,
|
||||
category: str,
|
||||
subcategory: str | None,
|
||||
pseudonym: str,
|
||||
source_product: str = "kiwi",
|
||||
) -> dict:
|
||||
"""Submit a new subcategory tag for a corpus recipe.
|
||||
|
||||
Inserts the tag with upvotes=1 and records the submitter's self-vote in
|
||||
recipe_tag_votes. Returns the created tag row as a dict.
|
||||
|
||||
Raises psycopg2.errors.UniqueViolation if the same user has already
|
||||
tagged this recipe to this location — let the caller handle it.
|
||||
"""
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO recipe_tags
|
||||
(recipe_source, recipe_ref, domain, category, subcategory,
|
||||
pseudonym, upvotes, source_product)
|
||||
VALUES ('corpus', %s, %s, %s, %s, %s, 1, %s)
|
||||
RETURNING id, recipe_ref, domain, category, subcategory,
|
||||
pseudonym, upvotes, created_at
|
||||
""",
|
||||
(str(recipe_id), domain, category, subcategory,
|
||||
pseudonym, source_product),
|
||||
)
|
||||
row = dict(zip([d[0] for d in cur.description], cur.fetchone()))
|
||||
# Record submitter's self-vote
|
||||
cur.execute(
|
||||
"INSERT INTO recipe_tag_votes (tag_id, pseudonym) VALUES (%s, %s)",
|
||||
(row["id"], pseudonym),
|
||||
)
|
||||
conn.commit()
|
||||
return row
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def upvote_recipe_tag(self, tag_id: int, pseudonym: str) -> int:
|
||||
"""Add an upvote to a tag from pseudonym. Returns new upvote count.
|
||||
|
||||
Raises psycopg2.errors.UniqueViolation if this pseudonym already voted.
|
||||
Raises ValueError if the tag does not exist.
|
||||
"""
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO recipe_tag_votes (tag_id, pseudonym) VALUES (%s, %s)",
|
||||
(tag_id, pseudonym),
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE recipe_tags SET upvotes = upvotes + 1 WHERE id = %s"
|
||||
" RETURNING upvotes",
|
||||
(tag_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"recipe_tag {tag_id} not found")
|
||||
conn.commit()
|
||||
return row[0]
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def get_recipe_tag_by_id(self, tag_id: int) -> dict | None:
|
||||
"""Return a single recipe_tag row by ID, or None if not found."""
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, recipe_ref, domain, category, subcategory,
|
||||
pseudonym, upvotes, created_at
|
||||
FROM recipe_tags WHERE id = %s
|
||||
""",
|
||||
(tag_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(zip([d[0] for d in cur.description], row))
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def list_tags_for_recipe(
|
||||
self,
|
||||
recipe_id: int,
|
||||
source_product: str = "kiwi",
|
||||
) -> list[dict]:
|
||||
"""Return all tags for a corpus recipe, accepted or not, newest first."""
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, domain, category, subcategory, pseudonym,
|
||||
upvotes, created_at
|
||||
FROM recipe_tags
|
||||
WHERE recipe_source = 'corpus'
|
||||
AND recipe_ref = %s
|
||||
AND source_product = %s
|
||||
ORDER BY upvotes DESC, created_at DESC
|
||||
""",
|
||||
(str(recipe_id), source_product),
|
||||
)
|
||||
cols = [d[0] for d in cur.description]
|
||||
return [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
||||
def get_accepted_recipe_ids_for_subcategory(
|
||||
self,
|
||||
domain: str,
|
||||
category: str,
|
||||
subcategory: str | None,
|
||||
source_product: str = "kiwi",
|
||||
threshold: int = 2,
|
||||
) -> list[int]:
|
||||
"""Return corpus recipe IDs with accepted community tags for a subcategory.
|
||||
|
||||
Used by browse_counts_cache refresh and browse_recipes() FTS fallback.
|
||||
Only includes tags that have reached the acceptance threshold.
|
||||
"""
|
||||
conn = self._db.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if subcategory is None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT recipe_ref::INTEGER
|
||||
FROM recipe_tags
|
||||
WHERE source_product = %s
|
||||
AND domain = %s AND category = %s
|
||||
AND subcategory IS NULL
|
||||
AND upvotes >= %s
|
||||
""",
|
||||
(source_product, domain, category, threshold),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT recipe_ref::INTEGER
|
||||
FROM recipe_tags
|
||||
WHERE source_product = %s
|
||||
AND domain = %s AND category = %s
|
||||
AND subcategory = %s
|
||||
AND upvotes >= %s
|
||||
""",
|
||||
(source_product, domain, category, subcategory, threshold),
|
||||
)
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
finally:
|
||||
self._db.putconn(conn)
|
||||
|
|
|
|||
Loading…
Reference in a new issue