feat: AI-assisted flow builder with 4-stage wizard

Implements the complete AI flow builder feature using a guided 4-stage
wizard (Foundation → Scaffold → Branch Detail → Review & Assemble).
AI assists at bounded points using Claude Haiku for cost-efficient
structured JSON generation (~$0.01-0.03/flow).

Backend: new models (ai_conversations, ai_usage), Alembic migration,
quota enforcement with billing anchor, Anthropic API integration with
prompt caching, tree validation, conversation CRUD with 24h TTL,
APScheduler cleanup job, 5 API endpoints, Pydantic schemas.

Frontend: TypeScript types, API client, Zustand store for wizard state,
7 components (modal, step indicator, foundation form, branch selector,
branch detail view, tree preview, quota display), MyTreesPage integration
with "Build with AI" button (hidden when AI not configured).

Tests: 14 validator unit tests + 11 endpoint integration tests with
mocked Anthropic (zero real API spend). All 25 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-20 08:07:08 -05:00
parent aef40078d0
commit 44432413c2
35 changed files with 3662 additions and 5 deletions

View File

@@ -26,6 +26,8 @@ from .user_pinned_tree import UserPinnedTree
from .target_list import TargetList
from .maintenance_schedule import MaintenanceSchedule
from .feedback import Feedback
from .ai_conversation import AIConversation
from .ai_usage import AIUsage
__all__ = [
"User",
@@ -63,4 +65,6 @@ __all__ = [
"TargetList",
"MaintenanceSchedule",
"Feedback",
"AIConversation",
"AIUsage",
]

View File

@@ -24,6 +24,8 @@ class AccountLimitOverride(Base):
override_max_trees: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
override_max_sessions_per_month: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
override_max_users: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
override_max_ai_builds_per_month: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
override_max_ai_builds_per_24h: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),

View File

@@ -0,0 +1,67 @@
"""AI Flow Builder conversation tracking.
Stores wizard session state across the 4-stage flow builder process.
Conversations expire after 24 hours and are cleaned up by the scheduler.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class AIConversation(Base):
__tablename__ = "ai_conversations"
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", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="foundation",
comment="foundation | scaffolding | detailing | reviewing | completed | expired",
)
# Conversation history across all wizard stages
messages: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB, nullable=False, default=list
)
# Wizard state: Stage 1 metadata, Stage 2 branches, Stage 3 detail
wizard_state: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
# Assembled tree from Stage 4 (null until assembly)
generated_tree: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True
)
# Tracks AI call count for per-flow limits
question_rounds: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,69 @@
"""AI usage tracking for quota enforcement and cost visibility.
Every AI API call is recorded here. Only rows with counts_toward_quota=True
and succeeded=True are counted against the user's monthly quota.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean, Numeric
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class AIUsage(Base):
__tablename__ = "ai_usage"
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", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
conversation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_conversations.id", ondelete="SET NULL"),
nullable=True,
)
generation_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="scaffold | branch_detail | branch_suggest",
)
tier_at_time: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="free | pro | team | enterprise",
)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
estimated_cost_usd: Mapped[float] = mapped_column(
Numeric(10, 6), nullable=False, default=0
)
succeeded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
counts_toward_quota: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
error_code: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
extra_data: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSONB, nullable=False, default=dict
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
index=True,
)

View File

@@ -14,3 +14,7 @@ class PlanLimits(Base):
custom_branding: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
priority_support: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
export_formats: Mapped[list] = mapped_column(JSONB, nullable=False, default=lambda: ["markdown", "text"])
# AI Flow Builder limits
max_ai_builds_per_month: Mapped[int | None] = mapped_column(Integer, nullable=True)
max_ai_builds_per_24h: Mapped[int | None] = mapped_column(Integer, nullable=True)

View File

@@ -67,6 +67,11 @@ class User(Base):
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# AI billing cycle anchor (for quota reset calculation)
ai_billing_cycle_anchor_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Soft delete
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),