中期成果提交:PPT自动生成 Agent(感知-规划-行动-记忆闭环 + aura-ppt 渲染引擎 + Web 交互界面 + 测试用例 + AI 使用日志)

This commit is contained in:
T1-AISUYISIN
2026-08-31 09:53:11 +08:00
commit 883a89682b
50 changed files with 10355 additions and 0 deletions
+80
View File
@@ -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)
+156
View File
@@ -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()
+187
View File
@@ -0,0 +1,187 @@
"""网络配图 e2eimage_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()
+153
View File
@@ -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()
+200
View File
@@ -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()
+225
View File
@@ -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()
+197
View File
@@ -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()
+98
View File
@@ -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()