💻 源码
"""调 _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