Files
archived-MoviePilot/app/agent/tools/impl/prepare_feedback_issue.py

286 lines
12 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.
"""生成反馈 Issue 预览并要求用户按钮确认。"""
from __future__ import annotations
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool, ToolChain
from app.agent.tools.impl.feedback_issue_state import (
FEEDBACK_CONFIRM_VALUE_PREFIX,
build_feedback_draft_hash,
feedback_issue_state_store,
)
from app.agent.tools.impl.submit_feedback_issue import (
ALLOWED_ENVIRONMENTS,
ALLOWED_ISSUE_TYPES,
MAX_TITLE_CHARS,
SubmitFeedbackIssueTool,
)
from app.helper.interaction import AgentInteractionOption, agent_interaction_manager
from app.log import logger
from app.schemas import Notification, NotificationType
from app.schemas.message import ChannelCapabilityManager
from app.schemas.types import MessageChannel
class PrepareFeedbackIssueInput(BaseModel):
"""反馈 Issue 预览确认工具输入。"""
explanation: str = Field(
...,
description="Clear explanation of why a feedback issue preview is being prepared",
)
title: str = Field(..., description="Issue title following `[错误报告]: <短描述>`")
version: str = Field(..., description="Current MoviePilot version")
environment: str = Field(..., description="Exactly Docker or Windows")
issue_type: str = Field(..., description="主程序运行问题 / 插件问题 / 其他问题")
description: str = Field(..., description="Structured issue description")
original_user_request: str = Field(..., description="Verbatim original user request")
diagnostics_id: str = Field(
...,
description=(
"diagnostics_id returned by collect_feedback_diagnostics. Logs are loaded from "
"the server-side state store via this id — do NOT pass the log text itself."
),
)
class PrepareFeedbackIssueTool(MoviePilotTool):
"""发送 Issue 草稿预览,并创建只能由按钮回调确认的 token。"""
name: str = "prepare_feedback_issue"
sends_message: bool = True
description: str = (
"Prepare a feedback issue preview and ask the user to confirm via buttons. "
"Must be called after collect_feedback_diagnostics and before submit_feedback_issue. "
"Returns a confirmation_token, but submit_feedback_issue will only accept it after "
"the user actually clicks the confirmation button."
)
args_schema: Type[BaseModel] = PrepareFeedbackIssueInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
"""侧边消息:告知用户正在生成提交预览。"""
return "生成问题反馈预览并等待确认"
@staticmethod
def _truncate_button_text(text: str, max_length: int) -> str:
"""按渠道限制裁剪按钮文案。"""
if max_length <= 0 or len(text) <= max_length:
return text
if max_length <= 3:
return text[:max_length]
return text[: max_length - 3] + "..."
@staticmethod
def _result_payload(**fields) -> str:
"""统一 JSON 返回,便于 Agent 按字段继续下一步。"""
return json.dumps(fields, ensure_ascii=False, indent=2)
async def run(
self,
title: str,
version: str,
environment: str,
issue_type: str,
description: str,
original_user_request: str,
diagnostics_id: str,
**kwargs,
) -> str:
"""校验草稿、发送预览按钮,并缓存待确认 token。"""
if not self._channel or not self._source:
return self._result_payload(
success=False,
reason="no_channel",
message="当前不在可回传消息的会话中,无法发送 Issue 预览确认按钮。",
)
try:
channel = MessageChannel(self._channel)
except ValueError:
return self._result_payload(
success=False,
reason="unsupported_channel",
message=f"不支持的消息渠道: {self._channel}",
)
if not (
ChannelCapabilityManager.supports_buttons(channel)
and ChannelCapabilityManager.supports_callbacks(channel)
):
return self._result_payload(
success=False,
reason="buttons_unsupported",
message=f"当前渠道 {channel.value} 不支持按钮确认,不能自动提交反馈 Issue。",
)
diagnostics = feedback_issue_state_store.get_diagnostics(
diagnostics_id,
session_id=self._session_id,
user_id=self._user_id,
)
if not diagnostics:
return self._result_payload(
success=False,
reason="diagnostics_missing",
message="缺少有效的诊断日志收集记录,请先调用 collect_feedback_diagnostics。",
)
# 日志全程只从服务端 state store 流转,避免日志在 LLM 上下文里反复
# 进出造成响应延迟(见 collect_feedback_diagnostics 中的设计注释)。
logs = diagnostics.logs
for value, allowed, field_name in (
(environment, ALLOWED_ENVIRONMENTS, "environment"),
(issue_type, ALLOWED_ISSUE_TYPES, "issue_type"),
):
err = SubmitFeedbackIssueTool._validate_enum(value, allowed, field_name)
if err:
return self._result_payload(success=False, reason="invalid_input", message=err)
title = SubmitFeedbackIssueTool._truncate(title, MAX_TITLE_CHARS, marker="")
quality_err = SubmitFeedbackIssueTool._check_content_quality(
title=title,
description=description,
original_user_request=original_user_request,
)
if quality_err:
self._agent_context["feedback_issue_rejected_quality"] = True
self._agent_context["feedback_issue_rejected_quality_reason"] = quality_err
return self._result_payload(
success=False,
reason="rejected_quality",
message=quality_err,
)
draft_hash = build_feedback_draft_hash(
title=title,
version=version,
environment=environment,
issue_type=issue_type,
description=description,
original_user_request=original_user_request,
logs=logs,
diagnostics_id=diagnostics_id,
)
# 同会话/用户已经发过预览且尚未被用户点击确认:拒绝重复发预览。
# Why: Issue #5806 实测中 agent 在一次用户输入里连续调用了两次
# prepare_feedback_issue导致 TG 里出现两份「确认提交」按钮,用户
# 点击两次后才进入提交。这里直接挡住重复预览:草稿一致就复用旧
# token草稿变了则要求 Agent 自己撤销旧 token 再发新预览(以免
# 残留按钮指向过期内容)。
active = feedback_issue_state_store.find_active_confirmation(
session_id=self._session_id,
user_id=self._user_id,
)
if active is not None:
if active.draft_hash == draft_hash:
logger.info(
"feedback issue preview deduped: session_id=%s reuse token=%s",
self._session_id,
active.confirmation_token[:8],
)
self._agent_context["user_reply_sent"] = True
self._agent_context["reply_mode"] = "feedback_issue_confirmation"
return self._result_payload(
success=True,
deduped=True,
confirmation_token=active.confirmation_token,
diagnostics_id=diagnostics_id,
message=(
"上一份相同内容的反馈预览仍在等待用户点击确认,"
"未重复发送按钮。请勿再次调用 prepare_feedback_issue。"
),
)
logger.info(
"feedback issue preview superseded: session_id=%s drop_token=%s",
self._session_id,
active.confirmation_token[:8],
)
feedback_issue_state_store.invalidate_active_confirmations(
session_id=self._session_id,
user_id=self._user_id,
)
confirmation = feedback_issue_state_store.create_confirmation(
session_id=self._session_id,
user_id=self._user_id,
username=self._username,
draft_hash=draft_hash,
diagnostics_id=diagnostics_id,
)
option_value = f"{FEEDBACK_CONFIRM_VALUE_PREFIX}{confirmation.confirmation_token}"
request = agent_interaction_manager.create_request(
session_id=self._session_id,
user_id=str(self._user_id),
channel=channel.value,
source=self._source,
username=self._username,
title="确认提交问题反馈",
prompt="请确认是否将以下问题反馈提交到 MoviePilot 上游仓库。",
options=[
AgentInteractionOption(label="确认提交", value=option_value),
AgentInteractionOption(label="取消提交", value="取消提交问题反馈"),
],
)
max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel)
buttons = [
[
{
"text": self._truncate_button_text("确认提交", max_text_length),
"callback_data": f"agent_interaction:choice:{request.request_id}:1",
}
],
[
{
"text": self._truncate_button_text("取消提交", max_text_length),
"callback_data": f"agent_interaction:choice:{request.request_id}:2",
}
],
]
preview = (
"请确认是否提交以下问题反馈:\n\n"
f"标题:{title}\n"
f"版本:{version}\n"
f"环境:{environment}\n"
f"类型:{issue_type}\n"
f"诊断日志:{'已找到相关日志' if diagnostics.found else '未找到明确相关日志'}\n\n"
f"{description.strip()[:1800]}"
)
await ToolChain().async_post_message(
Notification(
channel=channel,
source=self._source,
mtype=NotificationType.Agent,
userid=self._user_id,
username=self._username,
title="确认提交问题反馈",
text=preview,
buttons=buttons,
)
)
logger.info(
"feedback issue preview sent: session_id=%s diagnostics_id=%s token=%s",
self._session_id,
diagnostics_id,
confirmation.confirmation_token[:8],
)
self._agent_context["user_reply_sent"] = True
self._agent_context["reply_mode"] = "feedback_issue_confirmation"
return self._result_payload(
success=True,
confirmation_token=confirmation.confirmation_token,
diagnostics_id=diagnostics_id,
message=(
"已通过独立通知卡片发送 Issue 预览和「确认提交 / 取消提交」"
"按钮给用户。**本轮对话不要再生成任何额外文字回复**——按钮"
"卡片已经完整表达了 Issue 草稿和操作引导,复述「已生成 "
"Issue 预览,请点击确认按钮」会和卡片重复并让用户困惑。"
"请直接结束本轮,等待用户点击按钮触发下一轮。"
),
)