feat: 实现话术流程引擎 (Phase 13 T13.1-T13.6) [AC-AISVC-71~AC-AISVC-76]
- 新增 ScriptFlow 和 FlowInstance SQLModel 实体 - 实现 ScriptFlowService:流程定义 CRUD、步骤校验 - 实现 FlowEngine 状态机引擎:check_active_flow、start、advance、handle_timeout - 实现话术流程管理 API(POST/GET/PUT /admin/script-flows) - T13.7(单元测试)留待集成阶段
This commit is contained in:
parent
ff35538a01
commit
9d8ecf0bb2
|
|
@ -6,12 +6,13 @@ Admin API routes for AI Service management.
|
|||
from app.api.admin.api_key import router as api_key_router
|
||||
from app.api.admin.dashboard import router as dashboard_router
|
||||
from app.api.admin.embedding import router as embedding_router
|
||||
from app.api.admin.guardrails import router as guardrails_router
|
||||
from app.api.admin.intent_rules import router as intent_rules_router
|
||||
from app.api.admin.kb import router as kb_router
|
||||
from app.api.admin.llm import router as llm_router
|
||||
from app.api.admin.prompt_templates import router as prompt_templates_router
|
||||
from app.api.admin.rag import router as rag_router
|
||||
from app.api.admin.script_flows import router as script_flows_router
|
||||
from app.api.admin.sessions import router as sessions_router
|
||||
from app.api.admin.tenants import router as tenants_router
|
||||
|
||||
__all__ = ["api_key_router", "dashboard_router", "embedding_router", "intent_rules_router", "kb_router", "llm_router", "prompt_templates_router", "rag_router", "sessions_router", "tenants_router"]
|
||||
__all__ = ["api_key_router", "dashboard_router", "embedding_router", "guardrails_router", "intent_rules_router", "kb_router", "llm_router", "prompt_templates_router", "rag_router", "script_flows_router", "sessions_router", "tenants_router"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
"""
|
||||
Script Flow Management API.
|
||||
[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73] Script flow CRUD endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.entities import ScriptFlowCreate, ScriptFlowUpdate
|
||||
from app.services.flow.flow_service import ScriptFlowService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/script-flows", tags=["Script Flows"])
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
|
||||
"""Extract tenant ID from header."""
|
||||
if not x_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
|
||||
return x_tenant_id
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_flows(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
is_enabled: bool | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-72] List all script flows for a tenant.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-72] Listing script flows for tenant={tenant_id}, is_enabled={is_enabled}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
flows = await service.list_flows(tenant_id, is_enabled)
|
||||
|
||||
data = []
|
||||
for f in flows:
|
||||
linked_rule_count = await service._get_linked_rule_count(tenant_id, f.id)
|
||||
data.append({
|
||||
"id": str(f.id),
|
||||
"name": f.name,
|
||||
"description": f.description,
|
||||
"step_count": len(f.steps),
|
||||
"is_enabled": f.is_enabled,
|
||||
"linked_rule_count": linked_rule_count,
|
||||
"created_at": f.created_at.isoformat(),
|
||||
"updated_at": f.updated_at.isoformat(),
|
||||
})
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_flow(
|
||||
body: ScriptFlowCreate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-71] Create a new script flow.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-71] Creating script flow for tenant={tenant_id}, name={body.name}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
|
||||
try:
|
||||
flow = await service.create_flow(tenant_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return {
|
||||
"id": str(flow.id),
|
||||
"name": flow.name,
|
||||
"description": flow.description,
|
||||
"step_count": len(flow.steps),
|
||||
"is_enabled": flow.is_enabled,
|
||||
"created_at": flow.created_at.isoformat(),
|
||||
"updated_at": flow.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{flow_id}")
|
||||
async def get_flow_detail(
|
||||
flow_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-73] Get script flow detail with complete step definitions.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-73] Getting flow detail for tenant={tenant_id}, id={flow_id}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
detail = await service.get_flow_detail(tenant_id, flow_id)
|
||||
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
return detail
|
||||
|
||||
|
||||
@router.put("/{flow_id}")
|
||||
async def update_flow(
|
||||
flow_id: uuid.UUID,
|
||||
body: ScriptFlowUpdate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-73] Update script flow definition.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-73] Updating flow for tenant={tenant_id}, id={flow_id}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
|
||||
try:
|
||||
flow = await service.update_flow(tenant_id, flow_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not flow:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
return {
|
||||
"id": str(flow.id),
|
||||
"name": flow.name,
|
||||
"description": flow.description,
|
||||
"step_count": len(flow.steps),
|
||||
"is_enabled": flow.is_enabled,
|
||||
"created_at": flow.created_at.isoformat(),
|
||||
"updated_at": flow.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{flow_id}", status_code=204)
|
||||
async def delete_flow(
|
||||
flow_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""
|
||||
Delete a script flow.
|
||||
"""
|
||||
logger.info(f"Deleting flow for tenant={tenant_id}, id={flow_id}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
success = await service.delete_flow(tenant_id, flow_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
|
@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api import chat_router, health_router
|
||||
from app.api.admin import api_key_router, dashboard_router, embedding_router, intent_rules_router, kb_router, llm_router, prompt_templates_router, rag_router, sessions_router, tenants_router
|
||||
from app.api.admin import api_key_router, dashboard_router, embedding_router, guardrails_router, intent_rules_router, kb_router, llm_router, prompt_templates_router, rag_router, script_flows_router, sessions_router, tenants_router
|
||||
from app.api.admin.kb_optimized import router as kb_optimized_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import close_db, init_db
|
||||
|
|
@ -136,6 +136,7 @@ app.include_router(kb_optimized_router)
|
|||
app.include_router(llm_router)
|
||||
app.include_router(prompt_templates_router)
|
||||
app.include_router(rag_router)
|
||||
app.include_router(script_flows_router)
|
||||
app.include_router(sessions_router)
|
||||
app.include_router(tenants_router)
|
||||
|
||||
|
|
|
|||
|
|
@ -425,3 +425,296 @@ class IntentMatchResult:
|
|||
"matched": self.matched,
|
||||
"response_type": self.rule.response_type,
|
||||
}
|
||||
|
||||
|
||||
class ForbiddenWordCategory(str, Enum):
|
||||
"""[AC-AISVC-78] Forbidden word category."""
|
||||
COMPETITOR = "competitor"
|
||||
SENSITIVE = "sensitive"
|
||||
POLITICAL = "political"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class ForbiddenWordStrategy(str, Enum):
|
||||
"""[AC-AISVC-78] Forbidden word replacement strategy."""
|
||||
MASK = "mask"
|
||||
REPLACE = "replace"
|
||||
BLOCK = "block"
|
||||
|
||||
|
||||
class ForbiddenWord(SQLModel, table=True):
|
||||
"""
|
||||
[AC-AISVC-78] Forbidden word entity with tenant isolation.
|
||||
Supports mask/replace/block strategies for output filtering.
|
||||
"""
|
||||
|
||||
__tablename__ = "forbidden_words"
|
||||
__table_args__ = (
|
||||
Index("ix_forbidden_words_tenant_enabled", "tenant_id", "is_enabled"),
|
||||
Index("ix_forbidden_words_tenant_id", "tenant_id"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
|
||||
word: str = Field(..., description="Forbidden word to detect")
|
||||
category: str = Field(..., description="Category: competitor/sensitive/political/custom")
|
||||
strategy: str = Field(..., description="Replacement strategy: mask/replace/block")
|
||||
replacement: str | None = Field(default=None, description="Replacement text for 'replace' strategy")
|
||||
fallback_reply: str | None = Field(default=None, description="Fallback reply for 'block' strategy")
|
||||
is_enabled: bool = Field(default=True, description="Whether the word is enabled")
|
||||
hit_count: int = Field(default=0, ge=0, description="Hit count for statistics")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
||||
|
||||
|
||||
class ForbiddenWordCreate(SQLModel):
|
||||
"""[AC-AISVC-78] Schema for creating a new forbidden word."""
|
||||
|
||||
word: str
|
||||
category: str
|
||||
strategy: str
|
||||
replacement: str | None = None
|
||||
fallback_reply: str | None = None
|
||||
|
||||
|
||||
class ForbiddenWordUpdate(SQLModel):
|
||||
"""[AC-AISVC-80] Schema for updating a forbidden word."""
|
||||
|
||||
word: str | None = None
|
||||
category: str | None = None
|
||||
strategy: str | None = None
|
||||
replacement: str | None = None
|
||||
fallback_reply: str | None = None
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
class BehaviorRuleCategory(str, Enum):
|
||||
"""[AC-AISVC-84] Behavior rule category."""
|
||||
COMPLIANCE = "compliance"
|
||||
TONE = "tone"
|
||||
BOUNDARY = "boundary"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class BehaviorRule(SQLModel, table=True):
|
||||
"""
|
||||
[AC-AISVC-84] Behavior rule entity with tenant isolation.
|
||||
These rules are injected into Prompt system instruction as LLM behavior constraints.
|
||||
"""
|
||||
|
||||
__tablename__ = "behavior_rules"
|
||||
__table_args__ = (
|
||||
Index("ix_behavior_rules_tenant_enabled", "tenant_id", "is_enabled"),
|
||||
Index("ix_behavior_rules_tenant_id", "tenant_id"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
|
||||
rule_text: str = Field(..., description="Behavior constraint description, e.g., 'Do not promise specific compensation amounts'")
|
||||
category: str = Field(..., description="Category: compliance/tone/boundary/custom")
|
||||
is_enabled: bool = Field(default=True, description="Whether the rule is enabled")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
||||
|
||||
|
||||
class BehaviorRuleCreate(SQLModel):
|
||||
"""[AC-AISVC-84] Schema for creating a new behavior rule."""
|
||||
|
||||
rule_text: str
|
||||
category: str
|
||||
|
||||
|
||||
class BehaviorRuleUpdate(SQLModel):
|
||||
"""[AC-AISVC-85] Schema for updating a behavior rule."""
|
||||
|
||||
rule_text: str | None = None
|
||||
category: str | None = None
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
class GuardrailResult:
|
||||
"""
|
||||
[AC-AISVC-82] Result of guardrail filtering.
|
||||
Contains filtered reply and trigger information.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reply: str,
|
||||
blocked: bool = False,
|
||||
triggered_words: list[str] | None = None,
|
||||
triggered_categories: list[str] | None = None,
|
||||
):
|
||||
self.reply = reply
|
||||
self.blocked = blocked
|
||||
self.triggered_words = triggered_words or []
|
||||
self.triggered_categories = triggered_categories or []
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"reply": self.reply,
|
||||
"blocked": self.blocked,
|
||||
"triggered_words": self.triggered_words,
|
||||
"triggered_categories": self.triggered_categories,
|
||||
"guardrail_triggered": len(self.triggered_words) > 0,
|
||||
}
|
||||
|
||||
|
||||
class InputScanResult:
|
||||
"""
|
||||
[AC-AISVC-83] Result of input scanning.
|
||||
Contains flagged status and matched words (for logging only, no blocking).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
flagged: bool = False,
|
||||
matched_words: list[str] | None = None,
|
||||
matched_categories: list[str] | None = None,
|
||||
):
|
||||
self.flagged = flagged
|
||||
self.matched_words = matched_words or []
|
||||
self.matched_categories = matched_categories or []
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"input_flagged": self.flagged,
|
||||
"matched_words": self.matched_words,
|
||||
"matched_categories": self.matched_categories,
|
||||
}
|
||||
|
||||
|
||||
class FlowInstanceStatus(str, Enum):
|
||||
"""[AC-AISVC-74~AC-AISVC-77] Flow instance status."""
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
TIMEOUT = "timeout"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class TimeoutAction(str, Enum):
|
||||
"""[AC-AISVC-71] Timeout action for flow steps."""
|
||||
REPEAT = "repeat"
|
||||
SKIP = "skip"
|
||||
TRANSFER = "transfer"
|
||||
|
||||
|
||||
class ScriptFlow(SQLModel, table=True):
|
||||
"""
|
||||
[AC-AISVC-71] Script flow entity with tenant isolation.
|
||||
Stores flow definition with steps in JSONB format.
|
||||
"""
|
||||
|
||||
__tablename__ = "script_flows"
|
||||
__table_args__ = (
|
||||
Index("ix_script_flows_tenant_id", "tenant_id"),
|
||||
Index("ix_script_flows_tenant_enabled", "tenant_id", "is_enabled"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
|
||||
name: str = Field(..., description="Flow name")
|
||||
description: str | None = Field(default=None, description="Flow description")
|
||||
steps: list[dict[str, Any]] = Field(
|
||||
default=[],
|
||||
sa_column=Column("steps", JSON, nullable=False),
|
||||
description="Flow steps list with step_no, content, wait_input, timeout_seconds, timeout_action, next_conditions, default_next"
|
||||
)
|
||||
is_enabled: bool = Field(default=True, description="Whether the flow is enabled")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
||||
|
||||
|
||||
class FlowInstance(SQLModel, table=True):
|
||||
"""
|
||||
[AC-AISVC-74] Flow instance entity for runtime state.
|
||||
Tracks active flow execution per session.
|
||||
"""
|
||||
|
||||
__tablename__ = "flow_instances"
|
||||
__table_args__ = (
|
||||
Index("ix_flow_instances_tenant_session", "tenant_id", "session_id"),
|
||||
Index("ix_flow_instances_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True)
|
||||
session_id: str = Field(..., description="Session ID for conversation tracking", index=True)
|
||||
flow_id: uuid.UUID = Field(..., description="Foreign key to script_flows.id", foreign_key="script_flows.id", index=True)
|
||||
current_step: int = Field(default=1, ge=1, description="Current step number (1-indexed)")
|
||||
status: str = Field(default=FlowInstanceStatus.ACTIVE.value, description="Instance status: active/completed/timeout/cancelled")
|
||||
context: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column("context", JSON, nullable=True),
|
||||
description="Flow execution context, stores user inputs etc."
|
||||
)
|
||||
started_at: datetime = Field(default_factory=datetime.utcnow, description="Instance start time")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
||||
completed_at: datetime | None = Field(default=None, description="Completion time (nullable)")
|
||||
|
||||
|
||||
class FlowStep(SQLModel):
|
||||
"""[AC-AISVC-71] Schema for a single flow step."""
|
||||
|
||||
step_no: int = Field(..., ge=1, description="Step number (1-indexed)")
|
||||
content: str = Field(..., description="Script content for this step")
|
||||
wait_input: bool = Field(default=True, description="Whether to wait for user input")
|
||||
timeout_seconds: int = Field(default=120, ge=1, description="Timeout in seconds")
|
||||
timeout_action: str = Field(default=TimeoutAction.REPEAT.value, description="Action on timeout: repeat/skip/transfer")
|
||||
next_conditions: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description="Conditions for next step: [{'keywords': [...], 'goto_step': N}, {'pattern': '...', 'goto_step': N}]"
|
||||
)
|
||||
default_next: int | None = Field(default=None, description="Default next step if no condition matches")
|
||||
|
||||
|
||||
class ScriptFlowCreate(SQLModel):
|
||||
"""[AC-AISVC-71] Schema for creating a new script flow."""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
steps: list[dict[str, Any]]
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class ScriptFlowUpdate(SQLModel):
|
||||
"""[AC-AISVC-73] Schema for updating a script flow."""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
steps: list[dict[str, Any]] | None = None
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
class FlowAdvanceResult:
|
||||
"""
|
||||
[AC-AISVC-75] Result of flow step advancement.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
completed: bool,
|
||||
message: str | None = None,
|
||||
current_step: int | None = None,
|
||||
total_steps: int | None = None,
|
||||
timeout_action: str | None = None,
|
||||
):
|
||||
self.completed = completed
|
||||
self.message = message
|
||||
self.current_step = current_step
|
||||
self.total_steps = total_steps
|
||||
self.timeout_action = timeout_action
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = {
|
||||
"completed": self.completed,
|
||||
}
|
||||
if self.message is not None:
|
||||
result["message"] = self.message
|
||||
if self.current_step is not None:
|
||||
result["current_step"] = self.current_step
|
||||
if self.total_steps is not None:
|
||||
result["total_steps"] = self.total_steps
|
||||
if self.timeout_action is not None:
|
||||
result["timeout_action"] = self.timeout_action
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
Flow services for AI Service.
|
||||
[AC-AISVC-71~AC-AISVC-77] Script flow management and execution engine.
|
||||
"""
|
||||
|
||||
from app.services.flow.flow_service import ScriptFlowService
|
||||
from app.services.flow.engine import FlowEngine
|
||||
|
||||
__all__ = ["ScriptFlowService", "FlowEngine"]
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
"""
|
||||
Flow Engine for AI Service.
|
||||
[AC-AISVC-74~AC-AISVC-77] State machine engine for script flow execution.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import col
|
||||
|
||||
from app.models.entities import (
|
||||
ScriptFlow,
|
||||
FlowInstance,
|
||||
FlowInstanceStatus,
|
||||
FlowAdvanceResult,
|
||||
TimeoutAction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FlowEngine:
|
||||
"""
|
||||
[AC-AISVC-74~AC-AISVC-77] State machine engine for script flow execution.
|
||||
|
||||
State Machine:
|
||||
- IDLE: No active flow
|
||||
- ACTIVE: Flow is being executed
|
||||
- COMPLETED: Flow finished successfully
|
||||
- TIMEOUT: Flow timed out
|
||||
- CANCELLED: Flow was cancelled
|
||||
|
||||
Core Methods:
|
||||
- check_active_flow: Check if session has active flow
|
||||
- start: Start a new flow instance
|
||||
- advance: Advance flow based on user input
|
||||
- handle_timeout: Handle timeout for current step
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def check_active_flow(
|
||||
self,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
) -> FlowInstance | None:
|
||||
"""
|
||||
[AC-AISVC-75] Check if session has an active flow instance.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID for isolation
|
||||
session_id: Session ID to check
|
||||
|
||||
Returns:
|
||||
Active FlowInstance or None
|
||||
"""
|
||||
stmt = select(FlowInstance).where(
|
||||
FlowInstance.tenant_id == tenant_id,
|
||||
FlowInstance.session_id == session_id,
|
||||
FlowInstance.status == FlowInstanceStatus.ACTIVE.value,
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def start(
|
||||
self,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
flow_id: uuid.UUID,
|
||||
) -> tuple[FlowInstance | None, str | None]:
|
||||
"""
|
||||
[AC-AISVC-74] Start a new flow instance and return first step content.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID for isolation
|
||||
session_id: Session ID for the conversation
|
||||
flow_id: ID of the flow to start
|
||||
|
||||
Returns:
|
||||
Tuple of (FlowInstance, first_step_content) or (None, error_message)
|
||||
"""
|
||||
active = await self.check_active_flow(tenant_id, session_id)
|
||||
if active:
|
||||
logger.warning(
|
||||
f"[AC-AISVC-74] Session already has active flow: "
|
||||
f"tenant={tenant_id}, session={session_id}"
|
||||
)
|
||||
return None, "Session already has an active flow"
|
||||
|
||||
flow = await self._get_flow(tenant_id, flow_id)
|
||||
if not flow:
|
||||
logger.warning(
|
||||
f"[AC-AISVC-74] Flow not found: tenant={tenant_id}, flow_id={flow_id}"
|
||||
)
|
||||
return None, "Flow not found"
|
||||
|
||||
if not flow.is_enabled:
|
||||
logger.warning(
|
||||
f"[AC-AISVC-74] Flow is disabled: tenant={tenant_id}, flow_id={flow_id}"
|
||||
)
|
||||
return None, "Flow is disabled"
|
||||
|
||||
if not flow.steps:
|
||||
logger.warning(
|
||||
f"[AC-AISVC-74] Flow has no steps: tenant={tenant_id}, flow_id={flow_id}"
|
||||
)
|
||||
return None, "Flow has no steps"
|
||||
|
||||
instance = FlowInstance(
|
||||
tenant_id=tenant_id,
|
||||
session_id=session_id,
|
||||
flow_id=flow_id,
|
||||
current_step=1,
|
||||
status=FlowInstanceStatus.ACTIVE.value,
|
||||
context={"inputs": []},
|
||||
)
|
||||
self._session.add(instance)
|
||||
await self._session.flush()
|
||||
|
||||
first_step = flow.steps[0]
|
||||
first_content = first_step.get("content", "")
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-74] Started flow instance: tenant={tenant_id}, "
|
||||
f"session={session_id}, flow_id={flow_id}, step=1/{len(flow.steps)}"
|
||||
)
|
||||
|
||||
return instance, first_content
|
||||
|
||||
async def advance(
|
||||
self,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
user_input: str,
|
||||
) -> FlowAdvanceResult:
|
||||
"""
|
||||
[AC-AISVC-75, AC-AISVC-76] Advance flow based on user input.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID for isolation
|
||||
session_id: Session ID for the conversation
|
||||
user_input: User's input message
|
||||
|
||||
Returns:
|
||||
FlowAdvanceResult with completion status and next message
|
||||
"""
|
||||
instance = await self.check_active_flow(tenant_id, session_id)
|
||||
if not instance:
|
||||
logger.debug(
|
||||
f"[AC-AISVC-75] No active flow for session: tenant={tenant_id}, session={session_id}"
|
||||
)
|
||||
return FlowAdvanceResult(completed=True, message=None)
|
||||
|
||||
flow = await self._get_flow_by_id(instance.flow_id)
|
||||
if not flow:
|
||||
await self._cancel_instance(instance, "Flow definition not found")
|
||||
return FlowAdvanceResult(completed=True, message=None)
|
||||
|
||||
current_step_idx = instance.current_step - 1
|
||||
if current_step_idx >= len(flow.steps):
|
||||
await self._complete_instance(instance)
|
||||
return FlowAdvanceResult(completed=True, message=None)
|
||||
|
||||
current_step = flow.steps[current_step_idx]
|
||||
|
||||
self._record_input(instance, user_input)
|
||||
|
||||
next_step_no = self._match_next_step(current_step, user_input)
|
||||
|
||||
if next_step_no is None:
|
||||
default_next = current_step.get("default_next")
|
||||
if default_next:
|
||||
next_step_no = default_next
|
||||
else:
|
||||
logger.debug(
|
||||
f"[AC-AISVC-75] No condition matched, repeating step: "
|
||||
f"tenant={tenant_id}, session={session_id}, step={instance.current_step}"
|
||||
)
|
||||
return FlowAdvanceResult(
|
||||
completed=False,
|
||||
message=current_step.get("content", ""),
|
||||
current_step=instance.current_step,
|
||||
total_steps=len(flow.steps),
|
||||
)
|
||||
|
||||
if next_step_no > len(flow.steps):
|
||||
await self._complete_instance(instance)
|
||||
logger.info(
|
||||
f"[AC-AISVC-76] Flow completed: tenant={tenant_id}, "
|
||||
f"session={session_id}, flow_id={instance.flow_id}"
|
||||
)
|
||||
return FlowAdvanceResult(completed=True, message=None)
|
||||
|
||||
instance.current_step = next_step_no
|
||||
instance.updated_at = datetime.utcnow()
|
||||
await self._session.flush()
|
||||
|
||||
next_step = flow.steps[next_step_no - 1]
|
||||
next_content = next_step.get("content", "")
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-75] Advanced flow: tenant={tenant_id}, "
|
||||
f"session={session_id}, step={next_step_no}/{len(flow.steps)}"
|
||||
)
|
||||
|
||||
return FlowAdvanceResult(
|
||||
completed=False,
|
||||
message=next_content,
|
||||
current_step=next_step_no,
|
||||
total_steps=len(flow.steps),
|
||||
)
|
||||
|
||||
async def handle_timeout(
|
||||
self,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
) -> FlowAdvanceResult:
|
||||
"""
|
||||
[AC-AISVC-77] Handle timeout for current step.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID for isolation
|
||||
session_id: Session ID for the conversation
|
||||
|
||||
Returns:
|
||||
FlowAdvanceResult based on timeout_action configuration
|
||||
"""
|
||||
instance = await self.check_active_flow(tenant_id, session_id)
|
||||
if not instance:
|
||||
return FlowAdvanceResult(completed=True, message=None)
|
||||
|
||||
flow = await self._get_flow_by_id(instance.flow_id)
|
||||
if not flow:
|
||||
await self._cancel_instance(instance, "Flow definition not found")
|
||||
return FlowAdvanceResult(completed=True, message=None)
|
||||
|
||||
current_step_idx = instance.current_step - 1
|
||||
if current_step_idx >= len(flow.steps):
|
||||
await self._complete_instance(instance)
|
||||
return FlowAdvanceResult(completed=True, message=None)
|
||||
|
||||
current_step = flow.steps[current_step_idx]
|
||||
timeout_action = current_step.get("timeout_action", TimeoutAction.REPEAT.value)
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-77] Handling timeout: tenant={tenant_id}, "
|
||||
f"session={session_id}, step={instance.current_step}, action={timeout_action}"
|
||||
)
|
||||
|
||||
if timeout_action == TimeoutAction.REPEAT.value:
|
||||
return FlowAdvanceResult(
|
||||
completed=False,
|
||||
message=current_step.get("content", ""),
|
||||
current_step=instance.current_step,
|
||||
total_steps=len(flow.steps),
|
||||
timeout_action=timeout_action,
|
||||
)
|
||||
|
||||
elif timeout_action == TimeoutAction.SKIP.value:
|
||||
default_next = current_step.get("default_next")
|
||||
if default_next and default_next <= len(flow.steps):
|
||||
instance.current_step = default_next
|
||||
instance.updated_at = datetime.utcnow()
|
||||
await self._session.flush()
|
||||
|
||||
next_step = flow.steps[default_next - 1]
|
||||
return FlowAdvanceResult(
|
||||
completed=False,
|
||||
message=next_step.get("content", ""),
|
||||
current_step=default_next,
|
||||
total_steps=len(flow.steps),
|
||||
timeout_action=timeout_action,
|
||||
)
|
||||
else:
|
||||
await self._complete_instance(instance)
|
||||
return FlowAdvanceResult(completed=True, message=None)
|
||||
|
||||
elif timeout_action == TimeoutAction.TRANSFER.value:
|
||||
instance.status = FlowInstanceStatus.TIMEOUT.value
|
||||
instance.completed_at = datetime.utcnow()
|
||||
instance.updated_at = datetime.utcnow()
|
||||
await self._session.flush()
|
||||
|
||||
return FlowAdvanceResult(
|
||||
completed=True,
|
||||
message="抱歉,等待超时,正在为您转接人工客服...",
|
||||
timeout_action=timeout_action,
|
||||
)
|
||||
|
||||
return FlowAdvanceResult(
|
||||
completed=False,
|
||||
message=current_step.get("content", ""),
|
||||
timeout_action=timeout_action,
|
||||
)
|
||||
|
||||
async def cancel_flow(
|
||||
self,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
reason: str = "User cancelled",
|
||||
) -> bool:
|
||||
"""Cancel an active flow instance."""
|
||||
instance = await self.check_active_flow(tenant_id, session_id)
|
||||
if not instance:
|
||||
return False
|
||||
|
||||
await self._cancel_instance(instance, reason)
|
||||
return True
|
||||
|
||||
async def get_flow_status(
|
||||
self,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get the current flow status for a session."""
|
||||
stmt = select(FlowInstance).where(
|
||||
FlowInstance.tenant_id == tenant_id,
|
||||
FlowInstance.session_id == session_id,
|
||||
).order_by(col(FlowInstance.created_at).desc())
|
||||
result = await self._session.execute(stmt)
|
||||
instance = result.scalar_one_or_none()
|
||||
|
||||
if not instance:
|
||||
return None
|
||||
|
||||
flow = await self._get_flow_by_id(instance.flow_id)
|
||||
|
||||
return {
|
||||
"instance_id": str(instance.id),
|
||||
"flow_id": str(instance.flow_id),
|
||||
"flow_name": flow.name if flow else None,
|
||||
"current_step": instance.current_step,
|
||||
"total_steps": len(flow.steps) if flow else 0,
|
||||
"status": instance.status,
|
||||
"started_at": instance.started_at.isoformat(),
|
||||
"updated_at": instance.updated_at.isoformat(),
|
||||
"completed_at": instance.completed_at.isoformat() if instance.completed_at else None,
|
||||
}
|
||||
|
||||
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_by_id(
|
||||
self,
|
||||
flow_id: uuid.UUID,
|
||||
) -> ScriptFlow | None:
|
||||
"""Get flow by ID without tenant check (for internal use)."""
|
||||
stmt = select(ScriptFlow).where(ScriptFlow.id == flow_id)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _match_next_step(
|
||||
self,
|
||||
step: dict[str, Any],
|
||||
user_input: str,
|
||||
) -> int | None:
|
||||
"""
|
||||
Match user input against next_conditions.
|
||||
|
||||
Args:
|
||||
step: Current step definition
|
||||
user_input: User's input message
|
||||
|
||||
Returns:
|
||||
goto_step number if matched, None otherwise
|
||||
"""
|
||||
next_conditions = step.get("next_conditions", [])
|
||||
if not next_conditions:
|
||||
return None
|
||||
|
||||
user_input_lower = user_input.lower()
|
||||
|
||||
for condition in next_conditions:
|
||||
keywords = condition.get("keywords", [])
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in user_input_lower:
|
||||
return condition.get("goto_step")
|
||||
|
||||
pattern = condition.get("pattern")
|
||||
if pattern:
|
||||
try:
|
||||
if re.search(pattern, user_input, re.IGNORECASE):
|
||||
return condition.get("goto_step")
|
||||
except re.error:
|
||||
logger.warning(f"Invalid regex pattern: {pattern}")
|
||||
|
||||
return None
|
||||
|
||||
def _record_input(
|
||||
self,
|
||||
instance: FlowInstance,
|
||||
user_input: str,
|
||||
) -> None:
|
||||
"""Record user input in flow context."""
|
||||
if instance.context is None:
|
||||
instance.context = {"inputs": []}
|
||||
|
||||
inputs = instance.context.get("inputs", [])
|
||||
inputs.append({
|
||||
"step": instance.current_step,
|
||||
"input": user_input,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
})
|
||||
instance.context["inputs"] = inputs
|
||||
|
||||
async def _complete_instance(
|
||||
self,
|
||||
instance: FlowInstance,
|
||||
) -> None:
|
||||
"""Mark instance as completed."""
|
||||
instance.status = FlowInstanceStatus.COMPLETED.value
|
||||
instance.completed_at = datetime.utcnow()
|
||||
instance.updated_at = datetime.utcnow()
|
||||
await self._session.flush()
|
||||
|
||||
async def _cancel_instance(
|
||||
self,
|
||||
instance: FlowInstance,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""Mark instance as cancelled."""
|
||||
instance.status = FlowInstanceStatus.CANCELLED.value
|
||||
instance.completed_at = datetime.utcnow()
|
||||
instance.updated_at = datetime.utcnow()
|
||||
if instance.context is None:
|
||||
instance.context = {}
|
||||
instance.context["cancel_reason"] = reason
|
||||
await self._session.flush()
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
"""
|
||||
Script Flow Service for AI Service.
|
||||
[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73] Flow definition CRUD operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Sequence
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import col
|
||||
|
||||
from app.models.entities import (
|
||||
ScriptFlow,
|
||||
ScriptFlowCreate,
|
||||
ScriptFlowUpdate,
|
||||
FlowInstanceStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScriptFlowService:
|
||||
"""
|
||||
[AC-AISVC-71~AC-AISVC-73] Service for managing script flow definitions.
|
||||
|
||||
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.
|
||||
"""
|
||||
self._validate_steps(create_data.steps)
|
||||
|
||||
flow = ScriptFlow(
|
||||
tenant_id=tenant_id,
|
||||
name=create_data.name,
|
||||
description=create_data.description,
|
||||
steps=create_data.steps,
|
||||
is_enabled=create_data.is_enabled,
|
||||
)
|
||||
self._session.add(flow)
|
||||
await self._session.flush()
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-71] Created script flow: tenant={tenant_id}, "
|
||||
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
|
||||
)
|
||||
|
||||
if is_enabled is not None:
|
||||
stmt = stmt.where(ScriptFlow.is_enabled == is_enabled)
|
||||
|
||||
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.
|
||||
"""
|
||||
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,
|
||||
"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.
|
||||
"""
|
||||
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
|
||||
flow.updated_at = datetime.utcnow()
|
||||
|
||||
await self._session.flush()
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-73] Updated script flow: tenant={tenant_id}, id={flow_id}"
|
||||
)
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
if not step.get("content"):
|
||||
raise ValueError(f"Step {step_no} must have content")
|
||||
|
||||
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")
|
||||
|
|
@ -47,7 +47,7 @@ Phase 11 多知识库管理核心功能已完成 (T11.1-T11.5),T11.6(Optimiz
|
|||
|
||||
- [x] T11.1 扩展 `KnowledgeBase` 实体:新增 `kb_type`、`priority`、`is_enabled`、`doc_count` 字段 `[AC-AISVC-59]` ✅
|
||||
- [x] T11.2 实现知识库 CRUD 服务:创建时初始化 Qdrant Collection,删除时清理 Collection `[AC-AISVC-59, AC-AISVC-61, AC-AISVC-62]` ✅
|
||||
- [x] T11.3 实现知识库管理 API:`POST/GET/PUT/DELETE /admin/kb/knowledge-bases` `[AC-AISVC-59~AC-AISVC-62]` ✅
|
||||
- [x] T11.3 实现知识库管理 API:`POST/GET/PUT/DELETE /admin/kb/knowledge-bases` `[AC-AISVC-59, AC-AISVC-60, AC-AISVC-61, AC-AISVC-62]` ✅
|
||||
- [x] T11.4 升级 Qdrant Collection 命名:`kb_{tenant_id}_{kb_id}`,兼容现有 `kb_{tenant_id}` `[AC-AISVC-63]` ✅
|
||||
- [x] T11.5 修改文档上传流程:支持指定 `kbId` 参数,索引到对应 Collection `[AC-AISVC-63]` ✅
|
||||
|
||||
|
|
|
|||
|
|
@ -210,12 +210,12 @@ last_updated: "2026-02-27"
|
|||
|
||||
> 目标:实现固定话术步骤的状态机引擎,支持多轮引导对话。
|
||||
|
||||
- [ ] T13.1 定义 `ScriptFlow` 和 `FlowInstance` SQLModel 实体,创建数据库表 `[AC-AISVC-71, AC-AISVC-74]`
|
||||
- [ ] T13.2 实现 `ScriptFlowService`:流程定义 CRUD `[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73]`
|
||||
- [ ] T13.3 实现 `FlowEngine.check_active_flow()`:检查会话是否有活跃流程实例 `[AC-AISVC-75]`
|
||||
- [ ] T13.4 实现 `FlowEngine.start()`:创建流程实例,返回第一步话术 `[AC-AISVC-74]`
|
||||
- [ ] T13.5 实现 `FlowEngine.advance()`:根据用户输入匹配条件,推进步骤或重复当前步骤 `[AC-AISVC-75, AC-AISVC-76]`
|
||||
- [ ] T13.6 实现话术流程管理 API:`POST/GET/PUT /admin/script-flows` `[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73]`
|
||||
- [x] T13.1 定义 `ScriptFlow` 和 `FlowInstance` SQLModel 实体,创建数据库表 `[AC-AISVC-71, AC-AISVC-74]` ✅
|
||||
- [x] T13.2 实现 `ScriptFlowService`:流程定义 CRUD `[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73]` ✅
|
||||
- [x] T13.3 实现 `FlowEngine.check_active_flow()`:检查会话是否有活跃流程实例 `[AC-AISVC-75]` ✅
|
||||
- [x] T13.4 实现 `FlowEngine.start()`:创建流程实例,返回第一步话术 `[AC-AISVC-74]` ✅
|
||||
- [x] T13.5 实现 `FlowEngine.advance()`:根据用户输入匹配条件,推进步骤或重复当前步骤 `[AC-AISVC-75, AC-AISVC-76]` ✅
|
||||
- [x] T13.6 实现话术流程管理 API:`POST/GET/PUT /admin/script-flows` `[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73]` ✅
|
||||
- [ ] T13.7 编写话术流程引擎单元测试 `[AC-AISVC-71~AC-AISVC-77]`
|
||||
|
||||
---
|
||||
|
|
|
|||
Loading…
Reference in New Issue