feat(l1): FlowProposal l1_session_id source linkage (nullable source_session_id + exactly-one check)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 15:34:05 -04:00
parent 9a5cbc35ae
commit 0796874376
5 changed files with 113 additions and 7 deletions

View File

@@ -0,0 +1,61 @@
"""flow_proposal l1 source linkage
Revision ID: 1fd88a68b145
Revises: cb9e282267d2
Create Date: 2026-05-29 19:33:09.188681
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '1fd88a68b145'
down_revision: Union[str, None] = 'cb9e282267d2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"flow_proposals",
sa.Column("l1_session_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_index(
"ix_flow_proposals_l1_session_id",
"flow_proposals",
["l1_session_id"],
)
op.create_foreign_key(
"fk_flow_proposals_l1_session_id",
"flow_proposals",
"l1_walk_sessions",
["l1_session_id"],
["id"],
ondelete="SET NULL",
)
op.alter_column("flow_proposals", "source_session_id", nullable=True)
op.create_check_constraint(
"ck_flow_proposals_exactly_one_source",
"flow_proposals",
"(source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL)",
)
def downgrade() -> None:
op.drop_constraint(
"ck_flow_proposals_exactly_one_source",
"flow_proposals",
type_="check",
)
op.alter_column("flow_proposals", "source_session_id", nullable=False)
op.drop_constraint(
"fk_flow_proposals_l1_session_id",
"flow_proposals",
type_="foreignkey",
)
op.drop_index("ix_flow_proposals_l1_session_id", "flow_proposals")
op.drop_column("flow_proposals", "l1_session_id")

View File

@@ -19,6 +19,7 @@ if TYPE_CHECKING:
from app.models.account import Account
from app.models.tree import Tree
from app.models.ai_session import AISession
from app.models.l1_walk_session import L1WalkSession
class FlowProposal(Base):
@@ -56,6 +57,10 @@ class FlowProposal(Base):
"linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')",
name="ck_flow_proposals_linked_ticket_kind",
),
CheckConstraint(
"(source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL)",
name="ck_flow_proposals_exactly_one_source",
),
)
id: Mapped[uuid.UUID] = mapped_column(
@@ -73,10 +78,16 @@ class FlowProposal(Base):
nullable=True,
index=True,
)
source_session_id: Mapped[uuid.UUID] = mapped_column(
source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=False,
nullable=True,
index=True,
)
l1_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("l1_walk_sessions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
@@ -164,7 +175,17 @@ class FlowProposal(Base):
# ── Relationships ──
account: Mapped["Account"] = relationship("Account")
team: Mapped[Optional["Team"]] = relationship("Team")
source_session: Mapped["AISession"] = relationship("AISession")
target_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[target_flow_id])
published_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[published_flow_id])
source_session: Mapped[Optional["AISession"]] = relationship("AISession")
# Two FK paths exist between FlowProposal and L1WalkSession
# (FlowProposal.l1_session_id here, L1WalkSession.flow_proposal_id there),
# so each relationship must name its foreign_keys explicitly.
l1_session: Mapped[Optional["L1WalkSession"]] = relationship(
"L1WalkSession", foreign_keys="[FlowProposal.l1_session_id]"
)
target_flow: Mapped[Optional["Tree"]] = relationship(
"Tree", foreign_keys=[target_flow_id]
)
published_flow: Mapped[Optional["Tree"]] = relationship(
"Tree", foreign_keys=[published_flow_id]
)
reviewer: Mapped[Optional["User"]] = relationship("User")

View File

@@ -139,4 +139,9 @@ class L1WalkSession(Base):
account: Mapped["Account"] = relationship("Account")
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id])
flow: Mapped[Optional["Tree"]] = relationship("Tree")
flow_proposal: Mapped[Optional["FlowProposal"]] = relationship("FlowProposal")
# Two FK paths exist between L1WalkSession and FlowProposal
# (L1WalkSession.flow_proposal_id here, FlowProposal.l1_session_id there),
# so each relationship must name its foreign_keys explicitly.
flow_proposal: Mapped[Optional["FlowProposal"]] = relationship(
"FlowProposal", foreign_keys="[L1WalkSession.flow_proposal_id]"
)

View File

@@ -19,7 +19,10 @@ class FlowProposalSummary(BaseModel):
supporting_session_count: int
status: str
target_flow_id: UUID | None = None
source_session_id: UUID
# Exactly one source is set: source_session_id (FlowPilot ai_session) XOR
# l1_session_id (L1 ai_build walk). Both are nullable on the model.
source_session_id: UUID | None = None
l1_session_id: UUID | None = None
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,16 @@
import uuid
from app.models.flow_proposal import FlowProposal
def test_flow_proposal_accepts_l1_session_id_without_source_session():
p = FlowProposal(
account_id=uuid.uuid4(),
l1_session_id=uuid.uuid4(),
source_session_id=None,
proposal_type="new_flow",
title="AI L1 draft",
proposed_flow_data={"tree_structure": {"id": "root"}},
source="ai_realtime_l1",
status="pending",
)
assert p.l1_session_id is not None and p.source_session_id is None