|
|
|
@@ -0,0 +1,232 @@
|
|
|
|
|
# 5090 RAG Stream Timeout Implementation Plan
|
|
|
|
|
|
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
|
|
|
|
|
|
**Goal:** Make PlantAI RAG requests on 5090 complete through the full 7920 Qdrant + Neo4j + Agentic pipeline without fixed 120/150-second client aborts.
|
|
|
|
|
|
|
|
|
|
**Architecture:** The browser uses the existing SSE endpoint and treats timeout as inactivity rather than total elapsed time. PlantAI proxies 7920 keepalives unchanged, while the Python adapter uses separate connect, stream-idle, and non-stream compatibility timeouts. Retrieval and answer-generation behavior remain unchanged.
|
|
|
|
|
|
|
|
|
|
**Tech Stack:** FastAPI, `requests`, browser Fetch/ReadableStream, SSE, pytest.
|
|
|
|
|
|
|
|
|
|
## Global Constraints
|
|
|
|
|
|
|
|
|
|
- Preserve complete Qdrant + Neo4j retrieval; no Qdrant-only fallback.
|
|
|
|
|
- Do not change Agent prompts, rounds, evidence selection, or answer composition.
|
|
|
|
|
- Stream connect timeout: 10 seconds.
|
|
|
|
|
- Stream idle timeout: configurable, default 45 seconds.
|
|
|
|
|
- Non-stream compatibility timeout: configurable, default 900 seconds.
|
|
|
|
|
- Do not commit or push; the user will handle Git later.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### Task 1: Python timeout policy
|
|
|
|
|
|
|
|
|
|
**Files:**
|
|
|
|
|
- Modify: `gfa_core/mas/dual_kb_qa.py`
|
|
|
|
|
- Modify: `tests/test_dual_kb_qa_timeout.py`
|
|
|
|
|
|
|
|
|
|
**Interfaces:**
|
|
|
|
|
- Consumes: `QA_V1_1_73_TIMEOUT`, new `QA_V1_1_73_STREAM_IDLE_TIMEOUT` environment variables.
|
|
|
|
|
- Produces: `_non_stream_timeout(timeout: float) -> tuple[float, float]` and `_stream_timeout() -> tuple[float, float]` policies used by both adapter entry points.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 1: Replace the hard-cap assertions with failing policy tests**
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
def test_non_stream_defaults_to_900_second_read_timeout(monkeypatch):
|
|
|
|
|
_force_v1173(monkeypatch)
|
|
|
|
|
# capture requests.post timeout
|
|
|
|
|
assert captured == [(10.0, 900.0)]
|
|
|
|
|
|
|
|
|
|
def test_non_stream_honors_timeout_above_150(monkeypatch):
|
|
|
|
|
monkeypatch.setenv("QA_V1_1_73_TIMEOUT", "600")
|
|
|
|
|
assert captured == [(10.0, 600.0)]
|
|
|
|
|
|
|
|
|
|
def test_stream_uses_45_second_idle_read_timeout(monkeypatch):
|
|
|
|
|
assert captured == [(10.0, 45.0)]
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 2: Run the focused tests and verify RED**
|
|
|
|
|
|
|
|
|
|
Run: `pytest -q tests/test_dual_kb_qa_timeout.py`
|
|
|
|
|
|
|
|
|
|
Expected: failures showing the current 150-second cap.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 3: Implement separate timeout policies**
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
QA_CONNECT_TIMEOUT_SECONDS = 10.0
|
|
|
|
|
QA_V1_1_73_DEFAULT_TIMEOUT_SECONDS = 900.0
|
|
|
|
|
QA_V1_1_73_DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = 45.0
|
|
|
|
|
|
|
|
|
|
def _positive_timeout(value: object, default: float) -> float:
|
|
|
|
|
try:
|
|
|
|
|
parsed = float(value)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return default
|
|
|
|
|
return parsed if parsed > 0 else default
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Use `QA_V1_1_73_TIMEOUT` for non-stream reads and `QA_V1_1_73_STREAM_IDLE_TIMEOUT` for stream idle reads. Remove `min(..., 150)`.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 4: Run focused tests and verify GREEN**
|
|
|
|
|
|
|
|
|
|
Run: `pytest -q tests/test_dual_kb_qa_timeout.py`
|
|
|
|
|
|
|
|
|
|
Expected: all tests pass.
|
|
|
|
|
|
|
|
|
|
### Task 2: Browser SSE inactivity timer and RAG route
|
|
|
|
|
|
|
|
|
|
**Files:**
|
|
|
|
|
- Modify: `apps/web/vue3/index.html`
|
|
|
|
|
- Modify: `apps/web/vue3/index_ml.html`
|
|
|
|
|
- Create: `tests/unit/test_rag_stream_frontend_contract.py`
|
|
|
|
|
|
|
|
|
|
**Interfaces:**
|
|
|
|
|
- Consumes: `/api/agent/rag/stream` SSE frames and comment keepalives.
|
|
|
|
|
- Produces: `readRagEventStream(url, options, idleTimeoutMs)` that resets its timer on every received chunk; RAG mode calls `requestRagWithProgress`.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 1: Add failing source-contract tests for both page variants**
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
@pytest.mark.parametrize("name", ["index.html", "index_ml.html"])
|
|
|
|
|
def test_rag_mode_uses_streaming_request(name):
|
|
|
|
|
source = (FRONTEND_DIR / name).read_text(encoding="utf-8")
|
|
|
|
|
rag_block = source[source.index("// V2: RAG模式"):source.index("} catch (err)", source.index("// V2: RAG模式"))]
|
|
|
|
|
assert "requestRagWithProgress(" in rag_block
|
|
|
|
|
assert "fetchJsonWithTimeout('/api/agent/rag'" not in rag_block
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["index.html", "index_ml.html"])
|
|
|
|
|
def test_stream_timer_is_reset_after_each_chunk(name):
|
|
|
|
|
source = (FRONTEND_DIR / name).read_text(encoding="utf-8")
|
|
|
|
|
assert "resetIdleTimer();" in source
|
|
|
|
|
assert "const { value, done } = await reader.read();" in source
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 2: Run the frontend contract test and verify RED**
|
|
|
|
|
|
|
|
|
|
Run: `pytest -q tests/unit/test_rag_stream_frontend_contract.py`
|
|
|
|
|
|
|
|
|
|
Expected: both page variants fail because RAG mode uses the non-stream endpoint and the timer is total-duration based.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 3: Implement the inactivity timer in both page variants**
|
|
|
|
|
|
|
|
|
|
Define `resetIdleTimer()` to clear and re-arm the abort timer. Call it before fetch and immediately after every successful `reader.read()` that returns a non-empty chunk. Keep `AbortController` for genuine inactivity.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 4: Route both RAG-mode handlers through SSE**
|
|
|
|
|
|
|
|
|
|
Replace the direct `fetchJsonWithTimeout('/api/agent/rag', ..., 120000)` call with:
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
const data = await requestRagWithProgress({
|
|
|
|
|
query: userInput,
|
|
|
|
|
mode: 'rag',
|
|
|
|
|
top_k: 10
|
|
|
|
|
}, 45000);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 5: Run frontend contract tests and verify GREEN**
|
|
|
|
|
|
|
|
|
|
Run: `pytest -q tests/unit/test_rag_stream_frontend_contract.py`
|
|
|
|
|
|
|
|
|
|
Expected: all tests pass.
|
|
|
|
|
|
|
|
|
|
### Task 3: PlantAI API proxy contract
|
|
|
|
|
|
|
|
|
|
**Files:**
|
|
|
|
|
- Modify: `apps/web/vue3/main.py`
|
|
|
|
|
- Modify: `tests/test_dual_kb_qa_timeout.py` or create `tests/unit/test_rag_stream_proxy_contract.py`
|
|
|
|
|
|
|
|
|
|
**Interfaces:**
|
|
|
|
|
- Consumes: `stream_dual_kb_qa(..., idle_timeout=45)`.
|
|
|
|
|
- Produces: SSE response with unchanged keepalive/progress/complete/error frames.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 1: Add a failing contract test**
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
MAIN = ROOT / "apps/web/vue3/main.py"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rag_proxy_has_no_inline_150_second_timeout():
|
|
|
|
|
source = MAIN.read_text(encoding="utf-8")
|
|
|
|
|
start = source.index('@app.post("/api/agent/rag")')
|
|
|
|
|
end = source.index("# ==================== DiagnosisAgent API", start)
|
|
|
|
|
rag_routes = source[start:end]
|
|
|
|
|
assert "timeout=150.0" not in rag_routes
|
|
|
|
|
assert "complete QA exceeded 150 seconds" not in rag_routes
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 2: Run the proxy contract test and verify RED**
|
|
|
|
|
|
|
|
|
|
Run: `pytest -q tests/unit/test_rag_stream_proxy_contract.py`
|
|
|
|
|
|
|
|
|
|
Expected: failure on the current `timeout=150.0` literals.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 3: Remove inline fixed timeout literals**
|
|
|
|
|
|
|
|
|
|
Let `stream_dual_kb_qa` select the 45-second idle timeout and `query_dual_kb_qa` select the 900-second compatibility timeout. Preserve structured timeout and connection error responses.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 4: Run proxy and adapter tests and verify GREEN**
|
|
|
|
|
|
|
|
|
|
Run: `pytest -q tests/unit/test_rag_stream_proxy_contract.py tests/test_dual_kb_qa_timeout.py`
|
|
|
|
|
|
|
|
|
|
Expected: all tests pass.
|
|
|
|
|
|
|
|
|
|
### Task 4: Regression and 5090 deployment validation
|
|
|
|
|
|
|
|
|
|
**Files:**
|
|
|
|
|
- No production source additions.
|
|
|
|
|
- Runtime candidate service uses the mounted `sj/PlantAI-dev` tree.
|
|
|
|
|
|
|
|
|
|
**Interfaces:**
|
|
|
|
|
- Consumes: 5090 port 7920 and candidate PlantAI port 9010.
|
|
|
|
|
- Produces: health evidence, SSE keepalive evidence, final answer with graph chains/citations.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 1: Run focused and adjacent regression tests**
|
|
|
|
|
|
|
|
|
|
Run: `pytest -q tests/test_dual_kb_qa_timeout.py tests/unit/test_rag_stream_frontend_contract.py tests/unit/test_rag_stream_proxy_contract.py`
|
|
|
|
|
|
|
|
|
|
Expected: all pass.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 2: Validate syntax**
|
|
|
|
|
|
|
|
|
|
Run: `python -m py_compile gfa_core/mas/dual_kb_qa.py apps/web/vue3/main.py`
|
|
|
|
|
|
|
|
|
|
Expected: exit code 0.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 3: Start/restart only the candidate 9010 service on 5090**
|
|
|
|
|
|
|
|
|
|
Synchronize the mounted source into the explicit candidate directory `/home/userlab/plantAI/sj/PlantAI-dev-rag-stream-20260820` on 5090, excluding `.git`, caches, results, and virtual environments. Start `apps/web/vue3/main.py` on port 9010 with:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
GENE_FAMILY_MAS_API=http://127.0.0.1:7920 \
|
|
|
|
|
DUAL_KB_QA_ROUTE=qa-v1-1-73 \
|
|
|
|
|
QA_V1_1_73_TIMEOUT=900 \
|
|
|
|
|
QA_V1_1_73_STREAM_IDLE_TIMEOUT=45 \
|
|
|
|
|
python apps/web/vue3/main.py --host 0.0.0.0 --port 9010
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Do not restart or replace 7920.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 4: Verify health and SSE keepalive path**
|
|
|
|
|
|
|
|
|
|
Run:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
curl -N --max-time 60 -H 'Content-Type: application/json' \
|
|
|
|
|
-d '{"query":"What biological processes are regulated by the NAC transcription factor family in plants?","mode":"rag","top_k":10}' \
|
|
|
|
|
http://127.0.0.1:9010/api/agent/rag/stream
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Expected: an SSE progress frame or `: keepalive` is received before curl reaches 60 seconds; no 504 frame is returned.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 5: Verify a real long question end to end**
|
|
|
|
|
|
|
|
|
|
Run the same curl without a 60-second cap and save the SSE response under `/tmp/plantai-9010-rag-stream-validation.sse`. Success requires an `event: complete` frame whose JSON has a non-empty answer, citations, and `native_graph_chains` or `graph_knowledge_chains`. Record curl's `time_total`; a value greater than 150 seconds is acceptable when earlier progress/keepalive frames are present.
|
|
|
|
|
|
|
|
|
|
- [ ] **Step 6: Inspect process and Neo4j state**
|
|
|
|
|
|
|
|
|
|
On 5090, check `ps -ef` for only the intended 9010 candidate process and query Neo4j `SHOW TRANSACTIONS` through the existing Neo4j container/CLI. Success requires no transaction from the validation request remaining more than 10 seconds after the complete event.
|