上传multi_agent项目文件到Harry/hyx仓库

This commit is contained in:
Harry 2026-01-09 10:55:47 +08:00
commit 375da859e8
17 changed files with 6593 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# secrets
.env
*.env
# python virtual environments
.venv/
venv/
env/
# python cache
__pycache__/
*.py[cod]
*$py.class
# build artifacts
*.egg-info/
dist/
build/
# OS / editor
.DS_Store
Thumbs.db
.vscode/
.idea/

96
README.md Normal file
View File

@ -0,0 +1,96 @@
# 🤖 多Agent决策工作坊
## 简介
多Agent决策工作坊是一个基于 AI 技术的方案评审工具,通过模拟不同角色的专业人士进行评审讨论(当前为**单轮评审**:每个角色各输出一次观点),并自动提取关键决策要点,帮助团队做出更全面、更理性的决策。
## 团队成员与贡献(必填)
| 姓名 | 学号 | 主要贡献(具体分工) |
|---|---|---|
| 胡云翔 | 2310561224 | 独立完成选题与需求分析Prompt 设计项目结构搭建Streamlit 前端实现;多 Agent 评审逻辑实现;结果可视化与导出;文档与开发心得撰写;测试与 Bug 修复 |
## 如何运行
1. **安装依赖**
```bash
# 进入项目目录
cd multi_agent_submission
# 同步依赖uv 会自动创建虚拟环境)
uv sync
```
2. **配置 API Key**
- 复制 `env.example``.env`Windows 用 `copy` 命令)
- 在 `.env` 中填入你的 API Key支持两种格式
```
# 推荐OpenAI 兼容命名(二选一)
OPENAI_API_KEY=sk-xxxxxx
# 或 DeepSeek 兼容命名(二选一)
# DEEPSEEK_API_KEY=sk-xxxxxx
# 可选API 基础地址(默认 DeepSeek
# OPENAI_API_BASE=https://api.deepseek.com/v1
# MODEL_ID=deepseek-chat
```
> ⚠️ 注意:
> - 请勿将 `.env` 文件提交到 Git已通过 .gitignore 过滤)
> - 如果遇到 SSL 问题,可尝试添加 `REQUESTS_CA_BUNDLE=``CURL_CA_BUNDLE=`
3. **启动应用**
```bash
# 确保在 multi_agent_submission/ 目录下执行
uv run streamlit run app.py
```
- 如果一切正常,浏览器会自动打开应用
- 如果缺少 Key页面上会显示明确的错误提示和解决方案
## 功能列表
- [x] 📋 方案内容输入
- [x] 👥 多角色选择(产品经理、技术专家、用户代表等)
- [x] 🔄 单轮评审(固定 1 轮,减少调用次数、提升速度)
- [x] 🚀 自动评审流程
- [x] 📊 评审结果可视化
- [x] ✅ 智能决策要点生成
## 技术栈
- 🐍 **Python 3.12+**
- ⚡ **uv** - 极速Python包管理器
- 🤖 **DeepSeek API** - AI模型支持
- 🎨 **Streamlit** - 交互式Web界面
- 📦 **Pydantic** - 数据验证和管理
## 项目结构
```
multi_agent_submission/
├── app.py # 主应用入口Streamlit
├── agent.py # Agent 与评审管理逻辑
├── config.py # 配置文件
├── .env # 环境变量配置
├── pyproject.toml # 项目依赖配置
├── uv.lock # 依赖锁定文件
└── README.md # 项目说明文档
```
## 使用说明
1. **输入方案**:在左侧输入需要评审的方案内容
2. **选择角色**:选择参与评审的专业角色
3. **开始评审**:点击"开始评审"按钮(当前固定为单轮评审)
4. **查看结果**:等待评审完成后,查看各角色观点与生成的决策要点
## 核心特性
- **多角色模拟**:支持 5 种不同专业角色的方案评审(单轮)
- **智能决策提取**:自动从评审内容中提取关键决策要点
- **交互式界面**:友好的 Web 界面,易于操作
- **可扩展设计**:支持添加新的角色与评审规则(保留扩展空间)
## 开发心得
`REFLECTION.md` 文件。

112
REFLECTION.md Normal file
View File

