ai-robot-core/ai-service/app/services/flow/tester.py

300 lines
9.6 KiB
Python

"""
Script Flow Tester for AI Service.
[AC-AISVC-101, AC-AISVC-102] Flow simulation and coverage analysis.
"""
import logging
import re
import time
from dataclasses import dataclass, field
from typing import Any
from app.models.entities import ScriptFlow
logger = logging.getLogger(__name__)
@dataclass
class MatchedCondition:
"""Matched condition details."""
type: str
goto_step: int
keywords: list[str] | None = None
pattern: str | None = None
@dataclass
class FlowSimulationStep:
"""Single step in flow simulation."""
stepNo: int
botMessage: str
userInput: str
matchedCondition: MatchedCondition | None
nextStep: int | None
durationMs: int = 0
@dataclass
class FlowSimulationResult:
"""Result of flow simulation."""
flowId: str
flowName: str
simulation: list[FlowSimulationStep] = field(default_factory=list)
result: dict[str, Any] = field(default_factory=dict)
coverage: dict[str, Any] = field(default_factory=dict)
issues: list[str] = field(default_factory=list)
class ScriptFlowTester:
"""
[AC-AISVC-101, AC-AISVC-102] Script flow simulation and coverage analysis.
Features:
- Simulate flow execution with user inputs
- Calculate step coverage rate
- Detect issues: dead loops, low coverage, uncovered branches
- No database modification (read-only simulation)
"""
MIN_COVERAGE_THRESHOLD = 0.8
MAX_SIMULATION_STEPS_MULTIPLIER = 2
def simulate_flow(
self,
flow: ScriptFlow,
user_inputs: list[str],
) -> FlowSimulationResult:
"""
[AC-AISVC-101] Simulate flow execution and analyze coverage.
Args:
flow: ScriptFlow entity to simulate
user_inputs: List of user inputs to feed into the flow
Returns:
FlowSimulationResult with simulation steps, coverage, and issues
"""
logger.info(
f"[AC-AISVC-101] Starting flow simulation: flow_id={flow.id}, "
f"flow_name={flow.name}, inputs_count={len(user_inputs)}"
)
simulation: list[FlowSimulationStep] = []
current_step = 1
visited_steps: set[int] = set()
total_steps = len(flow.steps)
total_duration_ms = 0
final_message: str | None = None
completed = False
for user_input in user_inputs:
if current_step > total_steps:
completed = True
break
step_start = time.time()
step_def = flow.steps[current_step - 1]
visited_steps.add(current_step)
matched_condition, next_step = self._match_next_step(step_def, user_input)
if next_step is None:
default_next = step_def.get("default_next")
if default_next:
next_step = default_next
matched_condition = MatchedCondition(
type="default",
goto_step=default_next,
)
else:
next_step = current_step
simulation.append(
FlowSimulationStep(
stepNo=current_step,
botMessage=step_def.get("content", ""),
userInput=user_input,
matchedCondition=matched_condition,
nextStep=next_step,
durationMs=int((time.time() - step_start) * 1000),
)
)
total_duration_ms += simulation[-1].durationMs
if next_step > total_steps:
completed = True
final_message = step_def.get("content", "")
break
current_step = next_step
if len(simulation) > total_steps * self.MAX_SIMULATION_STEPS_MULTIPLIER:
logger.warning(
f"[AC-AISVC-101] Simulation exceeded max steps: "
f"flow_id={flow.id}, steps={len(simulation)}"
)
break
covered_steps = len(visited_steps)
coverage_rate = covered_steps / total_steps if total_steps > 0 else 0.0
uncovered_steps = set(range(1, total_steps + 1)) - visited_steps
issues = self._detect_issues(
simulation=simulation,
total_steps=total_steps,
coverage_rate=coverage_rate,
uncovered_steps=uncovered_steps,
)
result = FlowSimulationResult(
flowId=str(flow.id),
flowName=flow.name,
simulation=simulation,
result={
"completed": completed,
"totalSteps": total_steps,
"totalDurationMs": total_duration_ms,
"finalMessage": final_message,
},
coverage={
"totalSteps": total_steps,
"coveredSteps": covered_steps,
"coverageRate": round(coverage_rate, 2),
"uncoveredSteps": sorted(list(uncovered_steps)),
},
issues=issues,
)
logger.info(
f"[AC-AISVC-101] Flow simulation completed: flow_id={flow.id}, "
f"coverage_rate={coverage_rate:.2%}, issues_count={len(issues)}"
)
return result
def _match_next_step(
self,
step_def: dict[str, Any],
user_input: str,
) -> tuple[MatchedCondition | None, int | None]:
"""
[AC-AISVC-101] Match user input against next_conditions.
Args:
step_def: Current step definition
user_input: User's input message
Returns:
Tuple of (matched_condition, goto_step) or (None, None)
"""
next_conditions = step_def.get("next_conditions", [])
if not next_conditions:
return None, 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:
matched = MatchedCondition(
type="keyword",
keywords=keywords,
goto_step=condition.get("goto_step"),
)
return matched, condition.get("goto_step")
pattern = condition.get("pattern")
if pattern:
try:
if re.search(pattern, user_input, re.IGNORECASE):
matched = MatchedCondition(
type="pattern",
pattern=pattern,
goto_step=condition.get("goto_step"),
)
return matched, condition.get("goto_step")
except re.error:
logger.warning(f"Invalid regex pattern: {pattern}")
return None, None
def _detect_issues(
self,
simulation: list[FlowSimulationStep],
total_steps: int,
coverage_rate: float,
uncovered_steps: set[int],
) -> list[str]:
"""
[AC-AISVC-102] Detect issues in flow simulation.
Args:
simulation: List of simulation steps
total_steps: Total number of steps in flow
coverage_rate: Coverage rate (0.0 to 1.0)
uncovered_steps: Set of uncovered step numbers
Returns:
List of issue descriptions
"""
issues: list[str] = []
if coverage_rate < self.MIN_COVERAGE_THRESHOLD:
issues.append(
f"流程覆盖率低于 {int(self.MIN_COVERAGE_THRESHOLD * 100)}%,建议增加测试用例"
)
if len(simulation) > total_steps * self.MAX_SIMULATION_STEPS_MULTIPLIER:
issues.append("检测到可能的死循环")
if uncovered_steps:
issues.append(f"未覆盖步骤:{sorted(list(uncovered_steps))}")
step_visit_count: dict[int, int] = {}
for step in simulation:
step_visit_count[step.stepNo] = step_visit_count.get(step.stepNo, 0) + 1
for step_no, count in step_visit_count.items():
if count > 3:
issues.append(f"步骤 {step_no} 被重复访问 {count} 次,可能存在逻辑问题")
return issues
def to_dict(self, result: FlowSimulationResult) -> dict[str, Any]:
"""Convert simulation result to API response dict."""
simulation_data = []
for step in result.simulation:
step_data = {
"stepNo": step.stepNo,
"botMessage": step.botMessage,
"userInput": step.userInput,
"nextStep": step.nextStep,
"durationMs": step.durationMs,
}
if step.matchedCondition:
step_data["matchedCondition"] = {
"type": step.matchedCondition.type,
"gotoStep": step.matchedCondition.goto_step,
}
if step.matchedCondition.keywords:
step_data["matchedCondition"]["keywords"] = step.matchedCondition.keywords
if step.matchedCondition.pattern:
step_data["matchedCondition"]["pattern"] = step.matchedCondition.pattern
else:
step_data["matchedCondition"] = None
simulation_data.append(step_data)
return {
"flowId": result.flowId,
"flowName": result.flowName,
"simulation": simulation_data,
"result": result.result,
"coverage": result.coverage,
"issues": result.issues,
}