130 lines
6.8 KiB
Python
130 lines
6.8 KiB
Python
"""Streamlit UI for inspecting sources and asking evidence-grounded TDoc questions."""
|
||
|
||
import json
|
||
|
||
import streamlit as st
|
||
|
||
from agent_core import ProposalAgent
|
||
|
||
st.set_page_config(page_title="3GPP 提案阅读助手", page_icon="📡", layout="wide")
|
||
|
||
|
||
@st.cache_resource(show_spinner="正在建立本地提案检索索引……")
|
||
def load_agent():
|
||
return ProposalAgent()
|
||
|
||
|
||
def show_sources(sources):
|
||
"""Keep source excerpts visible independently of model loading or generation errors."""
|
||
for warning in sources.get("warnings", []):
|
||
st.warning(warning)
|
||
if sources.get("documents"):
|
||
st.dataframe([
|
||
{"提案": item["tdoc"], "标题": item.get("title", ""), "原文状态": item["status"]}
|
||
for item in sources["documents"]
|
||
], hide_index=True, width="stretch")
|
||
for item in sources.get("evidence", []):
|
||
with st.expander(f"[{item['source_id']}] {item['tdoc']} · 段落 {item['paragraph_start']}–{item['paragraph_end']}"):
|
||
st.write(item["title"])
|
||
st.caption(f"{item['meeting']} | {item['company']}")
|
||
st.text(item["text"])
|
||
st.caption(item["file_path"])
|
||
|
||
|
||
st.title("📡 3GPP 提案阅读助手")
|
||
st.caption("查找本地提案原文,查看依据,再生成带来源编号的回答。提案主张不等于已通过的标准。")
|
||
|
||
with st.sidebar:
|
||
st.subheader("使用方式")
|
||
retrieval_only = st.checkbox("只查原文,不调用模型", value=True)
|
||
use_lora = st.checkbox("使用现有 LoRA 适配器", value=False, help="默认使用原模型。本轮少量实测中,现有适配器出现了引用缺失和无依据的流程描述;其整体效果尚未确认。")
|
||
max_tokens = st.slider("最长生成 token 数", 128, 1536, 640, step=128)
|
||
temperature = st.slider("采样温度", 0.0, 0.7, 0.0, step=0.1)
|
||
st.caption("设为 0 可减少采样变化;不能保证事实正确。首次生成会加载本地模型。")
|
||
st.divider()
|
||
st.subheader("示例问题")
|
||
examples = {
|
||
"XR 速率控制": "请根据 R2-2408133 原文,说明提案要解决的问题、主要主张及尚未明确的部分。",
|
||
"比较两篇 XR 提案": "请对比 R2-2408133 和 R2-2408134,分别说明目标和主要主张,再解释两篇提案是否解决同一个问题。",
|
||
"NTN 设计要求": "请根据 R2-2508703 原文,说明 NTN 对 TN/NTN 共同设计提出了哪些要求。区分提案主张与已经达成的结论。",
|
||
"按标题找资料": "XR rate control signaling",
|
||
}
|
||
for label, example in examples.items():
|
||
if st.button(label, use_container_width=True):
|
||
st.session_state["question"] = example
|
||
if st.button("清除结果", use_container_width=True):
|
||
st.session_state.pop("last_run", None)
|
||
st.session_state.pop("last_error", None)
|
||
st.session_state.pop("last_sources", None)
|
||
st.caption("支持编号检索、标题关键词排序和 DOCX 原文读取。旧版 .doc 暂不读取;缺少原文时不生成内容分析。")
|
||
|
||
question = st.text_area("问题或提案编号", key="question", height=130, placeholder="例如:对比 R2-2408133 和 R2-2408134 的目标与主张。")
|
||
extra = st.text_area("补充材料(选填,会标记为未经核实)", height=80)
|
||
|
||
if st.button("检索原文" if retrieval_only else "检索并分析", type="primary"):
|
||
st.session_state.pop("last_run", None)
|
||
st.session_state.pop("last_error", None)
|
||
st.session_state.pop("last_sources", None)
|
||
if not question.strip():
|
||
st.warning("请输入问题或提案编号。")
|
||
else:
|
||
status = st.status("正在处理……", expanded=True)
|
||
with st.container():
|
||
source_area = st.container()
|
||
output = st.empty()
|
||
completed = False
|
||
try:
|
||
agent = load_agent()
|
||
for event in agent.run_stream(
|
||
question, extra_context=extra, max_new_tokens=max_tokens,
|
||
temperature=temperature, retrieval_only=retrieval_only, use_lora=use_lora,
|
||
):
|
||
stage = event["stage"]
|
||
if stage in {"1. RETRIEVAL", "1. RETRIEVAL_DONE", "2. SYNTHESIS_START"}:
|
||
status.write(event["msg"])
|
||
if stage == "1. RETRIEVAL_DONE":
|
||
st.session_state["last_sources"] = event["retrieval"]
|
||
with source_area:
|
||
show_sources(event["retrieval"])
|
||
elif stage == "2. STREAMING":
|
||
output.markdown(event["full_text"] + " ▌")
|
||
elif stage == "4. COMPLETE":
|
||
st.session_state["last_run"] = {**event, "question": question, "use_lora": use_lora}
|
||
completed = True
|
||
elif stage == "ERROR":
|
||
st.session_state["last_error"] = event["msg"]
|
||
except Exception as exc:
|
||
st.session_state["last_error"] = f"初始化失败:{exc}"
|
||
output.empty()
|
||
status.update(label="处理完成" if completed else "处理失败", state="complete" if completed else "error", expanded=False)
|
||
if st.session_state.get("last_error"):
|
||
st.warning("生成未完成。上方原文仍可查看;不完整回答未保存为结果。")
|
||
|
||
if st.session_state.get("last_error"):
|
||
st.error(st.session_state["last_error"])
|
||
if st.session_state.get("last_sources"):
|
||
with st.expander("查看本次已找到的原文"):
|
||
show_sources(st.session_state["last_sources"])
|
||
|
||
if run := st.session_state.get("last_run"):
|
||
st.divider()
|
||
if run.get("mode") == "needs_review":
|
||
st.warning("以下为待核对草稿:来源编号或提案引用不完整。")
|
||
st.caption("对应问题:" + run.get("question", ""))
|
||
st.markdown(run["result"])
|
||
with st.expander("查看所有来源及原文"):
|
||
show_sources(run["sources"])
|
||
for warning in run.get("validation", {}).get("warnings", []):
|
||
st.warning(warning)
|
||
metrics = run.get("metrics", {})
|
||
labels = {"retrieval_seconds": "检索", "first_text_seconds": "首段文本", "total_seconds": "总耗时"}
|
||
st.caption(" | ".join(f"{labels.get(name, name)}: {value:.2f} s" for name, value in metrics.items() if value is not None))
|
||
st.caption("首段文本时间包含检索和首次模型加载,不等于纯模型首 token 延迟。")
|
||
report = run["result"] + "\n\n---\n\n来源记录:\n\n" + "\n\n".join(
|
||
f"[{item['source_id']}] {item['tdoc']} | {item['title']}\n"
|
||
f"{item['file_path']}(段落 {item['paragraph_start']}–{item['paragraph_end']})\n{item['text']}"
|
||
for item in run["sources"].get("evidence", [])
|
||
)
|
||
st.download_button("下载回答与来源 Markdown", report, "3gpp_analysis.md", "text/markdown")
|
||
st.download_button("下载完整运行记录 JSON", json.dumps(run, ensure_ascii=False, indent=2), "3gpp_run.json", "application/json")
|