@ -0,0 +1,112 @@
# 开发心得 (Development Reflection)
## 1. 选题思考
> **核心问题**:为什么做这个?解决了谁的痛苦?
我选择做这个多Agent决策工作坊项目是因为在日常学习和工作中我深刻体会到团队决策的痛点。当一个团队面对复杂问题时往往会陷入观点分歧、讨论效率低下、决策质量参差不齐的困境。特别是在方案评审阶段不同角色如产品经理、技术专家、用户代表往往从各自的专业角度出发难以形成全面、平衡的决策。
这个项目主要解决了以下几个核心问题:
1. **决策片面性**:通过模拟多个专业角色的辩论,避免了单一视角的局限性,让决策更加全面。
2. **讨论效率低**:自动辩论流程大大缩短了传统会议的时间成本,提高了决策效率。
3. **要点提取难**:自动生成决策要点,避免了人工记录的遗漏和偏差。
4. **知识共享不足**:不同角色的观点碰撞,促进了团队成员之间的知识共享和相互理解。
这个项目的价值在于,它为团队决策提供了一个智能化的辅助工具,既能提高决策质量,又能提升决策效率,让团队能够更快速、更全面地做出理性决策。
## 2. AI 协作体验
### 2.1 初体验
> **核心问题**:第一次用 AI 写代码的感觉?
第一次用 AI 写代码的感觉可以用"惊喜"和"震撼"来形容。我原本以为需要花费数小时甚至数天才能完成的项目框架,在 AI 的辅助下只用了不到一个小时就搭建完成了。AI 不仅能理解我的需求,还能自动生成结构清晰、功能完整的代码,甚至还能提供一些我没有想到的优化建议。
最让我印象深刻的是当我描述了多Agent决策工作坊的基本需求后AI 不仅生成了核心的 Agent 类和辩论管理逻辑,还自动处理了 API 连接、环境变量配置、用户界面设计等细节问题。这种"描述意图AI 实现"的开发方式,让我感受到了 AI 时代编程的全新范式。
当然初体验也伴随着一些挑战。比如AI 生成的代码有时会存在一些细微的错误,需要我仔细检查和调试。另外,如何准确描述需求,让 AI 能够理解我的真实意图,也是一个需要不断学习和实践的过程。
### 2.2 Prompt 交互
> **核心问题**:哪个 Prompt 让你直呼"牛逼"?哪个让你想砸键盘?
- **最牛 Prompt**
```text
请设计一个多Agent辩论系统包含以下核心功能
1. 支持多种角色(产品经理、技术专家、用户代表等)
2. 能够自动进行多轮辩论
3. 可以从辩论内容中提取决策要点
4. 使用Streamlit构建交互式界面
5. 配置文件管理API连接
请使用Python语言按照模块化设计原则创建清晰的项目结构。
```
*这也是我觉得最神奇的地方*这个Prompt虽然简洁但包含了项目的核心需求和设计原则。AI 不仅理解了我的需求还按照模块化设计原则生成了完整的项目结构包括agent.py、config.py、app.py等文件甚至还自动创建了.env文件模板。这大大加快了项目的开发进度让我能够专注于核心功能的优化和调试。
- **最坑 Prompt / 交互**
在开发过程中我遇到了一个比较棘手的问题当我尝试使用硅基流动SiliconFlow作为API提供商时AI 生成的代码中使用了错误的模型ID格式。我最初的Prompt是
```text
请配置DeepSeek API连接使用硅基流动作为API提供商模型ID为openai/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B
```
然而AI 生成的代码中直接将模型ID作为OpenAI的model参数传递而没有考虑到硅基流动的特殊格式要求。这导致了API调用失败我花费了不少时间来调试这个问题。最终我通过查阅硅基流动的文档发现需要将完整的model ID包括前缀"openai/"作为model参数而不是像传统OpenAI API那样只使用模型名称。
这次经历让我意识到虽然AI 很强大但它有时会对特定平台的细节缺乏了解。在使用AI开发时我需要对技术细节保持警惕特别是当涉及到特定平台或服务的集成时需要仔细检查和验证AI生成的代码。
### 2.3 Bug 解决
> **核心问题**AI 生成的 Bug 你是怎么解的?
在开发过程中AI 生成的代码中出现了一个比较隐蔽的Bug当辩论轮次为1时辩论历史的处理逻辑出现了错误。具体来说AI 生成的代码在生成决策要点时会遍历从第1轮开始的所有辩论轮次但当辩论轮次为1时这个逻辑会导致索引错误。
我是通过以下步骤解决这个Bug的
1. **错误定位**当我测试辩论轮次为1的情况时应用抛出了一个索引错误。通过查看错误堆栈我定位到了错误发生在agent.py文件的generate_decision_points方法中。
2. **问题分析**仔细检查代码后我发现问题出在辩论历史的处理逻辑上。AI 生成的代码假设辩论历史中至少包含两个轮次初始轮次和至少一个辩论轮次但当辩论轮次为1时辩论历史中只有两个轮次初始轮次和一个辩论轮次导致索引访问错误。
3. **解决方案**我修改了generate_decision_points方法添加了对辩论历史长度的检查并调整了遍历逻辑确保当辩论轮次为1时代码也能正常工作。
4. **验证修复**修改后我再次测试了辩论轮次为1的情况确认Bug已经修复应用能够正常生成决策要点。
这次经历让我认识到虽然AI 能够生成高质量的代码但它有时会忽略一些边界情况。在使用AI开发时我需要仔细测试各种边界情况确保代码的鲁棒性和可靠性。
## 3. 自我反思
### 3.1 离开 AI
> **核心问题**:离开 AI我还能写出这个吗
诚实地说如果没有AI的帮助我可能无法在这么短的时间内完成这个项目。这个项目涉及到多个技术领域包括AI模型调用、Web界面设计、多线程处理等需要掌握大量的专业知识和技能。
但是如果给我足够的时间我相信我还是能够写出这个项目的。因为AI 虽然提供了代码实现但核心的设计思路和业务逻辑仍然是我自己思考和决策的。我需要理解项目的需求设计系统架构选择技术栈验证代码质量。这些能力是AI 无法替代的。
更重要的是通过与AI的协作我学到了很多新的知识和技能。比如我学会了如何使用Streamlit构建交互式Web应用如何设计模块化的代码结构如何处理API连接和错误情况。这些知识和技能将成为我自己的能力即使离开AI我也能够运用这些知识来开发类似的项目。
### 3.2 核心竞争力
> **核心问题**AI 时代,我作为程序员的核心竞争力到底是什么?
在AI时代作为程序员我的核心竞争力不在于编写代码的速度和数量而在于以下几个方面
1. **需求理解和分析能力**AI 可以生成代码,但它无法理解真实世界的复杂需求。我需要能够深入理解用户需求,将模糊的业务需求转化为清晰的技术需求。
2. **系统设计和架构能力**AI 可以生成模块级的代码,但它难以从整体上设计一个复杂系统的架构。我需要能够设计清晰、可扩展、可维护的系统架构,确保系统的长期稳定性和可扩展性。
3. **代码质量和可靠性意识**AI 生成的代码可能存在错误和安全漏洞,我需要能够仔细检查和验证代码,确保代码的质量和可靠性。
4. **问题解决和调试能力**当系统出现问题时AI 可能无法快速定位和解决问题。我需要能够运用调试工具和方法,快速定位和解决问题。
5. **创新思维和业务洞察力**AI 可以优化现有系统,但它难以提出全新的解决方案。我需要能够运用创新思维,结合业务洞察力,提出创造性的解决方案。
6. **持续学习和适应能力**AI 技术发展迅速,我需要能够持续学习和适应新的技术和工具,保持自己的竞争力。
总之在AI时代程序员的核心竞争力在于"人的能力"即理解需求、设计系统、解决问题、创新思维等能力。这些能力是AI 无法替代的,也是我在未来需要重点培养和提升的能力。
## 结语
通过这个项目我深刻体会到了AI 时代编程的全新范式。AI 不仅是一个强大的工具,可以帮助我提高开发效率,还可以成为我的"学习伙伴",帮助我学习新的知识和技能。
同时我也认识到在AI时代我需要重新定位自己的角色和价值。我不再是一个纯粹的"代码编写者",而是一个"系统设计者"和"问题解决者"。我需要将更多的精力放在需求理解、系统设计、问题解决等方面而将代码实现等重复性工作交给AI 来完成。
最后我想说的是AI 不是程序员的替代品而是程序员的增强工具。只要我们能够正确认识和使用AI它将成为我们在AI时代的强大助力帮助我们开发出更加优秀、更加创新的软件产品。

231
agent.py Normal file
View File

