"""Flow proposal model. Generated by the Knowledge Flywheel after AI sessions resolve. Represents a proposed new flow or enhancement awaiting human review. """ import uuid from datetime import datetime, timezone from typing import Optional, Any, TYPE_CHECKING from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, Boolean, CheckConstraint, text as sa_text 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.user import User from app.models.team import Team 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): """A proposed new flow or enhancement generated from an AI session. proposal_type: - new_flow: No similar flow exists. Full flow definition proposed. - enhancement: Similar flow exists but session discovered new branch/edge case. - branch_addition: A single new branch to add to an existing flow. - auto_reinforced: Session matched existing flow exactly (tracking only). status: - pending: Awaiting review - approved: Reviewed and published to knowledge base - modified: Reviewer edited before publishing - rejected: Reviewer decided not to publish (bad quality) - dismissed: Parked for later — not wrong, just not actionable now. - auto_reinforced: Session matched existing flow exactly (no review needed) """ __tablename__ = "flow_proposals" __table_args__ = ( CheckConstraint( "proposal_type IN ('new_flow', 'enhancement', 'branch_addition', 'auto_reinforced')", name="ck_flow_proposals_type", ), CheckConstraint( "status IN ('pending', 'approved', 'modified', 'rejected', 'dismissed', 'auto_reinforced')", name="ck_flow_proposals_status", ), CheckConstraint( "source IN ('ai_realtime_l1', 'kb_accelerator', 'manual_draft', 'ai_promoted')", name="ck_flow_proposals_source", ), CheckConstraint( "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( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) team_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"), nullable=True, index=True, ) source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=True, index=True, ) l1_session_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), # CASCADE, not SET NULL: the exactly-one-source CHECK below means an # L1-sourced proposal has source_session_id NULL by construction, so a # SET NULL on l1_session deletion would NULL both columns and the # non-deferrable CHECK would abort the DELETE — making any L1 session # referenced by a proposal undeletable (hard_delete_user, GDPR purge). # The proposal dies with its source, matching source_session_id's CASCADE. ForeignKey("l1_walk_sessions.id", ondelete="CASCADE"), nullable=True, index=True, ) # ── Proposal details ── proposal_type: Mapped[str] = mapped_column( String(30), nullable=False, ) target_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("trees.id", ondelete="SET NULL"), nullable=True, comment="For enhancements: which existing flow to modify", ) title: Mapped[str] = mapped_column( String(255), nullable=False, comment="Human-readable title for the proposed flow", ) description: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="AI-generated description of what this flow covers", ) proposed_flow_data: Mapped[dict[str, Any]] = mapped_column( JSONB, nullable=False, comment="Complete flow/tree_structure definition (nodes, edges, conditions)", ) proposed_diff: Mapped[Optional[dict[str, Any]]] = mapped_column( JSONB, nullable=True, comment="For enhancements: what changed vs existing flow", ) # ── Scoring ── confidence_score: Mapped[float] = mapped_column( Float, nullable=False, default=0.0, comment="How confident the system is in this proposal (0.0-1.0)", ) supporting_session_count: Mapped[int] = mapped_column( Integer, nullable=False, default=1, comment="Number of sessions with similar resolution paths", ) supporting_session_ids: Mapped[list] = mapped_column( JSONB, nullable=False, default=list, comment="Array of session IDs that support this proposal", ) problem_domain: Mapped[Optional[str]] = mapped_column( String(100), nullable=True, ) # ── Review ── status: Mapped[str] = mapped_column( String(30), nullable=False, default="pending", index=True, ) reviewed_by: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, ) reviewer_notes: Mapped[Optional[str]] = mapped_column( Text, nullable=True, ) published_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("trees.id", ondelete="SET NULL"), nullable=True, comment="The flow that was created/updated when this proposal was approved", ) # ── L1 workspace ── source: Mapped[str] = mapped_column( String(30), nullable=False, server_default=sa_text("'manual_draft'"), ) linked_ticket_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) linked_ticket_kind: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) validated_by_outcome: Mapped[bool] = mapped_column( Boolean(), nullable=False, server_default=sa_text('false'), ) # ── Timestamps ── created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) reviewed_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, ) # ── Relationships ── account: Mapped["Account"] = relationship("Account") team: Mapped[Optional["Team"]] = relationship("Team") 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")