上传multi_agent项目文件到Harry/hyx仓库
This commit is contained in:
commit
375da859e8
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
96
README.md
Normal 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
112
REFLECTION.md
Normal 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
231
agent.py
Normal 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
350
app.py
Normal 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
48
config.py
Normal 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
11
env.example
Normal 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
15
pyproject.toml
Normal 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
594
style.css
Normal 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
0
ui/__init__.py
Normal file
0
ui/components/__init__.py
Normal file
0
ui/components/__init__.py
Normal file
147
ui/rendering.py
Normal file
147
ui/rendering.py
Normal 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
0
ui/sections/__init__.py
Normal file
287
ui/ui_components.py
Normal file
287
ui/ui_components.py
Normal 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
0
utils/__init__.py
Normal file
55
utils/retry.py
Normal file
55
utils/retry.py
Normal 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
|
||||
|
||||
# jitter:delay*(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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user