@ -0,0 +1,231 @@
from __future__ import annotations
import logging
from typing import Any, List, Optional
import litellm
from config import (
API_BASE,
API_KEY,
MODEL_ID,
AGENT_ROLES,
MAX_TOKENS,
TEMPERATURE,
MAX_RETRIES,
RETRY_INITIAL_DELAY,
RETRY_BACKOFF_FACTOR,
RETRY_MAX_DELAY,
)
from utils.retry import retry
logger = logging.getLogger(__name__)
class Agent:
"""单个评审角色Agent
说明
- 每个 Agent 仅负责基于 system prompt + 用户输入生成一次回复
- 不在 Agent 内部保存历史历史由外部管理器控制
- 全部角色统一使用同一个 API Key符合课程对 Key 管理的简化要求
"""
def __init__(self, role_name: str):
if role_name not in AGENT_ROLES:
raise ValueError(f"未知角色:{role_name}")
self.role_name = role_name
self.system_prompt = AGENT_ROLES.get(role_name, "")
self.model = MODEL_ID
self.api_base = API_BASE
self.api_key = API_KEY
def reset(self):
"""保留接口:当前版本 Agent 不维护历史。"""
return
@retry(
max_retries=MAX_RETRIES,
initial_delay=RETRY_INITIAL_DELAY,
backoff_factor=RETRY_BACKOFF_FACTOR,
max_delay=RETRY_MAX_DELAY,
retry_exceptions=(Exception,),
)
def _completion(self, messages: list[dict[str, str]], stream: bool = False) -> Any:
return litellm.completion(
model=self.model,
api_base=self.api_base,
api_key=self.api_key,
messages=messages,
max_tokens=MAX_TOKENS,
temperature=TEMPERATURE,
stream=stream,
)
def generate_response(
self,
prompt: str,
stream: bool = False,
external_history: Optional[List[dict[str, str]]] = None,
):
"""生成回答。
参数
- prompt: 用户输入
- stream: 是否流式输出
- external_history: 预留的外部历史本项目单轮评审默认不使用
"""
messages: list[dict[str, str]] = [{"role": "system", "content": self.system_prompt}]
if external_history:
messages.extend(external_history)
messages.append({"role": "user", "content": prompt})
try:
response = self._completion(messages, stream=stream)
except Exception as e:
logger.exception("API 调用失败(已重试仍失败):%s", e)
if stream:
raise RuntimeError(f"API 调用失败:{e}") from e
return f"❌ API 调用失败:{e}"
if stream:
return response
try:
if not hasattr(response, "choices") or not response.choices:
raise ValueError("响应缺少 choices")
first = response.choices[0]
content = getattr(getattr(first, "message", None), "content", None)
if content is None:
content = getattr(first, "text", None)
if not content or not str(content).strip():
raise ValueError("响应内容为空")
if len(str(content).strip()) < 10:
raise ValueError("响应内容过短,疑似生成失败")
return str(content)
except Exception as e:
logger.exception("API 响应结构异常:%s", e)
return f"❌ API 响应结构异常:{e}"
class DebateManager:
"""评审管理器(保留类名以兼容现有 UI 调用)。
当前版本约束
- 固定单轮评审每个角色各生成一次初始评审意见
- 不做多轮互动不维护上下文摘要减少运行时间与 API 调用次数
数据结构说明
- debate_history: 仍沿用历史字段名便于 UI 复用
其中第一条为 init 信息第二条为 round=1 的初始观点
"""
def __init__(self):
self.agents: dict[str, Agent] = {}
self.debate_history: list[dict[str, Any]] = []
def add_agent(self, role_name: str) -> None:
if role_name not in self.agents:
self.agents[role_name] = Agent(role_name)
def remove_agent(self, role_name: str) -> None:
self.agents.pop(role_name, None)
def reset(self) -> None:
for agent in self.agents.values():
agent.reset()
self.debate_history = []
def start_debate(self, topic: str, rounds: int = 1):
"""开始评审(单轮)。
参数 rounds 为兼容 UI 保留但当前固定只执行 1
"""
self.debate_history.append(
{
"round": "init",
"topic": topic,
"participants": list(self.agents.keys()),
}
)
opinions_round_1: dict[str, str] = {}
for role_name, agent in self.agents.items():
prompt = (
f"你是{agent.role_name}。请对以下方案给出初始评审意见。\n"
"要求:\n"
"- 用 5-8 条要点(列表)输出\n"
"- 每条尽量具体,避免空话\n"
"- 总字数尽量控制在 250~400 字\n\n"
f"方案内容:\n{topic}"
)
response = agent.generate_response(prompt, external_history=None)
opinions_round_1[role_name] = response
self.debate_history.append(
{
"round": 1,
"type": "initial_opinions",
"opinions": opinions_round_1,
}
)
return self.debate_history
def generate_decision_points(self) -> str:
"""从评审历史生成“可执行的决策摘要”。"""
if not self.debate_history:
return ""
review_context = "\n".join(
[
f"Round {round_data['round']} ({round_data.get('type', 'review')}):\n"
+ "\n".join(
[
f"{role}: {opinion}"
for role, opinion in round_data.get("opinions", {}).items()
]
)
for round_data in self.debate_history[1:]
]
)
template = """你是一个严格的方案评审秘书,请把评审内容整理成一份“可执行的决策摘要”。
输出格式要求必须严格遵守 Markdown 标题
# 一句话结论
用一句话给出结论推荐/谨慎推进/不建议并说明原因
# 关键决策要点Top 5
1-5 编号每条包含问题 各方分歧/共识 建议决策
# 主要风险Top 3
1-3 编号每条包含风险描述 影响 缓解方案
# 下一步行动清单
用勾选列表- [ ]列出 4-8 条下一步动作尽量可执行可验证
# 需要进一步澄清的问题
列出 3-6 个必须向需求方追问的问题
要求
- 文字精炼避免空话每条尽量具体
- 不要复述全部评审只提炼结论
- 输出必须适合普通人快速阅读
"""
prompt = f"{template}\n\n【评审内容】\n{review_context}"
decision_agent = Agent("business_analyst")
return decision_agent.generate_response(prompt)

350
app.py Normal file
View File

