wip: qa7920 sidecar + rag stream timeout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
sj
2026-08-21 11:04:28 +08:00
co-authored by Claude Opus 5
parent b725a9c125
commit 90033214b4
66 changed files with 18990 additions and 68 deletions
+129
View File
@@ -0,0 +1,129 @@
import json
import requests
from gfa_core.mas import dual_kb_qa
class FakeResponse:
status_code = 200
content = b"{}"
text = ""
def __init__(self, iter_lines_calls=None):
self.iter_lines_calls = iter_lines_calls
def json(self):
return {"success": True, "answer": "ok", "sources": []}
def iter_lines(self, chunk_size=None, decode_unicode=True):
if self.iter_lines_calls is not None:
self.iter_lines_calls.append((chunk_size, decode_unicode))
payload = json.dumps({"success": True, "answer": "ok", "sources": []})
yield "event: complete"
yield f"data: {payload}"
yield ""
def close(self):
return None
def _force_v1173(monkeypatch):
monkeypatch.setattr(dual_kb_qa, "DUAL_KB_QA_ROUTE", "qa-v1-1-73")
monkeypatch.delenv("QA_V1_1_73_TIMEOUT", raising=False)
monkeypatch.delenv("QA_V1_1_73_STREAM_IDLE_TIMEOUT", raising=False)
def test_non_stream_uses_10_second_connect_and_900_second_read_timeout(monkeypatch):
_force_v1173(monkeypatch)
captured = []
def fake_post(*args, **kwargs):
captured.append(kwargs["timeout"])
return FakeResponse()
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
dual_kb_qa.query_dual_kb_qa("question")
assert captured == [(10.0, 900.0)]
def test_non_stream_honors_environment_timeout_above_150_seconds(monkeypatch):
_force_v1173(monkeypatch)
monkeypatch.setenv("QA_V1_1_73_TIMEOUT", "300")
captured = []
def fake_post(*args, **kwargs):
captured.append(kwargs["timeout"])
return FakeResponse()
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
dual_kb_qa.query_dual_kb_qa("question")
assert captured == [(10.0, 300.0)]
def test_v1173_requests_answer_response_detail(monkeypatch):
_force_v1173(monkeypatch)
captured = []
def fake_post(*args, **kwargs):
captured.append(kwargs["json"])
return FakeResponse()
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
dual_kb_qa.query_dual_kb_qa("question")
assert captured[0]["response_detail"] == "answer"
def test_v1173_stream_reads_one_byte_chunks(monkeypatch):
_force_v1173(monkeypatch)
iter_lines_calls = []
def fake_post(*args, **kwargs):
return FakeResponse(iter_lines_calls)
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
list(dual_kb_qa.stream_dual_kb_qa("question"))
assert iter_lines_calls == [(1, True)]
def test_stream_uses_configurable_idle_timeout(monkeypatch):
_force_v1173(monkeypatch)
captured = []
def fake_post(*args, **kwargs):
captured.append(kwargs["timeout"])
return FakeResponse()
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
list(dual_kb_qa.stream_dual_kb_qa("question"))
assert captured == [(10.0, 45.0)]
monkeypatch.setenv("QA_V1_1_73_STREAM_IDLE_TIMEOUT", "70")
captured.clear()
list(dual_kb_qa.stream_dual_kb_qa("question"))
assert captured == [(10.0, 70.0)]
def test_stream_timeout_is_reported_as_request_timeout(monkeypatch):
_force_v1173(monkeypatch)
def fake_post(*args, **kwargs):
raise requests.ReadTimeout("read timed out")
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
frames = list(dual_kb_qa.stream_dual_kb_qa("question"))
assert len(frames) == 1
assert "event: error" in frames[0]
assert '"status": 504' in frames[0]
assert '"error_code": "request_timeout"' in frames[0]
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
START = ROOT / "scripts" / "start_qa_v1_1_73_7920.sh"
SERVER = ROOT / "scripts" / "qa_v1_1_73_server.py"
PACKAGE = ROOT / "services" / "qa_v1_1_73" / "src" / "rag" / "qa_v1_1_73"
PLANTAI_MAIN = ROOT / "apps" / "web" / "vue3" / "main.py"
def test_formal_qa_sources_are_versioned_in_plantai():
assert SERVER.is_file()
assert (PACKAGE / "api.py").is_file()
assert (PACKAGE / "assertion_anchored_graph_retriever.py").is_file()
assert (PACKAGE / "agentic_hop_gate.py").is_file()
assert "sandbox" not in (PACKAGE / "__init__.py").read_text(encoding="utf-8").lower()
assert "Sandbox" not in (PACKAGE / "api.py").read_text(encoding="utf-8")
def test_7920_startup_has_no_sandbox_dependency():
text = START.read_text(encoding="utf-8")
assert "/sandboxes/" not in text
assert 'APP_MODULE="scripts.qa_v1_1_73_server:app"' in text
assert 'QA_V1_BOUNDED_NEO4J_TIMEOUT="8"' in text
assert 'export NEO4J_URI="bolt://127.0.0.1:18788"' in text
assert 'export V15_QDRANT_URL="http://127.0.0.1:26333"' in text
assert 'export V15_QDRANT_COLLECTION="plant_srt_direct_v1_bge_m3_20260702"' in text
assert "--host 127.0.0.1" in text
def test_sidecar_validation_has_a_fixed_admin_endpoint():
text = PLANTAI_MAIN.read_text(encoding="utf-8")
assert '@app.post("/api/admin/validate-qa-sidecar")' in text
assert '_validate_admin_ops_request(request, payload, "validate-qa-sidecar")' in text
assert 'PROJECT_ROOT / "scripts" / "validate_qa_v1_1_73_sidecar.sh"' in text
assert '@app.post("/api/admin/stop-qa-sidecar")' in text
assert '_validate_admin_ops_request(request, payload, "stop-qa-sidecar")' in text
def test_sidecar_status_has_a_fixed_admin_endpoint():
text = PLANTAI_MAIN.read_text(encoding="utf-8")
assert '@app.post("/api/admin/qa-sidecar-status")' in text
assert '_validate_admin_ops_request(request, payload, "qa-sidecar-status")' in text
@@ -0,0 +1,39 @@
from pathlib import Path
import pytest
FRONTEND_DIR = Path(__file__).resolve().parents[2] / "apps" / "web" / "vue3"
PAGE_NAMES = ("index.html", "index_ml.html")
PAGE_SOURCES = {
name: (FRONTEND_DIR / name).read_text(encoding="utf-8")
for name in PAGE_NAMES
}
def _source(name: str) -> str:
return PAGE_SOURCES[name]
@pytest.mark.parametrize("name", PAGE_NAMES)
def test_rag_mode_uses_streaming_request(name: str) -> None:
source = _source(name)
start = source.index("// V2: RAG模式")
end = source.index("} catch (err)", start)
rag_block = source[start:end]
assert "requestRagWithProgress(" in rag_block
assert "fetchJsonWithTimeout('/api/agent/rag'" not in rag_block
@pytest.mark.parametrize("name", PAGE_NAMES)
def test_rag_stream_timeout_is_reset_after_each_received_chunk(name: str) -> None:
source = _source(name)
start = source.index("const readRagEventStream")
end = source.index("const requestRagWithProgress", start)
stream_block = source[start:end]
read_position = stream_block.index("const { value, done } = await reader.read();")
assert "const resetIdleTimer = () =>" in stream_block
assert stream_block.index("resetIdleTimer();", read_position) > read_position
assert "知识库检索连接空闲超时" in stream_block
@@ -0,0 +1,28 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MAIN = ROOT / "apps" / "web" / "vue3" / "main.py"
MAIN_SOURCE = MAIN.read_text(encoding="utf-8")
def _rag_routes() -> str:
start = MAIN_SOURCE.index('@app.post("/api/agent/rag")')
end = MAIN_SOURCE.index("# ==================== DiagnosisAgent API", start)
return MAIN_SOURCE[start:end]
def test_rag_proxy_has_no_inline_150_second_timeout() -> None:
routes = _rag_routes()
assert "timeout=150.0" not in routes
assert "complete QA exceeded 150 seconds" not in routes
assert "完整知识图谱问答超过 150 秒" not in routes
def test_rag_stream_proxy_preserves_upstream_sse_frames() -> None:
routes = _rag_routes()
assert "for frame in stream_dual_kb_qa(" in routes
assert "yield frame" in routes
assert 'media_type="text/event-stream"' in routes