💻 源码
"""中医知识图谱问答 —— Streamlit 前端

用户在页面上提问,调 _005_ 后端的 POST /process,把回答显示出来。

运行(不能用 python 直接跑,streamlit 应用必须由 streamlit 启动):

    # 1. 先起后端
    python -X utf8 -m _005_fastapi.main
    # 2. 再起前端(哪个目录都行,下面的 sys.path 补丁会自己找到仓库根)
    streamlit run _006_streamlit/app.py

网络那一层在 api_client.py,这里只管界面。

两点和 Streamlit 执行模型有关的事:

- **每次交互,整个脚本从头重跑一遍。** 所以历史对话必须放 st.session_state,
  普通变量会在下一次 rerun 时归零。
- **user_id 每个浏览器会话生成一次**并存进 session_state,正好对上接口文档
  "同一用户多次请求传相同 user_id"。虽然后端目前还没实现会话记忆
  (AgentState 里没有这个字段),前端先把契约守住。
"""

import sys
import uuid
from pathlib import Path

# streamlit run 把**脚本自己所在的目录**放进 sys.path(不是仓库根目录,也不是 cwd),
# 和其它阶段用的 python -m 不一样。所以 `_006_streamlit.api_client` 这个绝对导入
# 在这里必然 ModuleNotFoundError —— 无论从哪个目录启动。
# 手动把仓库根补进去,路径从 __file__ 算,跟 common/path_utils.py 同一个思路。
# 这几行必须排在项目内的 import 之前,所以下面的 E402 是故意的。
_REPO_ROOT = str(Path(__file__).resolve().parent.parent)
if _REPO_ROOT not in sys.path:
    sys.path.insert(0, _REPO_ROOT)

import streamlit as st

from _006_streamlit.api_client import (
    DEFAULT_BASE_URL,
    DEFAULT_TIMEOUT,
    BackendError,
    ask_backend,
    check_health,
)

# 示例问题,取自 API_INTERFACE.md 的"常用测试问题",点一下就能提问,省得手打中文
SAMPLE_QUESTIONS = [
    "人参有什么功效?",
    "四君子汤由哪些药材组成?",
    "《伤寒论》里记载了哪些方剂?",
    "我最近总是拉肚子,有什么方剂可以调理?",
]

st.set_page_config(page_title="中医知识图谱问答", page_icon="🌿", layout="centered")


def init_state() -> None:
    """初始化 session_state。rerun 会重跑整个脚本,所以每个键都要判存在再写。"""
    if "messages" not in st.session_state:
        # [(角色, 内容)],角色是 "user" / "assistant"
        st.session_state.messages = []
    if "user_id" not in st.session_state:
        # 每个浏览器会话一个稳定 id,满足接口文档对 user_id 的要求
        st.session_state.user_id = f"streamlit-{uuid.uuid4().hex[:12]}"
    if "pending" not in st.session_state:
        # 点示例按钮时把问题放这里,让下一次 rerun 去处理。
        # 按钮回调发生在页面渲染中途,直接在那儿发请求会让聊天记录的渲染顺序错乱
        st.session_state.pending = None


def render_sidebar() -> tuple[str, int]:
    """侧边栏:后端地址、超时、状态灯、清空按钮。返回 (base_url, timeout)"""
    with st.sidebar:
        st.header("⚙️ 设置")
        base_url = st.text_input(
            "后端地址",
            value=DEFAULT_BASE_URL,
            help="_005_fastapi 服务的地址。端口被占用时改这里,不用改代码",
        )
        timeout = st.number_input(
            "超时(秒)",
            min_value=10,
            max_value=600,
            value=DEFAULT_TIMEOUT,
            step=10,
            help="单次问答通常 5-30 秒,接口文档建议 120 秒",
        )

        # 手动触发,不自动探活:每次 rerun 都发一个请求属于白费
        if st.button("检查后端连接", use_container_width=True):
            if check_health(base_url):
                st.success("后端正常")
            else:
                st.error("连不上后端,请先启动 _005_fastapi")

        st.divider()
        if st.button("🗑️ 清空对话", use_container_width=True):
            st.session_state.messages = []
            st.rerun()

        st.caption(f"会话 user_id:`{st.session_state.user_id}`")

    return base_url, int(timeout)


def handle_question(question: str, base_url: str, timeout: int) -> None:
    """把一轮问答追加进历史。

    先把用户消息写进历史再发请求,这样请求失败时提问也留在页面上,
    用户不用重新打一遍。
    """
    st.session_state.messages.append(("user", question))
    with st.chat_message("user"):
        st.markdown(question)

    with st.chat_message("assistant"):
        # 后端要跑 LLM + Neo4j,几十秒没有反馈会让人以为页面卡死了
        with st.spinner("正在查询中医知识图谱…"):
            try:
                answer = ask_backend(
                    question,
                    user_id=st.session_state.user_id,
                    base_url=base_url,
                    timeout=timeout,
                )
            except BackendError as exc:
                answer = f"⚠️ {exc}"
        # 回答里带 markdown(后端的 LLM 会输出加粗、列表、代码块),用 markdown 渲染
        st.markdown(answer)

    st.session_state.messages.append(("assistant", answer))


def main() -> None:
    init_state()
    base_url, timeout = render_sidebar()

    st.title("🌿 中医知识图谱问答")
    st.caption("基于 Neo4j 知识图谱 + LangGraph + 大模型,可以问中药、方剂、症状、出处")

    # 空历史时给几个示例,用户不必自己想第一个问题该怎么问
    if not st.session_state.messages:
        st.markdown("**试试这些问题:**")
        for i, sample in enumerate(SAMPLE_QUESTIONS):
            # key 必须唯一,否则 Streamlit 会报重复 key
            if st.button(sample, key=f"sample-{i}", use_container_width=True):
                # 存起来交给下一次 rerun 处理,别在渲染中途发请求
                st.session_state.pending = sample
                st.rerun()

    # 重放历史。rerun 会重跑整个脚本,页面上的消息全靠这一段重新画出来
    for role, content in st.session_state.messages:
        with st.chat_message(role):
            st.markdown(content)

    # 输入框固定在页面底部,回车提交
    question = st.chat_input("请输入你的问题,例如:人参有什么功效?")

    # 示例按钮排在输入框之后处理,两条来源汇成一个入口
    if st.session_state.pending:
        question = st.session_state.pending
        st.session_state.pending = None

    if question and question.strip():
        handle_question(question.strip(), base_url, timeout)


main()