hyx/agent.py

232 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)