💻 源码
"""中医知识图谱问答系统 —— FastAPI 接口层

按 API_INTERFACE.md 实现三个接口:

    GET  /                健康检查
    POST /process         中医问答(核心接口)
    POST /process_stream  中医问答(流式,逐节点返回思考过程)

这一层很薄:所有业务逻辑都在 `_004_langgraph_more_nodes.langgraph_more_nodes` 编译好的
图里,接口只做三件事 —— 校验入参、把图跑起来、把 `state["output"]` 取出来返回。
`output` 是整张图的统一出口(图谱路和兜底路都会写它),所以这里不用关心答案是查图谱
得来的还是大模型直接给的。

两个和"薄"有关的设计:

- **图是懒加载的模块级单例**(`get_graph()`)。导入这张图会连带加载 76 MB 的 FAISS
  索引和 embedding 模型,几十秒量级,所以不在 import 时做,也不每个请求做一次。
  `lifespan` 在服务启动时提前把它加载好,这样首个请求不会额外背上初始化的时间;
  加载失败只打日志不阻止启动,健康检查照样能用来看服务活着。
- **图是同步阻塞的**(LLM 调用 + Neo4j 查询,单次 5-30 秒),而接口是 async 的。
  直接 `graph.invoke()` 会把事件循环占死,一个请求就让整个服务失去并发能力,
  所以必须丢进 `run_in_threadpool`。

运行(在仓库根目录,导入用的是绝对包名):

    python -X utf8 -m _005_fastapi.main
"""

import asyncio
import json
import traceback
from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse, StreamingResponse
from langchain_core.runnables import RunnableConfig
from pydantic import BaseModel, Field, field_validator

# 出错时返回给用户的固定文案,和 API_INTERFACE.md 的错误响应示例保持一致
ERROR_OUTPUT = "系统出错了,请重试!"

# 流式接口里,这些键每个节点都会带回来,但对"思考过程"没有信息量,逐个节点重复推送
# 只会把有用的内容挤掉,所以不进 thinking 消息:
#   input                 每条消息都一样,最后的 final 里已经有了
#   history_messages      跨轮记忆,会越来越长,且每轮都变
#   cypher_retry_count    内部计数
#   output/neo4j_answer/direct_out  最终答案,final 消息负责给
STREAM_SKIP_KEYS = frozenset(
    {
        "input",
        "history_messages",
        "cypher_retry_count",
        "output",
        "neo4j_answer",
        "direct_out",
    }
)

# 队列结束标记。用一个独一无二的对象而不是 None,因为 None 本身可能是正常数据
_STREAM_DONE = object()

# 图的模块级缓存。测试可以直接把这个变量替换成假图,绕开 LLM 和 Neo4j
_graph = None


def get_graph():
    """懒加载并缓存编译好的 LangGraph。

    import 放在函数体内是故意的:模块顶层 import 会让 `import main` 就去加载
    FAISS 索引和 embedding 模型。
    """
    global _graph
    if _graph is None:
        from _004_langgraph_more_nodes.langgraph_more_nodes import graph

        _graph = graph
    return _graph


@asynccontextmanager
async def lifespan(app: FastAPI):
    """服务启动时预热图,省掉首个请求的初始化等待。

    加载失败只记日志、不抛异常:让服务照样起来,健康检查能回 200,
    /process 会在真正被调用时再报错,比整个服务起不来好排查。
    """
    try:
        await run_in_threadpool(get_graph)
        print("LangGraph 预加载完成")
    except Exception:
        traceback.print_exc()
        print("LangGraph 预加载失败,服务仍会启动,/process 将返回错误响应")
    yield


app = FastAPI(
    title="中医知识图谱问答系统",
    description="基于 FastAPI + LangGraph + Neo4j + FAISS 的中医知识图谱智能问答接口",
    version="1.0.0",
    lifespan=lifespan,
)


# ======================
# 请求 / 响应模型
# ======================
class ProcessRequest(BaseModel):
    """POST /process 的请求体。两个字段都是必填,见接口文档的请求参数表。"""

    input: str = Field(..., description="用户输入的问题")
    user_id: str = Field(..., description="用户唯一标识,用于隔离不同用户的会话")

    @field_validator("input", "user_id")
    @classmethod
    def not_blank(cls, value: str) -> str:
        """全空格的入参和空字符串一样没有意义,一并挡掉,并顺手去掉首尾空白"""
        value = value.strip()
        if not value:
            raise ValueError("不能为空")
        return value


