"""中医知识图谱问答 —— 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()