@ -0,0 +1,350 @@
import os
import streamlit as st
from dotenv import load_dotenv
from agent import DebateManager
from datetime import datetime
# 确保在任何机器/任何启动方式下都能加载 .env符合课程要求
load_dotenv()
# 启动即检查 API Key避免老师电脑运行时“无提示失败”
_api_key = os.getenv("OPENAI_API_KEY") or os.getenv("DEEPSEEK_API_KEY")
if not _api_key:
st.error(
"未检测到 API Key。\n\n"
"请按课程要求在 multi_agent_workshop/ 目录下创建 .env 文件(可参考 .env.example并设置\n"
"- OPENAI_API_KEY=sk-xxxxxx推荐\n"
"\n"
"- DEEPSEEK_API_KEY=sk-xxxxxx\n\n"
"然后重新运行uv run streamlit run app.py"
)
st.stop()
from ui.rendering import render_decision_summary
# 从 ui.ui_components 模块导入所需的函数
from ui.ui_components import (
local_css,
show_welcome_guide,
show_role_descriptions,
show_example_topic,
validate_input,
show_progress_indicator,
show_empty_state,
show_debate_summary,
show_debate_timeline,
show_debate_comparison,
)
# 读取本地 CSS原先大量内联 CSS 已经在 style.css此处仅负责注入
local_css("style.css")
st.set_page_config(
page_title="多Agent决策工作坊",
page_icon="🤖",
layout="wide",
initial_sidebar_state="expanded",
)
def save_debate_history(topic, selected_agents, debate_rounds, debate_history, decision_points):
"""保存辩论历史到session state"""
if "saved_debates" not in st.session_state:
st.session_state.saved_debates = []
debate_record = {
"id": len(st.session_state.saved_debates) + 1,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"topic": topic[:100] + "..." if len(topic) > 100 else topic,
"agents": selected_agents,
"rounds": debate_rounds,
"debate_history": debate_history,
"decision_points": decision_points,
}
st.session_state.saved_debates.append(debate_record)
return debate_record
def show_saved_debates(role_descriptions):
"""显示保存的辩论记录"""
if "saved_debates" not in st.session_state or not st.session_state.saved_debates:
return
st.markdown("---")
st.subheader("💾 历史记录")
for record in reversed(st.session_state.saved_debates[-5:]):
with st.expander(f"📅 {record['timestamp']} - {record['topic']}", expanded=False):
col1, col2, col3 = st.columns(3)
with col1:
st.markdown(f"**参与角色**{len(record['agents'])}")
with col2:
st.markdown(f"**评审轮次**{record['rounds']}")
with col3:
st.markdown(
f"**观点数量**{sum(len(r.get('opinions', {})) for r in record['debate_history'][1:])}"
)
if st.button(f"查看详情", key=f"view_{record['id']}", use_container_width=True):
st.session_state.debate_history = record["debate_history"]
st.session_state.decision_points = record["decision_points"]
st.rerun()
with st.sidebar:
st.markdown(
"""
<div style="text-align: center; padding: 20px 0; border-bottom: 1px solid #E2E8F0; margin-bottom: 24px;">
<div style="font-size: 48px; margin-bottom: 8px;">🤖</div>
<h2 style="margin: 0; color: #1E293B; font-size: 1.5rem;">多Agent决策工作坊</h2>
<p style="margin: 8px 0 0 0; color: #64748B; font-size: 0.875rem;">智能辅助决策系统</p>
</div>
""",
unsafe_allow_html=True,
)
st.markdown("---")
st.subheader("📋 方案描述")
with st.expander("🧩 一键插入结构化模板(推荐)", expanded=False):
st.markdown(
"""
如果你不知道怎么写方案建议用下面模板AI 输出会更稳定
- 目标要解决什么问题
- 目标用户谁用使用场景
- 核心功能3-5
- 约束条件时间/预算/技术/合规等
- 成功指标如何衡量
- 你最担心的风险可选
"""
)
if st.button("📌 插入模板到输入框", use_container_width=True):
st.session_state.topic = (
"【目标】\n"
"\n"
"【目标用户/场景】\n"
"\n"
"【核心功能3-5条\n"
"1. \n2. \n3. \n"
"\n"
"【约束条件】\n"
"- 时间:\n- 预算:\n- 技术:\n"
"\n"
"【成功指标】\n"
"\n"
"【已知风险/担忧(可选)】\n"
)
st.rerun()
topic = st.text_area(
"请输入需要评审的方案内容",
placeholder="请尽量结构化描述:目标 / 用户 / 核心功能 / 约束 / 指标...",
height=180,
help="建议提供至少50字的详细描述以获得更准确的分析结果",
)
if st.button("📝 使用示例方案", key="use_example"):
st.session_state.topic = show_example_topic()
st.rerun()
if "topic" in st.session_state:
topic = st.session_state.topic
st.text_area("方案内容", value=topic, height=180, key="topic_display")
st.markdown("---")
st.subheader("👥 参与角色")
role_descriptions = show_role_descriptions()
# 快捷评审模式(更像产品,演示更丝滑)
mode = st.radio(
"评审模式",
options=["快速2角色", "标准3角色", "全面5角色"],
index=1,
help="用于快速配置参与角色数量",
)
if mode.startswith("快速"):
default_roles = ["product_manager", "tech_expert"]
elif mode.startswith("全面"):
default_roles = list(role_descriptions.keys())
else:
default_roles = ["product_manager", "tech_expert", "user_representative"]
with st.expander("查看角色说明(可选)", expanded=False):
for role_key, role_info in role_descriptions.items():
st.markdown(f"**{role_info['icon']} {role_info['name']}**{role_info['focus']} ")
st.markdown(f"<span class='small-muted'>{role_info['description']}</span>", unsafe_allow_html=True)
st.markdown("---")
selected_agents = st.multiselect(
"选择参与评审的角色",
options=list(role_descriptions.keys()),
format_func=lambda x: f"{role_descriptions[x]['icon']} {role_descriptions[x]['name']}",
default=default_roles,
help="建议至少选择3-4个角色以获得全面的评估",
)
if selected_agents:
st.info(f"已选择 {len(selected_agents)} 个角色")
st.markdown("---")
st.subheader("🔄 评审轮次")
# 单轮版本:固定 1 轮,减少运行时间与 API 调用次数
debate_rounds = 1
est_low = max(1, len(selected_agents)) * 2
est_high = max(1, len(selected_agents)) * 4
st.markdown(
f"""
<div style=\"padding: 12px; background: #F1F5F9; border-radius: 8px; margin-top: 8px;\">
<div style=\"font-size: 0.875rem; color: #64748B; line-height: 1.6;\">
<strong>💡 提示</strong>
<br> 每个角色各输出一次观点
<br> 优点更快更省调用更适合课堂演示
<br><br>
<strong> 预计耗时</strong> {est_low} ~ {est_high} 与网络和模型负载有关
</div>
</div>
""",
unsafe_allow_html=True,
)
st.markdown("---")
errors, warnings = validate_input(topic, selected_agents)
if errors:
for error in errors:
st.error(error)
if warnings:
for warning in warnings:
st.warning(warning)
start_button = st.button(
"🚀 开始评审",
type="primary",
disabled=not topic or not selected_agents or len(errors) > 0,
use_container_width=True,
)
st.title("🤖 多Agent决策工作坊")
show_welcome_guide()
if "debate_manager" not in st.session_state:
st.session_state.debate_manager = DebateManager()
if "debate_history" not in st.session_state:
st.session_state.debate_history = []
if "decision_points" not in st.session_state:
st.session_state.decision_points = ""
if start_button:
st.session_state.debate_manager.reset()
show_progress_indicator(1, 4, "初始化评审环境")
with st.spinner("正在初始化评审环境..."):
for agent_name in selected_agents:
st.session_state.debate_manager.add_agent(agent_name)
show_progress_indicator(2, 4, "启动评审")
with st.spinner("正在进行评审..."):
debate_history = st.session_state.debate_manager.start_debate(topic, debate_rounds)
st.session_state.debate_history = debate_history
show_progress_indicator(3, 4, "生成决策要点")
with st.spinner("正在生成决策要点..."):
decision_points = st.session_state.debate_manager.generate_decision_points()
st.session_state.decision_points = decision_points
show_progress_indicator(4, 4, "完成")
save_debate_history(topic, selected_agents, debate_rounds, debate_history, decision_points)
st.success("✅ 评审完成!")
st.balloons()
if st.session_state.debate_history:
# 1) 先给“结论摘要”(首屏答案)
if st.session_state.decision_points:
st.subheader("✅ 一页结论摘要")
# 产品化渲染:优先用结构化展示;若解析失败,仍保留原 Markdown 兜底(不删除原功能)
try:
render_decision_summary(st.session_state.decision_points)
except Exception:
st.markdown(
f"""
<div style="padding: 20px; background: #F0F9FF; border: 1px solid #BAE6FD; border-radius: 12px; border-left: 4px solid #3B82F6;">
<div style="color: #1E293B; line-height: 1.75; white-space: pre-wrap;">{st.session_state.decision_points}</div>
</div>
""",
unsafe_allow_html=True,
)
col1, col2 = st.columns(2)
with col1:
st.download_button(
label="📄 下载结论Markdown",
data=st.session_state.decision_points,
file_name="decision_summary.md",
mime="text/markdown",
use_container_width=True,
)
with col2:
if st.button("🔄 重新开始", use_container_width=True):
st.session_state.debate_history = []
st.session_state.decision_points = ""
st.rerun()
st.markdown("---")
# 2) 再给简要统计
show_debate_summary(st.session_state.debate_history, st.session_state.decision_points)
# 3) 过程作为依据:默认折叠展示
with st.expander("查看评审过程(依据)", expanded=False):
show_debate_timeline(st.session_state.debate_history, role_descriptions)
st.markdown("---")
st.subheader("📊 详细辩论结果")
show_debate_comparison(st.session_state.debate_history, role_descriptions)
show_saved_debates(role_descriptions)
else:
show_empty_state()
st.markdown("---")
st.markdown(
"""
<div style="padding: 16px; background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 8px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 20px;">💡</span>
<strong style="color: #1E293B;">使用提示</strong>
</div>
<div style="color: #64748B; line-height: 1.6; font-size: 0.95rem;">
您可以随时调整方案内容与参与角色然后重新开始评审<br>
建议使用示例方案进行第一次测试以了解系统功能<br>
评审结果仅供参考最终决策请结合实际情况和专业判断
</div>
</div>
""",
unsafe_allow_html=True,
)

48
config.py Normal file
View File

