Files
PlantAI-rag-new/sdk/app/routers/qa.py
T

311 lines
13 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.
"""routers/qa.py —— /api/qa/* :7920 代理 + 归一化 + SSE 中继(A2 归属,API.md §3/§4)。
端点:
- GET /api/qa/info 上游 /info 精简(name/version/pipeline/config)
- POST /api/qa/chat 上游 /chat,300s 读超时,invalid_llm_citation 自动重试一次
- POST /api/qa/chat/stream SSE 中继(app.services.sse_relay.relay_stream)
- POST /api/qa/search 上游 /search,强制 use_llm:false,180s 读超时,不重试,
响应 mode:"search"、answer:""
请求体一律透传(前端字段集见 API.md §4,BFF 不增删字段)。
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from ..services import sse_relay
from ..services.history import assemble_history
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/qa", tags=["qa"])
# 超时阶梯(TODO(A2→A1 对齐点):A1 的 app.config.settings 就绪后改为配置读取)
CHAT_READ_TIMEOUT = 300.0
SEARCH_READ_TIMEOUT = 120.0 # 缺陷 #28:检索模式超时上限 120s(acceptance-r3 §3)
INFO_READ_TIMEOUT = 5.0
CONNECT_TIMEOUT = 10.0
# ---------------------------------------------------------------------------
# 错误信封(TODO(A2→A1 对齐点):HTTP JSON 信封换用 app.upstream.envelope())
# ---------------------------------------------------------------------------
MESSAGES = {
"UPSTREAM_DOWN": "知识问答服务当前不可用,请稍后重试",
"UPSTREAM_ERROR": "知识问答服务返回错误",
"TIMEOUT": "检索超时",
"INVALID_CITATION": "引用校验失败,请重试",
"VALIDATION": "请求参数不合法",
"INTERNAL": "内部错误",
}
def _envelope(code: str, status: int, message: Optional[str] = None, degraded: Optional[bool] = None) -> JSONResponse:
if degraded is None:
degraded = code == "UPSTREAM_DOWN"
return JSONResponse(
status_code=status,
content={"ok": False, "error": {"code": code, "message": message or MESSAGES[code], "degraded": degraded}},
)
# ---------------------------------------------------------------------------
# 上游调用
# ---------------------------------------------------------------------------
def _upstream_path(endpoint: str) -> str:
return f"{sse_relay.QA_UPSTREAM_PREFIX}/{endpoint}"
def _read_timeout(seconds: float) -> httpx.Timeout:
return httpx.Timeout(connect=CONNECT_TIMEOUT, read=seconds, write=30.0, pool=CONNECT_TIMEOUT)
async def _parse_json(text: Optional[bytes]) -> Any:
if not text:
return None
try:
return json.loads(text.decode("utf-8", errors="replace"))
except Exception:
return None
def _error_from_status(status: int, payload: Any) -> tuple[str, int, Optional[str]]:
"""上游 HTTP≥400 → (code, bff_http, message)。HTTP 按 API.md §1 固定映射,不透传上游状态码。"""
if sse_relay.is_invalid_citation(payload):
return "INVALID_CITATION", 502, None
if status in (408, 504):
return "TIMEOUT", 504, None
detail = payload.get("detail") if isinstance(payload, dict) else None
if isinstance(detail, dict):
message = detail.get("message") or detail.get("error")
return "UPSTREAM_ERROR", 502, message
if isinstance(payload, dict):
message = payload.get("message") or payload.get("error")
return "UPSTREAM_ERROR", 502, message
return "UPSTREAM_ERROR", 502, None
async def _proxy(
endpoint: str,
body: Optional[dict] = None,
*,
read_timeout: float,
retry_invalid_citation: bool,
mode: str,
normalizer=None,
owner_id: str | None = None,
) -> Any:
"""非流式上游代理:重试、错误信封、归一化。返回 (JSONResponse)。"""
client = sse_relay.get_qa_client()
normalizer = normalizer or sse_relay.get_qa_normalizer()
url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}"
timeout = _read_timeout(read_timeout)
json_body, _humanize = await _inject_history(body, owner_id=owner_id)
for attempt in (1, 2):
try:
resp = await client.request("POST", url, json=json_body, timeout=timeout)
except httpx.ReadTimeout:
if mode == "search":
# 缺陷 #28(2026-09-10):检索模式超时返回 200 降级体(跳过
# normalizer 直接构造),前端走「检索超时」可读提示而非错误
# 信封。其余 mode 的错误信封不变。
logger.warning("上游 %s 读超时,检索模式降级为 200 空结果", endpoint)
return JSONResponse(
content={
"mode": "search",
"answer": "",
"sources": [],
"source_count": 0,
"degraded": True,
"reason": "TIMEOUT",
}
)
return _envelope("TIMEOUT", 504)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
logger.warning("上游 %s 连接失败: %s", endpoint, exc)
return _envelope("UPSTREAM_DOWN", 503)
except httpx.HTTPError as exc:
logger.warning("上游 %s 请求失败: %s", endpoint, exc)
return _envelope("UPSTREAM_ERROR", 502)
payload = await _parse_json(resp.content)
if resp.status_code >= 400:
if (
retry_invalid_citation
and attempt == 1
and sse_relay.is_invalid_citation(payload)
):
logger.warning(
"上游 %s 返回 %d invalid_llm_citation,自动重试(第 1 次)", endpoint, resp.status_code
)
continue
code, bff_status, message = _error_from_status(resp.status_code, payload)
return _envelope(code, bff_status, message)
try:
normalized = normalizer(payload if isinstance(payload, dict) else {})
except Exception:
logger.exception("归一化上游 %s 响应失败", endpoint)
return _envelope("INTERNAL", 500)
normalized["mode"] = mode
if mode == "search":
normalized["answer"] = ""
# 图文并茂(2026-08-28):同步 /chat 同样注入图片(SSE 路径在 sse_relay)
if mode == "chat":
try:
from ..services.sse_relay import _inject_figures_async
normalized = await _inject_figures_async(normalized)
except Exception as exc:
logger.warning("图片注入失败(降级为纯文本答案): %s", exc)
return JSONResponse(content=normalized)
# 不可达(重试循环内必然 return);防御性兜底
return _envelope("UPSTREAM_ERROR", 502)
# ---------------------------------------------------------------------------
# 端点
# ---------------------------------------------------------------------------
@router.get("/info")
async def qa_info():
"""上游 /info 精简(API.md §3)。"""
client = sse_relay.get_qa_client()
url = f"{sse_relay.qa_base_url()}{_upstream_path('info')}"
try:
resp = await client.get(url, timeout=_read_timeout(INFO_READ_TIMEOUT))
except httpx.ReadTimeout:
return _envelope("TIMEOUT", 504)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
logger.warning("上游 /info 连接失败: %s", exc)
return _envelope("UPSTREAM_DOWN", 503)
except httpx.HTTPError as exc:
logger.warning("上游 /info 请求失败: %s", exc)
return _envelope("UPSTREAM_ERROR", 502)
payload = await _parse_json(resp.content)
if resp.status_code >= 400 or not isinstance(payload, dict):
return _envelope("UPSTREAM_ERROR", 502)
info = payload.get("info") if isinstance(payload.get("info"), dict) else payload
return JSONResponse(
content={
"name": info.get("name") or payload.get("name"),
"version": info.get("version") or payload.get("version"),
"pipeline": info.get("pipeline") or payload.get("pipeline") or [],
"config": info.get("config") or payload.get("config") or {},
}
)
@router.post("/chat")
async def qa_chat(request: Request):
"""非流式问答:上游 /chat + 归一化 + invalid_llm_citation 重试一次。"""
body = await request.json()
user = getattr(request.state, "user", None)
owner_id = user["id"] if user else None
return await _proxy(
"chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat", owner_id=owner_id
)
@router.post("/chat/stream")
async def qa_chat_stream(request: Request):
"""SSE 中继(API.md §4)。
evidence_first(2026-08-26 方案 3):两级体验开关——BFF 并发打上游
/search 先出证据帧;该字段是 BFF 契约字段,上游 QARequest 无此键,
必须剥离后再转发,否则上游 422。
"""
try:
body = await request.json()
except Exception:
return _envelope("VALIDATION", 422)
evidence_first = bool(body.pop("evidence_first", False))
user = getattr(request.state, "user", None)
owner_id = user["id"] if user else None
body, humanize_on = await _inject_history(body, owner_id=owner_id)
return StreamingResponse(
sse_relay.relay_stream(body, evidence_first=evidence_first, humanize_enabled=humanize_on),
media_type="text/event-stream",
headers=sse_relay.sse_headers,
)
@router.post("/search")
async def qa_search(request: Request):
"""检索模式:强制 use_llm:false,180s 读超时,不重试。
native_chain_enabled:False(缺陷 #28,2026-09-10):与 sse_relay.py
evidence_first 先例同法——检索模式只走 Qdrant 向量检索,关闭图链,
避免大体量库上图遍历把检索拖到超时。
"""
body = await request.json()
body = {**body, "use_llm": False, "native_chain_enabled": False}
user = getattr(request.state, "user", None)
owner_id = user["id"] if user else None
return await _proxy(
"search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search", owner_id=owner_id
)
# ---------------------------------------------------------------------------
# 多轮记忆(S2):剥离 session_id → 组装 history 注入上游
# ---------------------------------------------------------------------------
async def _inject_history(body: Optional[dict], owner_id: str | None = None) -> tuple[dict, bool]:
"""剥离 BFF 契约字段 session_id,读会话 meta 注入 history/capabilities。
- 不传 session_id / 会话不存在 → 行为与现役一致(history/capabilities 缺省)
- history 只在 memory 开启时注入(meta.capabilities.memory,缺省 True)
- capabilities(web_search/superpowers)注入上游——面板开关是唯一用户入口,
服务端 env 已改为熔断语义(默认放行)
- humanize 是纯 BFF 层处理,以返回值(第二元)回传
"""
from ..db import db as bff_db
from ..db import loads_json
body = dict(body or {})
session_id = str(body.pop("session_id", None) or "").strip()
if not session_id:
return body, False
if body.get("history"):
return body, False # 显式传入的历史优先(上游直传场景;capabilities 由请求体自带)
try:
if owner_id is None:
row = await bff_db.fetchone(
"SELECT meta FROM sessions WHERE id = ?", (session_id,)
)
else:
row = await bff_db.fetchone(
"SELECT meta FROM sessions WHERE id = ? AND owner_id = ?", (session_id, owner_id)
)
if row is None:
return body, False
meta = loads_json(row["meta"], default={})
if not isinstance(meta, dict):
meta = {}
caps = meta.get("capabilities")
if not isinstance(caps, dict):
caps = {}
if bool(caps.get("memory", True)) is False:
return body, False
history = await assemble_history(bff_db, session_id)
if history:
body["history"] = history
# 会话开关注入上游(web_search/superpowers 经 QA 门控/注入点生效)
body["capabilities"] = {
"web_search": bool(caps.get("web_search", False)),
"superpowers": bool(caps.get("superpowers", False)),
}
humanize = bool(caps.get("humanize", False))
except Exception as exc:
logger.warning("多轮记忆注入失败(降级为无历史): %s", exc)
humanize = False
return body, humanize