diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 069c808e..87f08042 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -24,6 +24,7 @@ from app.models.ai_session import AISession # noqa: F401 from app.models.ai_session_step import AISessionStep # noqa: F401 from app.models.psa_post_log import PsaPostLog # noqa: F401 from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401 +from app.models.script_builder_session import ScriptBuilderSession # noqa: F401 from app.core.config import settings # this is the Alembic Config object diff --git a/backend/alembic/versions/062_add_script_builder_sessions_table.py b/backend/alembic/versions/062_add_script_builder_sessions_table.py new file mode 100644 index 00000000..ceb8f701 --- /dev/null +++ b/backend/alembic/versions/062_add_script_builder_sessions_table.py @@ -0,0 +1,45 @@ +"""add_script_builder_sessions_table + +Revision ID: 062 +Revises: f0aad74ea51b +Create Date: 2026-03-21 20:54:41.621748 + +""" +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 = '062' +down_revision: Union[str, None] = 'f0aad74ea51b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('script_builder_sessions', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('team_id', sa.UUID(), nullable=True), + sa.Column('language', sa.String(length=30), nullable=False, comment='Script language: powershell, bash, python'), + sa.Column('title', sa.String(length=200), nullable=True, comment='Auto-generated from first AI response'), + sa.Column('messages', postgresql.JSONB(astext_type=sa.Text()), nullable=False, comment='Array of {role, content, script?, script_filename?, timestamp}'), + sa.Column('latest_script', sa.Text(), nullable=True, comment='Most recent generated script for quick access'), + sa.Column('latest_script_filename', sa.String(length=200), nullable=True, comment='Filename of the latest generated script'), + sa.Column('message_count', sa.Integer(), nullable=False), + sa.Column('ai_session_id', sa.UUID(), nullable=True, comment='Link to FlowPilot session if launched from there'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['ai_session_id'], ['ai_sessions.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_script_builder_sessions_user_id'), 'script_builder_sessions', ['user_id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_script_builder_sessions_user_id'), table_name='script_builder_sessions') + op.drop_table('script_builder_sessions') diff --git a/backend/app/models/script_builder_session.py b/backend/app/models/script_builder_session.py new file mode 100644 index 00000000..fe8009e9 --- /dev/null +++ b/backend/app/models/script_builder_session.py @@ -0,0 +1,76 @@ +"""Script Builder session model. + +Tracks AI-powered script generation conversations. +""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any, TYPE_CHECKING + +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer +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 + + +class ScriptBuilderSession(Base): + """A conversation session in the AI Script Builder.""" + __tablename__ = "script_builder_sessions" + + 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, + ) + team_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("teams.id", ondelete="SET NULL"), + nullable=True, + ) + language: Mapped[str] = mapped_column( + String(30), nullable=False, default="powershell", + comment="Script language: powershell, bash, python", + ) + title: Mapped[Optional[str]] = mapped_column( + String(200), nullable=True, + comment="Auto-generated from first AI response", + ) + messages: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB, nullable=False, default=list, + comment="Array of {role, content, script?, script_filename?, timestamp}", + ) + latest_script: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, + comment="Most recent generated script for quick access", + ) + latest_script_filename: Mapped[Optional[str]] = mapped_column( + String(200), nullable=True, + comment="Filename of the latest generated script", + ) + message_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + ) + ai_session_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("ai_sessions.id", ondelete="SET NULL"), + nullable=True, + comment="Link to FlowPilot session if launched from there", + ) + 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 + user: Mapped["User"] = relationship("User") diff --git a/backend/app/schemas/script_builder.py b/backend/app/schemas/script_builder.py new file mode 100644 index 00000000..9c74c450 --- /dev/null +++ b/backend/app/schemas/script_builder.py @@ -0,0 +1,69 @@ +"""Pydantic schemas for the AI Script Builder.""" +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class ScriptBuilderCreateRequest(BaseModel): + """Request to start a new builder session.""" + language: str = Field( + default="powershell", + pattern=r"^(powershell|bash|python)$", + description="Script language", + ) + + +class ScriptBuilderMessageRequest(BaseModel): + """User message in a builder session.""" + content: str = Field(min_length=1, max_length=5000) + + +class ScriptBuilderMessageResponse(BaseModel): + """AI response to a builder message.""" + role: str = "assistant" + content: str + script: str | None = None + script_filename: str | None = None + line_count: int | None = None + timestamp: datetime + + model_config = {"from_attributes": True} + + +class ScriptBuilderSessionSummary(BaseModel): + """Lightweight session for list views (no messages).""" + id: UUID + language: str + title: str | None = None + message_count: int + latest_script_filename: str | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ScriptBuilderSessionDetail(BaseModel): + """Full session with message history.""" + id: UUID + language: str + title: str | None = None + messages: list[dict[str, Any]] + latest_script: str | None = None + latest_script_filename: str | None = None + message_count: int + ai_session_id: UUID | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class SaveToLibraryRequest(BaseModel): + """Request to save a generated script to the Script Library.""" + name: str = Field(min_length=1, max_length=200) + description: str | None = None + category_id: UUID | None = None + share_with_team: bool = False