@ -0,0 +1,48 @@
"""全局配置模块。
课程要求不要把 API Key 写死在代码里
- API Key 从环境变量读取推荐用 .env + python-dotenv
- 若缺失则给出清晰错误提示
"""
from __future__ import annotations
import logging
import os
from typing import Dict
logger = logging.getLogger(__name__)
# --- API 配置 ---
# API Endpoint 和模型 ID 仍从环境变量读取,或使用默认值
OPENAI_API_BASE: str = os.getenv("OPENAI_API_BASE", "https://api.deepseek.com/v1")
MODEL_ID: str = os.getenv("MODEL_ID", "deepseek-chat")
# --- 统一使用一个 API Key ---
# 从环境变量读取(建议用 .env + python-dotenv
# 兼容多种命名:优先 OPENAI_API_KEY其次 DEEPSEEK_API_KEY
API_KEY: str = os.getenv("OPENAI_API_KEY") or os.getenv("DEEPSEEK_API_KEY", "")
API_BASE: str = OPENAI_API_BASE
# 兼容旧代码:历史版本可能引用 ROLE_API_KEY_MAP
ROLE_API_KEY_MAP: Dict[str, str] = {"default": API_KEY}
# --- Agent 默认参数 ---
MAX_TOKENS: int = 1000
TEMPERATURE: float = 0.7
MAX_RETRIES: int = 3
RETRY_INITIAL_DELAY: float = 1.5 # 秒
RETRY_BACKOFF_FACTOR: float = 2.0
RETRY_MAX_DELAY: float = 10.0
# --- 角色系统提示 ---
AGENT_ROLES: Dict[str, str] = {
"product_manager": "你是一位经验丰富的产品经理,擅长从用户需求和市场角度分析方案,关注产品的价值和可行性。",
"tech_expert": "你是一位资深技术专家,擅长从技术实现角度分析方案,关注架构设计、技术风险和性能优化。",
"user_representative": "你是一位典型的终端用户,擅长从实际使用角度分析方案,关注用户体验、易用性和实用性。",
"business_analyst": "你是一位专业的商业分析师,擅长从商业价值角度分析方案,关注成本效益、投资回报率和市场竞争力。",
"designer": "你是一位优秀的设计师,擅长从设计角度分析方案,关注视觉效果、交互设计和用户体验。",
}
# --- 为兼容旧代码,保持部分常量导出 ---
API_BASE: str = OPENAI_API_BASE

11
env.example Normal file
View File

@ -0,0 +1,11 @@
# 将本文件复制为 .env 后,填入你的 API Key
# -------------------------------------------------
# 推荐填法:仅保留一个兼容字段,使用 uv sync / uv run 时自动读取
OPENAI_API_KEY=sk-xxxxxx # 或者删除此行,改用 DEEPSEEK_API_KEY
# DEEPSEEK_API_KEY=sk-xxxxxx
# ---- 以下配置如无特殊需要,请保持默认 ----
# OPENAI_API_BASE=https://api.deepseek.com/v1
# MODEL_ID=deepseek-chat

15
pyproject.toml Normal file
View File

@ -0,0 +1,15 @@
[project]
name = "multi-agent-workshop"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"chainlit>=2.9.4",
"litellm>=1.80.11",
"openai>=2.14.0",
"pydantic>=2.12.5",
"pydantic-ai>=1.39.1",
"python-dotenv>=1.2.1",
"streamlit>=1.52.2",
]

594
style.css Normal file
View File

