Files
doubao/scenarios/tc_generator.py
2026-07-03 14:30:34 +08:00

391 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import re
import sys
import json
from pathlib import Path
from typing import Callable, Any
import pytest
import yaml
from glrocky.core.logger import logger
from glrocky.framework.marks import Marks as M
from glrocky.framework.schemas import Device
from glrocky.services.dify.dify import run_workflow
from loguru._logger import Logger
from pydantic import BaseModel, ConfigDict, Field, field_serializer
import socket
def _get_local_ip() -> str:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip: str = s.getsockname()[0]
s.close()
return str(ip)
PRODUCTION_EXECUTOR = "192.168.0.139"
IS_PRODUCTION = _get_local_ip() == PRODUCTION_EXECUTOR
class MetricManager:
def __init__(self, metric):
self.metric = metric
def new_span(self, m_type: str = "default", m_id: str = "default", m_iter: int = 1):
self.metric.span(m_type, m_id, m_iter)
def _smart_name_label(self, name: str, label: str | None) -> tuple[str, str]:
"""retruns: (name,label)"""
if not name:
raise ValueError()
if not name.strip():
raise ValueError("Empty name")
if label:
return name, label
for sep in ("|", ":"):
if sep in name:
_n, _l = name.split(sep, maxsplit=1)
return (_n, _l)
return name, name
def add_text_metric(self, name: str, value: str, label: str | None = None):
_n, _l = self._smart_name_label(name, label)
self.metric.add(
name=_n,
label=_l,
value=value,
type="text",
)
def add_number_metric(self, name: str, value: float, label: str | None = None):
_n, _l = self._smart_name_label(name, label)
self.metric.add(
name=_n,
label=_l,
value=value,
type="number",
)
def add_image_metric(self, name: str, value: Path | str, label: str | None = None):
_n, _l = self._smart_name_label(name, label)
self.metric.add(
name=_n,
label=_l,
value=str(value),
type="image",
)
def add_video_metric(self, name: str, value: Path | str, label: str | None = None):
_n, _l = self._smart_name_label(name, label)
self.metric.add(
name=_n,
label=_l,
value=value,
type="video",
)
def send(self):
self.metric.send_all()
def from_dict(self, the_dict: dict[str, Any]):
if not the_dict:
raise ValueError("dict is null")
for _k, v in the_dict.items():
k = {
"result": "result|结果", # True ,False
"_dify_result": "_dify_result|业务完成", # True ,False
"_dify_message": "_dify_message|业务错误信息", # string,None
"resultText": "resultText|文本结果",
"recordVideo": "recordVideo|录像",
"recordAudio": "recordAudio|录音",
"screenshotList": "screenshotList|截图",
"firstToken": "firstToken|首字符时长",
"timeSeries":"timeSeries|时间序列",
"fileList":"fileList|文件"
}.get(_k, _k)
logger.info(k)
if v is None:
self.add_text_metric(name=k, value="")
elif isinstance(v, (int, float)):
self.add_number_metric(name=k, value=v)
elif isinstance(v, str):
self.add_text_metric(name=k, value=v)
elif isinstance(v, Path):
if v.suffix.lower() in (".jpg", ".png", ".gif", ".bmp"):
self.add_image_metric(name=k, value=str(v.resolve()))
elif v.suffix.lower() in (".mp4", ".mkv"):
self.add_video_metric(name=k, value=str(v.resolve()))
else:
self.add_text_metric(name=k, value=str(v.resolve()))
else:
raise NotImplementedError(f"{type(v)} not supported")
class DifySettings(BaseModel):
difyUrl: str = Field(
title="Dify服务地址",
)
difyWorkflowId: str = Field(
title="Dify工作流ID",
)
difyApiKey: str = Field(
title="Dify API密钥",
)
class MaterialForDify(BaseModel):
paramGroupUuid: list[str] = Field(default=[], title="UUID", description="UUID")
inputTextList: list[str] = Field(
default=[], title="对话列表,支持多轮对话", description="对话列表,支持多轮对话"
)
prompt: list[str] = Field(default=[], title="提示词", description="提示词")
model_config = ConfigDict(arbitrary_types_allowed=True) # allows uuid extra...
def pre_process_material(input:list[str])->str:
assert isinstance(input,list)
if not input:
return json.dumps([],ensure_ascii=False)
result=[]
p=re.compile(r"Q\d+\s*[:\.]+\s*",flags=re.MULTILINE)
for text in input:
splited=re.split(p,text,maxsplit=50)
for part in splited:
if not part.strip():
continue
result.append(part.strip())
return json.dumps(result, ensure_ascii=False)
class DifyPayload(BaseModel):
inputTextList: list[str] | str
prompt: list[str] | str
deviceId: str
address: str
caseId:str # 用例名称,当前运行的用例编号
appName: str | None = None
@field_serializer("inputTextList")
def serialize_input_text(self, value: list[str] | str) -> str:
if isinstance(value, list):
return pre_process_material(value)
return value
@field_serializer("prompt")
def serialize_prompt_as_single_str(self, value: list[str] | str) -> str:
if isinstance(value, list):
return value[0] if len(value) > 0 else ""
return value
def call_dify(
case_meta_info:dict[str,str],
logger: Logger,
device_info: Device,
material: list[MaterialForDify],
dify_cfg: DifySettings,
metric,
material_reporter,
app_name: str | None = None,
):
# logger.info(device_info.device_serial)
logger.info(dify_cfg)
event_callbacks: dict[str, Callable[..., None]] = {}
def on_node_started(c, n, d: dict[str, Any]):
logger.info(f"开始执行:{d.get('title', '')}")
logger.info(f"输入节点参数:{d.get('inputs')}")
def on_node_finished(c, n, d):
logger.info(f"结束执行:{d.get('title', '')}")
logger.info(f"节点输出:{d.get('outputs')}")
event_callbacks["on_node_started"] = on_node_started
event_callbacks["on_node_finished"] = on_node_finished
if not material or len(material) < 1:
raise RuntimeError("缺少素材")
else:
logger.info(f"下发素材:{material}")
mm = MetricManager(metric)
for material_index, item in enumerate(material, 1):
paramGroupUuid = item.paramGroupUuid[0]
assert paramGroupUuid, "The param Group Uuid Value must be set."
material_reporter.begin(paramGroupUuid)
dify_final_status = False
try:
# IMPORTANT: the group uuid for params
payload = DifyPayload(
**item.model_dump(),
deviceId=device_info.device_serial,
address=_get_local_ip(),
caseId=case_meta_info.get('id','Unkown'),
appName=app_name,
)
logger.info(f"payload send to dify:\n{payload.model_dump_json()}\n")
mm.new_span("default", f"default-{material_index}", material_index)
result = run_workflow(
api_key=dify_cfg.difyApiKey,
base_url=dify_cfg.difyUrl,
workflow_id=dify_cfg.difyWorkflowId,
inputs=payload.model_dump(),
event_callbacks=event_callbacks,
)
logger.info(f"工作流返回结果:{result.outputs}")
logger.info(f"工作流最终状态:{result.success}")
if result.outputs and len(result.outputs):
logger.info("提交结果到执行器")
mm.from_dict(result.outputs)
metric.send_all()
logger.info("提交结果到执行器完成")
else:
logger.warning("dify 工作流无返回")
if not result.success:
logger.error(f"工作流执行失败:{result.error}")
dify_final_status = result.success
except Exception as e:
logger.error(e)
dify_final_status = False
continue # do not break next round
finally:
material_reporter.end_with(paramGroupUuid, dify_final_status)
def make_dify_test(
case_meta: dict[str, str],
apps_config: dict[str, DifySettings] | None = None,
) -> Callable[..., None]:
@M.meta(**case_meta)
def _func(
logger: Logger,
device_info: Device,
material: list[MaterialForDify],
metric,
material_reporter,
meta,
) -> None:
dify_settings = apps_config.get(meta.app_name) if apps_config else None
call_dify(
case_meta,
logger,
device_info,
material,
dify_cfg=dify_settings,
metric=metric,
material_reporter=material_reporter,
app_name=meta.app_name,
)
return _func
def make_skip_test(meta: dict[str, str]) -> Callable[..., None]:
@M.skip(reason=f"{meta['id']} 未实现")
@M.meta(**meta)
def _func(
logger: Logger,
device_info: Device,
material: list[MaterialForDify],
metric,
) -> None:
logger.error(f"此用例在手机{device_info.device_serial}上暂未实现")
assert False, "not implemented"
return _func
def generate_cases_from_yaml(module_name: str, yaml_path: Path):
if not yaml_path.exists():
logger.warning(f"Test case definition file not found: {yaml_path}")
return
with open(file=yaml_path, mode="r", encoding="utf-8") as f:
all_cases: list[dict[str, Any]] = yaml.safe_load( # pyright: ignore[reportAny]
f
)
if not all_cases:
return
for case_info in all_cases["cases"]:
if not isinstance(case_info, dict):
logger.debug(f"Skipping non-dictionary item in YAML file: {case_info}")
continue
case_id: str = case_info.get("id",'')
if not case_id:
logger.warning(f"用例 缺少 case_id 字段。{case_info=}")
pytest.fail(f"用例 缺少 case_id 字段。{case_info=}")
description: str = case_info.get("description",'')
if not description:
logger.warning(f"用例{case_id} 缺少 description 字段。")
pytest.fail(f"用例{case_id} 缺少 description 字段。")
action: str = case_info.get("action", "skipped")
meta = {"id": case_id, "description": description, "appNames": ["agent"]}
fn_name = f"test_{case_id.lower().replace('-', '_')}"
if action == "dify":
apps_config: dict[str, DifySettings] = {}
if apps_list := case_info.get("apps"):
app_names = []
for app_entry in apps_list:
name = app_entry.get("name", "")
app_names.append(name)
if cfg_block := app_entry.get("dify_config"):
env_key = "production" if IS_PRODUCTION else "testing"
if env_cfg := cfg_block.get(env_key):
apps_config[name] = DifySettings(
difyUrl=env_cfg.get("url"),
difyWorkflowId=env_cfg.get("workflow_id"),
difyApiKey=env_cfg.get("api_key"),
)
meta["appNames"] = app_names
elif cfg_block := case_info.get("dify_config"):
env_key = "production" if IS_PRODUCTION else "testing"
if env_cfg := cfg_block.get(env_key):
apps_config["agent"] = DifySettings(
difyUrl=env_cfg.get("url"),
difyWorkflowId=env_cfg.get("workflow_id"),
difyApiKey=env_cfg.get("api_key"),
)
meta["appNames"] = ["agent"]
fn = make_dify_test(meta, apps_config or None)
elif action == "skipped":
fn = make_skip_test(meta)
elif action == "custom":
logger.info(f"Case {case_id} is a custom test.")
continue
else:
logger.warning(f"Unknown action '{action}' for case {case_id}. Skipping.")
continue
setattr(sys.modules[module_name], fn_name, fn)
logger.info(f"Generated {fn_name} for {case_id} with action '{action}'")
# class TC_CUSTOME_CONFIG(BaseModel):
# phoneNumber: str = Field(
# default="15338070617", title="拨号电话", description="待测机的手机号码"
# )
# @M.meta(id="TC-9991")
# def tc_9991(cfg:TC_CUSTOME_CONFIG):
# ...
# class TC_CUSTOME_CONFIG2(BaseModel):
# phoneNumber: str = Field(
# default="15338070617", title="拨号电话", description="待测机的手机号码"
# )
# phoneNumber2: str = Field(
# default="15338070617", title="拨号电话", description="待测机的手机号码"
# )
# def tc_9992(cfg:TC_CUSTOME_CONFIG2):
# ...
YAML_FILE = Path(__file__).parent / "test_cases.yaml"
generate_cases_from_yaml(__name__, YAML_FILE)