2026-02-27 07:27:02 +00:00
|
|
|
"""
|
|
|
|
|
Script Flow Service for AI Service.
|
|
|
|
|
[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73] Flow definition CRUD operations.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import uuid
|
2026-02-28 04:52:50 +00:00
|
|
|
from collections.abc import Sequence
|
2026-02-27 07:27:02 +00:00
|
|
|
from datetime import datetime
|
2026-02-28 04:52:50 +00:00
|
|
|
from typing import Any
|
2026-02-27 07:27:02 +00:00
|
|
|
|
2026-02-28 04:52:50 +00:00
|
|
|
from sqlalchemy import func, select
|
2026-02-27 07:27:02 +00:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from sqlmodel import col
|
|
|
|
|
|
|
|
|
|
from app.models.entities import (
|
|
|
|
|
ScriptFlow,
|
|
|
|
|
ScriptFlowCreate,
|
|
|
|
|
ScriptFlowUpdate,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ScriptFlowService:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-71~AC-AISVC-73] Service for managing script flow definitions.
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
Features:
|
|
|
|
|
- Flow CRUD with tenant isolation
|
|
|
|
|
- Step validation
|
|
|
|
|
- Linked intent rule count tracking
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, session: AsyncSession):
|
|
|
|
|
self._session = session
|
|
|
|
|
|
|
|
|
|
async def create_flow(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
create_data: ScriptFlowCreate,
|
|
|
|
|
) -> ScriptFlow:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-71] Create a new script flow with steps.
|
2026-03-02 14:15:19 +00:00
|
|
|
[AC-IDSMETA-16] Support metadata field.
|
2026-02-27 07:27:02 +00:00
|
|
|
"""
|
|
|
|
|
self._validate_steps(create_data.steps)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
flow = ScriptFlow(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
name=create_data.name,
|
|
|
|
|
description=create_data.description,
|
|
|
|
|
steps=create_data.steps,
|
|
|
|
|
is_enabled=create_data.is_enabled,
|
2026-03-02 14:15:19 +00:00
|
|
|
metadata_=create_data.metadata_,
|
2026-02-27 07:27:02 +00:00
|
|
|
)
|
|
|
|
|
self._session.add(flow)
|
|
|
|
|
await self._session.flush()
|
|
|
|
|
|
|
|
|
|
logger.info(
|
2026-03-02 14:15:19 +00:00
|
|
|
f"[AC-AISVC-71][AC-IDSMETA-16] Created script flow: tenant={tenant_id}, "
|
2026-02-27 07:27:02 +00:00
|
|
|
f"id={flow.id}, name={flow.name}, steps={len(flow.steps)}"
|
|
|
|
|
)
|
|
|
|
|
return flow
|
|
|
|
|
|
|
|
|
|
async def list_flows(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
is_enabled: bool | None = None,
|
|
|
|
|
) -> Sequence[ScriptFlow]:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-72] List flows for a tenant, optionally filtered by enabled status.
|
|
|
|
|
"""
|
|
|
|
|
stmt = select(ScriptFlow).where(
|
|
|
|
|
ScriptFlow.tenant_id == tenant_id
|
|
|
|
|
)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
if is_enabled is not None:
|
|
|
|
|
stmt = stmt.where(ScriptFlow.is_enabled == is_enabled)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
stmt = stmt.order_by(col(ScriptFlow.created_at).desc())
|
|
|
|
|
result = await self._session.execute(stmt)
|
|
|
|
|
return result.scalars().all()
|
|
|
|
|
|
|
|
|
|
async def get_flow(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
flow_id: uuid.UUID,
|
|
|
|
|
) -> ScriptFlow | None:
|
|
|
|
|
"""
|
|
|
|
|
Get flow by ID with tenant isolation.
|
|
|
|
|
"""
|
|
|
|
|
stmt = select(ScriptFlow).where(
|
|
|
|
|
ScriptFlow.tenant_id == tenant_id,
|
|
|
|
|
ScriptFlow.id == flow_id,
|
|
|
|
|
)
|
|
|
|
|
result = await self._session.execute(stmt)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
async def get_flow_detail(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
flow_id: uuid.UUID,
|
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-73] Get flow detail with complete step definitions.
|
2026-03-02 14:15:19 +00:00
|
|
|
[AC-IDSMETA-16] Include metadata field.
|
2026-02-27 07:27:02 +00:00
|
|
|
"""
|
|
|
|
|
flow = await self.get_flow(tenant_id, flow_id)
|
|
|
|
|
if not flow:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
linked_rule_count = await self._get_linked_rule_count(tenant_id, flow_id)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"id": str(flow.id),
|
|
|
|
|
"name": flow.name,
|
|
|
|
|
"description": flow.description,
|
|
|
|
|
"steps": flow.steps,
|
|
|
|
|
"is_enabled": flow.is_enabled,
|
|
|
|
|
"step_count": len(flow.steps),
|
|
|
|
|
"linked_rule_count": linked_rule_count,
|
2026-03-02 14:15:19 +00:00
|
|
|
"metadata": flow.metadata_,
|
2026-02-27 07:27:02 +00:00
|
|
|
"created_at": flow.created_at.isoformat(),
|
|
|
|
|
"updated_at": flow.updated_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def update_flow(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
flow_id: uuid.UUID,
|
|
|
|
|
update_data: ScriptFlowUpdate,
|
|
|
|
|
) -> ScriptFlow | None:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-73] Update flow definition.
|
2026-03-02 14:15:19 +00:00
|
|
|
[AC-IDSMETA-16] Support metadata field.
|
2026-02-27 07:27:02 +00:00
|
|
|
"""
|
|
|
|
|
flow = await self.get_flow(tenant_id, flow_id)
|
|
|
|
|
if not flow:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if update_data.name is not None:
|
|
|
|
|
flow.name = update_data.name
|
|
|
|
|
if update_data.description is not None:
|
|
|
|
|
flow.description = update_data.description
|
|
|
|
|
if update_data.steps is not None:
|
|
|
|
|
self._validate_steps(update_data.steps)
|
|
|
|
|
flow.steps = update_data.steps
|
|
|
|
|
if update_data.is_enabled is not None:
|
|
|
|
|
flow.is_enabled = update_data.is_enabled
|
2026-03-02 14:15:19 +00:00
|
|
|
if update_data.metadata_ is not None:
|
|
|
|
|
flow.metadata_ = update_data.metadata_
|
2026-02-27 07:27:02 +00:00
|
|
|
flow.updated_at = datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
await self._session.flush()
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
logger.info(
|
2026-03-02 14:15:19 +00:00
|
|
|
f"[AC-AISVC-73][AC-IDSMETA-16] Updated script flow: tenant={tenant_id}, id={flow_id}"
|
2026-02-27 07:27:02 +00:00
|
|
|
)
|
|
|
|
|
return flow
|
|
|
|
|
|
|
|
|
|
async def delete_flow(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
flow_id: uuid.UUID,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Delete a flow definition."""
|
|
|
|
|
flow = await self.get_flow(tenant_id, flow_id)
|
|
|
|
|
if not flow:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
await self._session.delete(flow)
|
|
|
|
|
await self._session.flush()
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
logger.info(
|
|
|
|
|
f"Deleted script flow: tenant={tenant_id}, id={flow_id}"
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def get_step_count(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
flow_id: uuid.UUID,
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Get the number of steps in a flow."""
|
|
|
|
|
flow = await self.get_flow(tenant_id, flow_id)
|
|
|
|
|
return len(flow.steps) if flow else 0
|
|
|
|
|
|
|
|
|
|
async def _get_linked_rule_count(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
flow_id: uuid.UUID,
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Get count of intent rules linked to this flow."""
|
|
|
|
|
from app.models.entities import IntentRule
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
stmt = select(func.count()).select_from(IntentRule).where(
|
|
|
|
|
IntentRule.tenant_id == tenant_id,
|
|
|
|
|
IntentRule.flow_id == flow_id,
|
|
|
|
|
)
|
|
|
|
|
result = await self._session.execute(stmt)
|
|
|
|
|
return result.scalar() or 0
|
|
|
|
|
|
|
|
|
|
def _validate_steps(self, steps: list[dict[str, Any]]) -> None:
|
|
|
|
|
"""Validate step definitions."""
|
|
|
|
|
if not steps:
|
|
|
|
|
raise ValueError("Flow must have at least one step")
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
step_nos = set()
|
|
|
|
|
for step in steps:
|
|
|
|
|
step_no = step.get("step_no")
|
|
|
|
|
if step_no is None:
|
|
|
|
|
raise ValueError("Each step must have a step_no")
|
|
|
|
|
if step_no in step_nos:
|
|
|
|
|
raise ValueError(f"Duplicate step_no: {step_no}")
|
|
|
|
|
step_nos.add(step_no)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
if not step.get("content"):
|
|
|
|
|
raise ValueError(f"Step {step_no} must have content")
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 07:27:02 +00:00
|
|
|
next_conditions = step.get("next_conditions", [])
|
|
|
|
|
for cond in next_conditions:
|
|
|
|
|
if cond.get("goto_step") is None:
|
|
|
|
|
raise ValueError(f"next_condition in step {step_no} must have goto_step")
|