mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
523 lines
32 KiB
Python
523 lines
32 KiB
Python
"""Source-backed service docs checks without loading models or starting servers."""
|
|
|
|
import ast
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
EXAMPLE = ROOT / "examples/openai_api"
|
|
READMES = [EXAMPLE / name for name in ("README.md", "README_zh.md", "README_ja.md", "README_ko.md")]
|
|
DOCS = [EXAMPLE / "CLIENTS.md", *READMES]
|
|
PACKAGED = ROOT / "funasr/bin/_server_app.py"
|
|
WORKFLOW_DOCS = [EXAMPLE / name for name in ("WORKFLOWS.md", "WORKFLOWS_zh.md")]
|
|
|
|
# Frozen from the two pre-edit workflow guides, not translated from one language.
|
|
WORKFLOW_HEADINGS = {
|
|
"WORKFLOWS.md": "Low-code Workflow Recipes for the FunASR OpenAI-Compatible API|Server preflight|Postman smoke test|Multipart HTTP request|Dify custom tool or HTTP node|Direct file upload path|Audio URL path|n8n HTTP Request node|n8n OpenAI Audio node|Webhook worker pattern|Production guardrails|Troubleshooting",
|
|
"WORKFLOWS_zh.md": "FunASR OpenAI 兼容 API 低代码工作流配方|服务预检|Postman smoke test|Multipart HTTP 请求|Dify 自定义工具或 HTTP 节点|直接上传文件|音频 URL 转写|n8n HTTP Request 节点|n8n OpenAI Audio 节点|Webhook worker 模式|生产环境护栏|故障排查",
|
|
}
|
|
|
|
# Frozen from the pre-edit README inventory, excluding fenced code comments.
|
|
LEGACY_HEADINGS = {
|
|
"README.md": "FunASR OpenAI-Compatible API Server|API Contract|Quick Start|End-to-end smoke test|Browser demo with Gradio|Usage with OpenAI SDK (Python)|Usage with curl|Available Models|API Endpoints|Agent Framework Integration|LangChain Example|Docker Deployment|Kubernetes Deployment|Configuration|Troubleshooting",
|
|
"README_zh.md": "FunASR OpenAI 兼容 API 服务|API Contract|快速开始|端到端 smoke test|Gradio 浏览器 Demo|使用 OpenAI SDK|使用 curl|可用模型|API 端点|Agent 与低代码工作流|Docker 部署|Kubernetes 部署|配置|故障排查",
|
|
"README_ja.md": "FunASR OpenAI 互換 API サーバー|クイックスタート|エンドツーエンド smoke test|Gradio ブラウザデモ|OpenAI SDK で使う|curl で使う|利用できるモデル|API エンドポイント|エージェントとローコードワークフロー|Docker デプロイ|Kubernetes デプロイ|設定|トラブルシューティング",
|
|
"README_ko.md": "FunASR OpenAI 호환 API 서버|빠른 시작|엔드투엔드 smoke test|Gradio 브라우저 데모|OpenAI SDK로 사용하기|curl로 사용하기|사용 가능한 모델|API 엔드포인트|에이전트 및 로우코드 워크플로|Docker 배포|Kubernetes 배포|설정|문제 해결",
|
|
}
|
|
|
|
|
|
def supported_module_command(args):
|
|
"""Allow only the documented environment setup commands, not arbitrary modules."""
|
|
return args in (["python3.11", "-m", "venv", ".venv"],
|
|
["python", "-m", "pip", "install", "-e", "."],
|
|
["python", "-m", "pip", "install", "fastapi", "uvicorn", "python-multipart"],
|
|
["python", "-m", "pip", "install", "openai"],
|
|
["python", "-m", "pip", "install", "gradio"],
|
|
["python", "-m", "pip", "check"])
|
|
|
|
|
|
def blocks(text, language):
|
|
return re.findall(r"^```" + language + r"\n(.*?)^```", text, re.M | re.S)
|
|
|
|
|
|
def tree(path):
|
|
return ast.parse(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def function(path, name):
|
|
return next(node for node in ast.walk(tree(path))
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
and node.name == name)
|
|
|
|
|
|
def form_defaults(node):
|
|
defaults = {}
|
|
for arg, value in zip(node.args.args[-len(node.args.defaults):], node.args.defaults):
|
|
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name) and value.func.id == "Form":
|
|
defaults[arg.arg] = ast.literal_eval(next(
|
|
keyword.value for keyword in value.keywords if keyword.arg == "default"))
|
|
return defaults
|
|
|
|
|
|
def headings(text):
|
|
text = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
|
result = set()
|
|
counts = {}
|
|
for heading in re.findall(r"^#{1,6}\s+(.+?)\s*#*\s*$", text, re.M):
|
|
slug = re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-")
|
|
count = counts.get(slug, 0)
|
|
counts[slug] = count + 1
|
|
result.add(f"{slug}-{count}" if count else slug)
|
|
return result
|
|
|
|
|
|
class ServiceDocsContract(unittest.TestCase):
|
|
def test_preserves_existing_readme_headings(self):
|
|
for path in READMES:
|
|
text = path.read_text(encoding="utf-8")
|
|
source = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
|
actual = re.findall(r"^#{1,6}\s+(.+?)\s*#*\s*$", source, re.M)
|
|
expected = LEGACY_HEADINGS[path.name].split("|")
|
|
self.assertEqual([value for value in actual if value in expected], expected, path.name)
|
|
|
|
def test_long_contract_and_model_explanations_are_readable_lists(self):
|
|
model_headings = ("Available Models", "可用模型", "利用できるモデル", "사용 가능한 모델")
|
|
configs = next(node for node in ast.walk(tree(EXAMPLE / "server.py"))
|
|
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name)
|
|
and target.id == "MODEL_CONFIGS" for target in node.targets))
|
|
aliases = [ast.literal_eval(key) for key in configs.value.keys]
|
|
for path, model_heading in zip(READMES, model_headings):
|
|
text = path.read_text(encoding="utf-8")
|
|
for heading in ("API Contract", model_heading):
|
|
section = text.split("## " + heading + "\n", 1)[1].split("\n## ", 1)[0]
|
|
self.assertNotRegex(section, r"(?m)^\|", path.name)
|
|
if heading == model_heading:
|
|
self.assertEqual(re.findall(r"(?m)^- `([^`]+)`", section), aliases, path.name)
|
|
|
|
def test_module_command_allowlist_rejects_unchecked_variants(self):
|
|
self.assertTrue(supported_module_command(["python", "-m", "pip", "check"]))
|
|
for args in (["python", "-m", "missing"], ["python", "-m", "pip", "uninstall", "funasr"],
|
|
["python3.11", "-m", "venv"], ["python", "-m", "pip", "check", "--bogus"]):
|
|
self.assertFalse(supported_module_command(args))
|
|
|
|
def test_readme_checkout_and_loopback_recipes(self):
|
|
for path in READMES:
|
|
text = path.read_text(encoding="utf-8")
|
|
quick_start = blocks(text, "bash")[0]
|
|
ordered = ["git clone https://github.com/modelscope/FunASR.git FunASR-api", "cd FunASR-api",
|
|
"git checkout --detach e19029adca384a06a2f60bd8c18cb98f1a0499aa",
|
|
"python3.11 -m venv .venv", "source .venv/bin/activate",
|
|
"python -m pip install -e .", "python -m pip install fastapi uvicorn python-multipart",
|
|
"python -m pip check", "cd examples/openai_api",
|
|
"python server.py --host 127.0.0.1 --model sensevoice --device cpu --port 8000"]
|
|
positions = [quick_start.index(command) for command in ordered]
|
|
self.assertEqual(positions, sorted(positions), path.name)
|
|
self.assertIn("SECURITY", text[:text.index("```bash")])
|
|
for marker in ("/v1/models", "/openapi.json", "Fun-ASR-MLT-Nano", '--model-path', '--hub',
|
|
'model="custom"', 'whisper-1', 'sentence_info', 'ctc_timestamps', '0.0.0.0'):
|
|
self.assertIn(marker, text, path.name)
|
|
self.assertIn("FUNASR_HOST_PORT=127.0.0.1:8000 docker compose up --build", text)
|
|
for code in blocks(text, "bash"):
|
|
for line in code.replace("\\\n", " ").splitlines():
|
|
args = shlex.split(line, comments=True)
|
|
if args[:2] == ["docker", "run"]:
|
|
self.assertEqual(args[args.index("-p") + 1], "127.0.0.1:8000:8000")
|
|
self.assertNotRegex(text, r"(?:170|120|3\.6)[x×倍]")
|
|
|
|
def test_readme_clients_close_files_and_use_supported_form_fields(self):
|
|
allowed = set(form_defaults(function(EXAMPLE / "server.py", "transcribe"))) | {"file"}
|
|
for path in READMES:
|
|
formats = []
|
|
for code in blocks(path.read_text(encoding="utf-8"), "python"):
|
|
parsed = ast.parse(code)
|
|
calls = [node for node in ast.walk(parsed) if isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute) and node.func.attr == "create"]
|
|
for call in calls:
|
|
kwargs = {kw.arg: kw.value for kw in call.keywords}
|
|
formats.append(ast.literal_eval(kwargs["response_format"]) if "response_format" in kwargs else None)
|
|
self.assertEqual(ast.literal_eval(kwargs["model"]), "sensevoice")
|
|
self.assertLessEqual(set(kwargs), allowed)
|
|
self.assertIsInstance(kwargs["file"], ast.Name)
|
|
contexts = [node for node in ast.walk(parsed) if isinstance(node, ast.With)
|
|
and call in list(ast.walk(node))]
|
|
self.assertTrue(any(isinstance(item.optional_vars, ast.Name)
|
|
and item.optional_vars.id == kwargs["file"].id
|
|
and isinstance(item.context_expr, ast.Call)
|
|
and isinstance(item.context_expr.func, ast.Name)
|
|
and item.context_expr.func.id == "open"
|
|
and len(item.context_expr.args) == 2
|
|
and ast.literal_eval(item.context_expr.args[1]) == "rb"
|
|
for node in contexts for item in node.items))
|
|
self.assertGreaterEqual(len(formats), 2, path.name)
|
|
self.assertEqual(set(formats), {None, "verbose_json"}, path.name)
|
|
|
|
def test_relative_links_and_owned_page_anchors(self):
|
|
owned = {path.resolve() for path in DOCS}
|
|
for path in DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
self.assertIn("## API Contract", text)
|
|
self.assertIn("](#api-contract)", text)
|
|
for link in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text):
|
|
parts = urlsplit(link)
|
|
if parts.scheme or parts.netloc:
|
|
continue
|
|
target = (path.parent / unquote(parts.path)).resolve() if parts.path else path.resolve()
|
|
self.assertIn(ROOT.resolve(), target.parents)
|
|
self.assertNotIn(".mcp-tasks", target.parts)
|
|
self.assertTrue(target.exists(), f"{path.name}: {link}")
|
|
if parts.fragment and target in owned:
|
|
self.assertIn(unquote(parts.fragment), headings(target.read_text(encoding="utf-8")), link)
|
|
|
|
def test_sdk_links_and_unsupported_claims(self):
|
|
for path in DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
for link in ("../../docs/python_api.md", "../../docs/python_api_zh.md"):
|
|
self.assertIn(f"]({link})", text)
|
|
for marker in ("spk=true", "segments=[]", "--model auto", "OpenAI API"):
|
|
self.assertIn(marker, text)
|
|
for stale in ("170x realtime", "120x realtime", "3.6x realtime",
|
|
"Server starts in ~20s", "Works with **any agent framework**",
|
|
"Fast default with language, emotion, and event tags",
|
|
"Returns: text, segments (with start/end/speaker), duration"):
|
|
self.assertNotIn(stale, text)
|
|
|
|
def test_python_and_shell_examples_are_syntactically_valid(self):
|
|
for path in DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
for index, code in enumerate(blocks(text, "python")):
|
|
compile(code, f"{path}:{index}", "exec")
|
|
for index, code in enumerate(blocks(text, "bash")):
|
|
result = subprocess.run(["bash", "-n"], input=code, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, f"{path}:{index}: {result.stderr}")
|
|
|
|
def test_local_script_commands_and_options_match_source(self):
|
|
for path in DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
if path.name.startswith("README"):
|
|
quick_start = blocks(text, "bash")[0]
|
|
self.assertIn("cd examples/openai_api", quick_start)
|
|
self.assertLess(quick_start.index("cd examples/openai_api"), quick_start.index("python server.py"))
|
|
for code in blocks(text, "bash"):
|
|
for line in code.replace("\\\n", " ").splitlines():
|
|
args = shlex.split(line, comments=True)
|
|
if len(args) < 2 or args[0] not in ("python", "python3.11", "bash"):
|
|
continue
|
|
if args[1] == "-m":
|
|
self.assertTrue(supported_module_command(args), args)
|
|
continue
|
|
script = EXAMPLE / args[1]
|
|
self.assertTrue(script.is_file(), str(script))
|
|
if args[0] != "python":
|
|
continue
|
|
options = {ast.literal_eval(arg) for node in ast.walk(tree(script))
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "add_argument"
|
|
for arg in node.args if isinstance(arg, ast.Constant) and isinstance(arg.value, str)}
|
|
for arg in args[2:]:
|
|
if arg.startswith("--"):
|
|
self.assertIn(arg.split("=", 1)[0], options, str(script))
|
|
|
|
def test_form_defaults_and_alias_boundaries_match_source(self):
|
|
example = form_defaults(function(EXAMPLE / "server.py", "transcribe"))
|
|
packaged = form_defaults(function(PACKAGED, "transcribe"))
|
|
self.assertEqual(example["model"], "sensevoice")
|
|
self.assertNotIn("spk", example)
|
|
self.assertEqual(packaged["model"], "fun-asr-nano")
|
|
self.assertIs(packaged["spk"], False)
|
|
for path, name, expected in ((EXAMPLE / "server.py", "MODEL_CONFIGS", True),
|
|
(PACKAGED, "FALLBACK_CONFIGS", False)):
|
|
assignment = next(node for node in ast.walk(tree(path))
|
|
if isinstance(node, ast.Assign) and any(
|
|
isinstance(target, ast.Name) and target.id == name for target in node.targets))
|
|
aliases = [ast.literal_eval(key) for key in assignment.value.keys]
|
|
self.assertEqual("paraformer-en" in aliases, expected)
|
|
|
|
def test_example_serializes_sentence_info_not_raw_sdk_timestamps(self):
|
|
handler = function(EXAMPLE / "server.py", "transcribe")
|
|
branch = next(node for node in ast.walk(handler) if isinstance(node, ast.If)
|
|
and isinstance(node.test, ast.Compare)
|
|
and isinstance(node.test.left, ast.Name)
|
|
and node.test.left.id == "response_format")
|
|
|
|
class CaptureReturn(ast.NodeTransformer):
|
|
def visit_Return(self, node):
|
|
return ast.copy_location(ast.Assign(
|
|
targets=[ast.Name(id="response", ctx=ast.Store())], value=node.value), node)
|
|
|
|
executable = ast.fix_missing_locations(CaptureReturn().visit(ast.Module(body=[branch], type_ignores=[])))
|
|
clean = function(EXAMPLE / "server.py", "clean_text")
|
|
namespace = {"re": re, "JSONResponse": lambda value: value, "response_format": "verbose_json",
|
|
"elapsed": 0.42, "language": None, "model": "sensevoice", "text": "hello"}
|
|
exec(compile(ast.Module(body=[clean], type_ignores=[]), "clean-text", "exec"), namespace)
|
|
raw_token = {"token": "hello", "start_time": 100, "end_time": 400}
|
|
cases = [({"timestamp": [[100, 400]], "timestamps": [raw_token], "ctc_timestamps": [raw_token]}, []),
|
|
({"sentence_info": [{"start": 100, "end": 400, "text": "<|HAPPY|>hello"}]},
|
|
[{"start": 0.1, "end": 0.4, "text": "hello", "speaker": None}])]
|
|
for result, expected in cases:
|
|
namespace["result"] = [result]
|
|
exec(compile(executable, "example-verbose-branch", "exec"), namespace)
|
|
self.assertEqual(namespace["response"]["segments"], expected)
|
|
self.assertEqual(namespace["response"]["language"], "auto")
|
|
self.assertEqual(namespace["response"]["duration"], 0.42)
|
|
|
|
def test_workflow_alias_and_compose_host_binding_match_source(self):
|
|
resolver = function(EXAMPLE / "server.py", "resolve_openai_transcription_model")
|
|
namespace = {"N8N_OPENAI_MODEL_ALIAS": "whisper-1", "DEFAULT_MODEL": "sensevoice"}
|
|
exec(compile(ast.Module(body=[resolver], type_ignores=[]), "workflow-alias", "exec"), namespace)
|
|
self.assertEqual(namespace[resolver.name]("whisper-1"), "sensevoice")
|
|
self.assertEqual(namespace[resolver.name]("paraformer"), "paraformer")
|
|
compose = (EXAMPLE / "docker-compose.yml").read_text(encoding="utf-8")
|
|
self.assertIn('"${FUNASR_HOST_PORT:-8000}:8000"', compose)
|
|
dockerfile = (EXAMPLE / "Dockerfile").read_text(encoding="utf-8")
|
|
command = next(json.loads(line[4:]) for line in dockerfile.splitlines() if line.startswith("CMD "))
|
|
args = shlex.split(command[-1])
|
|
self.assertEqual(args[args.index("--host") + 1], "0.0.0.0")
|
|
|
|
def test_startup_defaults_match_source(self):
|
|
for path, expected in ((EXAMPLE / "server.py", "sensevoice"),
|
|
(ROOT / "funasr/bin/server.py", "auto")):
|
|
option = next(node for node in ast.walk(tree(path)) if isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute) and node.func.attr == "add_argument"
|
|
and node.args and isinstance(node.args[0], ast.Constant)
|
|
and node.args[0].value == "--model")
|
|
self.assertEqual(next(ast.literal_eval(kw.value) for kw in option.keywords
|
|
if kw.arg == "default"), expected)
|
|
startup = function(PACKAGED, "create_app")
|
|
selection = next(node for node in ast.walk(startup) if isinstance(node, ast.IfExp)
|
|
and isinstance(node.body, ast.Constant) and node.body.value == "fun-asr-nano")
|
|
for device, expected in (("cuda:0", "fun-asr-nano"), ("cpu", "sensevoice"), ("mps", "sensevoice")):
|
|
self.assertEqual(eval(compile(ast.Expression(selection), str(PACKAGED), "eval"), {"device": device}), expected)
|
|
|
|
def test_packaged_verbose_example_matches_real_builder(self):
|
|
samples = [json.loads(code) for code in blocks(DOCS[0].read_text(encoding="utf-8"), "json")]
|
|
self.assertEqual(len(samples), 3)
|
|
namespace = {"resolve_transcription_language": lambda requested, result: requested or result["language"]}
|
|
builder = function(PACKAGED, "build_openai_verbose_json")
|
|
exec(compile(ast.Module(body=[builder], type_ignores=[]), str(PACKAGED), "exec"), namespace)
|
|
result = {"text": "recognized speech", "language": "en", "duration": 3.2,
|
|
"segments": [{"start": 0.0, "end": 3.2, "text": "recognized speech"}]}
|
|
self.assertEqual(namespace[builder.name](result), samples[1])
|
|
self.assertNotIn("speaker", samples[1]["segments"][0])
|
|
self.assertLessEqual(samples[1]["segments"][0]["end"], samples[1]["duration"])
|
|
|
|
def test_example_verbose_sample_and_duration_meaning(self):
|
|
samples = [json.loads(code) for code in blocks(DOCS[0].read_text(encoding="utf-8"), "json")]
|
|
handler = function(EXAMPLE / "server.py", "transcribe")
|
|
response = next(node for node in ast.walk(handler) if isinstance(node, ast.Dict)
|
|
and any(isinstance(key, ast.Constant) and key.value == "duration" for key in node.keys))
|
|
expected = eval(compile(ast.Expression(response), "example-response", "eval"),
|
|
{"text": "recognized speech", "segments": [], "language": None,
|
|
"elapsed": 0.42, "model": "sensevoice"})
|
|
self.assertEqual(expected, samples[2])
|
|
source = PACKAGED.read_text(encoding="utf-8")
|
|
self.assertIn("float(sf.info(audio_path).duration)", source)
|
|
self.assertIn('"duration": len(audio_data) / sr', source)
|
|
clients = DOCS[0].read_text(encoding="utf-8")
|
|
self.assertIn("not audio duration", clients)
|
|
self.assertIn("Audio duration in seconds", clients)
|
|
self.assertIn("does not enable diarization", clients)
|
|
|
|
|
|
class WorkflowDocsContract(unittest.TestCase):
|
|
def test_workflow_explanatory_sections_use_complete_readable_lists(self):
|
|
sections = [
|
|
(("Multipart HTTP request", "Multipart HTTP 请求"),
|
|
["Method", "URL", "Body type", "File field", "Text field", "Text field", "Timeout"],
|
|
[("POST",), ("/v1/audio/transcriptions",), ("multipart/form-data",), ("`file`",),
|
|
("model=sensevoice",), ("response_format=verbose_json",), ("300",)]),
|
|
(("n8n HTTP Request node", "n8n HTTP Request 节点"),
|
|
["Method", "URL", "Send Body", "Body Content Type", "Binary file field", "Additional form fields", "Response Format", "Timeout"],
|
|
[("POST",), ("/v1/audio/transcriptions",), ("enabled",), ("Form-Data", "multipart"),
|
|
("`file`",), ("model=sensevoice", "response_format=verbose_json"), ("JSON",), ()]),
|
|
]
|
|
symptoms = [
|
|
["Workflow can call `/health` but transcription fails", "`localhost` connection fails from Dify or n8n",
|
|
"Response has no usable `segments`", "Requests time out", "First request is slow", "Unknown model alias"],
|
|
["工作流能访问 `/health`,但转写失败", "Dify 或 n8n 访问 `localhost` 失败", "响应中没有可用 `segments`",
|
|
"请求超时", "第一次请求很慢", "模型别名未知"],
|
|
]
|
|
fixes = [("multipart/form-data", "`file`"), ("Compose", "Kubernetes"),
|
|
("sentence_info", "verbose_json"), ("HTTP timeout",), ("--model sensevoice", "/health"), ("/v1/models",)]
|
|
for language, path in enumerate(WORKFLOW_DOCS):
|
|
text = path.read_text(encoding="utf-8")
|
|
cases = [*sections, (("Troubleshooting", "故障排查"), symptoms[language], fixes)]
|
|
for titles, labels, tokens in cases:
|
|
with self.subTest(path=path.name, section=titles[language]):
|
|
section = text.split("## " + titles[language] + "\n", 1)[1].split("\n## ", 1)[0]
|
|
self.assertNotRegex(section, r"(?m)^\s*\|")
|
|
self.assertNotRegex(section, r"(?i)<table\b")
|
|
lines = section.splitlines()
|
|
start = next((i for i, line in enumerate(lines) if line.startswith("- **")), None)
|
|
self.assertIsNotNone(start, "Missing explanation list")
|
|
items = []
|
|
for line in lines[start:]:
|
|
if not line.startswith("- "):
|
|
break
|
|
match = re.fullmatch(r"- \*\*(.+?):\*\* (.+)", line)
|
|
self.assertIsNotNone(match, line)
|
|
items.append(match.groups())
|
|
self.assertEqual([label for label, _ in items], labels)
|
|
for (_, value), required in zip(items, tokens):
|
|
for token in required:
|
|
self.assertIn(token, value)
|
|
|
|
def test_preserves_workflow_headings_and_links(self):
|
|
for path in WORKFLOW_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
source = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
|
actual = re.findall(r"^(#{1,6}) (.+)$", source, re.M)
|
|
titles = WORKFLOW_HEADINGS[path.name].split("|")
|
|
levels = [1, 2, 2, 2, 2, 3, 3, 2, 3, 2, 2, 2]
|
|
self.assertEqual([(len(level), title) for level, title in actual], list(zip(levels, titles)))
|
|
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
|
preserved = {"WORKFLOWS.md" if suffix else "WORKFLOWS_zh.md",
|
|
f"POSTMAN{suffix}.md", f"OPENAPI{suffix}.md", f"SECURITY{suffix}.md",
|
|
f"../../docs/moss_transcribe_diarize{suffix}.md"}
|
|
links = set(re.findall(r"\[[^\]]+\]\(([^)]+)\)", text))
|
|
self.assertLessEqual(preserved, links)
|
|
for link in links:
|
|
parts = urlsplit(link)
|
|
if parts.scheme or parts.netloc:
|
|
continue
|
|
target = (path.parent / unquote(parts.path)).resolve()
|
|
self.assertIn(ROOT.resolve(), target.parents)
|
|
self.assertNotIn(".mcp-tasks", target.parts)
|
|
self.assertTrue(target.is_file(), link)
|
|
if parts.fragment:
|
|
self.assertIn(unquote(parts.fragment), headings(target.read_text(encoding="utf-8")), link)
|
|
|
|
def test_workflow_preflight_uses_prepared_loopback_server(self):
|
|
options = {ast.literal_eval(arg) for node in ast.walk(tree(EXAMPLE / "server.py"))
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "add_argument" for arg in node.args
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)}
|
|
for path in WORKFLOW_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
prefix = text.split("```bash", 1)[0]
|
|
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
|
self.assertIn(f"](README{suffix}.md", prefix)
|
|
self.assertIn(f"](SECURITY{suffix}.md)", prefix)
|
|
self.assertIn(f"](../../docs/agent_integration{suffix}.md)", text)
|
|
startup = blocks(text, "bash")[0]
|
|
commands = [shlex.split(line, comments=True) for line in startup.splitlines() if line.strip()]
|
|
self.assertIn(["source", "../../.venv/bin/activate"], commands)
|
|
launches = [args for args in commands if args[:2] == ["python", "server.py"]]
|
|
self.assertEqual(len(launches), 1)
|
|
args = launches[0]
|
|
for option, expected in (("--host", "127.0.0.1"), ("--model", "sensevoice"), ("--device", "cpu")):
|
|
self.assertIn(option, args)
|
|
self.assertEqual(args[args.index(option) + 1], expected)
|
|
self.assertLessEqual({arg for arg in args if arg.startswith("--")}, options)
|
|
|
|
def test_workflow_url_assignments_are_copyable_and_checks_are_explicit(self):
|
|
for path in WORKFLOW_DOCS:
|
|
codes = blocks(path.read_text(encoding="utf-8"), "bash")
|
|
assignments = []
|
|
for code in codes:
|
|
self.assertNotRegex(code, r"<[^\n>]+>")
|
|
result = subprocess.run(["bash", "-n"], input=code, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
for line in code.replace("\\\n", " ").splitlines():
|
|
args = shlex.split(line, comments=True)
|
|
if args[:1] == ["export"]:
|
|
assignments.extend(arg for arg in args[1:] if arg.startswith("FUNASR_BASE_URL="))
|
|
self.assertEqual(assignments, ["FUNASR_BASE_URL=http://127.0.0.1:8000"])
|
|
for endpoint in ("health", "v1/models", "openapi.json"):
|
|
self.assertIn(f'"$FUNASR_BASE_URL/{endpoint}"', "\n".join(codes))
|
|
|
|
def test_workflow_json_examples_match_actual_service_response_builders(self):
|
|
handler = function(EXAMPLE / "server.py", "transcribe")
|
|
response = next(node for node in ast.walk(handler) if isinstance(node, ast.Dict)
|
|
and any(isinstance(key, ast.Constant) and key.value == "duration" for key in node.keys))
|
|
example = eval(compile(ast.Expression(response), "example-response", "eval"),
|
|
{"text": "recognized speech", "segments": [], "language": None,
|
|
"elapsed": 0.42, "model": "sensevoice"})
|
|
namespace = {}
|
|
functions = [function(PACKAGED, name) for name in
|
|
("_split_text_for_openai_segments", "build_openai_fallback_segments",
|
|
"resolve_transcription_language", "build_openai_verbose_json")]
|
|
exec(compile(ast.Module(body=functions, type_ignores=[]), str(PACKAGED), "exec"), namespace)
|
|
result = {"text": "recognized speech", "duration": 3.2, "language": "en",
|
|
"segments": namespace["build_openai_fallback_segments"]("recognized speech", 3.2)}
|
|
packaged = namespace["build_openai_verbose_json"](result)
|
|
for path in WORKFLOW_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
samples = [json.loads(code) for code in blocks(text, "json")]
|
|
self.assertEqual(samples, [example, packaged], path.name)
|
|
self.assertIn("](CLIENTS.md#response-formats)", text)
|
|
for marker in ("sentence_info", "segments=[]", "spk=true", "ctc_timestamps", "use_itn", "hotwords"):
|
|
self.assertIn(marker, text)
|
|
for speaker in (None, 0, "SPK0", "S01"):
|
|
result["segments"][0]["speaker"] = speaker
|
|
segment = namespace["build_openai_verbose_json"](result)["segments"][0]
|
|
if speaker is None:
|
|
self.assertNotIn("speaker", segment)
|
|
else:
|
|
self.assertEqual(segment["speaker"], speaker)
|
|
|
|
def test_workflow_url_worker_warns_before_code_and_declares_dependency(self):
|
|
for path in WORKFLOW_DOCS:
|
|
text = path.read_text(encoding="utf-8")
|
|
section = text.split("### Audio URL path" if path.name == "WORKFLOWS.md" else "### 音频 URL 转写", 1)[1]
|
|
warning = section.split("```python", 1)[0]
|
|
markers = ("not implemented", "redirect", "private-network", "byte", "trusted") if path.name == "WORKFLOWS.md" else (
|
|
"未实现", "重定向", "私网", "字节", "可信")
|
|
for marker in markers:
|
|
self.assertIn(marker, warning)
|
|
self.assertIn("python -m pip install requests", text[:text.index("```python")])
|
|
|
|
def test_workflow_python_examples_are_equivalent_and_send_real_multipart_fields(self):
|
|
snippets = [blocks(path.read_text(encoding="utf-8"), "python") for path in WORKFLOW_DOCS]
|
|
self.assertEqual([len(items) for items in snippets], [2, 2])
|
|
self.assertEqual([ast.dump(ast.parse(code)) for code in snippets[0]],
|
|
[ast.dump(ast.parse(code)) for code in snippets[1]])
|
|
allowed = set(form_defaults(function(EXAMPLE / "server.py", "transcribe"))) | {"file"}
|
|
for code, name in zip(snippets[0], ("transcribe_from_url", "transcribe_bytes")):
|
|
calls = []
|
|
handles = []
|
|
payload = b"test audio bytes, no acoustic inference"
|
|
|
|
def post(url, *, files, data, timeout):
|
|
self.assertEqual(urlsplit(url).path, "/v1/audio/transcriptions")
|
|
self.assertEqual(set(files), {"file"})
|
|
self.assertLessEqual(set(data) | set(files), allowed)
|
|
self.assertEqual(data, {"model": "sensevoice", "response_format": "verbose_json"})
|
|
self.assertGreater(timeout, 0)
|
|
body = files["file"][1]
|
|
if hasattr(body, "read"):
|
|
handles.append(body)
|
|
body = body.read()
|
|
self.assertEqual(body, payload)
|
|
calls.append(url)
|
|
return SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"text": "hello", "segments": []})
|
|
|
|
downloads = []
|
|
|
|
def get(url, *, timeout):
|
|
downloads.append(url)
|
|
self.assertGreater(timeout, 0)
|
|
return SimpleNamespace(content=payload, raise_for_status=lambda: None)
|
|
|
|
parsed = ast.parse(code)
|
|
# Substitute only the HTTP boundary; execute the documented worker body unchanged.
|
|
parsed.body = [node for node in parsed.body if not isinstance(node, (ast.Import, ast.ImportFrom))]
|
|
namespace = {"Path": Path, "tempfile": tempfile, "requests": SimpleNamespace(get=get, post=post)}
|
|
exec(compile(parsed, name, "exec"), namespace)
|
|
args = ("https://storage.example.invalid/approved.wav",) if name == "transcribe_from_url" else ("audio.wav", payload)
|
|
self.assertEqual(namespace[name](*args), {"text": "hello", "segments": []})
|
|
self.assertEqual(len(calls), 1)
|
|
self.assertEqual(downloads, list(args) if name == "transcribe_from_url" else [])
|
|
self.assertTrue(all(handle.closed for handle in handles))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|