wip: qa7920 sidecar + rag stream timeout
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user