中期成果提交:PPT自动生成 Agent(感知-规划-行动-记忆闭环 + aura-ppt 渲染引擎 + Web 交互界面 + 测试用例 + AI 使用日志)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""候选免费模型基准测试:大纲任务耗时 + JSON 有效性。"""
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, r"D:\00.张华丹\VS专用\04.PPT自动生成\ppt-agent")
|
||||
|
||||
BASE = "http://127.0.0.1:4096"
|
||||
SAMPLE = """# 项目背景
|
||||
为提升知识周转效率启动平台建设,前期调研两个月
|
||||
# 本期成果
|
||||
部署完成基础服务,导入文档120份
|
||||
混合检索准确率较基线提升明显
|
||||
完成三个部门试点接入
|
||||
# 下一步
|
||||
扩大试点范围,建立运营机制
|
||||
"""
|
||||
|
||||
PROMPT = """请把下面的内容规划成一份 PPT 的页面大纲。
|
||||
要求:
|
||||
1. 页面总数 6~9 页(含封面、目录、结尾)
|
||||
2. 输出语言:中文
|
||||
3. 页面类型只能用:cover / toc / section / content / two_column / end
|
||||
4. 第一页 cover,最后一页 end
|
||||
5. content 页的 content 数组为要点(每条一句话,4~6 条)
|
||||
6. 每页写 notes:2~3 句口语化演讲备注
|
||||
7. 标题要有信息量
|
||||
输出格式:{"slides": [{"type": "...", "title": "...", "content": ["..."], "notes": "..."}]}
|
||||
|
||||
【用户标题】知识平台周报
|
||||
【用户内容】
|
||||
""" + SAMPLE
|
||||
|
||||
|
||||
def post(path, payload, timeout):
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=json.dumps(payload).encode("utf-8"), method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read() or b"{}")
|
||||
|
||||
|
||||
def run_model(model_id):
|
||||
t0 = time.time()
|
||||
try:
|
||||
sess = post("/session", {"title": "bench"}, 30)
|
||||
sid = sess["id"]
|
||||
provider, _, model = model_id.partition("/")
|
||||
result = post(f"/session/{sid}/message", {
|
||||
"model": {"providerID": provider, "modelID": model},
|
||||
"parts": [{"type": "text", "text": PROMPT}],
|
||||
}, 300)
|
||||
dt = time.time() - t0
|
||||
text = "\n".join(p.get("text", "") for p in result.get("parts", [])
|
||||
if p.get("type") == "text")
|
||||
s, e = text.find("{"), text.rfind("}")
|
||||
ok = False
|
||||
n = 0
|
||||
if 0 <= s < e:
|
||||
try:
|
||||
data = json.loads(text[s:e + 1])
|
||||
slides = data.get("slides", [])
|
||||
n = len(slides)
|
||||
ok = 4 <= n <= 12 and all("title" in x for x in slides)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
print(f"{model_id:45s} {dt:6.1f}s pages={n:2d} valid={ok}")
|
||||
return dt, ok
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"{model_id:45s} FAIL {str(exc)[:60]}")
|
||||
return 999, False
|
||||
|
||||
|
||||
for m in sys.argv[1:] or ["opencode/nemotron-3.5-lightning-free",
|
||||
"opencode/mimo-v2.5-free",
|
||||
"opencode/hy3-free"]:
|
||||
run_model(m)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""对话修改闭环 e2e:生成 → 换色 → 改标题 → 改页内容 → 两版本/下载验证。
|
||||
|
||||
用法:python tests/e2e_chat.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5098"
|
||||
USER = f"chat_e2e_{int(time.time())}"
|
||||
PASS = "chat_e2e_123"
|
||||
SAMPLE = "# 项目背景\n为提升内部知识周转效率,启动知识库平台建设\n已完成技术选型与原型验证\n# 本期成果\n部署完成基础服务\n导入文档120份\n"
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def call(method, path, payload=None, raw=False):
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
body = resp.read()
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, (body if raw else json.loads(body or b"{}"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
try:
|
||||
parsed = json.loads(body or b"{}")
|
||||
except Exception:
|
||||
parsed = {"raw": body.decode("utf-8", "ignore")}
|
||||
sc = e.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, env=env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5098)"],
|
||||
cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": USER, "password": PASS, "confirm_password": PASS})
|
||||
check("register", s == 200 and b.get("code") == 0)
|
||||
|
||||
s, b = call("POST", "/api/generate", {
|
||||
"title": "对话修改测试", "content": SAMPLE,
|
||||
"scene": "report", "language": "zh", "canvas": "ppt169",
|
||||
"image_strategy": "off", "color_scheme": "blue",
|
||||
"page_min": 5, "page_max": 8,
|
||||
})
|
||||
check("generate accepted", b.get("code") == 0, str(b)[:60])
|
||||
task_id = b["data"]["task_id"]
|
||||
for _ in range(120):
|
||||
time.sleep(1)
|
||||
s, b = call("GET", f"/api/generate/{task_id}/status")
|
||||
if b["data"]["status"] in ("done", "failed"):
|
||||
break
|
||||
check("generation done", b["data"]["status"] == "done", str(b)[:80])
|
||||
s, b = call("GET", f"/api/generate/{task_id}/result")
|
||||
rid = b["data"]["record"]["id"]
|
||||
check("record ready", bool(rid), f"rid={rid}")
|
||||
|
||||
def chat(msg):
|
||||
return call("POST", "/api/chat", {"record_id": rid, "message": msg})
|
||||
|
||||
s, b = chat("换成绿色")
|
||||
d = b.get("data") or {}
|
||||
check("change color", d.get("changed") is True, str(d.get("reply", ""))[:60])
|
||||
check("record color updated", (d.get("record") or {}).get("color_scheme") == "green")
|
||||
|
||||
s, b = chat('把标题改成"知识平台周报"')
|
||||
d = b.get("data") or {}
|
||||
check("change title", d.get("changed") is True
|
||||
and (d.get("record") or {}).get("title") == "知识平台周报",
|
||||
str(d.get("reply", ""))[:60])
|
||||
|
||||
s, b = chat('把"知识周转"改成"信息流转"')
|
||||
d = b.get("data") or {}
|
||||
check("edit page replace", d.get("changed") is True, str(d.get("reply", ""))[:60])
|
||||
|
||||
s, b = chat("今天天气怎么样")
|
||||
d = b.get("data") or {}
|
||||
check("unknown intent no change", d.get("changed") is False
|
||||
and "不支持" in d.get("reply", ""), str(d.get("reply", ""))[:50])
|
||||
|
||||
s, b = call("GET", f"/api/records/{rid}")
|
||||
versions = b["data"]["versions"]
|
||||
check("two versions kept", len(versions) == 2 and versions[0]["version_no"] == 1,
|
||||
f"n={len(versions)}")
|
||||
|
||||
s, b = call("GET", f"/api/chat/{rid}/messages")
|
||||
msgs = b["data"]["messages"]
|
||||
check("messages logged", len(msgs) == 8, f"n={len(msgs)}")
|
||||
|
||||
s, raw = call("GET", f"/api/files/{rid}/download", raw=True)
|
||||
check("download ok", s == 200 and raw[:2] == b"PK", f"{len(raw)}B")
|
||||
p = ROOT / "tests" / "_chat_download.pptx"
|
||||
p.write_bytes(raw)
|
||||
from pptx import Presentation
|
||||
prs = Presentation(str(p))
|
||||
texts = "\n".join(sh.text_frame.text for sl in prs.slides
|
||||
for sh in sl.shapes if sh.has_text_frame)
|
||||
check("new title in pptx", "知识平台周报" in texts)
|
||||
check("edited text in pptx", "信息流转" in texts and "知识周转" not in texts)
|
||||
p.unlink()
|
||||
finally:
|
||||
proc.terminate()
|
||||
|
||||
print("\n" + ("CHAT E2E ALL PASSED" if not failures
|
||||
else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""网络配图 e2e:image_strategy=web 全链路(test 通道)+ 空图源优雅降级。
|
||||
|
||||
用法:python tests/e2e_image.py
|
||||
环境:IMAGE_SEARCH_MODE=test + IMAGE_SEARCH_TEST_DIR=<样例图目录>
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5102"
|
||||
USER = f"img_e2e_{int(time.time())}"
|
||||
PASS = "img_e2e_123"
|
||||
SAMPLE = ("# 配图测试主题\n本季度完成检索服务上线\n准确率达到百分之九十八\n"
|
||||
"# 运营情况\n用户增长明显\n文档数量翻倍\n")
|
||||
|
||||
TMP = ROOT / "tests" / "_up"
|
||||
|
||||
|
||||
def make_test_images():
|
||||
from PIL import Image
|
||||
TMP.mkdir(exist_ok=True)
|
||||
for i, color in enumerate([(21, 101, 192), (46, 125, 50), (123, 31, 162)]):
|
||||
Image.new("RGB", (800, 500), color).save(str(TMP / f"sample_{i}.jpg"))
|
||||
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def call(method, path, payload=None):
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(BASE + path, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, json.loads(resp.read() or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read() or b"{}")
|
||||
except Exception:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def generate_and_wait(title):
|
||||
s, b = call("POST", "/api/generate", {
|
||||
"title": title, "content": SAMPLE,
|
||||
"scene": "report", "language": "zh", "canvas": "ppt169",
|
||||
"image_strategy": "web", "color_scheme": "blue",
|
||||
"page_min": 5, "page_max": 7,
|
||||
})
|
||||
if b.get("code") != 0:
|
||||
return None, str(b)[:100]
|
||||
task_id = b["data"]["task_id"]
|
||||
for _ in range(120):
|
||||
time.sleep(1)
|
||||
s, b = call("GET", f"/api/generate/{task_id}/status")
|
||||
st = b.get("data", {}).get("status")
|
||||
if st in ("done", "failed"):
|
||||
return (task_id if st == "done" else None), json.dumps(b.get("data"), ensure_ascii=False)[:120]
|
||||
return None, "timeout"
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
make_test_images()
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8",
|
||||
"LLM_BACKEND": "off",
|
||||
"IMAGE_SEARCH_MODE": "test",
|
||||
"IMAGE_SEARCH_TEST_DIR": str(TMP),
|
||||
"MAX_IMAGE_SLIDES": "3"}
|
||||
subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, env=env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5102)"],
|
||||
cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": USER, "password": PASS, "confirm_password": PASS})
|
||||
check("register", s == 200 and b.get("code") == 0)
|
||||
|
||||
# ---- 场景1:配图成功 ----
|
||||
task_id, info = generate_and_wait("配图全链路测试")
|
||||
check("generation done (with images)", task_id is not None, info)
|
||||
if task_id:
|
||||
s, b = call("GET", f"/api/generate/{task_id}/result")
|
||||
rec = b["data"]["record"]
|
||||
project_dir = ROOT / "src" / "web" / "data" / "projects" / rec["project_dir"] \
|
||||
if rec.get("project_dir") else None
|
||||
# 兜底:从 versions 表拿不到目录名时按标题匹配最新目录
|
||||
if not project_dir or not project_dir.exists():
|
||||
dirs = sorted((ROOT / "src" / "web" / "data" / "projects").glob("配图全链路测试_*"))
|
||||
project_dir = dirs[-1]
|
||||
plan = json.loads((project_dir / "plan.json").read_text(encoding="utf-8"))
|
||||
with_img = [sl for sl in plan["slides"] if sl.get("image")]
|
||||
check("plan has image slides", len(with_img) >= 2, f"n={len(with_img)}")
|
||||
check("image files exist", all(Path(sl["image"]).exists() for sl in with_img))
|
||||
check("images inside project dir",
|
||||
all(str(sl["image"]).startswith(str(project_dir)) for sl in with_img))
|
||||
|
||||
s, raw = call("GET", f"/api/files/{rec['id']}/download", raw=True) \
|
||||
if False else (None, None)
|
||||
# 直接读导出文件验证 PPTX 内嵌图片形状
|
||||
pptx_path = project_dir / "exports" / rec["file_name"] \
|
||||
if rec.get("file_name") else None
|
||||
if not pptx_path or not pptx_path.exists():
|
||||
files = sorted((project_dir / "exports").glob("*.pptx"))
|
||||
pptx_path = files[-1]
|
||||
from pptx import Presentation
|
||||
prs = Presentation(str(pptx_path))
|
||||
pic_slides = [i for i, sl in enumerate(prs.slides)
|
||||
if any(sh.shape_type == 13 for sh in sl.shapes)]
|
||||
check("pptx contains pictures", len(pic_slides) >= 2, f"slides={pic_slides}")
|
||||
# 保真验证已在生成流程内通过(done 状态),此处确认无 image 键误报的副作用
|
||||
check("no cover/end images", all(
|
||||
sl.get("type") == "content" for sl in with_img))
|
||||
|
||||
# ---- 场景2:空图源 → 优雅降级 ----
|
||||
empty_dir = TMP / "empty_src"
|
||||
empty_dir.mkdir(exist_ok=True)
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
env2 = {**env, "IMAGE_SEARCH_TEST_DIR": str(empty_dir)}
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5102)"],
|
||||
cwd=str(ROOT), env=env2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
task_id, info = generate_and_wait("空图源降级测试")
|
||||
check("generation done (fallback no-image)", task_id is not None, info)
|
||||
if task_id:
|
||||
s, b = call("GET", f"/api/generate/{task_id}/result")
|
||||
rec = b["data"]["record"]
|
||||
check("record still created", bool(rec and rec["id"]), str(rec)[:60])
|
||||
finally:
|
||||
proc.terminate()
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
print("\n" + ("IMAGE E2E ALL PASSED" if not failures
|
||||
else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""LLM 接入集成测试:规划/标题/意图/禁止降级/长文档两级规划。
|
||||
|
||||
用法:python tests/e2e_llm.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
os.environ["PLAN_CHUNK_CHARS"] = "2500" # 压低阈值,确保长文档用例真正走分段通读
|
||||
|
||||
from src.agent import llm # noqa: E402
|
||||
from src.agent.perception import GenerateConfig # noqa: E402
|
||||
from src.agent import planning as P # noqa: E402
|
||||
from src.agent.planning import build_plan, PlanningError # noqa: E402
|
||||
from src.agent.chat import parse_intent_llm, parse_intent # noqa: E402
|
||||
|
||||
SAMPLE = """# 项目背景
|
||||
为提升知识周转效率启动平台建设,前期调研两个月
|
||||
# 本期成果
|
||||
部署完成基础服务,导入文档120份
|
||||
混合检索准确率较基线提升明显
|
||||
完成三个部门试点接入
|
||||
# 下一步
|
||||
扩大试点范围,建立运营机制
|
||||
"""
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def make_long_doc(sections=40):
|
||||
"""构造约 1.5-2 万字符的多节长文档(含数字,验证分段通读与页数硬约束)。"""
|
||||
parts = []
|
||||
for i in range(1, sections + 1):
|
||||
parts.append(f"# 第{i}章节 阶段性工作汇报")
|
||||
parts.append(f"本阶段完成任务{i}的设计与开发,投入人力3人,周期2周")
|
||||
parts.append(f"完成度达到{i*2}%,质量抽检合格率98%")
|
||||
parts.append("- 关键交付物已通过评审")
|
||||
parts.append("- 遗留问题已登记跟踪")
|
||||
parts.append("下一步将推进与业务系统的对接联调")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def main():
|
||||
print("== 0. 禁止降级:LLM 失败必须报错而非照搬 ==")
|
||||
cfg_nf = GenerateConfig(title="降级测试", user_id=1, content=SAMPLE,
|
||||
page_min=5, page_max=8)
|
||||
orig_chat = llm.chat_json
|
||||
try:
|
||||
llm.chat_json = lambda *a, **k: None
|
||||
try:
|
||||
build_plan(cfg_nf)
|
||||
check("no silent fallback", False, "未抛出 PlanningError")
|
||||
except PlanningError as e:
|
||||
check("no silent fallback", True, str(e)[:50])
|
||||
finally:
|
||||
llm.chat_json = orig_chat
|
||||
|
||||
ok = llm.ensure_backend()
|
||||
check("llm backend", ok)
|
||||
|
||||
print("== 1. LLM 内容规划(短文档直通) ==")
|
||||
t = time.time()
|
||||
cfg = GenerateConfig(title="知识平台周报", user_id=1, content=SAMPLE,
|
||||
scene="report", language="zh", page_min=6, page_max=9)
|
||||
r = build_plan(cfg)
|
||||
plan = r.plan
|
||||
slides = plan["slides"]
|
||||
print(f" mode={r.mode} pages={len(slides)} 耗时={time.time()-t:.0f}s")
|
||||
check("llm mode used", r.mode == "llm")
|
||||
check("page count in range", 6 <= len(slides) <= 9, f"n={len(slides)}")
|
||||
check("cover first / end last", slides[0]["type"] == "cover" and slides[-1]["type"] == "end")
|
||||
notes_n = sum(1 for s in slides if s.get("notes"))
|
||||
check("per-slide notes", notes_n >= len(slides) - 2, f"notes={notes_n}")
|
||||
generic = sum(1 for s in slides if s["title"] in ("",) or s["title"].startswith("要点"))
|
||||
check("no generic titles", generic == 0)
|
||||
|
||||
def avg_len(sl):
|
||||
bl = [len(b) for x in sl if x["type"] == "content" for b in x.get("content", [])]
|
||||
return sum(bl) / max(len(bl), 1)
|
||||
check("full-sentence bullets (short doc)", avg_len(slides) >= 12,
|
||||
f"avg={avg_len(slides):.0f}")
|
||||
for s in slides:
|
||||
print(f" - [{s['type']:8s}] {s['title'][:24]} notes={'Y' if s.get('notes') else '-'}")
|
||||
|
||||
print("== 2. 长文档两级规划(分段通读→汇总大纲) ==")
|
||||
long_doc = make_long_doc()
|
||||
t = time.time()
|
||||
cfg2 = GenerateConfig(title="季度工作总结", user_id=1, content=long_doc,
|
||||
scene="report", language="zh", page_min=10, page_max=15)
|
||||
r2 = build_plan(cfg2)
|
||||
slides2 = r2.plan["slides"]
|
||||
n2 = len(slides2)
|
||||
print(f" mode={r2.mode} pages={n2} 源={len(long_doc)}字 耗时={time.time()-t:.0f}s")
|
||||
check("long doc page hard cap", n2 <= 15 and n2 >= 4, f"n={n2}")
|
||||
notes2 = sum(1 for s in slides2 if s.get("notes"))
|
||||
check("long doc has notes", notes2 >= n2 - 2, f"notes={notes2}")
|
||||
covered = sum(1 for i in range(1, 41) if str(i) in "".join(
|
||||
s["title"] + "".join(s["content"]) for s in slides2))
|
||||
check("late sections represented", covered >= 3, f"covered_sections={covered}")
|
||||
check("full-sentence bullets (long doc)", avg_len(slides2) >= 12,
|
||||
f"avg={avg_len(slides2):.0f}")
|
||||
|
||||
print("== 3. 智能标题 ==")
|
||||
t = time.time()
|
||||
data = llm.chat_json(
|
||||
"根据以下 PPT 内容拟 3 个标题(每个不超过 20 字)。"
|
||||
'输出 JSON {"titles": ["..."]}。\n\n' + SAMPLE)
|
||||
titles = [str(x).strip() for x in (data or {}).get("titles", []) if str(x).strip()]
|
||||
print(f" titles={titles} 耗时={time.time()-t:.0f}s")
|
||||
check("title suggest", len(titles) >= 1)
|
||||
|
||||
print("== 4. LLM 意图解析(规则无法处理的句式) ==")
|
||||
msg = '第2页末尾添加一条要点:风险与依赖已同步全组'
|
||||
rule = parse_intent(msg, {})
|
||||
llm_it = parse_intent_llm(msg, plan)
|
||||
check("rule cannot parse (no quotes)", rule["type"] == "unknown", str(rule))
|
||||
check("llm parses add", llm_it and llm_it.get("type") == "edit_page"
|
||||
and llm_it.get("op") == "add" and llm_it.get("page_no") == 2,
|
||||
str(llm_it))
|
||||
msg2 = "把标题改成 AI 知识平台建设汇报"
|
||||
it2 = parse_intent_llm(msg2, plan)
|
||||
check("llm parses title", it2 and it2.get("type") == "change_title"
|
||||
and "知识平台" in it2.get("title", ""), str(it2))
|
||||
|
||||
print("== 5. 渲染 LLM plan(含 notes) ==")
|
||||
import json
|
||||
import tempfile
|
||||
from src.engine_bridge import render_plan, verify_output
|
||||
tmp = Path(tempfile.mkdtemp(prefix="llm_plan_"))
|
||||
p = tmp / "plan.json"
|
||||
p.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
out = tmp / "out.pptx"
|
||||
render_plan(p, out)
|
||||
vok, detail = verify_output(out, p)
|
||||
check("render+verify llm plan", vok, detail[:60])
|
||||
|
||||
print("\n" + ("LLM E2E ALL PASSED" if not failures else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""个人设置 e2e:偏好读写校验、改用户名、改密码、历史语言筛选。
|
||||
|
||||
用法:python tests/e2e_settings.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5101"
|
||||
_TS = int(time.time())
|
||||
UA = f"sxa{_TS}"
|
||||
UB = f"sxb{_TS}"
|
||||
NEW_NAME = f"sxc{_TS}"
|
||||
PASS = "set_e2e_123"
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def call(method, path, payload=None):
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read()
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, json.loads(body or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read() or b"{}")
|
||||
except Exception:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def drop_cookie():
|
||||
COOKIE["value"] = ""
|
||||
|
||||
|
||||
def insert_record(user_id, title, language, created_at):
|
||||
from config import DB_PATH
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.execute(
|
||||
"INSERT INTO ppt_records (user_id,title,content,scene,language,color_scheme,"
|
||||
"page_min,page_max,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(user_id, title, "内容示例文本,用于语言筛选测试", "report", language,
|
||||
"blue", 10, 15, "done", created_at),
|
||||
)
|
||||
conn.commit()
|
||||
uid = conn.execute("SELECT id FROM ppt_records WHERE title=?", (title,)).fetchone()[0]
|
||||
conn.close()
|
||||
return uid
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, env=env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5101)"],
|
||||
cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": UA, "password": PASS, "confirm_password": PASS})
|
||||
check("register A", s == 200 and b.get("code") == 0)
|
||||
|
||||
# ---- GET /api/settings 默认值 ----
|
||||
s, b = call("GET", "/api/settings")
|
||||
p = (b.get("data") or {}).get("preferences") or {}
|
||||
check("settings defaults", s == 200 and b["data"]["username"] == UA
|
||||
and p.get("default_scene") == "training" and p.get("default_color") == "blue"
|
||||
and p.get("default_language") == "zh" and p.get("default_canvas") == "ppt169"
|
||||
and p.get("default_image_strategy") == "web"
|
||||
and p.get("default_page_min") == 10 and p.get("default_page_max") == 15,
|
||||
str(p)[:80])
|
||||
|
||||
# ---- 偏好保存(部分字段)----
|
||||
s, b = call("PUT", "/api/settings/preferences",
|
||||
{"default_color": "purple", "default_language": "ja",
|
||||
"default_page_min": 5, "default_page_max": 8})
|
||||
p = ((b.get("data") or {}).get("preferences")) or {}
|
||||
check("save prefs partial", s == 200 and p.get("default_color") == "purple"
|
||||
and p.get("default_language") == "ja" and p.get("default_page_min") == 5
|
||||
and p.get("default_page_max") == 8 and p.get("default_scene") == "training",
|
||||
str(p)[:80])
|
||||
s, b = call("PUT", "/api/settings/preferences", {"default_color": "pink"})
|
||||
check("prefs invalid enum -> 5002", s == 400 and b.get("code") == 5002, str(b)[:60])
|
||||
s, b = call("PUT", "/api/settings/preferences", {"default_page_min": 20, "default_page_max": 8})
|
||||
check("prefs min>max -> 2002", s == 400 and b.get("code") == 2002, str(b)[:60])
|
||||
|
||||
# ---- 改用户名 ----
|
||||
s, b = call("PUT", "/api/settings/username", {"username": "ab"})
|
||||
check("username too short -> 5002", s == 400 and b.get("code") == 5002, str(b)[:50])
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": UB, "password": PASS, "confirm_password": PASS})
|
||||
drop_cookie()
|
||||
call("POST", "/api/login", {"username": UA, "password": PASS})
|
||||
s, b = call("PUT", "/api/settings/username", {"username": UB})
|
||||
check("username duplicate -> 1001", s == 409 and b.get("code") == 1001, str(b)[:50])
|
||||
new_name = NEW_NAME
|
||||
s, b = call("PUT", "/api/settings/username", {"username": new_name})
|
||||
s2, b2 = call("GET", "/api/me")
|
||||
check("username changed", s == 200 and b2["data"]["username"] == new_name,
|
||||
str(b2)[:60])
|
||||
|
||||
# ---- 改密码 ----
|
||||
s, b = call("PUT", "/api/settings/password",
|
||||
{"old_password": "wrong_old", "new_password": "newpwd456",
|
||||
"confirm_password": "newpwd456"})
|
||||
check("wrong old pwd -> 5001", s == 400 and b.get("code") == 5001, str(b)[:50])
|
||||
s, b = call("PUT", "/api/settings/password",
|
||||
{"old_password": PASS, "new_password": "newpwd456",
|
||||
"confirm_password": "different"})
|
||||
check("confirm mismatch -> 1003", s == 400 and b.get("code") == 1003, str(b)[:50])
|
||||
s, b = call("PUT", "/api/settings/password",
|
||||
{"old_password": PASS, "new_password": "abc",
|
||||
"confirm_password": "abc"})
|
||||
check("short new pwd -> 5002", s == 400 and b.get("code") == 5002, str(b)[:50])
|
||||
s, b = call("PUT", "/api/settings/password",
|
||||
{"old_password": PASS, "new_password": "newpwd456",
|
||||
"confirm_password": "newpwd456"})
|
||||
check("password changed", s == 200 and b.get("code") == 0)
|
||||
drop_cookie()
|
||||
s, _ = call("POST", "/api/login", {"username": new_name, "password": PASS})
|
||||
check("old password rejected -> 401", s == 401)
|
||||
s, b = call("POST", "/api/login", {"username": new_name, "password": "newpwd456"})
|
||||
check("login with new password", s == 200 and b.get("code") == 0)
|
||||
|
||||
# ---- 历史语言筛选 ----
|
||||
uid = None
|
||||
from src.agent import memory as mem
|
||||
u = mem.get_user_by_name(new_name)
|
||||
uid = u["id"]
|
||||
insert_record(uid, "筛选测试中文稿", "zh", "2026-08-25 10:00:00")
|
||||
insert_record(uid, "フィルタ日本語資料", "ja", "2026-08-25 10:01:00")
|
||||
s, b = call("GET", "/api/records?language=ja")
|
||||
titles_ja = [r["title"] for r in b["data"]["records"]]
|
||||
s, b = call("GET", "/api/records?language=zh")
|
||||
titles_zh = [r["title"] for r in b["data"]["records"]]
|
||||
s, b = call("GET", "/api/records")
|
||||
titles_all = [r["title"] for r in b["data"]["records"]]
|
||||
check("filter ja only", "フィルタ日本語資料" in titles_ja
|
||||
and "筛选测试中文稿" not in titles_ja, str(titles_ja)[:70])
|
||||
check("filter zh only", "筛选测试中文稿" in titles_zh
|
||||
and "フィルタ日本語資料" not in titles_zh, str(titles_zh)[:70])
|
||||
check("no filter shows both", "筛选测试中文稿" in titles_all
|
||||
and "フィルタ日本語資料" in titles_all, f"n={len(titles_all)}")
|
||||
|
||||
# 清理测试插入的记录
|
||||
conn = sqlite3.connect(str(ROOT / "src" / "web" / "data" / "ppt.db"))
|
||||
conn.execute("DELETE FROM ppt_records WHERE title IN (?,?)",
|
||||
("筛选测试中文稿", "フィルタ日本語資料"))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
finally:
|
||||
proc.terminate()
|
||||
|
||||
print("\n" + ("SETTINGS E2E ALL PASSED" if not failures
|
||||
else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,225 @@
|
||||
"""上传解析 e2e:上传 → 解析回填全链路(覆盖 md/txt/pdf/html/xlsx/pptx/URL + 异常)。
|
||||
|
||||
用法:python tests/e2e_upload.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5099"
|
||||
USER = f"up_e2e_{int(time.time())}"
|
||||
PASS = "up_e2e_123"
|
||||
TMP = ROOT / "tests" / "_up"
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def call(method, path, payload=None, raw=False):
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
body = resp.read()
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, (body if raw else json.loads(body or b"{}"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
try:
|
||||
parsed = json.loads(body or b"{}")
|
||||
except Exception:
|
||||
parsed = {"raw": body.decode("utf-8", "ignore")}
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def upload(filename, data, with_cookie=True):
|
||||
"""multipart 上传,返回 (status, body)。"""
|
||||
boundary = "----pptagente2e" + uuid.uuid4().hex[:8]
|
||||
head = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
|
||||
f'filename="{filename}"\r\nContent-Type: application/octet-stream\r\n\r\n')
|
||||
body = head.encode("utf-8") + data + f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
req = urllib.request.Request(BASE + "/api/upload", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
if with_cookie and COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return resp.status, json.loads(resp.read() or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read() or b"{}")
|
||||
except Exception:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def make_samples():
|
||||
"""生成本地样例文件,返回 {格式名: (文件名, 字节)}。"""
|
||||
TMP.mkdir(exist_ok=True)
|
||||
samples = {}
|
||||
|
||||
md = "# UPLOAD_E2E_MARKER_MD\n知识库平台建设背景说明\n本期完成检索服务上线\n"
|
||||
samples["md"] = ("sample.md", md.encode("utf-8"))
|
||||
|
||||
txt = "UPLOAD_E2E_MARKER_TXT\n知识库平台建设背景说明\n本期完成检索服务上线"
|
||||
samples["txt"] = ("sample.txt", txt.encode("utf-8"))
|
||||
|
||||
html = ("<html><head><style>.x{color:red}</style></head><body>"
|
||||
"<h1>UPLOAD_E2E_MARKER_HTML</h1><p>知识库平台季度汇报</p></body></html>")
|
||||
samples["html"] = ("sample.html", html.encode("utf-8"))
|
||||
|
||||
import fitz
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
page.insert_text((72, 72), "UPLOAD_E2E_MARKER_PDF quarterly report of knowledge platform")
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
samples["pdf"] = ("sample.pdf", buf.getvalue())
|
||||
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws["A1"] = "UPLOAD_E2E_MARKER_XLSX"
|
||||
ws["A2"] = "knowledge platform kpi"
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
samples["xlsx"] = ("sample.xlsx", buf.getvalue())
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
prs = Presentation()
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[5])
|
||||
tb = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(6), Inches(1))
|
||||
tb.text_frame.text = "UPLOAD_E2E_MARKER_PPTX knowledge platform review"
|
||||
buf = io.BytesIO()
|
||||
prs.save(buf)
|
||||
samples["pptx"] = ("sample.pptx", buf.getvalue())
|
||||
|
||||
return samples
|
||||
|
||||
|
||||
def start_url_server():
|
||||
"""本地起一个极简网页服务器,供 URL 抓取分支测试。"""
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = (b"<html><head><title>t</title></head><body>"
|
||||
b"<h1>UPLOAD_E2E_MARKER_URL</h1><p>web capture ok</p></body></html>")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
srv = HTTPServer(("127.0.0.1", 5199), H)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
return srv
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, env=env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5099)"],
|
||||
cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
srv = start_url_server()
|
||||
samples = make_samples()
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": USER, "password": PASS, "confirm_password": PASS})
|
||||
check("register", s == 200 and b.get("code") == 0)
|
||||
|
||||
# ---- 鉴权与非法输入 ----
|
||||
s, b = upload("x.md", b"# hi", with_cookie=False)
|
||||
check("upload without login -> 401", s == 401, str(s))
|
||||
s, b = upload("evil.exe", b"MZ fake")
|
||||
check("upload exe -> 3001", s == 400 and b.get("code") == 3001, str(b)[:60])
|
||||
s, b = upload("huge.txt", b"A" * (21 * 1024 * 1024))
|
||||
check("upload 21MB -> 3002", b.get("code") == 3002, str(b)[:60])
|
||||
s, b = call("POST", "/api/parse", {"file_id": "nothex"})
|
||||
check("parse bad file_id -> 400", s == 400, str(b)[:60])
|
||||
s, b = call("POST", "/api/parse", {"file_id": "f" * 32})
|
||||
check("parse unknown file_id -> 404", s == 404 and b.get("code") == 3003, str(b)[:60])
|
||||
|
||||
# ---- 各格式解析 ----
|
||||
file_ids = {}
|
||||
for fmt, (fname, data) in samples.items():
|
||||
s, b = upload(fname, data)
|
||||
ok_up = s == 200 and b.get("code") == 0 and b.get("data", {}).get("file_id")
|
||||
check(f"upload {fmt}", bool(ok_up), str(b)[:60])
|
||||
if not ok_up:
|
||||
continue
|
||||
fid = b["data"]["file_id"]
|
||||
file_ids[fmt] = fid
|
||||
marker = {
|
||||
"md": "UPLOAD_E2E_MARKER_MD", "txt": "UPLOAD_E2E_MARKER_TXT",
|
||||
"html": "UPLOAD_E2E_MARKER_HTML", "pdf": "UPLOAD_E2E_MARKER_PDF",
|
||||
"xlsx": "UPLOAD_E2E_MARKER_XLSX", "pptx": "UPLOAD_E2E_MARKER_PPTX",
|
||||
}[fmt]
|
||||
s, b = call("POST", "/api/parse", {"file_id": fid})
|
||||
d = b.get("data") or {}
|
||||
check(f"parse {fmt}", s == 200 and b.get("code") == 0
|
||||
and marker in d.get("text", "") and d.get("chars", 0) >= 20,
|
||||
str(b)[:80])
|
||||
|
||||
# ---- URL 抓取分支 ----
|
||||
s, b = call("POST", "/api/parse", {"url": "notaurl"})
|
||||
check("parse bad url -> 400", s == 400, str(b)[:60])
|
||||
s, b = call("POST", "/api/parse", {"url": "http://127.0.0.1:5199/page"})
|
||||
d = b.get("data") or {}
|
||||
check("parse local url", s == 200 and "UPLOAD_E2E_MARKER_URL" in d.get("text", ""),
|
||||
str(b)[:80])
|
||||
finally:
|
||||
proc.terminate()
|
||||
srv.shutdown()
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
print("\n" + ("UPLOAD E2E ALL PASSED" if not failures
|
||||
else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Web 全链路端到端测试:初始化DB → 启动Flask → 注册 → 生成 → 轮询 → 下载。
|
||||
|
||||
用法:
|
||||
python tests/e2e_web.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5099"
|
||||
USER = f"e2e_user_{int(time.time())}"
|
||||
PASS = "e2e_pass_123"
|
||||
SAMPLE = """# 项目背景
|
||||
为提升内部知识周转效率,启动知识库平台建设
|
||||
已完成技术选型与原型验证
|
||||
# 本期成果
|
||||
部署完成基础服务,导入文档120份
|
||||
混合检索准确率显著优于基线方案
|
||||
# 下一步
|
||||
扩大试点范围至三个部门
|
||||
建立内容运营与质量巡检机制
|
||||
"""
|
||||
|
||||
|
||||
def call(method: str, path: str, payload=None, raw=False):
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
# cookie 会话
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
body = resp.read()
|
||||
set_cookie = resp.headers.get("Set-Cookie")
|
||||
return resp.status, (body if raw else json.loads(body or b"{}")), set_cookie
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
try:
|
||||
return e.code, json.loads(body or b"{}"), e.headers.get("Set-Cookie")
|
||||
except Exception:
|
||||
return e.code, {"raw": body.decode("utf-8", "ignore")}, e.headers.get("Set-Cookie")
|
||||
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def callx(method, path, payload=None, raw=False):
|
||||
status, body, sc = call(method, path, payload, raw)
|
||||
if sc and COOKIE["value"] == "":
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return status, body
|
||||
|
||||
|
||||
# 注入 cookie
|
||||
_orig_call = call
|
||||
|
||||
|
||||
def call(method, path, payload=None, raw=False): # noqa: F811
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read()
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, (body if raw else json.loads(body or b"{}"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
try:
|
||||
parsed = json.loads(body or b"{}")
|
||||
except Exception:
|
||||
parsed = {"raw": body.decode("utf-8", "ignore")}
|
||||
sc = e.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
print("== 初始化数据库 ==")
|
||||
import subprocess
|
||||
r = subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
env={**__import__("os").environ, "PYTHONIOENCODING": "utf-8"})
|
||||
check("db init", r.returncode == 0, r.stdout.strip()[-40:] + r.stderr.strip()[-80:])
|
||||
|
||||
print("== 启动 Flask ==")
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5099)"],
|
||||
cwd=str(ROOT), env={**__import__("os").environ, "PYTHONIOENCODING": "utf-8"},
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
s, _b = call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
print("== 认证链路 ==")
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": USER, "password": PASS, "confirm_password": PASS})
|
||||
check("register", s == 200 and b.get("code") == 0, str(b)[:60])
|
||||
|
||||
s, b = call("GET", "/api/me")
|
||||
check("me with session", s == 200 and b.get("data", {}).get("username") == USER)
|
||||
prefs = b.get("data", {}).get("preferences") or {}
|
||||
|
||||
print("== 校验拦截 ==")
|
||||
s, b = call("POST", "/api/generate",
|
||||
{**{"title": "t", "content": "太短", "scene": "report"},
|
||||
**{k: prefs.get("default_" + k[0:-4], v) for k, v in []}})
|
||||
check("short content rejected (2001)", b.get("code") == 2001, str(b)[:60])
|
||||
|
||||
print("== 真实生成 ==")
|
||||
gen_payload = {
|
||||
"title": "E2E冒烟报告",
|
||||
"content": SAMPLE,
|
||||
"scene": "report", "language": "zh", "canvas": "ppt169",
|
||||
"image_strategy": "off", "color_scheme": "blue",
|
||||
"page_min": 5, "page_max": 8,
|
||||
}
|
||||
s, b = call("POST", "/api/generate", gen_payload)
|
||||
check("generate accepted", s == 200 and b.get("code") == 0, str(b)[:80])
|
||||
task_id = (b.get("data") or {}).get("task_id")
|
||||
|
||||
final = None
|
||||
for _ in range(120):
|
||||
time.sleep(1)
|
||||
s, b = call("GET", f"/api/generate/{task_id}/status")
|
||||
d = b.get("data") or {}
|
||||
if d.get("status") in ("done", "failed"):
|
||||
final = d
|
||||
break
|
||||
check("generation done", final and final.get("status") == "done", str(final)[:120])
|
||||
|
||||
s, b = call("GET", f"/api/generate/{task_id}/result")
|
||||
record = (b.get("data") or {}).get("record") or {}
|
||||
rid = record.get("id")
|
||||
check("result has record", bool(rid), f"id={rid} pages={record.get('page_count')} size={record.get('file_size')}")
|
||||
check("page_count sane", 5 <= (record.get("page_count") or 0) <= 8,
|
||||
str(record.get("page_count")))
|
||||
|
||||
print("== 历史与下载 ==")
|
||||
s, b = call("GET", "/api/records?keyword=E2E")
|
||||
recs = ((b.get("data") or {}).get("records")) or []
|
||||
check("records list contains new", any(x["id"] == rid for x in recs))
|
||||
|
||||
s, body_bytes = call("GET", f"/api/files/{rid}/download", raw=True)
|
||||
ok_pptx = body_bytes[:2] == b"P" + b"K" # PK zip header
|
||||
check("download pptx bytes", s == 200 and ok_pptx, f"{len(body_bytes)} bytes head={body_bytes[:2]}")
|
||||
|
||||
pptx_path = ROOT / "tests" / "_e2e_download.pptx"
|
||||
pptx_path.write_bytes(body_bytes)
|
||||
from pptx import Presentation
|
||||
prs = Presentation(str(pptx_path))
|
||||
texts = "\n".join(sh.text_frame.text for sl in prs.slides
|
||||
for sh in sl.shapes if sh.has_text_frame)
|
||||
check("pptx contains title", "E2E冒烟报告" in texts)
|
||||
check("pptx contains content keyword", "混合检索" in texts)
|
||||
finally:
|
||||
proc.terminate()
|
||||
|
||||
print("\n" + ("E2E ALL PASSED" if not failures else "E2E FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""冒烟测试:build_plan 结构 + aura 引擎直接渲染。
|
||||
|
||||
用法:
|
||||
python tests/smoke_build_plan.py # 仅构建 plan 并打印结构
|
||||
python tests/smoke_build_plan.py --render # 追加真实渲染 + 保真验证
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from src.agent.perception import GenerateConfig # noqa: E402
|
||||
from src.agent.planning import build_plan # noqa: E402
|
||||
|
||||
SAMPLE_ZH = """# 本月完成情况
|
||||
完成模块A开发,进度符合计划
|
||||
修复线上3个缺陷,系统可用性提升至99.9%
|
||||
新增用户反馈收集通道
|
||||
# 下月计划
|
||||
启动模块B概要设计
|
||||
组织跨部门代码评审
|
||||
开展性能压测与容量评估
|
||||
补充核心链路自动化测试用例
|
||||
# 风险与求助
|
||||
人力存在缺口,希望增援1名后端
|
||||
第三方接口联调依赖对方排期
|
||||
"""
|
||||
|
||||
SAMPLE_JA = """# 今月の進捗
|
||||
モジュールAの開発完了、予定どおり
|
||||
本番障害3件を修正し、可用性99.9%を達成
|
||||
# 来月の計画
|
||||
モジュールBの概要設計を開始
|
||||
性能テストを実施する
|
||||
"""
|
||||
|
||||
|
||||
def make_cfg(lang="zh"):
|
||||
return GenerateConfig(
|
||||
title="七月项目周报" if lang == "zh" else "7月度プロジェクト報告",
|
||||
user_id=1, content=SAMPLE_ZH if lang == "zh" else SAMPLE_JA,
|
||||
scene="report", language=lang, canvas="ppt169",
|
||||
image_strategy="off",
|
||||
color_scheme="blue", page_min=6, page_max=10,
|
||||
)
|
||||
|
||||
|
||||
def show(plan):
|
||||
print(f"design={plan['design']} slides={len(plan['slides'])}")
|
||||
for i, s in enumerate(plan["slides"], 1):
|
||||
n = len(s.get("content") or [])
|
||||
print(f" {i:02d}. [{s['type']:9s}] {s.get('title','')[:30]} bullets={n}")
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
for lang in ("zh", "ja"):
|
||||
r = build_plan(make_cfg(lang))
|
||||
plan = r.plan
|
||||
print(f"\n===== {lang} ===== warnings={r.warnings}")
|
||||
show(plan)
|
||||
types = [s["type"] for s in plan["slides"]]
|
||||
if types[0] != "cover" or types[-1] != "end":
|
||||
failures.append(f"{lang}: 首尾页面类型错误")
|
||||
if not (6 <= len(plan["slides"]) <= 10) and lang == "zh":
|
||||
failures.append(f"{lang}: 页数 {len(plan['slides'])} 超出目标区间")
|
||||
if lang == "ja" and plan["design"] != "report-jp":
|
||||
failures.append("ja 未映射到 report-jp")
|
||||
if lang == "zh" and plan["design"] != "corp-blue":
|
||||
failures.append("zh/blue 未映射到 corp-blue")
|
||||
|
||||
if "--render" in sys.argv:
|
||||
from config import COMPANY_TEMPLATE
|
||||
from src.engine_bridge import render_plan, verify_output
|
||||
tmp = Path(tempfile.mkdtemp(prefix="aura_smoke_"))
|
||||
for lang in ("zh", "ja"):
|
||||
r = build_plan(make_cfg(lang))
|
||||
p = tmp / f"plan_{lang}.json"
|
||||
p.write_text(json.dumps(r.plan, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
out = tmp / f"smoke_{lang}.pptx"
|
||||
tpl = COMPANY_TEMPLATE if (lang == "ja" and COMPANY_TEMPLATE.exists()) else None
|
||||
render_plan(p, out, template=tpl)
|
||||
ok, detail = verify_output(out, p)
|
||||
print(f"\n[render {lang}] -> {out}")
|
||||
print(f"[verify {lang}] passed={ok}\n{detail}")
|
||||
if not ok:
|
||||
failures.append(f"{lang}: 渲染保真验证未通过")
|
||||
|
||||
print("\n" + ("ALL SMOKE PASSED" if not failures else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user