mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
Improve agent file reading and structured LLM summaries
This commit is contained in:
@@ -447,7 +447,7 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
|
|||||||
llm = await LLMHelper.get_llm(streaming=False)
|
llm = await LLMHelper.get_llm(streaming=False)
|
||||||
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
|
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
|
||||||
response = await llm.ainvoke(prompt)
|
response = await llm.ainvoke(prompt)
|
||||||
summary = response.content.strip()
|
summary = LLMHelper.extract_text_content(response.content).strip()
|
||||||
# 清理模型可能输出的前缀(如 "摘要:" "总结:")
|
# 清理模型可能输出的前缀(如 "摘要:" "总结:")
|
||||||
summary = re.sub(r"^(摘要|总结|活动记录)[::]\s*", "", summary)
|
summary = re.sub(r"^(摘要|总结|活动记录)[::]\s*", "", summary)
|
||||||
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
|
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
|||||||
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
|
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
|
||||||
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
|
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
|
||||||
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
|
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
|
||||||
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known.
|
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
|
||||||
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
|
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
|
||||||
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
|
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
|
||||||
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
|
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ from app.log import logger
|
|||||||
|
|
||||||
# 最大读取大小 50KB
|
# 最大读取大小 50KB
|
||||||
MAX_READ_SIZE = 50 * 1024
|
MAX_READ_SIZE = 50 * 1024
|
||||||
|
READ_FILE_TRUNCATION_MESSAGE = (
|
||||||
|
"文件内容超过50KB,本次结果已截断。"
|
||||||
|
"请使用 start_line 和 end_line 参数指定行号范围分段读取。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReadFileInput(BaseModel):
|
class ReadFileInput(BaseModel):
|
||||||
@@ -39,7 +43,11 @@ class ReadFileTool(MoviePilotTool):
|
|||||||
ToolTag.Read,
|
ToolTag.Read,
|
||||||
ToolTag.File,
|
ToolTag.File,
|
||||||
]
|
]
|
||||||
description: str = "Read the content of a text file. Supports reading by line range. Each read is limited to 50KB; content exceeding this limit will be truncated."
|
description: str = (
|
||||||
|
"Read the content of a text file. Supports reading by line range. Each "
|
||||||
|
"read is limited to 50KB; when content is truncated, continue with "
|
||||||
|
"smaller start_line and end_line ranges."
|
||||||
|
)
|
||||||
args_schema: Type[BaseModel] = ReadFileInput
|
args_schema: Type[BaseModel] = ReadFileInput
|
||||||
|
|
||||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
@@ -99,22 +107,21 @@ class ReadFileTool(MoviePilotTool):
|
|||||||
truncated = True
|
truncated = True
|
||||||
|
|
||||||
if include_metadata:
|
if include_metadata:
|
||||||
return json.dumps(
|
payload = {
|
||||||
{
|
"file_path": str(resolved_path),
|
||||||
"file_path": str(resolved_path),
|
"sha256": hashlib.sha256(raw_content).hexdigest(),
|
||||||
"sha256": hashlib.sha256(raw_content).hexdigest(),
|
"size_bytes": len(raw_content),
|
||||||
"size_bytes": len(raw_content),
|
"start_line": start_line,
|
||||||
"start_line": start_line,
|
"end_line": end_line,
|
||||||
"end_line": end_line,
|
"truncated": truncated,
|
||||||
"truncated": truncated,
|
}
|
||||||
"content": content,
|
if truncated:
|
||||||
},
|
payload["truncation_message"] = READ_FILE_TRUNCATION_MESSAGE
|
||||||
ensure_ascii=False,
|
payload["content"] = content
|
||||||
indent=2,
|
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||||
)
|
|
||||||
|
|
||||||
if truncated:
|
if truncated:
|
||||||
return f"{content}\n\n[警告:文件内容已超过50KB限制,以上内容已被截断。请使用 start_line/end_line 参数分段读取。]"
|
return f"{content}\n\n[警告:{READ_FILE_TRUNCATION_MESSAGE}]"
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|||||||
@@ -490,6 +490,8 @@ moviepilot tool run search_torrents media_type=movie tmdb_id=12345
|
|||||||
- `read_file`、`write_file`、`edit_file` 和 `execute_command`
|
- `read_file`、`write_file`、`edit_file` 和 `execute_command`
|
||||||
属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时
|
属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时
|
||||||
由 Agent 按当前用户权限直接调用这些工具。
|
由 Agent 按当前用户权限直接调用这些工具。
|
||||||
|
- `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
|
||||||
|
`start_line`、`end_line` 指定更小的行号范围继续读取。
|
||||||
|
|
||||||
## Scheduler 命令
|
## Scheduler 命令
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
|
|||||||
内置 Agent 的本地文件与命令工具 `read_file`、`write_file`、`edit_file`、
|
内置 Agent 的本地文件与命令工具 `read_file`、`write_file`、`edit_file`、
|
||||||
`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的
|
`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的
|
||||||
用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。
|
用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。
|
||||||
|
其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
|
||||||
|
`start_line`、`end_line` 指定更小的行号范围继续读取。
|
||||||
|
|
||||||
媒体相关 MCP 工具(如 `query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。
|
媒体相关 MCP 工具(如 `query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ a local plugin source and installed into the running MoviePilot instance.
|
|||||||
`list_directory` only when inspecting one known folder or a configured remote
|
`list_directory` only when inspecting one known folder or a configured remote
|
||||||
storage backend.
|
storage backend.
|
||||||
- Read the relevant implementation and adjacent example before editing.
|
- Read the relevant implementation and adjacent example before editing.
|
||||||
|
- If `read_file` reports truncation, continue with smaller `start_line` and
|
||||||
|
`end_line` ranges until all relevant sections have been inspected.
|
||||||
- Before using a Python or Node.js dependency API, determine the exact installed
|
- Before using a Python or Node.js dependency API, determine the exact installed
|
||||||
or locked version from requirements, package manifests, lockfiles, local
|
or locked version from requirements, package manifests, lockfiles, local
|
||||||
package source, and `.pyi`/`.d.ts` declarations. If those are insufficient,
|
package source, and `.pyi`/`.d.ts` declarations. If those are insufficient,
|
||||||
|
|||||||
@@ -151,6 +151,29 @@ def test_summarize_with_llm_ignores_skip_marker():
|
|||||||
llm.ainvoke.assert_awaited_once()
|
llm.ainvoke.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_with_llm_extracts_text_blocks():
|
||||||
|
"""活动摘要应兼容 LLM 返回的结构化文本块。"""
|
||||||
|
llm = SimpleNamespace(
|
||||||
|
ainvoke=AsyncMock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
content=[
|
||||||
|
{"type": "reasoning", "text": "内部推理"},
|
||||||
|
{"type": "text", "text": "摘要:用户完成了文件工具排查。"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.llm.LLMHelper.get_llm",
|
||||||
|
new=AsyncMock(return_value=llm),
|
||||||
|
):
|
||||||
|
summary = asyncio.run(_summarize_with_llm("用户: 排查文件工具"))
|
||||||
|
|
||||||
|
assert summary == "用户完成了文件工具排查。"
|
||||||
|
llm.ainvoke.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
def test_activity_log_records_detailed_summary(tmp_path):
|
def test_activity_log_records_detailed_summary(tmp_path):
|
||||||
"""有实际工具动作的交互应写入较完整的活动摘要。"""
|
"""有实际工具动作的交互应写入较完整的活动摘要。"""
|
||||||
summary = (
|
summary = (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from app.agent.tools.impl.edit_file import EditFileTool
|
from app.agent.tools.impl.edit_file import EditFileTool
|
||||||
from app.agent.tools.impl.list_directory import ListDirectoryTool
|
from app.agent.tools.impl.list_directory import ListDirectoryTool
|
||||||
from app.agent.tools.impl.read_file import ReadFileTool
|
from app.agent.tools.impl.read_file import MAX_READ_SIZE, ReadFileTool
|
||||||
from app.agent.tools.impl.write_file import WriteFileTool
|
from app.agent.tools.impl.write_file import WriteFileTool
|
||||||
from app.chain.storage import StorageChain
|
from app.chain.storage import StorageChain
|
||||||
|
|
||||||
@@ -135,6 +135,31 @@ def test_read_file_can_return_sha256_metadata(tmp_path):
|
|||||||
assert payload["truncated"] is False
|
assert payload["truncated"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_returns_line_range_hint_when_truncated(tmp_path):
|
||||||
|
"""超过50KB时应保留前段内容并提示按行号范围继续读取。"""
|
||||||
|
file_path = tmp_path / "large.py"
|
||||||
|
exact_content = "a" * MAX_READ_SIZE
|
||||||
|
file_path.write_text(exact_content, encoding="utf-8")
|
||||||
|
tool = _make_admin_tool(ReadFileTool)
|
||||||
|
|
||||||
|
exact_result = asyncio.run(tool.ainvoke({"file_path": str(file_path)}))
|
||||||
|
file_path.write_text(f"{exact_content}b", encoding="utf-8")
|
||||||
|
truncated_result = asyncio.run(tool.ainvoke({"file_path": str(file_path)}))
|
||||||
|
metadata_result = asyncio.run(
|
||||||
|
tool.run(str(file_path), include_metadata=True)
|
||||||
|
)
|
||||||
|
metadata = json.loads(metadata_result)
|
||||||
|
|
||||||
|
assert exact_result == exact_content
|
||||||
|
assert truncated_result.startswith(exact_content)
|
||||||
|
assert "50KB" in truncated_result
|
||||||
|
assert "start_line" in truncated_result
|
||||||
|
assert "end_line" in truncated_result
|
||||||
|
assert "tool_result_truncated" not in truncated_result
|
||||||
|
assert metadata["truncated"] is True
|
||||||
|
assert "行号范围" in metadata["truncation_message"]
|
||||||
|
|
||||||
|
|
||||||
def test_list_directory_returns_paged_items_with_next_offset(tmp_path):
|
def test_list_directory_returns_paged_items_with_next_offset(tmp_path):
|
||||||
"""目录工具应返回可继续查询的分页元数据。"""
|
"""目录工具应返回可继续查询的分页元数据。"""
|
||||||
items = [
|
items = [
|
||||||
|
|||||||
Reference in New Issue
Block a user