v2: Program research, Groq extraction, and workout tracking (REVIEW — not deployed)

Backend:
- New models: ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
- Program.py: added catalog_id, current_week, current_day columns
- program_research.py: ttp-crawler integration, Groq Llama 3.3 70B extraction, known sources map, validation
- crawl_scheduler.py: priority queue with off-peak/weekend scheduling
- catalog.py router: research, catalog browse, adopt, schedule, complete, progress endpoints
- Points: 75pts/workout, 50pts weekly bonus, 200pts completion bonus
- config.py: added groq_api_key setting
- main.py: catalog router, background scheduler, v2 migration

Frontend:
- ProgramSearch.jsx: search, research submission, catalog browsing, adopt programs
- ProgramDetail.jsx: schedule view, today's workout, completion modal, progress tracking
- App.jsx: Programs nav tab, new routes
- api.js: catalog and tracking API functions

Config:
- docker-compose.yml: GROQ_API_KEY env var

NOT DEPLOYED — requires Groq API key and Scot review
This commit is contained in:
claude 2026-04-03 16:22:53 +00:00
parent a81ca9c275
commit 93f24fff83
13 changed files with 1738 additions and 9 deletions

View file

@ -7,9 +7,11 @@ from app.models.points import PointRule, DailyTarget
from app.models.reward import Reward, RewardRedemption
from app.models.oauth import OAuthToken
from app.models.resource import Resource
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
__all__ = [
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
"OAuthToken", "Resource",
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
]

View file

@ -0,0 +1,102 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Text, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class ProgramCatalog(Base):
"""Shared program definitions — one per program, reusable by all users."""
__tablename__ = "program_catalog"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
source_url: Mapped[str | None] = mapped_column(Text)
duration_weeks: Mapped[int | None] = mapped_column(Integer)
frequency_per_week: Mapped[int | None] = mapped_column(Integer)
equipment: Mapped[dict] = mapped_column(JSONB, default=list)
difficulty: Mapped[str | None] = mapped_column(String(20))
category: Mapped[str | None] = mapped_column(String(50))
progression_rules: Mapped[str | None] = mapped_column(Text)
structured_data: Mapped[dict | None] = mapped_column(JSONB)
crawl_status: Mapped[str] = mapped_column(String(20), default="pending")
crawl_error: Mapped[str | None] = mapped_column(Text)
crawled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
workouts: Mapped[list["CatalogWorkout"]] = relationship(
back_populates="catalog", cascade="all, delete-orphan",
order_by="CatalogWorkout.week_number, CatalogWorkout.day_number"
)
class CatalogWorkout(Base):
"""Individual workout within a catalog program."""
__tablename__ = "catalog_workouts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
catalog_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("program_catalog.id", ondelete="CASCADE"), nullable=False
)
week_number: Mapped[int] = mapped_column(Integer, nullable=False)
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
workout_name: Mapped[str | None] = mapped_column(String(200))
exercises: Mapped[dict] = mapped_column(JSONB, nullable=False)
notes: Mapped[str | None] = mapped_column(Text)
rest_day: Mapped[bool] = mapped_column(Boolean, default=False)
catalog: Mapped["ProgramCatalog"] = relationship(back_populates="workouts")
__table_args__ = (
UniqueConstraint("catalog_id", "week_number", "day_number", name="uq_catalog_week_day"),
)
class CrawlQueue(Base):
"""Job queue for program research crawls."""
__tablename__ = "crawl_queue"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True
)
search_query: Mapped[str] = mapped_column(String(500), nullable=False)
priority: Mapped[str] = mapped_column(String(10), default="low")
status: Mapped[str] = mapped_column(String(20), default="pending")
urls_to_crawl: Mapped[dict] = mapped_column(JSONB, default=list)
crawled_content: Mapped[str | None] = mapped_column(Text)
error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class WorkoutLog(Base):
"""Per-user workout completion tracking against catalog workouts."""
__tablename__ = "workout_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
)
program_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("programs.id"), nullable=False
)
catalog_workout_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("catalog_workouts.id"), nullable=False
)
completed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
exercises_completed: Mapped[dict | None] = mapped_column(JSONB)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
notes: Mapped[str | None] = mapped_column(Text)

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Text, Date, DateTime, ForeignKey
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
@ -22,4 +22,11 @@ class Program(Base):
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# v2: Link to catalog program
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True
)
current_week: Mapped[int] = mapped_column(Integer, default=1)
current_day: Mapped[int] = mapped_column(Integer, default=1)
user: Mapped["User"] = relationship(back_populates="programs")