Server-assigns a uuid4 id to every AI-generated node (Finding 1 showstopper:
nodes had no id but the advance protocol keys on node_id, so ai_build walks
never advanced past question 1). Replaces the hidden {"node_type":"meta"}
walked_path convention with real category/problem_text/pending_node columns on
l1_walk_sessions (migration 61dda4f615c6) — fixes junk proposals + off-by-one
depth cap (Findings 8,9), and pending_node replays the served node on re-mount
(no duplicate paid LLM call). Intake honors explicit flow_id and adhoc=True
(Findings 4,5); flow_proposals.l1_session_id FK -> CASCADE (Finding 6 time
bomb); L1 category GET is owner+admin like PATCH and require_account_owner_or_admin
delegates to User.can_manage_account (Finding 7); escalate falls back to default
recipients + filters deleted_at + warns when empty (Finding 10). Cleanups: dead
ticket_ref removed, IntakeResponse per-outcome validator, unused acknowledged
dropped, escalations partial index, restored a deleted audit assertion.
Full Phase 2A backend set: 110 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
7.7 KiB
Python
198 lines
7.7 KiB
Python
"""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")
|