feat(ai-session): add FlowPilot AI-powered troubleshooting sessions
Implements Phase 1 of the FlowPilot-First pivot — the core AI session experience where engineers describe a problem and FlowPilot guides them through structured diagnosis with selectable options, free-text escape hatches, and auto-generated documentation on resolution. Backend: AISession + AISessionStep models, FlowPilot Engine (LLM orchestration with structured JSON output), Flow Matching Engine v1 (semantic + keyword + recency scoring), 8 API endpoints with auth, rate limiting, and AI quota enforcement. Frontend: Intake screen, conversational session view with sidebar, step cards with options/actions/resolution suggestions, resolve/escalate modals, documentation view with rating, session history integration, and /pilot route with sidebar navigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,8 @@ from .survey_response import SurveyResponse
|
||||
from .survey_invite import SurveyInvite
|
||||
from .kb_import import KBImport, KBImportNode
|
||||
from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
|
||||
from .ai_session import AISession
|
||||
from .ai_session_step import AISessionStep
|
||||
from .psa_connection import PsaConnection
|
||||
from .psa_post_log import PsaPostLog
|
||||
from .psa_member_mapping import PsaMemberMapping
|
||||
@@ -90,6 +92,8 @@ __all__ = [
|
||||
"ScriptCategory",
|
||||
"ScriptTemplate",
|
||||
"ScriptGeneration",
|
||||
"AISession",
|
||||
"AISessionStep",
|
||||
"PsaConnection",
|
||||
"PsaPostLog",
|
||||
"PsaMemberMapping",
|
||||
|
||||
204
backend/app/models/ai_session.py
Normal file
204
backend/app/models/ai_session.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""AI-powered troubleshooting session model.
|
||||
|
||||
Represents a complete FlowPilot interaction from intake to resolution/escalation.
|
||||
This is the central entity of the FlowPilot-First pivot.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint
|
||||
import sqlalchemy as sa
|
||||
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.psa_connection import PsaConnection
|
||||
|
||||
|
||||
class AISession(Base):
|
||||
"""A FlowPilot-guided troubleshooting session.
|
||||
|
||||
Lifecycle: active → resolved | escalated | abandoned
|
||||
Sessions may be paused and resumed (e.g., escalation handoff).
|
||||
"""
|
||||
__tablename__ = "ai_sessions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"intake_type IN ('free_text', 'psa_ticket', 'screenshot', 'log_paste', 'combined')",
|
||||
name="ck_ai_sessions_intake_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('active', 'paused', 'resolved', 'escalated', 'abandoned')",
|
||||
name="ck_ai_sessions_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"confidence_tier IN ('guided', 'exploring', 'discovery')",
|
||||
name="ck_ai_sessions_confidence_tier",
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("teams.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ── Intake ──
|
||||
intake_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="free_text"
|
||||
)
|
||||
intake_content: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict,
|
||||
comment="Original intake data: {text, image_urls, log_content, ticket_data}",
|
||||
)
|
||||
problem_summary: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="AI-generated one-line problem summary from intake",
|
||||
)
|
||||
problem_domain: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
comment="Classified domain: active_directory, networking, m365, hardware, etc.",
|
||||
)
|
||||
|
||||
# ── Session state ──
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", index=True,
|
||||
)
|
||||
confidence_tier: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="discovery",
|
||||
comment="Current AI confidence: guided (>80%), exploring (40-80%), discovery (<40%)",
|
||||
)
|
||||
confidence_score: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.0,
|
||||
comment="Numeric confidence 0.0-1.0 for internal tracking",
|
||||
)
|
||||
|
||||
# ── Flow matching ──
|
||||
matched_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="If following an existing flow, which one",
|
||||
)
|
||||
match_score: Mapped[Optional[float]] = mapped_column(
|
||||
Float, nullable=True,
|
||||
comment="Similarity score of the matched flow (0.0-1.0)",
|
||||
)
|
||||
|
||||
# ── PSA link ──
|
||||
psa_ticket_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
comment="External PSA ticket ID if session was started from a ticket",
|
||||
)
|
||||
psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("psa_connections.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
ticket_data: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSONB, nullable=True,
|
||||
comment="Snapshot of PSA ticket data at session start",
|
||||
)
|
||||
|
||||
# ── Resolution / Escalation ──
|
||||
resolution_summary: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="What fixed the issue (set on resolution)",
|
||||
)
|
||||
resolution_action: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="The specific action/step that resolved the issue",
|
||||
)
|
||||
escalation_reason: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Why escalated (set on escalation)",
|
||||
)
|
||||
escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSONB, nullable=True,
|
||||
comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions",
|
||||
)
|
||||
escalated_to_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# ── Feedback ──
|
||||
session_rating: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True,
|
||||
comment="1-5 engineer feedback rating",
|
||||
)
|
||||
session_feedback: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Optional feedback text from engineer",
|
||||
)
|
||||
|
||||
# ── AI tracking ──
|
||||
total_input_tokens: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
)
|
||||
total_output_tokens: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
)
|
||||
step_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
)
|
||||
|
||||
# ── Timestamps ──
|
||||
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),
|
||||
)
|
||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
# ── LLM conversation context ──
|
||||
system_prompt_snapshot: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Snapshot of the system prompt used (for debugging/training)",
|
||||
)
|
||||
conversation_messages: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSONB, nullable=False, default=list,
|
||||
comment="Full LLM message history for context continuity",
|
||||
)
|
||||
|
||||
# ── Relationships ──
|
||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||
account: Mapped["Account"] = relationship("Account")
|
||||
team: Mapped[Optional["Team"]] = relationship("Team")
|
||||
matched_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[matched_flow_id])
|
||||
escalated_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[escalated_to_id])
|
||||
psa_connection: Mapped[Optional["PsaConnection"]] = relationship("PsaConnection")
|
||||
steps: Mapped[list["AISessionStep"]] = relationship(
|
||||
"AISessionStep", back_populates="session",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AISessionStep.step_order",
|
||||
)
|
||||
133
backend/app/models/ai_session_step.py
Normal file
133
backend/app/models/ai_session_step.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""AI session step model.
|
||||
|
||||
Every interaction within an AI session is captured as a step.
|
||||
Steps are the raw material that becomes flow nodes in the Knowledge Flywheel.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, CheckConstraint
|
||||
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.script_template import ScriptGeneration
|
||||
|
||||
|
||||
class AISessionStep(Base):
|
||||
"""A single interaction step within a FlowPilot session.
|
||||
|
||||
Step types:
|
||||
- question: FlowPilot asks a diagnostic question with options
|
||||
- action: FlowPilot suggests an action for the engineer to perform
|
||||
- script_generation: FlowPilot invokes the Script Generator
|
||||
- verification: FlowPilot asks engineer to verify a condition
|
||||
- info_request: FlowPilot asks engineer to gather specific data
|
||||
- note: Engineer or FlowPilot adds a contextual note
|
||||
- intake_analysis: Initial analysis of the intake content
|
||||
"""
|
||||
__tablename__ = "ai_session_steps"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"step_type IN ('question', 'action', 'script_generation', 'verification', "
|
||||
"'info_request', 'note', 'intake_analysis')",
|
||||
name="ck_ai_session_steps_step_type",
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
index=True,
|
||||
)
|
||||
step_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False,
|
||||
comment="Sequential position in the session (0-indexed)",
|
||||
)
|
||||
step_type: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False,
|
||||
)
|
||||
|
||||
# ── Content presented to engineer ──
|
||||
content: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict,
|
||||
comment="The question/action content rendered in the session UI",
|
||||
)
|
||||
context_message: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Why FlowPilot is asking this (shown above the question)",
|
||||
)
|
||||
|
||||
# ── Options (for question steps) ──
|
||||
options_presented: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
|
||||
JSONB, nullable=True,
|
||||
comment="Array of {label, value, followup_hint} options shown to engineer",
|
||||
)
|
||||
|
||||
# ── Engineer response ──
|
||||
selected_option: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True,
|
||||
comment="Which option the engineer selected (value field)",
|
||||
)
|
||||
free_text_input: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="If engineer typed a custom response instead of selecting an option",
|
||||
)
|
||||
was_free_text: Mapped[bool] = mapped_column(
|
||||
default=False,
|
||||
comment="True if the engineer used the free-text escape hatch",
|
||||
)
|
||||
was_skipped: Mapped[bool] = mapped_column(
|
||||
default=False,
|
||||
comment="True if engineer selected 'I don't know / Can't check'",
|
||||
)
|
||||
|
||||
# ── Action results ──
|
||||
action_result: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSONB, nullable=True,
|
||||
comment="Outcome of action step: {success: bool, details: str, next_hint: str}",
|
||||
)
|
||||
|
||||
# ── Script generation link ──
|
||||
script_generation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("script_generations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# ── AI internals ──
|
||||
confidence_at_step: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.0,
|
||||
comment="FlowPilot confidence level at this point (0.0-1.0)",
|
||||
)
|
||||
ai_reasoning: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Why FlowPilot chose this step (internal, for debugging/training)",
|
||||
)
|
||||
input_tokens: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
)
|
||||
output_tokens: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
)
|
||||
|
||||
# ── Timestamps ──
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
responded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
comment="When the engineer responded to this step",
|
||||
)
|
||||
|
||||
# ── Relationships ──
|
||||
session: Mapped["AISession"] = relationship("AISession", back_populates="steps")
|
||||
script_generation: Mapped[Optional["ScriptGeneration"]] = relationship("ScriptGeneration")
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index, CheckConstraint
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, Index, CheckConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.core.database import Base
|
||||
@@ -161,6 +161,25 @@ class Tree(Base):
|
||||
comment="Provenance metadata from .rfflow file import"
|
||||
)
|
||||
|
||||
# Flow matching (FlowPilot AI sessions)
|
||||
origin: Mapped[Optional[str]] = mapped_column(
|
||||
String(20), nullable=True,
|
||||
comment="manual | ai_generated | ai_enhanced"
|
||||
)
|
||||
source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True,
|
||||
)
|
||||
match_keywords: Mapped[Optional[list[Any]]] = mapped_column(
|
||||
JSONB, nullable=True,
|
||||
comment="Keywords for FlowPilot flow matching"
|
||||
)
|
||||
success_rate: Mapped[Optional[float]] = mapped_column(
|
||||
Float, nullable=True,
|
||||
)
|
||||
last_matched_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees")
|
||||
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
|
||||
|
||||
Reference in New Issue
Block a user