"""调 _005_ 后端 /process、/process_stream 的客户端
单独一个模块、不 import streamlit,为的是能被测试直接导入 —— 导入 streamlit 应用
会执行页面上所有的 st.* 调用,没法当函数测。UI 在 app.py / pages/,网络这一层在这里。
这一层只跟 HTTP 打交道,不 import 项目里的任何东西(common、_004_ 都不碰)。
前端进程因此不会去加载 76 MB 的 FAISS 索引和 embedding 模型 —— 那些只属于后端。
前后端唯一的耦合就是两个接口的请求/响应契约。
"""
import json
import requests
# 后端默认地址。_005_fastapi/main.py 里 uvicorn 监听的是 8001(接口文档写的 8000 是旧值),
# 端口不对时侧边栏可以直接改,不用动代码
DEFAULT_BASE_URL = "http://127.0.0.1:8001"
# 单次问答 5-30 秒,复杂问题更久,接口文档建议 120 秒
DEFAULT_TIMEOUT = 120
# 后端出错时返回的固定文案(_005_fastapi.main.ERROR_OUTPUT),前端照原样展示
ERROR_OUTPUT = "系统出错了,请重试!"
class BackendError(Exception):
"""调后端失败。message 是可以直接显示给用户的中文说明。
包装成一个异常类型,UI 就只需要 catch 一次,不用分别处理 ConnectionError /
Timeout / HTTPError / JSONDecodeError 四种情况。
"""
def ask_backend(
question: str,
user_id: str,
base_url: str = DEFAULT_BASE_URL,
timeout: int = DEFAULT_TIMEOUT,
) -> str:
"""POST /process,返回 output 里的回答文本。
失败一律抛 BackendError,附一句能直接显示的中文原因。
"""
url = f"{base_url.rstrip('/')}/process"
payload = {"input": question, "user_id": user_id}
try:
response = requests.post(url, json=payload, timeout=timeout)
except requests.Timeout:
raise BackendError(f"后端响应超时(超过 {timeout} 秒),请稍后重试。")
except requests.ConnectionError:
raise BackendError(
f"连不上后端 {base_url}。请先启动服务:"
"python -X utf8 -m _005_fastapi.main"
)
except requests.RequestException as exc:
raise BackendError(f"请求后端失败:{exc}")
# 422 是入参校验没过,报错信息藏在 detail 里,对着提示改比看到 "422" 有用
if response.status_code == 422:
raise BackendError(f"请求参数不合法:{response.text}")
# 500 时后端仍然返回 {"input", "output"} 这个结构,把它的文案透出来
if response.status_code >= 400:
try:
output = (response.json() or {}).get("output")
except ValueError:
output = None
raise BackendError(output or f"后端返回 {response.status_code}:{response.text[:200]}")
try:
body = response.json()
except ValueError:
raise BackendError(f"后端返回的不是 JSON:{response.text[:200]}")
answer = (body.get("output") or "").strip()
if not answer:
raise BackendError("后端返回了空回答,请重试。")
return answer
def ask_backend_stream(
question: str,
user_id: str,
base_url: str = DEFAULT_BASE_URL,
timeout: int = DEFAULT_TIMEOUT,
):
"""POST /process_stream,逐条 yield 后端推来的消息字典。
这是个**生成器**:调用方 for 一次拿一条,才能边收边往页面上画。
如果这里先把消息攒成 list 再返回,流式就白做了 —— 用户还是要等满 30 秒。
yield 出来的消息就是接口文档里那三种 type:
{"type": "thinking", "node": 节点名, "data": {该节点的状态增量}}
{"type": "final", "input": 原始问题, "output": 最终回答}
{"type": "error", "input": 原始问题, "output": 固定错误文案}
error 消息是**后端正常返回的一行数据**(图跑挂了,但 HTTP 是 200,头早发出去了),
所以不在这里抛异常,照原样 yield,让 UI 决定怎么显示。
只有连不上、超时、状态码不对这类**没能开始流**的情况才抛 BackendError。
"""
url = f"{base_url.rstrip('/')}/process_stream"
payload = {"input": question, "user_id": user_id}
try:
# stream=True 是关键:不加的话 requests 会把整个响应体读完才返回,
# 客户端这边就退化成一次性接口。with 保证连接一定被释放(包括中途 break)
with requests.post(url, json=payload, stream=True, timeout=timeout) as response:
if response.status_code == 422:
raise BackendError(f"请求参数不合法:{response.text}")
if response.status_code >= 400:
# 出错响应不会是流,直接读完取文案
try:
output = (response.json() or {}).get("output")
except ValueError:
output = None
raise BackendError(
output or f"后端返回 {response.status_code}:{response.text[:200]}"
)
got_any = False
# 后端是换行分隔的 JSON(不是严格 SSE),iter_lines 正好对上
for line in response.iter_lines(decode_unicode=True):
if not line or not line.strip():
continue
try:
message = json.loads(line)
except ValueError:
# 单行坏了不该让整轮问答报废,跳过继续读后面的
continue
got_any = True
yield message
if not got_any:
raise BackendError("后端没有返回任何消息,请重试。")
except requests.Timeout:
raise BackendError(f"后端响应超时(超过 {timeout} 秒),请稍后重试。")
except requests.ConnectionError:
raise BackendError(
f"连不上后端 {base_url}。请先启动服务:python -X utf8 -m _005_fastapi.main"
)
except requests.RequestException as exc:
raise BackendError(f"请求后端失败:{exc}")
def check_health(base_url: str = DEFAULT_BASE_URL, timeout: int = 5) -> bool:
"""GET /,后端活着返回 True。用于侧边栏那个状态灯,所以超时给得很短。"""
try:
response = requests.get(f"{base_url.rstrip('/')}/", timeout=timeout)
return response.status_code == 200 and response.json().get("status") == "ok"
except (requests.RequestException, ValueError):
return False