311 lines
13 KiB
Python
311 lines
13 KiB
Python
"""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
|