"""中医知识图谱问答 —— 流式页面(调后端 /process_stream)
和首页(app.py,调 /process)问的是同一张图,区别只在**什么时候给反馈**:
首页要等 5-30 秒才一次性出答案,中间只有一个转圈;这一页把图里每个节点的产出
边跑边显示出来,用户能看到"正在抽取实体""正在查图谱",等待就不再是黑盒。
Streamlit 1.57 的多页机制:`pages/` 目录下每个 .py 自动成为一页,侧边栏出现导航,
入口仍然是 app.py。所以这一页只管自己的界面,不用改 app.py。
运行(和首页同一条命令,起来后从侧边栏切页):
python -X utf8 -m _005_fastapi.main # 1. 后端
streamlit run _006_streamlit/app.py # 2. 前端
网络那一层在 api_client.ask_backend_stream,这里只管界面。
"""
import sys
import uuid
from pathlib import Path
# 和 app.py 同一个理由:streamlit run 只把**脚本所在目录**放进 sys.path。
# 这一页在 pages/ 下,比 app.py 还深一层,所以要往上走两级才是仓库根。
# 这几行必须排在项目内的 import 之前,下面的 E402 是故意的。
_REPO_ROOT = str(Path(__file__).resolve().parent.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_stream,
check_health,
)
# 示例问题挑的是**要查图谱**的那种:闲聊只走两个节点,看不出流式的效果
SAMPLE_QUESTIONS = [
"人参有什么功效?",
"四君子汤由哪些药材组成?",
"我最近总是拉肚子,有什么方剂可以调理?",
]
# AgentState 里的键名对用户没有意义,翻译成人看得懂的中文。
# 键取自 _004_langgraph_more_nodes/agent_state.py —— 那边加字段,这里补一行,
# 没补也不会坏,下面的 fallback 会直接显示原始键名。
FIELD_LABELS = {
"input_semantic_trans": "改写后的问题",
"is_zhongyi_intent": "是否中医相关",
"user_input_herbs": "问题里的中药",
"user_input_formulas": "问题里的方剂",
"user_input_symptoms": "问题里的症状",
"user_input_diseases": "问题里的疾病",
"user_input_effects": "问题里的功效",
"user_input_sources": "问题里的出处",
"matched_herbs": "图谱中的中药",
"matched_formulas": "图谱中的方剂",
"matched_symptoms": "图谱中的症状",
"matched_diseases": "图谱中的疾病",
"matched_effects": "图谱中的功效",
"matched_sources": "图谱中的出处",
"cypher_query": "生成的查询语句",
"is_all_validate_cypher": "查询语句校验",
"cypher_results": "图谱返回的数据",
}
st.set_page_config(page_title="流式问答 · 中医知识图谱", page_icon="🌊", layout="centered")
def init_state() -> None:
"""初始化 session_state。rerun 会重跑整个脚本,所以每个键都要判存在再写。"""
if "stream_messages" not in st.session_state:
# [{"role", "content", "thinking": [(节点名, 增量), ...]}]。
# thinking 也要存:rerun 会重跑整个脚本,不存的话一提下一个问题,
# 上一轮的思考过程就消失了。用和首页不同的键,两页历史互不打扰
st.session_state.stream_messages = []
if "user_id" not in st.session_state:
# 和首页共用这个键:同一个浏览器会话就是同一个用户,
# 后端拿它当 thread_id,切页不该换身份
st.session_state.user_id = f"streamlit-{uuid.uuid4().hex[:12]}"
if "stream_pending" not in st.session_state:
# 点示例按钮时把问题存这里,交给下一次 rerun 处理。
# 按钮回调发生在页面渲染中途,直接在那儿发请求会让聊天记录的渲染顺序错乱
st.session_state.stream_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 服务的地址。端口被占用时改这里,不用改代码",
key="stream_base_url",
)
timeout = st.number_input(
"超时(秒)",
min_value=10,
max_value=600,
value=DEFAULT_TIMEOUT,
step=10,
help="流式接口的超时是**读一行**的间隔,不是总时长,给到 120 秒很宽裕",
key="stream_timeout",
)
# 手动触发,不自动探活:每次 rerun 都发一个请求属于白费
if st.button("检查后端连接", use_container_width=True, key="stream_health"):
if check_health(base_url):
st.success("后端正常")
else:
st.error("连不上后端,请先启动 _005_fastapi")
st.divider()
if st.button("🗑️ 清空对话", use_container_width=True, key="stream_clear"):
st.session_state.stream_messages = []
st.rerun()
st.caption(f"会话 user_id:`{st.session_state.user_id}`")
return base_url, int(timeout)
def format_value(value) -> str:
"""把状态值渲染成一行 markdown。
值的形状不统一:布尔、字符串、字符串列表(实体、cypher)、嵌套的查询结果。
列表用顿号连起来比原始 JSON 好读;cypher 和查询结果太长,截断,
这里是"思考过程"不是调试面板,看个大概就够。
"""
if isinstance(value, bool):
return "是" if value else "否"
if isinstance(value, list):
# 全是字符串的列表(实体名、cypher 语句)直接拼
if all(isinstance(v, str) for v in value):
text = "、".join(value)
else:
# cypher_results 这种嵌套结构,只报个条数,展开了没人看
return f"共 {len(value)} 组结果"
return text if len(text) <= 300 else text[:300] + "…"
text = str(value)
return text if len(text) <= 300 else text[:300] + "…"
def render_thinking(container, node: str, data: dict) -> None:
"""把一个节点的状态增量画进容器。
每个节点一个小标题 + 若干"字段:值"。用 container.markdown 而不是 st.markdown,
是为了把内容写进 status 里那块固定区域,而不是追加到页面末尾。
"""
lines = [f"**{node}**"]
for key, value in data.items():
# 没登记的键显示原始键名,加字段时不至于漏掉不显示
label = FIELD_LABELS.get(key, key)
lines.append(f"- {label}:{format_value(value)}")
container.markdown("\n".join(lines))
def handle_question(question: str, base_url: str, timeout: int) -> None:
"""跑一轮流式问答,边收边画。
先把用户消息写进历史再发请求,这样请求失败时提问也留在页面上,
用户不用重新打一遍。
"""
st.session_state.stream_messages.append({"role": "user", "content": question})
with st.chat_message("user"):
st.markdown(question)
with st.chat_message("assistant"):
# st.status 给的是一块带标题和状态图标的固定区域,思考过程往里写。
# expanded=True 且跑完也不收,这一页存在的意义就是让人看见中间过程
status = st.status("正在思考…", expanded=True)
answer = ""
is_error = False
# 边收边记,跑完存进历史,rerun 时重放
thinking_log: list = []
try:
for message in ask_backend_stream(
question,
user_id=st.session_state.user_id,
base_url=base_url,
timeout=timeout,
):
kind = message.get("type")
if kind == "thinking":
node = message.get("node") or "未知节点"
data = message.get("data") or {}
# 节点名直接当进度文案,比"处理中"有信息量
status.update(label=f"正在执行:{node}")
render_thinking(status, node, data)
thinking_log.append((node, data))
elif kind == "final":
answer = (message.get("output") or "").strip()
elif kind == "error":
# 后端图跑挂了,HTTP 仍是 200,错误文案在 output 里
answer = (message.get("output") or "").strip()
is_error = True
except BackendError as exc:
# 连不上/超时/参数不合法:流根本没开始
answer = f"⚠️ {exc}"
is_error = True
if not answer:
# 流正常结束但没收到 final,属于后端不该有的行为,兜一下
answer = "⚠️ 后端没有返回最终答案,请重试。"
is_error = True
# 跑的时候展开(用户要看进度),跑完收起(答案才是结论,中间过程让位)。
# 点一下标题还能再展开,信息没丢
status.update(
label="思考失败" if is_error else "思考完成",
state="error" if is_error else "complete",
expanded=False,
)
# 回答里带 markdown(后端的 LLM 会输出加粗、列表),用 markdown 渲染
st.markdown(answer)
st.session_state.stream_messages.append(
{
"role": "assistant",
"content": answer,
"thinking": thinking_log,
"is_error": is_error,
}
)
def main() -> None:
init_state()
base_url, timeout = render_sidebar()
st.title("🌊 流式问答")
st.caption("和首页问的是同一张图,但会把每个节点的思考过程实时显示出来")
# 空历史时给几个示例,用户不必自己想第一个问题该怎么问
if not st.session_state.stream_messages:
st.markdown("**试试这些问题:**")
for i, sample in enumerate(SAMPLE_QUESTIONS):
# key 必须唯一,且不能和首页的 sample-N 撞
if st.button(sample, key=f"stream-sample-{i}", use_container_width=True):
# 存起来交给下一次 rerun 处理,别在渲染中途发请求
st.session_state.stream_pending = sample
st.rerun()
# 重放历史。rerun 会重跑整个脚本,页面上的消息全靠这一段重新画出来,
# 往轮的思考过程装在收起的 expander 里 —— 想回看点开就有,不占版面
for item in st.session_state.stream_messages:
with st.chat_message(item["role"]):
thinking = item.get("thinking")
if thinking:
with st.expander("💭 思考过程", expanded=False):
for node, data in thinking:
render_thinking(st, node, data)
st.markdown(item["content"])
# 输入框固定在页面底部,回车提交
question = st.chat_input("请输入你的问题,例如:人参有什么功效?")
# 示例按钮排在输入框之后处理,两条来源汇成一个入口
if st.session_state.stream_pending:
question = st.session_state.stream_pending
st.session_state.stream_pending = None
if question and question.strip():
handle_question(question.strip(), base_url, timeout)
main()