From 9d037dc2c2fb7616701ec14811817891ddcea831 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Mar 2026 08:23:34 +0000 Subject: [PATCH] feat: add SessionBranch model for conversational branching Introduces the session_branches table to represent diagnostic hypothesis paths within a FlowPilot session, supporting parent/child branch relationships, status lifecycle, and per-branch conversation history. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/models/session_branch.py | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 backend/app/models/session_branch.py diff --git a/backend/app/models/session_branch.py b/backend/app/models/session_branch.py new file mode 100644 index 00000000..a6e648f1 --- /dev/null +++ b/backend/app/models/session_branch.py @@ -0,0 +1,58 @@ +"""Session branch model — represents a diagnostic hypothesis path within a FlowPilot session.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any, TYPE_CHECKING + +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, CheckConstraint, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.ai_session import AISession + from app.models.ai_session_step import AISessionStep + from app.models.user import User + + +class SessionBranch(Base): + """A diagnostic branch within a FlowPilot session.""" + __tablename__ = "session_branches" + __table_args__ = ( + CheckConstraint( + "status IN ('active', 'dead_end', 'solved', 'untried', 'revived')", + name="ck_session_branches_status", + ), + CheckConstraint( + "branch_order > 0", + name="ck_session_branches_branch_order_positive", + ), + Index("ix_session_branches_session_id", "session_id"), + Index("ix_session_branches_parent_branch_id", "parent_branch_id"), + Index("ix_session_branches_session_status", "session_id", "status"), + Index("ix_session_branches_session_order", "session_id", "branch_order"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False) + parent_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=True) + fork_point_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True) + branch_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + label: Mapped[str] = mapped_column(String(200), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") + status_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status_changed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + status_changed_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + conversation_messages: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list, comment="LLM message history scoped to this branch") + context_summary: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="{tried: [], concluded: str, artifacts: []}") + evidence_from_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True) + evidence_description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + 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)) + + # Relationships + session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id]) + parent_branch: Mapped[Optional["SessionBranch"]] = relationship("SessionBranch", remote_side="SessionBranch.id", foreign_keys=[parent_branch_id]) + fork_point_step: Mapped[Optional["AISessionStep"]] = relationship("AISessionStep", foreign_keys=[fork_point_step_id]) + status_changed_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[status_changed_by]) + evidence_source: Mapped[Optional["SessionBranch"]] = relationship("SessionBranch", remote_side="SessionBranch.id", foreign_keys=[evidence_from_branch_id])