class ProcessResponse(BaseModel):
    """POST /process 的响应体:原始输入 + 系统回答"""

    input: str = Field(..., description="用户原始输入")
    output: str = Field(..., description="系统生成的回答")


class HealthResponse(BaseModel):
    """GET / 的响应体"""

    status: str = Field(..., description="服务状态,正常为 ok")
    message: str = Field(..., description="状态说明")


# ======================
# 流式接口的辅助函数
# ======================
def _json_safe(value):
    """把状态值转成能 json.dumps 的形状。

    状态里绝大多数是 str/list/bool,但 cypher_results 里是 Neo4j 驱动返回的行,
    真出现不认识的类型时不能让整条流断掉,退化成 str() 至少还能看。
    """
    if value is None or isinstance(value, (str, int, float, bool)):
        return value
    if isinstance(value, dict):
        return {str(k): _json_safe(v) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [_json_safe(v) for v in value]
    return str(value)


def _state_delta(previous: dict, current: dict) -> dict:
    """算出这个节点相比上一个节点新写了什么。

    我们的节点都是"改完整个 state 再返回",所以 LangGraph 的 updates 模式给回来的是
    全量状态,不是增量 —— 直接推给前端每条消息都有二十多个键。这里跟上一步的状态做差,
    才是接口文档里 data 想要的"该节点这一步产生的状态增量"。
    """
    delta = {}
    for key, value in current.items():
        if key in STREAM_SKIP_KEYS:
            continue
        # 语义转写节点会把上一轮的残留键清成 None,这种"清空"不算这一步的产出
        if value is None or value == [] or value == {}:
            continue
        if previous.get(key) != value:
            delta[key] = _json_safe(value)
    return delta


# ======================
# 接口 1:健康检查
# ======================
@app.get("/", response_model=HealthResponse, summary="健康检查")
async def health_check() -> HealthResponse:
    """检查服务是否正常运行。无请求参数,永远返回 200。

    这里刻意不去探测 Neo4j / LLM / FAISS:健康检查要快且不花钱,
    它回答的是"HTTP 服务活着吗",而不是"所有依赖都好吗"。
    """
    return HealthResponse(status="ok", message="中医知识图谱问答系统服务运行正常")


# ======================
# 接口 2:中医问答(核心)
# ======================
@app.post("/process", response_model=ProcessResponse, summary="中医问答接口")
async def process(request: ProcessRequest) -> ProcessResponse:
    """跑一遍 LangGraph,把最终回答返回给调用方。


    graph.invoke 是同步阻塞的(LLM + Neo4j,单次 5-30 秒),所以扔到线程池里,
    否则事件循环被占死,服务就退化成一次只能处理一个请求。

    任何异常都收敛成 500 + 固定文案:内部报错细节(cypher、堆栈)不应该漏给调用方,
    完整堆栈打在服务端日志里。
    """
    print(f"[/process] user_id={request.user_id} input={request.input!r}")
    try:
        graph = get_graph()
        # thread_id="user_0001"
        thread_id=request.user_id
        config = RunnableConfig(configurable={
            "thread_id": thread_id
        })
        state = await run_in_threadpool(graph.invoke, {"input": request.input},config)
        print("===================")
        print(state)
        print("===================")
        # output 是图的统一出口。理论上每条路径都会写它,兜底一层防止图改坏了返回 None
        output = (state.get("output") or "").strip() or ERROR_OUTPUT
        return ProcessResponse(input=request.input, output=output)
    except Exception:
        traceback.print_exc()
        # 错误响应的 input 按文档示例返回空串
        return JSONResponse(
            status_code=500,
            content={"input": "", "output": ERROR_OUTPUT},
        )


# ======================
# 接口 3:流式中医问答
# ======================
async def _stream_messages(user_input: str, user_id: str):
    """生产者-消费者:把图的执行过程变成一行行 JSON。

    生产者是图里的各个节点 —— 每个节点执行完,LangGraph 的 stream(updates) 就吐出
    一条它的状态,producer 把它塞进队列。消费者是这个异步生成器,从队列取出来
    yield 给 StreamingResponse。

    为什么要队列:`graph.stream()` 是**同步**生成器,里面是 LLM 调用和 Neo4j 查询,
    一次 5-30 秒。直接在 async 函数里 for 它,事件循环会被卡住,服务退化成单并发
    (和 /process 用 run_in_threadpool 是同一个理由)。所以让它在工作线程里跑,
    通过队列把结果交回事件循环 —— 队列是这两个世界之间唯一的通道。

    队列有界(maxsize=32):万一客户端读得慢,节点又一直产出,无界队列会一直吃内存。
    """
    queue: asyncio.Queue = asyncio.Queue(maxsize=32)
    loop = asyncio.get_running_loop()

    def producer():
        """在工作线程里跑图,每个节点产出一条消息。

        这个函数整体在 run_in_threadpool 的线程里执行,不能直接 await,
        所以往队列里放东西要用 run_coroutine_threadsafe 跨回事件循环。
        """

        def put(item):
            # .result() 是必须的:它让这里
            # 在队列满时阻塞住,形成反压。
            # 不等的话,慢客户端场景下 put 会无声堆积,maxsize 就白设了。
            asyncio.run_coroutine_threadsafe(queue.put(item), loop).result()

        try:
            graph = get_graph()
            config = RunnableConfig(configurable={"thread_id": user_id})
            previous: dict = {}

            for chunk in graph.stream(
                {"input": user_input}, config, stream_mode="updates"
            ):
                # chunk 形如 {节点名: 该节点返回的状态}
                for node_name, node_state in chunk.items():
                    if not isinstance(node_state, dict):
                        continue
                    delta = _state_delta(previous, node_state)
                    previous = {**previous, **node_state}
                    # 没有新增内容的节点(比如纯路由)不推空消息,前端不用过滤
                    if delta:
                        put({"type": "thinking", "node": node_name, "data": delta})

            # 图跑完了,最后一条给最终答案。previous 累积了全程状态,output 在里面
            output = (previous.get("output") or "").strip() or ERROR_OUTPUT
            put({"type": "final", "input": user_input, "output": output})
        except Exception:
            # 堆栈只打在服务端,返回给调用方的是固定文案,和 /process 保持一致
            traceback.print_exc()
            put({"type": "error", "input": user_input, "output": ERROR_OUTPUT})
        finally:
            # 无论成功失败都要放结束标记,否则消费者会一直等下去,请求挂住
            put(_STREAM_DONE)

    # 不 await:让它在后台线程跑,下面一边跑一边从队列取
    task = asyncio.create_task(run_in_threadpool(producer))
    try:
        while True:
            message = await queue.get()
            if message is _STREAM_DONE:
                break
            # 换行分隔的 JSON(不是严格 SSE),对应文档里的响应格式。
            # ensure_ascii=False 保留中文原样,前端按行 json.loads 即可
            yield json.dumps(message, ensure_ascii=False) + "\n"
    finally:
        # 客户端提前断开时,生成器会被关掉。这里把线程任务收干净,
        # 不然异常会以 "Task exception was never retrieved" 的形式漏出来
        if not task.done():
            task.cancel()
        try:
            await task
        except (asyncio.CancelledError, Exception):
            pass


@app.post("/process_stream", summary="中医问答接口(流式)")
async def process_stream(request: ProcessRequest) -> StreamingResponse:
    """和 /process 同样的入参,但每个节点执行完就先返回一条"思考过程"。

    一次问答要 5-30 秒,一次性接口在这段时间里什么都没有,用户只能干等;
    流式把中间过程吐出来,前端可以显示"正在抽取实体""正在查图谱"。

    响应体不是严格 SSE,是换行分隔的 JSON(text/plain),见接口文档。
    这里不套 try:异常在 producer 内部就收敛成 error 消息了,
    HTTP 状态码已经随第一个字节发出去,改不了了。
    """
    print(f"[/process_stream] user_id={request.user_id} input={request.input!r}")
    return StreamingResponse(
        _stream_messages(request.input, request.user_id),
        media_type="text/plain; charset=utf-8",
        # 关掉 nginx 一类反向代理的缓冲,否则它会攒够一批再转发,流式就没了
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8001)