feat: enhance Milkdown editor and file system functionality
- Normalize line endings in Markdown export for DOCX files. - Improve selection serialization to Markdown with better handling of empty documents. - Add a new `updateFile` function to the file system for updating file properties. - Introduce video transcoding capabilities using FFmpeg, supporting various video formats. - Update AGENTS.md for clearer plugin structure and responsibilities. - Add scoped styles for TreeNodeItem component to improve UI consistency. - Implement cross-origin isolation headers in Vite configuration for enhanced security. - Remove obsolete test_cross.py file.
This commit is contained in:
+114
-33
@@ -1,40 +1,121 @@
|
||||
# Backend 模块指南
|
||||
# Backend 后端指引
|
||||
|
||||
## OVERVIEW
|
||||
FastAPI 后端,处理 AI 补全、OCR、文档转换、TTS/ASR。
|
||||
本文件适用于 backend/ 下的后端实现。进入 backend/tests/ 后,以子目录 AGENTS.md 为准。
|
||||
|
||||
## STRUCTURE
|
||||
- main.py - API 入口、路由、CORS、启动逻辑
|
||||
- llm.py - Ollama 异步调用、超时控制、日志
|
||||
- prompt.py - Prompt 构建、上下文准备、语言处理
|
||||
- geoip.py - IP 地理位置查询
|
||||
- tts_asr.py - TTS/ASR 处理、Apple Silicon 优化
|
||||
- prompts/ - JSON 格式提示模板(PromptManager 单例)
|
||||
- tests/ - pytest 测试套件(见子目录 AGENTS.md)
|
||||
## 后端职责
|
||||
|
||||
## WHERE TO LOOK
|
||||
- 对外提供补全、取消补全、OCR、文档转换和 TTS 相关接口。
|
||||
- 组织 Prompt,上下文清洗,调用 Ollama 模型。
|
||||
- 负责 API Key 校验、日志记录和部分启动预热逻辑。
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| API 路由定义 | main.py | /v1/completions、/v1/ocr、/v1/convert 等 |
|
||||
| LLM 调用封装 | llm.py | call_ollama、call_vlm_ocr、超时控制 |
|
||||
| Prompt 构建 | prompt.py | build_completion_prompts、语言处理 |
|
||||
| 提示模板 | prompts/__init__.py | PromptManager、JSON 模板加载 |
|
||||
| TTS/ASR | tts_asr.py | 模型预热、设备检测、音频处理 |
|
||||
| 测试 | tests/ | pytest 测试套件 |
|
||||
## 先看哪里
|
||||
|
||||
## CONVENTIONS
|
||||
- Python 4 空格缩进
|
||||
- 函数/变量:snake_case
|
||||
- 类:PascalCase
|
||||
- 文件名:全小写+短横线
|
||||
- API 入口和路由:main.py
|
||||
- Ollama 调用封装:llm.py
|
||||
- Prompt 清洗和拼装:prompt.py
|
||||
- 数据模型:models.py
|
||||
- 地理位置:geoip.py
|
||||
- TTS 路由:tts_asr.py
|
||||
- Prompt 模板:prompts/
|
||||
- 后端测试:tests/
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- 硬编码 API_KEY(必须从环境变量读取)
|
||||
- 空 catch 块
|
||||
- 类型错误使用 as any / @ts-ignore
|
||||
## 当前接口面
|
||||
|
||||
## 注意事项
|
||||
- 端口:8001
|
||||
- 启动:`python backend/main.py` 或 `uvicorn backend.main:app --reload`
|
||||
- 依赖:`pip install -r backend/requirements.txt`
|
||||
- POST /v1/completions
|
||||
- POST /v1/completions/cancel
|
||||
- POST /v1/ocr
|
||||
- POST /v1/convert
|
||||
- /v1/tts-asr/* 由 tts_asr.py 延迟注册
|
||||
|
||||
## 请求流转
|
||||
|
||||
### /v1/completions
|
||||
|
||||
- 读取或生成 request_id。
|
||||
- privacy_mode 为 false 时,尝试根据客户端 IP 生成 location 文本。
|
||||
- 调用 prepare_prompt_context 清洗 prefix 和 suffix。
|
||||
- 调用 build_completion_prompts 生成 system_prompt 和 user_prompt。
|
||||
- 创建异步任务调用 call_ollama。
|
||||
- 用 request_id 把任务登记到 ACTIVE_COMPLETIONS。
|
||||
- 成功时返回 JSON:content 和 request_id。
|
||||
- finally 中清理当前 request_id 对应任务。
|
||||
|
||||
### /v1/completions/cancel
|
||||
|
||||
- 通过 request_id 在 ACTIVE_COMPLETIONS 中查找任务。
|
||||
- 未找到返回 not_found。
|
||||
- 已完成返回 already_done。
|
||||
- 仍在执行则调用 task.cancel() 并返回 ok。
|
||||
|
||||
### /v1/ocr
|
||||
|
||||
- 把 base64 图片解码成字节。
|
||||
- 调用 call_vlm_ocr。
|
||||
- 返回识别文本和原始文件名。
|
||||
|
||||
### /v1/convert
|
||||
|
||||
- 接收 base64 文件内容和文件名。
|
||||
- 当前允许的扩展名只有 txt、docx、pptx、pdf。
|
||||
- txt 直接解码后清洗。
|
||||
- 其他格式写入临时文件,用 MarkItDown 转换,再做 Markdown 清洗。
|
||||
- 清洗逻辑会移除图片 Markdown 和 img HTML 标签,并压缩多余空行。
|
||||
|
||||
### /v1/tts-asr/*
|
||||
|
||||
- 通过 _register_tts_asr_routes 延迟导入并挂到主应用。
|
||||
- 当前代码里的 tts_asr.py 主要是 TTS 能力,不要自行假设存在完整 ASR 实现。
|
||||
|
||||
## 开发命令
|
||||
|
||||
- 安装依赖:pip install -r backend/requirements.txt
|
||||
- 启动:python backend/main.py
|
||||
- 开发启动:uvicorn backend.main:app --reload --port 8001
|
||||
- 路由相关测试:
|
||||
- pytest backend/tests/test_main_endpoints.py -v
|
||||
- pytest backend/tests/test_main_cancel.py -v
|
||||
- Prompt 测试:
|
||||
- pytest backend/tests/test_prompt.py -v
|
||||
- pytest backend/tests/test_prompt_extended.py -v
|
||||
- LLM 测试:
|
||||
- pytest backend/tests/test_llm.py -v
|
||||
- pytest backend/tests/test_llm_extended.py -v
|
||||
|
||||
## 编码约定
|
||||
|
||||
- Python 使用 4 空格缩进。
|
||||
- 函数、变量使用 snake_case,类使用 PascalCase。
|
||||
- 新逻辑优先保留显式类型和明确的输入输出。
|
||||
- 异步边界要清晰;阻塞操作优先放进 asyncio.to_thread,而不是直接阻塞事件循环。
|
||||
- 异常要么转成 HTTPException,要么转成结构化 JSONResponse;不要静默吞掉后端错误。
|
||||
- 日志尽量带 request_id 或短 tag,便于把前后端一次请求串起来。
|
||||
|
||||
## 容易误判的点
|
||||
|
||||
- 补全接口当前不是流式响应,不要按 SSE 方式改造周边代码。
|
||||
- ACTIVE_COMPLETIONS 在补全和取消路径里都被读写,任务生命周期要谨慎处理。
|
||||
- main.py 里虽然有 _convert_docx_to_pdf 辅助函数,但当前 /v1/convert 路径实际走的是 MarkItDown,不要误以为 DOCX 转 PDF 桥接脚本已接入主流程。
|
||||
- API_KEY 存在占位默认值,这更像本地开发兜底,不是推荐的安全模式。
|
||||
- 历史 TTS/ASR 文档和部分测试覆盖的是旧实现;代码与文档冲突时,先确认产品方向,再决定修代码还是修文档。
|
||||
|
||||
## 改动时的定位建议
|
||||
|
||||
- 如果问题是补全结果不对,先查 prompt.py,再查 llm.py,不要只盯着 main.py。
|
||||
- 如果问题是取消不生效,先查 main.py 里的 request_id 生命周期,再对照前端的 X-Request-Id 和 cancel 调用。
|
||||
- 如果问题是 OCR 识别为空,先看 main.py 的 base64 解码,再看 llm.py 的 call_vlm_ocr。
|
||||
- 如果问题是转换结果脏,重点看 main.py 里的 _sanitize_converted_markdown。
|
||||
- 如果问题是 TTS 行为和文档不一致,以 tts_asr.py 为准,不要以 README 为准。
|
||||
|
||||
## 测试映射
|
||||
|
||||
- 路由主行为:tests/test_main_endpoints.py
|
||||
- 取消逻辑:tests/test_main_cancel.py
|
||||
- Prompt 逻辑:tests/test_prompt.py、tests/test_prompt_extended.py
|
||||
- LLM 包装层:tests/test_llm.py、tests/test_llm_extended.py
|
||||
- GeoIP:tests/test_geoip.py
|
||||
- TTS 相关:tests/test_tts_asr_*.py
|
||||
|
||||
## 文档使用原则
|
||||
|
||||
- README.md、TTS_ASR_MACOS_FIX.md、tests/TESTING_GUIDE.md 可以作为背景材料。
|
||||
- 一旦这些文档和 main.py、llm.py、prompt.py、tts_asr.py 冲突,以代码为准。
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
# API Benchmarking Report (2026-04-06 13:45:31)
|
||||
|
||||
**Base URL:** `https://api.imageteach.tech:8002`
|
||||
|
||||
## Executive Summary
|
||||
| Task | Success Rate | Avg TTFB | Avg Latency | P95 Latency | TPS | RPS |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| Completion-Short | 90.0% | 9123.6ms | 9123.8ms | 20222.9ms | 7.9 | 0.08 |
|
||||
| Completion-Normal | 10.0% | 10559.3ms | 10559.6ms | 10559.6ms | 66.4 | 0.66 |
|
||||
| Completion-Long | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 8.97 |
|
||||
| OCR-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 6.75 |
|
||||
| TTS-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 10.17 |
|
||||
| ASR-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 13.02 |
|
||||
| Convert-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 5.98 |
|
||||
|
||||
## Stability & Context Analysis
|
||||
Detailed analysis of how context length affects TTFB and overall performance.
|
||||
|
||||
### Completion-Short Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 123.93s
|
||||
- **Top Errors:**
|
||||
- `[0]`
|
||||
|
||||
### Completion-Normal Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 15.21s
|
||||
- **Top Errors:**
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
### Completion-Long Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 1.11s
|
||||
- **Top Errors:**
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
### OCR-Concurrent Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 1.48s
|
||||
- **Top Errors:**
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
### TTS-Concurrent Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 0.98s
|
||||
- **Top Errors:**
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
+4
-5
@@ -1,4 +1,4 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
@@ -9,9 +9,9 @@ import tempfile
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Security, File, UploadFile
|
||||
from fastapi import FastAPI, HTTPException, Request, Security
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import APIKeyHeader
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -133,8 +133,7 @@ def _sanitize_converted_markdown(text: str) -> str:
|
||||
value = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
value = IMAGE_MARKDOWN_RE.sub("", value)
|
||||
value = IMAGE_HTML_RE.sub("", value)
|
||||
value = re.sub(r"\n{3,}", "\n\n", value)
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
|
||||
+6
-5
@@ -302,23 +302,24 @@ Requirements:
|
||||
- Non-empty and meaningful
|
||||
- Concise unless structure needs more
|
||||
- Follows markdown rules in system prompt
|
||||
- Use real line breaks instead of spelled-out escape sequences unless PREFIX or SUFFIX clearly requires that text
|
||||
|
||||
=== BOUNDARY DECISION GUIDE ===
|
||||
|
||||
Step 1: Check PREFIX_ENDS_WITH_NEWLINE
|
||||
If false, ask: "Does output need to start on a new line?"
|
||||
- YES if PREFIX ends with: ":", "steps:", "items:", heading text, or complete sentence before heading
|
||||
- If YES: start output with \\n
|
||||
- If YES: make the first character of OUTPUT a real newline
|
||||
|
||||
Step 2: Check SUFFIX_STARTS_WITH_NEWLINE
|
||||
If false, ask: "Does output need to end with a newline?"
|
||||
- YES if SUFFIX starts with: heading (##), new paragraph, or list marker
|
||||
- If YES: end output with \\n
|
||||
- If YES: make the last character of OUTPUT a real newline
|
||||
|
||||
Step 3: Choose newline type
|
||||
- Use \\n\\n for: new paragraphs, before headings, starting lists
|
||||
- Use \\n for: continuing within blocks, list items, table cells
|
||||
- Exception: inside code fences, use \\n freely
|
||||
- Use a blank line for: new paragraphs, before headings, starting lists
|
||||
- Use a single line break for: continuing within blocks, list items, table cells
|
||||
- Exception: inside code fences, use real newline characters freely
|
||||
|
||||
=== CONTEXT NOTES ===
|
||||
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"template": "You are an inline completion engine for a {language_id} editor with ghost-text suggestions.\n\nReturn only the insertion text that should be placed between PREFIX and SUFFIX.\n\nCORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.\n\nPRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)\n- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?\n- CURSOR_FENCE_LANGUAGE: What language is the current fence?\n- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?\n- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?\n- MERMAID_CONTEXT: Is this a Mermaid diagram context?\n\nPRIORITY 2: SPECIALIZED CONTENT RULES\n\n2.1 Code Block Handling:\nIf CURSOR_IN_FENCED_CODE_BLOCK=true:\n- You are inside a code fence\n- Output code lines ONLY (no triple backticks)\n- Use single \\n for code line separation\n\nIf CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:\n- Wrap code in fenced block with language tag:\n```{language}\ncode here\n```\n- Never use inline backticks for code snippets\n\n2.2 Math Formatting (KaTeX):\n- Inline math: wrap with $...$\n- Block math: wrap with $$...$$\n- Never output bare formulas\n- Exception: inside latex/tex/katex fence, output raw LaTeX\n\n2.3 Mermaid Diagrams:\nIf CURSOR_FENCE_LANGUAGE=mermaid:\n- Output Mermaid syntax ONLY\n- No backticks, no explanations\n\nIf MERMAID_CONTEXT=true and outside fence:\n- Output complete fenced block:\n```mermaid\ndiagram syntax\n```\n\nPRIORITY 3: MARKDOWN STRUCTURE\n\n3.1 Newline Semantics:\n- Single \\n: soft break (same paragraph, renders as space or <br>)\n- Double \\n\\n: hard break (new paragraph/block)\n- Use \\n\\n for: new paragraphs, before headings, starting lists/tables\n- Use \\n for: continuation within blocks (list items, table cells)\n- Exception: inside code blocks, use \\n freely for code lines\n\n3.2 Boundary Management:\nCheck PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:\n- If PREFIX lacks needed newline: start OUTPUT with \\n\n- If SUFFIX lacks needed newline: end OUTPUT with \\n\n- Common cases requiring leading \\n:\n* Starting a list after \"Steps:\"\n* Creating new paragraph after text\n* Adding heading after paragraph\n- Common cases requiring trailing \\n:\n* Before new heading\n* End of section\n\n3.3 Context Stitching:\n- Never repeat text from SUFFIX beginning\n- Match PREFIX tone, style, indentation\n- Continue structures: lists, tables, quotes, headings\n\nPRIORITY 4: HIDDEN CONTEXT\n- OCR metadata like <OCR:...> is hidden context\n- Never copy OCR tags to output\n- Use OCR content as semantic hint only"
|
||||
"template": "You are an inline completion engine for a {language_id} editor with ghost-text suggestions.\n\nReturn only the insertion text that should be placed between PREFIX and SUFFIX.\n\nCORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.\n\nPRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)\n- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?\n- CURSOR_FENCE_LANGUAGE: What language is the current fence?\n- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?\n- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?\n- MERMAID_CONTEXT: Is this a Mermaid diagram context?\n\nPRIORITY 2: SPECIALIZED CONTENT RULES\n\n2.1 Code Block Handling:\nIf CURSOR_IN_FENCED_CODE_BLOCK=true:\n- You are inside a code fence\n- Output code lines ONLY (no triple backticks)\n- Separate code lines with actual newline characters\n\nIf CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:\n- Wrap code in fenced block with language tag:\n```{language}\ncode here\n```\n- Never use inline backticks for code snippets\n\n2.2 Math Formatting (KaTeX):\n- Inline math: wrap with $...$\n- Block math: wrap with $$...$$\n- Never output bare formulas\n- Exception: inside latex/tex/katex fence, output raw LaTeX\n\n2.3 Mermaid Diagrams:\nIf CURSOR_FENCE_LANGUAGE=mermaid:\n- Output Mermaid syntax ONLY\n- No backticks, no explanations\n\nIf MERMAID_CONTEXT=true and outside fence:\n- Output complete fenced block:\n```mermaid\ndiagram syntax\n```\n\nPRIORITY 3: MARKDOWN STRUCTURE\n\n3.1 Newline Semantics:\n- Use actual line breaks in output, not spelled-out escape sequences, unless the surrounding content explicitly needs that text\n- A single line break usually continues the current block\n- A blank line starts a new paragraph or block\n- Use blank lines for: new paragraphs, before headings, starting lists/tables\n- Use single line breaks for: continuation within blocks (list items, table cells)\n- Exception: inside code blocks, use actual newline characters freely for code lines\n\n3.2 Boundary Management:\nCheck PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:\n- If PREFIX lacks needed newline: start OUTPUT on a new line\n\n- If SUFFIX lacks needed newline: end OUTPUT with a trailing line break\n\n- Common cases requiring a leading line break:\n* Starting a list after \"Steps:\"\n* Creating new paragraph after text\n* Adding heading after paragraph\n- Common cases requiring a trailing line break:\n* Before new heading\n* End of section\n\n3.3 Context Stitching:\n- Never repeat text from SUFFIX beginning\n- Match PREFIX tone, style, indentation\n- Continue structures: lists, tables, quotes, headings\n\nPRIORITY 4: HIDDEN CONTEXT\n- OCR metadata like <OCR:...> is hidden context\n- Never copy OCR tags to output\n- Use OCR content as semantic hint only"
|
||||
}
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import argparse
|
||||
import uuid
|
||||
import sys
|
||||
import statistics
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)]
|
||||
)
|
||||
logger = logging.getLogger("api_benchmarker")
|
||||
|
||||
# Constants
|
||||
DEFAULT_BASE_URL = "http://localhost:8001"
|
||||
DEFAULT_API_KEY = "your-secret-key-here"
|
||||
CHARS_PER_TOKEN = 4
|
||||
|
||||
# Data Generators
|
||||
def get_dummy_base64_image():
|
||||
return "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
||||
|
||||
def get_dummy_base64_audio():
|
||||
# A bit longer dummy audio to pass validation (44 bytes header + some data)
|
||||
return "UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQAAAAAAA" + "A" * 100 + "=="
|
||||
|
||||
def generate_context_text(tokens: int) -> str:
|
||||
"""Generate synthetic text of approximately 'tokens' tokens."""
|
||||
base_phrase = "The quick brown fox jumps over the lazy dog. "
|
||||
repeat_count = (tokens * CHARS_PER_TOKEN) // len(base_phrase) + 1
|
||||
return (base_phrase * repeat_count)[:tokens * CHARS_PER_TOKEN]
|
||||
|
||||
# Metric Models
|
||||
class RequestMetric(BaseModel):
|
||||
task_name: str
|
||||
endpoint: str
|
||||
status_code: int
|
||||
ttfb_ms: float
|
||||
total_ms: float
|
||||
success: bool
|
||||
tokens: int
|
||||
error: Optional[str] = None
|
||||
|
||||
class BenchStats:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.metrics: List[RequestMetric] = []
|
||||
self.start_time = 0.0
|
||||
self.end_time = 0.0
|
||||
|
||||
def add(self, m: RequestMetric):
|
||||
self.metrics.append(m)
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
if not self.metrics:
|
||||
return {}
|
||||
|
||||
total = len(self.metrics)
|
||||
successes = [m for m in self.metrics if m.success]
|
||||
success_count = len(successes)
|
||||
fail_count = total - success_count
|
||||
|
||||
total_latencies = [m.total_ms for m in successes] if successes else [0]
|
||||
ttfb_latencies = [m.ttfb_ms for m in successes] if successes else [0]
|
||||
|
||||
duration = self.end_time - self.start_time
|
||||
total_tokens = sum(m.tokens for m in successes)
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"total_requests": total,
|
||||
"success_rate": (success_count / total) * 100 if total > 0 else 0,
|
||||
"avg_latency": statistics.mean(total_latencies),
|
||||
"p50_latency": statistics.median(total_latencies),
|
||||
"p95_latency": sorted(total_latencies)[int(len(total_latencies)*0.95)] if total_latencies else 0,
|
||||
"avg_ttfb": statistics.mean(ttfb_latencies),
|
||||
"tps": total_tokens / duration if duration > 0 else 0,
|
||||
"rps": total / duration if duration > 0 else 0,
|
||||
"duration": duration
|
||||
}
|
||||
|
||||
# Benchmarking Engine
|
||||
class ApiBenchmarker:
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.headers = {"X-API-Key": api_key}
|
||||
self.semaphores = {
|
||||
"completions": asyncio.Semaphore(5),
|
||||
"ocr": asyncio.Semaphore(2),
|
||||
"convert": asyncio.Semaphore(2),
|
||||
"tts-asr": asyncio.Semaphore(3)
|
||||
}
|
||||
self.results: Dict[str, BenchStats] = {}
|
||||
|
||||
async def _execute_request(self, client: httpx.AsyncClient, name: str, method: str, path: str, **kwargs) -> RequestMetric:
|
||||
url = f"{self.base_url}{path}"
|
||||
start = time.perf_counter()
|
||||
ttfb = 0.0
|
||||
tokens_count = 0
|
||||
|
||||
# Estimate input + output tokens (mock for output)
|
||||
if "json" in kwargs:
|
||||
input_text = str(kwargs["json"].get("prefix", "")) + str(kwargs["json"].get("text", ""))
|
||||
tokens_count += len(input_text) // CHARS_PER_TOKEN
|
||||
|
||||
try:
|
||||
async with client.stream(method, url, **kwargs) as response:
|
||||
ttfb = (time.perf_counter() - start) * 1000
|
||||
|
||||
body = await response.aread()
|
||||
total_ms = (time.perf_counter() - start) * 1000
|
||||
success = 200 <= response.status_code < 300
|
||||
|
||||
error_msg = None
|
||||
if not success:
|
||||
error_msg = body.decode(errors="ignore")[:200]
|
||||
else:
|
||||
# Estimate output tokens from response content
|
||||
try:
|
||||
resp_json = json.loads(body)
|
||||
content = resp_json.get("content", "") or resp_json.get("text", "") or resp_json.get("markdown", "")
|
||||
tokens_count += len(content) // CHARS_PER_TOKEN
|
||||
except:
|
||||
pass
|
||||
|
||||
return RequestMetric(
|
||||
task_name=name,
|
||||
endpoint=path,
|
||||
status_code=response.status_code,
|
||||
ttfb_ms=ttfb,
|
||||
total_ms=total_ms,
|
||||
success=success,
|
||||
tokens=tokens_count,
|
||||
error=error_msg
|
||||
)
|
||||
except Exception as e:
|
||||
total_ms = (time.perf_counter() - start) * 1000
|
||||
return RequestMetric(
|
||||
task_name=name,
|
||||
endpoint=path,
|
||||
status_code=0,
|
||||
ttfb_ms=ttfb or total_ms,
|
||||
total_ms=total_ms,
|
||||
success=False,
|
||||
tokens=tokens_count,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def run_task(self, client: httpx.AsyncClient, task_type: str, name: str, iterations: int):
|
||||
if name not in self.results:
|
||||
self.results[name] = BenchStats(name)
|
||||
|
||||
stats = self.results[name]
|
||||
stats.start_time = time.perf_counter()
|
||||
|
||||
sem = self.semaphores.get(task_type, self.semaphores["completions"])
|
||||
|
||||
async def worker():
|
||||
async with sem:
|
||||
if task_type == "completions":
|
||||
# Stability Test Variation
|
||||
prefix_len = 100
|
||||
if "Normal" in name: prefix_len = 1000
|
||||
if "Long" in name: prefix_len = 4000
|
||||
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/completions", json={
|
||||
"prefix": generate_context_text(prefix_len),
|
||||
"suffix": "End of document.",
|
||||
"model_thinking": "low"
|
||||
})
|
||||
elif task_type == "ocr":
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/ocr", json={
|
||||
"image": get_dummy_base64_image(),
|
||||
"filename": "bench.png"
|
||||
})
|
||||
elif task_type == "convert":
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/convert", json={
|
||||
"file": base64.b64encode(b"Performance test data").decode(),
|
||||
"filename": "bench.txt"
|
||||
})
|
||||
elif task_type == "tts":
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/tts-asr/tts", json={
|
||||
"text": "This is a performance benchmark for the text to speech engine.",
|
||||
"voice": "v2/en_speaker_6",
|
||||
"format": "wav"
|
||||
})
|
||||
elif task_type == "asr":
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/tts-asr/asr", json={
|
||||
"audio_base64": get_dummy_base64_audio(),
|
||||
"language": "en"
|
||||
})
|
||||
else:
|
||||
metric = await self._execute_request(client, name, "GET", "/v1/tts-asr/status")
|
||||
|
||||
stats.add(metric)
|
||||
|
||||
tasks = [worker() for _ in range(iterations)]
|
||||
await asyncio.gather(*tasks)
|
||||
stats.end_time = time.perf_counter()
|
||||
|
||||
def generate_report(self, output_file: str):
|
||||
report = []
|
||||
report.append(f"# API Benchmarking Report ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})")
|
||||
report.append(f"\n**Base URL:** `{self.base_url}`")
|
||||
|
||||
# Summary Table
|
||||
report.append("\n## Executive Summary")
|
||||
report.append("| Task | Success Rate | Avg TTFB | Avg Latency | P95 Latency | TPS | RPS |")
|
||||
report.append("| :--- | :--- | :--- | :--- | :--- | :--- | :--- |")
|
||||
|
||||
for name, stats in self.results.items():
|
||||
s = stats.get_summary()
|
||||
if not s: continue
|
||||
report.append(f"| {s['name']} | {s['success_rate']:.1f}% | {s['avg_ttfb']:.1f}ms | {s['avg_latency']:.1f}ms | {s['p95_latency']:.1f}ms | {s['tps']:.1f} | {s['rps']:.2f} |")
|
||||
|
||||
# Stability Analysis
|
||||
report.append("\n## Stability & Context Analysis")
|
||||
report.append("Detailed analysis of how context length affects TTFB and overall performance.")
|
||||
|
||||
# Details per category
|
||||
for name, stats in self.results.items():
|
||||
s = stats.get_summary()
|
||||
if not s: continue
|
||||
report.append(f"\n### {name} Details")
|
||||
report.append(f"- **Total Samples:** {s['total_requests']}")
|
||||
report.append(f"- **Duration:** {s['duration']:.2f}s")
|
||||
failures = [m for m in stats.metrics if not m.success]
|
||||
if failures:
|
||||
report.append(f"- **Top Errors:**")
|
||||
for f in failures[:3]:
|
||||
report.append(f" - `[{f.status_code}]` {f.error}")
|
||||
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(report))
|
||||
logger.info(f"Report generated: {output_file}")
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="Advanced LLM API Benchmarker")
|
||||
parser.add_argument("--url", default=DEFAULT_BASE_URL, help="Base URL")
|
||||
parser.add_argument("--key", default=DEFAULT_API_KEY, help="API Key")
|
||||
parser.add_argument("--c-comp", type=int, default=5, help="Completion Concurrency")
|
||||
parser.add_argument("--c-ocr", type=int, default=2, help="OCR Concurrency")
|
||||
parser.add_argument("--c-audio", type=int, default=2, help="TTS/ASR Concurrency")
|
||||
parser.add_argument("--iters", type=int, default=10, help="Iterations per test suite")
|
||||
parser.add_argument("--output", default="api_performance_report.md", help="Output report file")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
bench = ApiBenchmarker(args.url, args.key)
|
||||
bench.semaphores["completions"] = asyncio.Semaphore(args.c_comp)
|
||||
bench.semaphores["ocr"] = asyncio.Semaphore(args.c_ocr)
|
||||
bench.semaphores["tts-asr"] = asyncio.Semaphore(args.c_audio)
|
||||
|
||||
async with httpx.AsyncClient(headers=bench.headers, timeout=120.0) as client:
|
||||
logger.info("Starting Benchmark Suites...")
|
||||
|
||||
# Suite 1: Stability - Completion Contexts
|
||||
logger.info("Running Stability Suite (Short Context)...")
|
||||
await bench.run_task(client, "completions", "Completion-Short", args.iters)
|
||||
|
||||
logger.info("Running Stability Suite (Normal Context)...")
|
||||
await bench.run_task(client, "completions", "Completion-Normal", args.iters)
|
||||
|
||||
logger.info("Running Stability Suite (Long Context)...")
|
||||
await bench.run_task(client, "completions", "Completion-Long", args.iters)
|
||||
|
||||
# Suite 2: Functional Concurrency
|
||||
logger.info("Running OCR Concurrency Suite...")
|
||||
await bench.run_task(client, "ocr", "OCR-Concurrent", args.iters)
|
||||
|
||||
logger.info("Running TTS Concurrency Suite...")
|
||||
await bench.run_task(client, "tts", "TTS-Concurrent", args.iters)
|
||||
|
||||
logger.info("Running ASR Concurrency Suite...")
|
||||
await bench.run_task(client, "asr", "ASR-Concurrent", args.iters)
|
||||
|
||||
logger.info("Running File Transformation Suite...")
|
||||
await bench.run_task(client, "convert", "Convert-Concurrent", args.iters)
|
||||
|
||||
bench.generate_report(args.output)
|
||||
print(f"\nBenchmark Complete! View the report at: {args.output}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,80 +0,0 @@
|
||||
"""
|
||||
GeoIP2 IP归属地查询测试脚本
|
||||
|
||||
使用方法:
|
||||
1. 安装依赖:pip install geoip2
|
||||
2. 下载数据库:https://dev.maxmind.com/geoip/geoip2/geolite2/
|
||||
3. 运行测试:python test_geoip.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import geoip2.database
|
||||
except ImportError:
|
||||
print("请先安装 geoip2: pip install geoip2")
|
||||
sys.exit(1)
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "GeoLite2-City.mmdb")
|
||||
|
||||
TEST_IPS = [
|
||||
"8.8.8.8", # Google DNS (美国)
|
||||
"114.114.114.114", # 114 DNS (中国南京)
|
||||
"223.5.5.5", # 阿里DNS (中国杭州)
|
||||
"1.1.1.1", # Cloudflare DNS (澳大利亚)
|
||||
"119.29.29.29", # 腾讯DNS (中国)
|
||||
]
|
||||
|
||||
|
||||
def get_location(reader, ip: str) -> dict:
|
||||
try:
|
||||
response = reader.city(ip)
|
||||
return {
|
||||
"ip": ip,
|
||||
"country": response.country.name,
|
||||
"country_code": response.country.iso_code,
|
||||
"region": response.subdivisions.most_specific.name if response.subdivisions else None,
|
||||
"city": response.city.name,
|
||||
"latitude": response.location.latitude,
|
||||
"longitude": response.location.longitude,
|
||||
"timezone": response.location.time_zone,
|
||||
}
|
||||
except geoip2.errors.AddressNotFoundError:
|
||||
return {"ip": ip, "error": "IP未在数据库中找到"}
|
||||
except Exception as e:
|
||||
return {"ip": ip, "error": str(e)}
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"数据库文件不存在: {DB_PATH}")
|
||||
print("请从 https://dev.maxmind.com/geoip/geoip2/geolite2/ 下载 GeoLite2-City.mmdb")
|
||||
return
|
||||
|
||||
print(f"加载数据库: {DB_PATH}")
|
||||
reader = geoip2.database.Reader(DB_PATH)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("IP归属地查询测试")
|
||||
print("=" * 60)
|
||||
|
||||
for ip in TEST_IPS:
|
||||
result = get_location(reader, ip)
|
||||
if "error" in result:
|
||||
print(f"\n{ip}: {result['error']}")
|
||||
else:
|
||||
print(f"\n{ip}:")
|
||||
print(f" 国家: {result['country']} ({result['country_code']})")
|
||||
print(f" 地区: {result['region'] or '未知'}")
|
||||
print(f" 城市: {result['city'] or '未知'}")
|
||||
print(f" 坐标: {result['latitude']}, {result['longitude']}")
|
||||
print(f" 时区: {result['timezone']}")
|
||||
|
||||
reader.close()
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+52
-35
@@ -1,45 +1,62 @@
|
||||
OVERVIEW: pytest 测试套件,覆盖率要求 90%
|
||||
STRUCTURE:
|
||||
- test_*.py - 各模块测试
|
||||
- run_tests.py - 测试执行脚本(unit/integration/all)
|
||||
- simulate_macos.py - macOS 环境模拟
|
||||
- TESTING_GUIDE.md - 测试指南文档
|
||||
# Backend Tests 测试指引
|
||||
|
||||
WHERE TO LOOK
|
||||
表格
|
||||
本文件适用于 backend/tests/ 下的测试和测试脚本。
|
||||
|
||||
| Area | Path |
|
||||
|---|---|
|
||||
| 单元测试 | backend/tests/ |
|
||||
| 集成测试 | backend/tests/ |
|
||||
| 测试执行脚本 | backend/tests/run_tests.py |
|
||||
| macOS 模拟 | backend/tests/simulate_macos.py |
|
||||
| 测试指南 | backend/tests/TESTING_GUIDE.md |
|
||||
## 测试入口
|
||||
|
||||
运行命令:
|
||||
- pytest - 运行所有测试
|
||||
- python backend/tests/run_tests.py unit - 单元测试
|
||||
- python backend/tests/run_tests.py integration - 集成测试
|
||||
- pytest.ini 指定默认测试目录为 backend/tests,并设置后端覆盖率门槛为 90%。
|
||||
- run_tests.py 提供 unit、integration、simulate、all 几种快捷入口。
|
||||
- 默认优先使用 pytest 跑窄测试;只有在需要脚本封装参数时再用 run_tests.py。
|
||||
|
||||
测试命名约定:test_*.py、Test* 类、test_* 函数
|
||||
## 测试分布
|
||||
|
||||
ANTI-PATTERNS:删除测试以通过覆盖率
|
||||
- test_main_endpoints.py:主 API 路由行为
|
||||
- test_main_cancel.py:补全取消和任务生命周期
|
||||
- test_prompt.py、test_prompt_extended.py:Prompt 上下文与规则
|
||||
- test_llm.py、test_llm_extended.py:LLM 包装层
|
||||
- test_geoip.py:GeoIP 逻辑
|
||||
- test_tts_asr_*.py:TTS 相关与历史 TTS/ASR 面
|
||||
- simulate_macos.py:历史模拟脚本
|
||||
- quick_verify.py、verify_cross.py、play_audio.py:人工验证或辅助脚本
|
||||
|
||||
验证
|
||||
- 保证测试覆盖率≥90% 时,报告合格
|
||||
- 使用 CI 运行 pytest,确保通过率
|
||||
## 常用命令
|
||||
|
||||
注意事项
|
||||
- 不要重复父目录内容
|
||||
- 不要超过 60 行
|
||||
- pytest
|
||||
- pytest backend/tests/test_main_endpoints.py -v
|
||||
- pytest backend/tests/test_main_cancel.py -v
|
||||
- pytest backend/tests/test_prompt.py -v
|
||||
- pytest backend/tests/test_llm.py -v
|
||||
- python backend/tests/run_tests.py unit
|
||||
- python backend/tests/run_tests.py integration --url http://localhost:8001 --key your-secret-key-here
|
||||
|
||||
测试应尽量独立,不要依赖全局状态
|
||||
- 运行单元测试时应使用 unit 标签
|
||||
- 运行集成测试时应使用 integration 标签
|
||||
## 测试原则
|
||||
|
||||
区分环境
|
||||
- unit 测试应尽量快速、稳定
|
||||
- integration 测试应覆盖接口和数据库交互
|
||||
- 优先跑与改动直接对应的窄测试,不要动不动全量跑。
|
||||
- 单元测试尽量 mock 掉外部依赖,不要直连真实 Ollama。
|
||||
- 涉及 main.py 时,优先用 monkeypatch 或 fake 对象替代:
|
||||
- call_ollama
|
||||
- call_vlm_ocr
|
||||
- MarkItDown
|
||||
- GeoIP 查询
|
||||
- TTS 模型加载
|
||||
- 测试要保持确定性,不依赖全局状态、环境顺序或人工输入。
|
||||
|
||||
维护
|
||||
- 如扩展新模块,优先增加 test_*.py 文件并在其中添加对应的测试类和方法
|
||||
## 容易误判的点
|
||||
|
||||
- 覆盖率门槛是针对多个 backend 模块一起算的,改核心文件时,即使单测通过也可能因为覆盖率不够失败。
|
||||
- htmlcov、.pytest_cache、api_performance_report.md 属于生成产物,不是需要维护的源码。
|
||||
- 这一目录里有一批 TTS/ASR 测试和说明明显继承自旧实现;当它们与当前 backend/tts_asr.py 冲突时,不要默认代码错了,先确认目标产品面。
|
||||
- integration 脚本通常假设本地服务在 http://localhost:8001,且默认 API Key 还是占位值。
|
||||
|
||||
## 改动定位建议
|
||||
|
||||
- 路由返回值不对:先看 test_main_endpoints.py 和 test_main_cancel.py
|
||||
- Prompt 规则不对:先看 test_prompt.py 和 test_prompt_extended.py
|
||||
- Ollama 调用包装不对:先看 test_llm.py 和 test_llm_extended.py
|
||||
- TTS 面变化:先确认当前 backend/tts_asr.py 是不是仍然以旧文档描述为目标,再决定修测试还是修实现
|
||||
|
||||
## 维护原则
|
||||
|
||||
- 新增后端行为时,优先给对应模块补测试,不要只依赖全量回归。
|
||||
- 如果变更的是历史 TTS/ASR 面,先把“当前规范是什么”确定下来,再批量修测试。
|
||||
- 如果覆盖率策略变化,记得同步这个文件,而不是只改 pytest.ini。
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from backend.tts_asr import _tts_sync_with_retry
|
||||
|
||||
async def play_audio():
|
||||
print("生成测试音频中,请稍候...")
|
||||
test_text = "这是一段用以测试新语音模型音质的中文合成音频。"
|
||||
|
||||
try:
|
||||
audio_bytes, sr = await _tts_sync_with_retry(test_text, rate=1.0)
|
||||
|
||||
# 保存到本地文件
|
||||
wav_path = os.path.join(os.path.dirname(__file__), "test_audio.wav")
|
||||
with open(wav_path, "wb") as f:
|
||||
f.write(audio_bytes)
|
||||
|
||||
print(f"音频已生成并保存到: {wav_path}")
|
||||
print("正在尝试在 macOS 上播放...")
|
||||
|
||||
# Mac OS 的播放命令
|
||||
os.system(f"afplay '{wav_path}'")
|
||||
|
||||
print("播放完成。")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"音频生成失败: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(play_audio())
|
||||
@@ -12,7 +12,6 @@ macOS环境模拟测试工具
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,4 @@
|
||||
import sys
|
||||
import os
|
||||
import types
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
import base64
|
||||
import asyncio
|
||||
import types
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -11,6 +11,10 @@ BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
|
||||
if BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
|
||||
fake_tts_asr = types.ModuleType("tts_asr")
|
||||
fake_tts_asr.register_tts_asr_routes = lambda app: None
|
||||
sys.modules.setdefault("tts_asr", fake_tts_asr)
|
||||
|
||||
import main # type: ignore
|
||||
|
||||
API_KEY = main.API_KEY
|
||||
@@ -61,13 +65,13 @@ def test_sanitize_markdown_strips_img_tag():
|
||||
assert "<img" not in main._sanitize_converted_markdown("<img src='x.png'/>")
|
||||
|
||||
|
||||
def test_sanitize_markdown_collapse_newlines():
|
||||
assert main._sanitize_converted_markdown("a\n\n\nb\n\n\n\nc") == "a\n\nb\n\nc"
|
||||
def test_sanitize_markdown_preserves_extra_newlines():
|
||||
assert main._sanitize_converted_markdown("a\n\n\nb\n\n\n\nc") == "a\n\n\nb\n\n\n\nc"
|
||||
|
||||
|
||||
def test_sanitize_markdown_normalize_crlf():
|
||||
result = main._sanitize_converted_markdown("line1\r\nline2\r\n")
|
||||
assert "line1\nline2" in result
|
||||
assert result == "line1\nline2\n"
|
||||
assert "\r" not in result
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ def test_prompt_builds_system_and_user():
|
||||
assert "MERMAID_CONTEXT" in user_prompt
|
||||
assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt
|
||||
assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt
|
||||
assert "actual line breaks" in system_prompt
|
||||
assert "start OUTPUT on a new line" in system_prompt
|
||||
assert "Use real line breaks instead of spelled-out escape sequences" in user_prompt
|
||||
assert "make the first character of OUTPUT a real newline" in user_prompt
|
||||
assert "make the last character of OUTPUT a real newline" in user_prompt
|
||||
assert "start output with \\n" not in user_prompt
|
||||
assert "Use single \\n" not in system_prompt
|
||||
|
||||
|
||||
def test_cursor_in_fence_detection():
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -15,9 +15,7 @@ TTS/ASR模块集成测试
|
||||
python backend/tests/test_tts_asr_integration.py --test config
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -12,8 +12,7 @@ TTS/ASR模块单元测试
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
|
||||
# 确保可以导入backend和tts_asr模块
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 确保能找到backend模块
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
|
||||
from backend.tts_asr import _tts_sync_with_retry, _load_asr_pipeline_with_retry, _get_asr_pipeline
|
||||
|
||||
async def verify_tts_asr_cross():
|
||||
print("====================================")
|
||||
print(" 开始严格交叉验证: TTS 生成 -> ASR 解析")
|
||||
print("====================================")
|
||||
|
||||
test_text = "苹果设备支持离线大模型运算"
|
||||
print(f"\n[1] 正在调用 TTS 引擎 (suno/bark-small)...")
|
||||
print(f"目标文本: '{test_text}'")
|
||||
|
||||
try:
|
||||
# TTS生成
|
||||
audio_bytes, sr = await _tts_sync_with_retry(test_text, rate=1.0)
|
||||
print(f"-> TTS 成功生成音频数据,大小: {len(audio_bytes)} Bytes, 采样率: {sr}Hz")
|
||||
except Exception as e:
|
||||
print(f"-> TTS 失败: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n[2] 正在调用 ASR 引擎 (Whisper)...")
|
||||
try:
|
||||
loaded = await _load_asr_pipeline_with_retry()
|
||||
if not loaded:
|
||||
print("-> ASR 核心加载失败!")
|
||||
sys.exit(1)
|
||||
|
||||
print("-> ASR 加载成功,开始解析音频...")
|
||||
|
||||
# 将生成的wav bytes传递给ASR进行语音识别
|
||||
asr_pipeline = _get_asr_pipeline()
|
||||
result = asr_pipeline(audio_bytes, generate_kwargs={"task": "transcribe"})
|
||||
recognized_text = result.get('text', '')
|
||||
print(f"-> ASR 识别结果: '{recognized_text.strip()}'")
|
||||
|
||||
if len(recognized_text.strip()) > 0:
|
||||
print("\n结论: ✅ 验证成功!TTS和ASR模块功能链路闭环完成。")
|
||||
else:
|
||||
print("\n结论: ❌ ASR输出为空字符,闭环失败。")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"-> ASR 分析阶段失败: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(verify_tts_asr_cross())
|
||||
Reference in New Issue
Block a user