Files
PlantAI-rag-new/sdk/app/services/test_sse_relay.py
T
jerryandClaude Fable 5 0875bb036c P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架
- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
  历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
  单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
  tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
  生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:56:47 +08:00

602 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""test_sse_relay.py —— sse_relay 单元测试(A2 归属)。
全部测试对注入的假数据/假 client 运行,不依赖真实上游。看门狗测试用缩短的
超时参数注入(idle_timeout / keepalive_interval),避免真实 240s。
环境无 respx/pytest-asyncio:异步测试用 anyio 插件(pytest.mark.anyio)。
"""
from __future__ import annotations
import json
import pytest
from app.services import sse_relay
from app.services.sse_relay import (
STAGE_LABEL,
STAGE_PHASE,
UNKNOWN_STAGE_PHASE,
_classify,
phase_for,
relay_stream,
)
def _frame_texts(frames: list[str]) -> list[str]:
return [f for f in frames if f.strip()]
def _event_data_map(frames: list[str]) -> dict[str, list[dict]]:
"""把 SSE 帧序列解析为 {event_name: [data_dict, ...]}。"""
out: dict[str, list[dict]] = {}
event = None
for frame in frames:
for line in frame.split("\n"):
if line.startswith("event:"):
event = line[len("event:"):].strip()
out.setdefault(event, [])
elif line.startswith("data:") and event:
out[event].append(json.loads(line[len("data:"):].strip()))
return out
def _sse_bytes(events: list[tuple[bytes, bytes]]) -> list[bytes]:
return [b"event: " + e + b"\ndata: " + d + b"\n\n" for e, d in events]
# ===========================================================================
# STAGE_PHASE / STAGE_LABEL 全覆盖
# ===========================================================================
def test_stage_phase_maps_all_11_known_stages():
expected = {
"understanding_started": ("planning", "开始理解问题"),
"entities_identified": ("planning", "已识别实体"),
"related_search_started": ("planning", "开始关联检索"),
"concepts_found": ("retrieval", "发现相关概念"),
"subquestion_retrieved": ("retrieval", "子问题召回完成"),
"agent_review_started": ("retrieval", "开始智能体审核"),
"subquestion_retry_started": ("retrieval", "子问题重试中"),
"evidence_accepted": ("evidence", "证据已采纳"),
"evidence_review_completed": ("evidence", "证据审核完成"),
"answer_generation_started": ("generation", "开始生成答案"),
"completed": ("done", "检索完成"),
}
assert len(STAGE_PHASE) == 11
assert len(STAGE_LABEL) == 11
for stage, (phase, label) in expected.items():
assert STAGE_PHASE[stage] == phase
assert STAGE_LABEL[stage] == label
assert phase_for("not_a_real_stage") == (UNKNOWN_STAGE_PHASE, "not_a_real_stage")
def test_unknown_stage_falls_back_to_retrieval():
assert STAGE_PHASE.get("future_stage_xyz") is None
phase, label = phase_for("future_stage_xyz")
assert phase == "retrieval"
assert label == "future_stage_xyz"
# ===========================================================================
# 帧分类:无 event 名按形态、多行 data、: 注释
# ===========================================================================
def test_classify_stage_payload():
kind, payload = _classify("stage", '{"stage":"entities_identified","details":{"entities":["OsSPL14"]}}')
assert kind == "progress"
assert payload["stage"] == "entities_identified"
def test_classify_terminal_complete():
kind, payload = _classify("complete", '{"answer":"xxx","success":true,"sources":[]}')
assert kind == "complete"
def test_classify_no_event_name_by_shape():
# 无 event 名:按 payload 形态分类
assert _classify(None, '{"stage":"concepts_found","details":{}}')[0] == "progress"
assert _classify(None, '{"answer":"...","sources":[]}')[0] == "complete"
assert _classify(None, '{"success":false,"detail":{"error_code":"x"}}')[0] == "complete" # 含 success 判终态
assert _classify(None, '{"foo":1}')[0] == "raw"
def test_classify_error_event():
kind, payload = _classify("error", '{"detail":{"error_code":"invalid_llm_citation"}}')
assert kind == "error"
assert payload["code"] == "INVALID_CITATION"
def test_classify_raw_never_drops():
# 非 JSON data
kind, payload = _classify(None, "not-json-at-all")
assert kind == "raw"
assert payload["payload"] == "not-json-at-all"
# 无法分类的 JSON
kind, payload = _classify("mystery", '{"unexpected":true}')
assert kind == "raw"
assert payload == {"unexpected": True}
# JSON 数组
kind, payload = _classify(None, "[1,2,3]")
assert kind == "raw"
assert payload == [1, 2, 3]
# 空 data 但有 event 名
kind, payload = _classify("some_event", "")
assert kind == "raw"
assert payload["event_name"] == "some_event"
# ===========================================================================
# 中继整体:mock client 注入
# ===========================================================================
class FakeUpstreamStream:
"""httpx stream 上下文的假实现:按给定 chunks 序列吐字节。
silent=True 时 aiter_raw 永久挂起(模拟上游连接保持但完全静默)。
"""
def __init__(self, chunks: list[bytes], status: int = 200, headers: dict | None = None,
json_body: dict | None = None, silent: bool = False):
self._chunks = list(chunks)
self.status_code = status
self.headers = headers or {}
self._json = json_body
self._silent = silent
async def aiter_raw(self):
if self._silent:
import anyio
await anyio.sleep_forever()
return # pragma: no cover
for c in self._chunks:
yield c
async def aread(self):
return json.dumps(self._json).encode() if self._json is not None else b""
class _StreamCtx:
def __init__(self, response: FakeUpstreamStream):
self._resp = response
async def __aenter__(self):
return self._resp
async def __aexit__(self, exc_type, exc, tb):
return False
class FakeClient:
"""按顺序弹出响应的假 httpx.AsyncClient;记录请求供断言。"""
def __init__(self, responses: list[FakeUpstreamStream], exc: Exception | None = None):
self._responses = list(responses)
self._exc = exc
self.calls: list[dict] = []
def stream(self, method, url, **kwargs):
self.calls.append({"method": method, "url": url, **kwargs})
if self._exc is not None:
exc, self._exc = self._exc, None
raise exc
resp = self._responses.pop(0)
return _StreamCtx(resp)
def _collect(agen) -> list[str]:
import asyncio
async def run():
return [f async for f in agen]
return asyncio.run(run())
def test_relay_emits_progress_complete_sequence():
import anyio
frames = [
("stage", '{"stage":"understanding_started","details":{}}'),
("stage", '{"stage":"entities_identified","details":{"entities":["OsSPL14"]}}'),
("stage", '{"stage":"completed","details":{"elapsed_seconds":38.3}}'),
("complete", '{"answer":"final answer","success":true,"sources":[{"title":"T","score":0.9}]}'),
]
client = FakeClient([FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in frames]))])
async def run():
out = [f async for f in relay_stream({"question": "q"}, client=client, base_url="http://up")]
return out
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert len(events["progress"]) == 3
assert events["progress"][0]["stage"] == "understanding_started"
assert events["progress"][0]["phase"] == "planning"
assert events["progress"][1]["phase"] == "planning"
assert events["progress"][2]["phase"] == "done"
assert events["complete"][0]["mode"] == "chat"
assert "at" in events["progress"][0]
assert client.calls[0]["url"] == "http://up/api/v1/qa-v1-1-73/chat/stream"
assert client.calls[0]["json"] == {"question": "q"}
def test_relay_multiline_data_and_comment_frames():
import anyio
comment = b": keepalive\n\n"
chunks = [
b"event: stage\ndata: {\"stage\":\"evidence_accepted\",\n",
b"data: \"details\":{\"accepted_count\":12}}\n\n",
comment,
b"event: complete\ndata: {\"answer\":\"A\"}\n\n",
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({"question": "q"}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert "progress" in events
assert events["progress"][0]["details"] == {"accepted_count": 12}
assert "complete" in events
assert all(not f.startswith(": keepalive") for f in _frame_texts(out))
# ===========================================================================
# evidence_first(2026-08-26 方案 3):并发 search 注入非终态 evidence 帧
# ===========================================================================
def test_evidence_first_off_no_search_call():
import anyio
frames = [("complete", '{"answer":"A","success":true,"sources":[]}')]
# 只放一个响应:若 evidence_first=False 却发了 search,会 pop 掉 chat 的响应导致挂死
client = FakeClient([FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in frames]))])
async def run():
return [f async for f in relay_stream({"question": "q"}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert "evidence" not in events
assert len(client.calls) == 1 # 只有 chat/stream 一次调用
assert client.calls[0]["url"].endswith("/chat/stream")
def test_evidence_first_emits_evidence_before_complete():
import anyio
chat_frames = [
("stage", '{"stage":"concepts_found","details":{}}'),
("complete", '{"answer":"final","success":true,"sources":[{"title":"chat-src"}]}'),
]
search_body = {"success": True, "answer": "", "sources": [{"title": "search-src"}],
"native_graph_chains": [], "timings": {"total": 92.0}}
chat = FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in chat_frames]))
search = FakeUpstreamStream([], status=200, json_body=search_body)
client = FakeClient([chat, search]) # 顺序确定:主链先弹 chat,evidence task 弹 search
async def run():
return [f async for f in relay_stream(
{"question": "q", "use_llm": True}, client=client, base_url="http://up",
evidence_first=True)]
out = anyio.run(run)
texts = _frame_texts(out)
events = _event_data_map(texts)
# evidence 帧存在且 answer 为空、mode 为 search
assert "evidence" in events
assert events["evidence"][0]["answer"] == ""
assert events["evidence"][0]["mode"] == "search"
assert events["evidence"][0]["sources"][0]["title"] == "search-src"
# 顺序:evidence 在 complete 之前
ev_idx = texts.index(next(f for f in texts if f.startswith("event: evidence")))
cp_idx = texts.index(next(f for f in texts if f.startswith("event: complete")))
assert ev_idx < cp_idx
# 两次上游调用:chat/stream 与 search(use_llm 强制 false、图链关闭防抢 Neo4j)
assert len(client.calls) == 2
assert client.calls[1]["url"].endswith("/search")
assert client.calls[1]["json"]["use_llm"] is False
assert client.calls[1]["json"]["native_chain_enabled"] is False
assert client.calls[1]["json"]["question"] == "q"
def test_evidence_first_search_failure_degrades_to_plain_chat():
import anyio
chat_frames = [("complete", '{"answer":"final","success":true,"sources":[]}')]
chat = FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in chat_frames]))
search_fail = FakeUpstreamStream([], status=500, json_body={"detail": "boom"})
client = FakeClient([chat, search_fail])
async def run():
return [f async for f in relay_stream(
{"question": "q"}, client=client, base_url="http://up", evidence_first=True)]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert "evidence" not in events # 静默降级
assert "complete" in events # 主链不受影响
def test_evidence_first_complete_first_no_evidence_injected():
import anyio
# chat 先终态;search 虽成功但已无机会注入(complete 已发)
chat_frames = [("complete", '{"answer":"fast","success":true,"sources":[]}')]
chat = FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in chat_frames]))
search_body = {"success": True, "answer": "", "sources": [{"title": "late"}]}
search = FakeUpstreamStream([], status=200, json_body=search_body)
client = FakeClient([chat, search])
async def run():
return [f async for f in relay_stream(
{"question": "q"}, client=client, base_url="http://up", evidence_first=True)]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert "complete" in events
# evidence 要么不存在,要么在 complete 之后(前端 ignore);BFF 保证不插在终态后
texts = _frame_texts(out)
ev = [f for f in texts if f.startswith("event: evidence")]
cp_idx = texts.index(next(f for f in texts if f.startswith("event: complete")))
for e in ev:
assert texts.index(e) < cp_idx
def test_relay_no_event_name_classified_by_shape():
import anyio
# 上游不带 event: 行,纯 data: JSON(模拟变体)
chunks = [
b'data: {"stage":"concepts_found","details":{}}\n\n',
b'data: {"answer":"A","success":true}\n\n',
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({"q": 1}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["progress"][0]["phase"] == "retrieval"
assert events["complete"][0]["answer"] == "A"
def test_relay_unknown_json_goes_raw():
import anyio
chunks = [
b"data: {\"mystery\":\"payload\"}\n\n",
b"event: complete\ndata: {\"answer\":\"A\"}\n\n",
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["raw"] == [{"mystery": "payload"}]
assert "complete" in events
def test_relay_unknown_stage_maps_to_retrieval_with_raw_label():
import anyio
chunks = [b'data: {"stage":"brand_new_stage","details":{"x":1}}\n\n']
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["progress"][0]["phase"] == "retrieval"
assert events["progress"][0]["label"] == "brand_new_stage"
def test_relay_error_event_closes():
import anyio
chunks = [
b'event: error\ndata: {"detail":{"error_code":"invalid_llm_citation"}}\n\n',
b"data: {\"stage\":\"completed\"}\n\n", # 关闭后不应被处理
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert list(events.keys()) == ["error"]
assert events["error"][0]["code"] == "INVALID_CITATION"
def test_relay_upstream_close_before_terminal_reports_error():
import anyio
chunks = [b'data: {"stage":"concepts_found","details":{}}\n\n'] # 无 complete 即 EOF
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
# 残余帧先转发(绝不丢数据),再报错误
assert "progress" in events
assert events["error"][0]["code"] == "UPSTREAM_ERROR"
# ===========================================================================
# 看门狗(缩短超时注入)
# ===========================================================================
def test_watchdog_timeout_after_idle():
import anyio
# 上游连接保持但完全静默;idle_timeout=0.3s 注入
client = FakeClient([FakeUpstreamStream([], silent=True)])
async def run():
return [
f
async for f in relay_stream(
{}, client=client, base_url="http://up", idle_timeout=0.3, keepalive_interval=0.15
)
]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert list(events.keys()) == ["timeout"]
assert events["timeout"][0]["message"] == "检索超时"
def test_watchdog_ping_before_timeout():
import anyio
client = FakeClient([FakeUpstreamStream([], silent=True)])
async def run():
return [
f
async for f in relay_stream(
{}, client=client, base_url="http://up", idle_timeout=0.6, keepalive_interval=0.2
)
]
out = anyio.run(run)
pings = [f for f in _frame_texts(out) if f.startswith(": ping")]
assert len(pings) >= 1
events = _event_data_map(_frame_texts(out))
assert "timeout" in events
def test_watchdog_does_not_fire_when_events_keep_coming():
import anyio
# 上游在 idle_timeout 窗口内持续给事件(逐块喂,模拟慢速流)
chunks = [
b'data: {"stage":"understanding_started"}\n\n',
b'data: {"stage":"entities_identified"}\n\n',
b'event: complete\ndata: {"answer":"A"}\n\n',
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [
f
async for f in relay_stream(
{}, client=client, base_url="http://up", idle_timeout=10.0, keepalive_interval=0.05
)
]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert "complete" in events
assert "timeout" not in events
# ===========================================================================
# 上游 HTTP 头阶段失败:invalid_llm_citation 重试一次 → 仍失败 → error
# ===========================================================================
def _http_error_response(body: dict, status: int) -> FakeUpstreamStream:
return FakeUpstreamStream([], status=status, json_body=body)
def test_http_error_402_invalid_citation_retries_then_errors():
import anyio
client = FakeClient([
_http_error_response({"detail": {"error_code": "invalid_llm_citation"}}, 402),
_http_error_response({"detail": {"error_code": "invalid_llm_citation"}}, 402),
])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert list(events.keys()) == ["error"]
assert events["error"][0]["code"] == "INVALID_CITATION"
assert events["error"][0]["retryable"] is True
assert len(client.calls) == 2 # 重试一次 = 总共 2 次请求
def test_http_error_invalid_citation_retries_then_succeeds():
import anyio
good = FakeUpstreamStream(
[b'event: complete\ndata: {"answer":"ok"}\n\n'],
status=200,
)
client = FakeClient([
_http_error_response({"detail": {"error_code": "invalid_llm_citation"}}, 402),
good,
])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert "complete" in events
assert "error" not in events
assert len(client.calls) == 2
def test_http_error_other_status_no_retry():
import anyio
client = FakeClient([
_http_error_response({"detail": "boom"}, 500),
])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["error"][0]["code"] == "UPSTREAM_ERROR"
assert len(client.calls) == 1
def test_connect_error_emits_upstream_down():
import anyio
import httpx
client = FakeClient([], exc=httpx.ConnectError("conn refused", request=None))
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["error"][0]["code"] == "UPSTREAM_DOWN"
# ===========================================================================
# 工具函数
# ===========================================================================
def test_sse_headers_contract():
assert sse_relay.sse_headers["Cache-Control"] == "no-cache"
assert sse_relay.sse_headers["X-Accel-Buffering"] == "no"
def test_error_payload_shapes():
assert sse_relay.error_payload("TIMEOUT") == {"code": "TIMEOUT", "message": "检索超时", "retryable": True}
assert sse_relay.error_payload("UPSTREAM_DOWN")["retryable"] is False
assert sse_relay.error_payload("INVALID_CITATION")["retryable"] is True