@ -0,0 +1,594 @@
/* UI restyle: 现代化设计 - 蓝色系主色调 + 卡片式布局 */
:root{
--bg: #F8FAFC;
--primary: #3B82F6;
--primary-dark: #2563EB;
--primary-light: #60A5FA;
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-muted: #94A3B8;
--surface: #FFFFFF;
--surface-hover: #F1F5F9;
--border: #E2E8F0;
--border-light: #F1F5F9;
--success: #10B981;
--warning: #F59E0B;
--error: #EF4444;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
}
/* Page background */
[data-testid="stAppViewContainer"] {
background-color: var(--surface) !important;
background-image: none !important;
}
[data-testid="stMain"] {
background-color: var(--surface) !important;
}
[data-testid="stHeader"] {
background-color: var(--surface) !important;
border-bottom: 1px solid var(--border) !important;
}
[data-testid="stToolbar"] {
background-color: transparent !important;
}
[data-testid="stAppViewBlockContainer"] {
padding: 0 !important;
}
[data-testid="stMarkdownContainer"] {
color: var(--text-primary) !important;
}
/* Sidebar styling */
[data-testid="stSidebar"] {
background-color: var(--surface) !important;
border-right: 1px solid var(--border) !important;
padding: 12px !important;
box-shadow: var(--shadow-sm) !important;
}
[data-testid="stSidebar"] > div:first-child {
gap: 12px !important;
}
/* Markdown list styling - reduce spacing between list items */
[data-testid="stMarkdownContainer"] ul,
[data-testid="stMarkdownContainer"] ol {
margin-bottom: 12px !important;
}
[data-testid="stMarkdownContainer"] li {
margin-bottom: 4px !important; /* Reduced from default spacing */
line-height: 1.3 !important; /* Ensure good readability */
}
/* Body and text styling - MODIFIED FOR READABILITY */
body, .stApp, .reportview-container {
background-color: transparent !important;
color: var(--text-primary) !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft Yahei", sans-serif !important;
line-height: 1.35 !important; /* MODIFIED: Reduced for denser content */
}
/* Headings - MODIFIED FOR HIERARCHY */
h1, h2, h3, h4, .stMarkdown h1, .stMarkdown h2, .stMarkdown h3 {
color: var(--text-primary) !important;
font-weight: 700 !important;
margin-top: 0.7em !important;
margin-bottom: 0.35em !important;
line-height: 1.2 !important;
}
h1, .stMarkdown h1 { font-size: 2rem !important; letter-spacing: -0.02em; }
h2, .stMarkdown h2 { font-size: 1.5rem !important; letter-spacing: -0.01em; }
h3, .stMarkdown h3 { font-size: 1.25rem !important; }
/* Links */
a, .stMarkdown a {
color: var(--primary) !important;
text-decoration: none !important;
font-weight: 500 !important;
}
a:hover, .stMarkdown a:hover {
color: var(--primary-dark) !important;
text-decoration: underline !important;
}
/* Buttons */
button, .stButton>button {
background-color: var(--primary) !important;
color: #fff !important;
border: none !important;
padding: 10px 20px !important;
border-radius: var(--radius-md) !important;
font-weight: 600 !important;
font-size: 0.95rem !important;
box-shadow: var(--shadow-sm) !important;
transition: all 0.2s ease !important;
}
button:hover, .stButton>button:hover {
background-color: var(--primary-dark) !important;
box-shadow: var(--shadow-md) !important;
transform: translateY(-1px) !important;
}
button:active, .stButton>button:active {
transform: translateY(0) !important;
}
button[disabled], .stButton>button[disabled] {
background-color: var(--text-muted) !important;
cursor: not-allowed !important;
transform: none !important;
box-shadow: none !important;
}
/* Secondary button */
.stButton>button[kind="secondary"] {
background-color: transparent !important;
color: var(--primary) !important;
border: 1px solid var(--border) !important;
}
.stButton>button[kind="secondary"]:hover {
background-color: var(--surface-hover) !important;
}
/* Primary button emphasis */
.stButton>button[type="primary"] {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%) !important;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
}
.stButton>button[type="primary"]:hover {
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4) !important;
}
/* Inputs and textareas */
input, textarea, .stTextArea textarea, .stTextInput input {
border: 1px solid var(--border) !important;
background: var(--surface) !important;
color: var(--text-primary) !important;
border-radius: var(--radius-md) !important;
padding: 10px 14px !important;
font-size: 0.95rem !important;
transition: all 0.2s ease !important;
}
input:focus, textarea:focus, .stTextArea textarea:focus, .stTextInput input:focus {
border-color: var(--primary) !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
outline: none !important;
}
input::placeholder, textarea::placeholder {
color: var(--text-muted) !important;
}
/* Textarea specific */
.stTextArea textarea {
min-height: 180px !important;
resize: vertical !important;
line-height: 1.35 !important;
}
/* Multi-select styling - complete overhaul */
.stMultiSelect {
background: var(--surface) !important;
border-radius: var(--radius-md) !important;
margin-bottom: 12px !important;
}
/* Base select container */
.stMultiSelect [data-baseweb="select"] {
background-color: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius-md) !important;
padding: 8px 12px !important;
color: var(--text-primary) !important;
box-shadow: none !important;
background-image: none !important;
}
/* Select container hover */
.stMultiSelect [data-baseweb="select"]:hover {
border-color: var(--primary-light) !important;
background-color: var(--surface) !important;
}
/* Select container focus */
.stMultiSelect [data-baseweb="select"]:focus-within {
border-color: var(--primary) !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
}
/* Select container text */
.stMultiSelect [data-baseweb="select"] > div,
.stMultiSelect [data-baseweb="select"] > span,
.stMultiSelect [data-baseweb="select"] input {
color: var(--text-primary) !important;
font-size: 0.9rem !important;
background-color: transparent !important;
}
/* Tags container */
.stMultiSelect [data-baseweb="select"] [data-baseweb="tags"] {
background-color: transparent !important;
}
/* Individual tag */
.stMultiSelect [data-baseweb="tag"] {
background-color: rgba(59, 130, 246, 0.15) !important;
color: var(--primary-dark) !important;
border: 1px solid rgba(59, 130, 246, 0.25) !important;
border-radius: var(--radius-sm) !important;
padding: 3px 8px !important;
font-size: 0.85rem !important;
font-weight: 500 !important;
margin: 2px !important;
box-shadow: none !important;
}
/* Tag close button */
.stMultiSelect [data-baseweb="tag"] svg {
color: var(--primary) !important;
width: 14px !important;
height: 14px !important;
}
/* Tag close button hover */
.stMultiSelect [data-baseweb="tag"]:hover svg {
color: var(--primary-dark) !important;
}
/* Menu dropdown */
.stMultiSelect [data-baseweb="menu"] {
background-color: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-lg) !important;
}
/* Menu options */
.stMultiSelect [data-baseweb="menu"] [role="option"] {
color: var(--text-primary) !important;
background-color: var(--surface) !important;
padding: 8px 12px !important;
font-size: 0.9rem !important;
}
/* Menu option hover */
.stMultiSelect [data-baseweb="menu"] [role="option"]:hover {
background-color: var(--surface-hover) !important;
}
/* Menu option selected */
.stMultiSelect [data-baseweb="menu"] [role="option"][aria-selected="true"] {
background-color: rgba(59, 130, 246, 0.1) !important;
border-left: 3px solid var(--primary) !important;
}
/* Clear selection button */
.stMultiSelect [data-baseweb="select"] [data-baseweb="clear"] {
color: var(--text-primary) !important;
}
/* Dropdown arrow */
.stMultiSelect [data-baseweb="select"] [data-baseweb="dropdown"] {
color: var(--text-primary) !important;
}
/* Override any remaining default styles */
.stMultiSelect [data-baseweb="select"] * {
background-color: transparent !important;
color: var(--text-primary) !important;
}
/* Force background color for all select elements */
.css-1n543e5 {
background-color: var(--surface) !important;
color: var(--text-primary) !important;
}
.css-12jo7m5 {
background-color: var(--surface) !important;
color: var(--text-primary) !important;
}
.css-1jux5v5 {
background-color: var(--surface) !important;
color: var(--text-primary) !important;
}
/* Slider styling */
.stSlider [data-baseweb="slider"] {
background-color: var(--border-light) !important;
height: 6px !important;
border-radius: 3px !important;
}
.stSlider [data-baseweb="slider"] .css-1v7bxtl {
background-color: var(--primary) !important;
height: 6px !important;
border-radius: 3px !important;
}
.stSlider [data-baseweb="thumb"] {
background-color: var(--surface) !important;
border: 2px solid var(--primary) !important;
box-shadow: var(--shadow-md) !important;
width: 20px !important;
height: 20px !important;
}
.stSlider [data-baseweb="thumb"]:hover {
background-color: var(--primary) !important;
}
/* Expander panels */
.streamlit-expanderHeader, .stExpander, .st-expander {
background-color: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius-md) !important;
padding: 16px 20px !important;
margin-bottom: 12px !important;
box-shadow: var(--shadow-sm) !important;
}
.streamlit-expanderHeader:hover, .stExpander:hover {
background-color: var(--surface-hover) !important;
border-color: var(--primary-light) !important;
}
.stExpander .stExpanderContent {
padding: 16px 20px !important;
border-top: 1px solid var(--border-light) !important;
}
/* Block container */
.block-container {
padding: 32px !important;
max-width: 1400px !important;
}
/* Card styling */
.stCard, .stAlert, .stError, .stWarning, .stSuccess, .stInfo {
background-color: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-sm) !important;
padding: 20px !important;
margin-bottom: 16px !important;
}
.stSuccess {
border-left: 4px solid var(--success) !important;
}
.stWarning {
border-left: 4px solid var(--warning) !important;
}
.stError {
border-left: 4px solid var(--error) !important;
}
.stInfo {
border-left: 4px solid var(--primary) !important;
}
/* Checkbox and radio button */
.stCheckbox [data-baseweb="checkbox"] div,
.stRadio [data-baseweb="radio"] div {
border-color: var(--border) !important;
background-color: var(--surface) !important;
}
.stCheckbox [data-baseweb="checkbox"]:hover div,
.stRadio [data-baseweb="radio"]:hover div {
border-color: var(--primary-light) !important;
}
.stCheckbox [data-baseweb="checkbox"]:checked div,
.stRadio [data-baseweb="radio"]:checked div {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
}
/* Progress bar */
.stProgress .progress-bar {
background-color: var(--primary) !important;
border-radius: var(--radius-sm) !important;
}
/* Horizontal rule */
hr {
border: 0;
border-top: 1px solid var(--border) !important;
margin: 16px 0 !important;
}
/* Info boxes and tips */
.stInfo, .stSuccess, .stWarning, .stError {
font-size: 0.95rem !important;
line-height: 1.35 !important;
}
/* Sidebar specific improvements */
[data-testid="stSidebar"] .stTextArea,
[data-testid="stSidebar"] .stMultiSelect,
[data-testid="stSidebar"] .stSlider {
margin-bottom: 12px !important;
}
[data-testid="stSidebar"] h3 {
font-size: 1rem !important;
color: var(--text-primary) !important;
margin-bottom: 8px !important;
font-weight: 600 !important;
margin-top: 4px !important;
}
[data-testid="stSidebar"] hr {
margin: 12px 0 !important;
}
/* Disable elements */
.stButton>button[disabled],
.stTextInput input[disabled],
.stTextArea textarea[disabled] {
background-color: var(--surface-hover) !important;
color: var(--text-muted) !important;
cursor: not-allowed !important;
}
/* Smooth transitions */
* {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
/* Remove unwanted shadows */
.css-ffhzg2, .css-12oz5g7 {
box-shadow: none !important;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--surface-hover);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Empty state styling */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
/* Loading animation */
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-spinner {
animation: spin 1s linear infinite;
}
/* Badge styling */
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-primary {
background-color: rgba(59, 130, 246, 0.1);
color: var(--primary);
}
.badge-success {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success);
}
.badge-warning {
background-color: rgba(245, 158, 11, 0.1);
color: var(--warning);
}
/* Tooltip hint */
.hint-text {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 8px;
line-height: 1.5;
}
/* Step indicator */
.step-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.step-number {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
/* Stats card */
.stats-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-sm);
}
.stats-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary);
margin: 8px 0;
}
.stats-label {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
.small-muted {
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.35;
}

0
ui/__init__.py Normal file
View File

View File

147
ui/rendering.py Normal file
View File

@ -0,0 +1,147 @@
"""用于把模型生成的 Markdown 摘要做轻量解析并用更产品化的方式展示。
说明不引入复杂依赖不要求模型输出 JSON
通过解析固定标题
- # 一句话结论
- # 关键决策要点Top 5
- # 主要风险Top 3
- # 下一步行动清单
- # 需要进一步澄清的问题
另外提供
- render_opinion_preview观点一句话预览
- summarize_round_opinions把一轮的多角色观点压缩为可扫读摘要用于时间线
"""
from __future__ import annotations
import re
import streamlit as st
SECTION_TITLES = [
"一句话结论",
"关键决策要点Top 5",
"主要风险Top 3",
"下一步行动清单",
"需要进一步澄清的问题",
]
def _split_sections(md: str) -> dict[str, str]:
if not md:
return {}
pattern = r"^#\s+(.*)$"
lines = md.splitlines()
sections: dict[str, list[str]] = {}
current = None
for line in lines:
m = re.match(pattern, line.strip())
if m:
title = m.group(1).strip()
current = title
sections.setdefault(current, [])
continue
if current is not None:
sections[current].append(line)
return {k: "\n".join(v).strip() for k, v in sections.items()}
def _extract_decision_badge(one_liner: str) -> tuple[str, str]:
t = (one_liner or "").strip()
if any(k in t for k in ["不建议", "否决", "不推荐"]):
return ("不建议", "error")
if any(k in t for k in ["谨慎", "有条件", "需要修改"]):
return ("谨慎推进", "warning")
if any(k in t for k in ["推荐", "可行", "建议推进"]):
return ("推荐", "success")
return ("结论", "info")
def render_decision_summary(md: str) -> None:
"""以更产品化的方式渲染结论摘要。"""
sections = _split_sections(md)
one_liner = sections.get("一句话结论", "").strip()
badge_text, badge_kind = _extract_decision_badge(one_liner)
col1, col2 = st.columns([1, 5])
with col1:
if badge_kind == "success":
st.success(badge_text)
elif badge_kind == "warning":
st.warning(badge_text)
elif badge_kind == "error":
st.error(badge_text)
else:
st.info(badge_text)
with col2:
if one_liner:
st.markdown(f"**{one_liner}**")
else:
st.markdown("**(未识别到一句话结论,请检查模型输出格式)**")
st.markdown("---")
c1, c2 = st.columns(2)
with c1:
st.subheader("🎯 关键决策要点")
content = sections.get("关键决策要点Top 5", "").strip()
st.markdown(content if content else "(暂无)")
st.subheader("✅ 下一步行动")
actions = sections.get("下一步行动清单", "").strip()
items = [
re.sub(r"^-\s*\[.\]\s*", "", l).strip()
for l in actions.splitlines()
if l.strip().startswith("-")
]
if items:
for i, it in enumerate(items):
st.checkbox(it, value=False, key=f"action_{i}")
else:
st.markdown(actions if actions else "(暂无)")
with c2:
st.subheader("⚠️ 主要风险")
risks = sections.get("主要风险Top 3", "").strip()
st.markdown(risks if risks else "(暂无)")
st.subheader("❓ 需要澄清")
qs = sections.get("需要进一步澄清的问题", "").strip()
st.markdown(qs if qs else "(暂无)")
def render_opinion_preview(opinion: str, limit: int = 160) -> str:
"""返回用于预览的短文本。"""
if not opinion:
return ""
t = opinion.strip().replace("\n", " ")
if len(t) <= limit:
return t
return t[:limit].rstrip() + "..."
def summarize_round_opinions(opinions: dict, role_descriptions: dict, limit_per_role: int = 60) -> str:
"""把一轮 opinions 生成适合扫读的摘要(不调用模型,避免额外成本与不稳定)。
输出格式示例
- 📊 产品经理xxx...
- 💻 技术专家xxx...
"""
if not opinions:
return ""
lines: list[str] = []
for role, text in opinions.items():
info = role_descriptions.get(role, {"icon": "👤", "name": role})
preview = render_opinion_preview(str(text or ""), limit=limit_per_role)
if not preview:
preview = "(本轮未生成/生成失败)"
lines.append(f"- {info['icon']} {info['name']}{preview}")
return "\n".join(lines)

0
ui/sections/__init__.py Normal file
View File

287
ui/ui_components.py Normal file
View File

@ -0,0 +1,287 @@
"""UI 组件:将 app.py 中大量 UI 渲染函数集中到这里,减少主入口文件体积。
说明
- 为了不大改原有逻辑本文件基本搬运并小幅整理原 app.py 的函数
- HTML/CSS 仍有少量内联Streamlit 常见但我们会把大段模板集中在这里避免 app.py 过长
"""
from __future__ import annotations
import streamlit as st
from ui.rendering import render_opinion_preview, summarize_round_opinions
def local_css(file_name: str) -> None:
"""将本地 CSS 注入 Streamlit 页面"""
try:
with open(file_name, encoding="utf-8") as f:
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
except FileNotFoundError:
st.warning("未找到样式文件 style.css ,页面将使用默认样式。")
except UnicodeDecodeError:
st.warning("样式文件编码错误,页面将使用默认样式。")
def show_welcome_guide() -> None:
"""显示新手引导(精简版:更适合展示,不喧宾夺主)"""
with st.expander("📖 使用速览30秒上手", expanded=False):
st.markdown(
"""
- **在左侧输入方案**尽量包含目标/用户/约束/关键指标至少 50
- **选择评审角色**建议 3-4 个角色观点更全面
- **评审轮次**当前固定为 **1 **每个角色各输出一次观点
- **点击开始评审**首屏会给出**一页可执行结论**结论/风险/行动清单/需澄清问题
> 提示过程是依据结论摘要是答案
"""
)
def show_role_descriptions() -> dict:
"""返回角色详细说明UI 用)"""
return {
"product_manager": {
"icon": "📊",
"name": "产品经理",
"focus": "用户需求、市场定位、产品价值",
"description": "擅长从用户需求和市场角度分析方案,关注产品的价值和可行性。",
},
"tech_expert": {
"icon": "💻",
"name": "技术专家",
"focus": "架构设计、技术风险、性能优化",
"description": "擅长从技术实现角度分析方案,关注架构设计、技术风险和性能优化。",
},
"user_representative": {
"icon": "👤",
"name": "用户代表",
"focus": "用户体验、易用性、实用性",
"description": "擅长从实际使用角度分析方案,关注用户体验、易用性和实用性。",
},
"business_analyst": {
"icon": "💰",
"name": "商业分析师",
"focus": "成本效益、投资回报、市场竞争力",
"description": "擅长从商业价值角度分析方案,关注成本效益、投资回报率和市场竞争力。",
},
"designer": {
"icon": "🎨",
"name": "设计师",
"focus": "视觉效果、交互设计、用户体验",
"description": "擅长从设计角度分析方案,关注视觉效果、交互设计和用户体验。",
},
}
def show_example_topic() -> str:
return """我们计划开发一个AI辅助学习平台主要功能包括
1. **智能答疑系统**基于大语言模型能够回答学生在学习过程中的各种问题
2. **个性化学习路径**根据学生的学习进度和能力自动推荐合适的学习内容和练习
3. **学习数据分析**收集和分析学生的学习数据生成学习报告和改进建议
4. **互动练习模块**提供丰富的练习题和模拟考试支持实时反馈和错题本功能
目标用户高中生和大学生
技术栈Python + React + MongoDB + OpenAI API
预期效果提高学习效率 30%用户满意度达到 4.5/5.0"""
def validate_input(topic: str, selected_agents: list[str]):
"""验证用户输入"""
errors: list[str] = []
warnings: list[str] = []
if not topic or len(topic.strip()) < 50:
errors.append("方案描述太短请提供更详细的信息至少50字")
if not selected_agents or len(selected_agents) < 2:
errors.append("请至少选择 2 个参与角色以获得全面的评估")
if topic and len(topic) > 5000:
warnings.append("方案描述较长,可能会影响响应速度")
return errors, warnings
def show_progress_indicator(current_step: int, total_steps: int, step_name: str) -> None:
progress_percent = (current_step / total_steps) * 100
st.markdown(
f"""
<div style="padding: 16px; background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 8px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<span style="font-weight: 600; color: #1E293B;">{step_name}</span>
<span style="font-size: 0.875rem; color: #64748B;">{current_step}/{total_steps}</span>
</div>
<div style="width: 100%; height: 6px; background: #E2E8F0; border-radius: 3px; overflow: hidden;">
<div style="width: {progress_percent}%; height: 100%; background: linear-gradient(90deg, #3B82F6, #2563EB); border-radius: 3px; transition: width 0.3s ease;"></div>
</div>
</div>
""",
unsafe_allow_html=True,
)
def show_empty_state() -> None:
st.markdown(
"""
<div style="text-align: center; padding: 80px 20px; color: #94A3B8;">
<div style="font-size: 64px; margin-bottom: 16px;">🤖</div>
<h3 style="color: #1E293B; margin-bottom: 12px;">准备开始评审</h3>
<p style="font-size: 1.1rem; line-height: 1.6; max-width: 600px; margin: 0 auto;">
在左侧配置您的方案并选择参与角色然后点击"开始评审"按钮启动智能分析
</p>
</div>
""",
unsafe_allow_html=True,
)
def show_debate_summary(debate_history: list[dict], decision_points) -> None:
if not debate_history:
return
total_rounds = len(debate_history) - 1
total_opinions = sum(len(round_data.get("opinions", {})) for round_data in debate_history[1:])
col1, col2, col3 = st.columns(3)
with col1:
st.markdown(
f"""
<div class="stats-card">
<div class="stats-label">评审轮次</div>
<div class="stats-value">{total_rounds}</div>
</div>
""",
unsafe_allow_html=True,
)
with col2:
st.markdown(
f"""
<div class="stats-card">
<div class="stats-label">参与观点</div>
<div class="stats-value">{total_opinions}</div>
</div>
""",
unsafe_allow_html=True,
)
with col3:
st.markdown(
f"""
<div class="stats-card">
<div class="stats-label">决策要点</div>
<div class="stats-value">{len(decision_points) if decision_points else 0}</div>
</div>
""",
unsafe_allow_html=True,
)
def show_debate_timeline(debate_history: list[dict], role_descriptions: dict) -> None:
if not debate_history:
return
st.markdown("---")
st.subheader("📈 评审时间线")
for idx, round_data in enumerate(debate_history[1:], 1):
round_type_map = {"initial_opinions": "初始观点", "interactive_debate": "互动讨论"}
round_type = round_type_map.get(round_data["type"], round_data["type"])
# 新增:本轮摘要(可扫读),不删除原信息
round_summary = summarize_round_opinions(round_data.get("opinions", {}), role_descriptions)
with st.container():
st.markdown(
f"""
<div style="padding: 16px; background: #F8FAFC; border-left: 4px solid #3B82F6; border-radius: 0 8px 8px 0; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<div style="width: 32px; height: 32px; background: #3B82F6; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem;">{idx}</div>
<div>
<div style="font-weight: 600; color: #1E293B; font-size: 1.1rem;">{round_data['round']}{round_type}</div>
<div style="font-size: 0.875rem; color: #64748B; margin-top: 2px;">{len(round_data.get('opinions', {}))} 个角色参与讨论</div>
</div>
</div>
""",
unsafe_allow_html=True,
)
# 摘要展示
if round_summary:
st.markdown("**本轮摘要:**")
st.markdown(round_summary)
# 保留原有参与角色标签(不删)
st.markdown('<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px;">', unsafe_allow_html=True)
for role in round_data.get("opinions", {}).keys():
role_info = role_descriptions.get(role, {"icon": "👤", "name": role})
st.markdown(
f"""
<div style="padding: 6px 12px; background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 6px; font-size: 0.875rem; color: #64748B;">
{role_info['icon']} {role_info['name']}
</div>
""",
unsafe_allow_html=True,
)
st.markdown("</div></div>", unsafe_allow_html=True)
def show_debate_comparison(debate_history: list[dict], role_descriptions: dict) -> None:
if not debate_history or len(debate_history) < 2:
return
st.markdown("---")
st.subheader("🔍 角色观点对比")
st.markdown(f"**共 {len(debate_history) - 1} 轮评审**")
for round_num, round_data in enumerate(debate_history[1:], 1):
round_type_map = {"initial_opinions": "初始观点", "interactive_debate": "互动讨论"}
round_type = round_type_map.get(round_data["type"], round_data["type"])
st.subheader(f"🔄 第{round_num}轮:{round_type}")
st.markdown(f"**本轮参与角色:{len(round_data['opinions'])}个**")
for role, opinion in round_data["opinions"].items():
role_info = role_descriptions.get(role, {"icon": "👤", "name": role})
preview = render_opinion_preview(opinion or "")
title = f"{role_info['icon']} {role_info['name']}"
if preview:
title = f"{title}{preview}"
with st.expander(title, expanded=False):
if opinion:
st.markdown(
f"""
<div style="padding: 4px 8px; background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 6px;">
<div style="color: #1E293B; line-height: 1.6; white-space: pre-wrap;">{opinion}</div>
</div>
""",
unsafe_allow_html=True,
)
else:
st.warning(f"{role_info['name']} 在本轮没有生成观点")
st.markdown("---")
st.subheader("📊 最终观点对比")
final_round = debate_history[-1]
for role, opinion in final_round["opinions"].items():
role_info = role_descriptions.get(role, {"icon": "👤", "name": role})
with st.container():
st.markdown(
f"""
<div style="padding: 8px; background: #FFFFFF; border-left: 4px solid #3B82F6; border-radius: 0 8px 8px 0; margin-bottom: 8px;">
<div style="display: flex; align-items: center; margin-bottom: 2px;">
<span style="font-size: 20px; margin-right: 8px;">{role_info['icon']}</span>
<strong style="color: #1E293B; font-size: 1.1rem;">{role_info['name']}</strong>
</div>
<div style="color: #1E293B; line-height: 1.6; white-space: pre-wrap;">{opinion}</div>
</div>
""",
unsafe_allow_html=True,
)

0
utils/__init__.py Normal file
View File

55
utils/retry.py Normal file
View File

@ -0,0 +1,55 @@
"""简单的重试工具:指数退避 + 抖动。
课程设计场景不引入额外依赖 tenacity保持轻量
"""
from __future__ import annotations
import random
import time
from typing import Callable, TypeVar, Tuple
T = TypeVar("T")
def retry(
*,
max_retries: int = 3,
initial_delay: float = 1.0,
backoff_factor: float = 2.0,
max_delay: float = 10.0,
jitter: float = 0.2,
retry_exceptions: Tuple[type[BaseException], ...] = (Exception,),
):
"""重试装饰器。
- 第一次失败后等待 initial_delay
- 之后按照 backoff_factor 指数增长
- 加一点 jitter 防止固定间隔
"""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
def wrapper(*args, **kwargs) -> T:
delay = initial_delay
last_exc: BaseException | None = None
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except retry_exceptions as exc: # noqa: PERF203
last_exc = exc
if attempt >= max_retries:
raise
# jitterdelay*(1±jitter)
factor = 1.0 + random.uniform(-jitter, jitter)
sleep_s = min(max_delay, max(0.0, delay * factor))
time.sleep(sleep_s)
delay = min(max_delay, delay * backoff_factor)
# 理论上不会走到这里
assert last_exc is not None
raise last_exc
return wrapper
return decorator

4622
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff