appQQQ commited on
Commit
a61dbb4
·
verified ·
1 Parent(s): 8b2e901

chore: upload app/agents/tools.py

Browse files
Files changed (1) hide show
  1. app/agents/tools.py +276 -0
app/agents/tools.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic AI 工具集 (供 LangGraph tool_executor 节点调用).
2
+
3
+ 工具列表:
4
+ - summarize_document: 总结指定文档
5
+ - compare_documents: 对比 2 个文档的某方面
6
+ - calculate: 安全数学计算 (用 AST 而不是 eval)
7
+ - list_documents: 列出已上传的文档
8
+ - get_current_time: 当前时间 (调试用)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import datetime as _dt
14
+ import logging
15
+ import operator
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, Field
19
+
20
+ from app.models import db
21
+ from app.services.embedding import get_embedder
22
+ from app.services.vector_store import hybrid_query
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ========== Tool schemas (OpenAI function calling 格式) ==========
28
+ TOOL_SCHEMAS: list[dict[str, Any]] = [
29
+ {
30
+ "type": "function",
31
+ "function": {
32
+ "name": "summarize_document",
33
+ "description": "根据 doc_id 总结指定文档的主要内容 (取前若干 chunks 拼接 + LLM 摘要).",
34
+ "parameters": {
35
+ "type": "object",
36
+ "properties": {
37
+ "doc_id": {
38
+ "type": "string",
39
+ "description": "要总结的文档 ID",
40
+ },
41
+ "max_chunks": {
42
+ "type": "integer",
43
+ "description": "最多取前多少个 chunk (默认 30)",
44
+ "default": 30,
45
+ },
46
+ },
47
+ "required": ["doc_id"],
48
+ },
49
+ },
50
+ },
51
+ {
52
+ "type": "function",
53
+ "function": {
54
+ "name": "compare_documents",
55
+ "description": "对比两个文档在某方面的异同. 返回结构化对比结果.",
56
+ "parameters": {
57
+ "type": "object",
58
+ "properties": {
59
+ "doc_id_a": {"type": "string"},
60
+ "doc_id_b": {"type": "string"},
61
+ "aspect": {
62
+ "type": "string",
63
+ "description": "对比的方面, 如 '技术选型', '营收', '风险' 等",
64
+ },
65
+ },
66
+ "required": ["doc_id_a", "doc_id_b", "aspect"],
67
+ },
68
+ },
69
+ },
70
+ {
71
+ "type": "function",
72
+ "function": {
73
+ "name": "calculate",
74
+ "description": "安全地计算数学表达式 (加减乘除/括号/比较), 用 AST 解析避免注入.",
75
+ "parameters": {
76
+ "type": "object",
77
+ "properties": {
78
+ "expression": {
79
+ "type": "string",
80
+ "description": "数学表达式, 如 '(2+3)*4'",
81
+ },
82
+ },
83
+ "required": ["expression"],
84
+ },
85
+ },
86
+ },
87
+ {
88
+ "type": "function",
89
+ "function": {
90
+ "name": "list_documents",
91
+ "description": "列出已上传的所有文档 (ID + 文件名 + 状态).",
92
+ "parameters": {
93
+ "type": "object",
94
+ "properties": {},
95
+ },
96
+ },
97
+ },
98
+ {
99
+ "type": "function",
100
+ "function": {
101
+ "name": "get_current_time",
102
+ "description": "获取服务器当前时间 (UTC / 本地时区).",
103
+ "parameters": {
104
+ "type": "object",
105
+ "properties": {
106
+ "tz": {
107
+ "type": "string",
108
+ "description": "时区, 如 'UTC', 'Asia/Shanghai'. 默认 UTC",
109
+ "default": "UTC",
110
+ },
111
+ },
112
+ },
113
+ },
114
+ },
115
+ ]
116
+
117
+
118
+ # ========== 工具实现 ==========
119
+ async def tool_summarize_document(args: dict[str, Any]) -> str:
120
+ doc_id = args.get("doc_id")
121
+ if not doc_id:
122
+ return "❌ 缺少 doc_id"
123
+ doc = db.doc_get(doc_id)
124
+ if doc is None:
125
+ return f"❌ 文档 {doc_id} 不存在"
126
+
127
+ chunks = db.chunk_get_by_doc(doc_id, limit=int(args.get("max_chunks", 30)))
128
+ if not chunks:
129
+ return f"❌ 文档 {doc_id} 暂无内容"
130
+
131
+ text = "\n\n".join(c["text"] for c in chunks)
132
+ # 简单截断前 6000 字给 LLM 摘要
133
+ snippet = text[:6000]
134
+
135
+ from app.llm.factory import get_llm
136
+ llm = get_llm()
137
+ resp = await llm.chat(
138
+ messages=[
139
+ {"role": "system", "content": "你是文档摘要助手. 用中文输出简洁摘要, 列出主要章节和关键事实."},
140
+ {"role": "user", "content": f"请摘要以下内容 (来自 {doc.get('filename', doc_id)}):\n\n{snippet}"},
141
+ ],
142
+ temperature=0.3,
143
+ max_tokens=600,
144
+ )
145
+ return resp.content or "(空)"
146
+
147
+
148
+ async def tool_compare_documents(args: dict[str, Any]) -> str:
149
+ doc_a_id = args.get("doc_id_a")
150
+ doc_b_id = args.get("doc_id_b")
151
+ aspect = args.get("aspect", "整体")
152
+ if not (doc_a_id and doc_b_id):
153
+ return "��� 缺少 doc_id_a 或 doc_id_b"
154
+
155
+ chunks_a = db.chunk_get_by_doc(doc_a_id, limit=20)
156
+ chunks_b = db.chunk_get_by_doc(doc_b_id, limit=20)
157
+ if not chunks_a or not chunks_b:
158
+ return f"❌ 文档 {doc_a_id if not chunks_a else doc_b_id} 无内容"
159
+
160
+ text_a = "\n".join(c["text"] for c in chunks_a)[:3000]
161
+ text_b = "\n".join(c["text"] for c in chunks_b)[:3000]
162
+
163
+ from app.llm.factory import get_llm
164
+ llm = get_llm()
165
+ resp = await llm.chat(
166
+ messages=[
167
+ {"role": "system", "content": "你是文档对比助手. 输出 Markdown 表格, 列出差異与相同点."},
168
+ {"role": "user", "content": (
169
+ f"对比以下两份文档的「{aspect}」方面.\n\n"
170
+ f"## 文档 A\n{text_a}\n\n## 文档 B\n{text_b}\n\n"
171
+ f"输出: 1) 关键差异 (表格) 2) 相同点 3) 建议"
172
+ )},
173
+ ],
174
+ temperature=0.3,
175
+ max_tokens=800,
176
+ )
177
+ return resp.content or "(空)"
178
+
179
+
180
+ _BIN_OPS = {
181
+ ast.Add: operator.add, ast.Sub: operator.sub,
182
+ ast.Mult: operator.mul, ast.Div: operator.truediv,
183
+ ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
184
+ ast.Pow: operator.pow,
185
+ }
186
+ _CMP_OPS = {
187
+ ast.Eq: operator.eq, ast.NotEq: operator.ne,
188
+ ast.Lt: operator.lt, ast.LtE: operator.le,
189
+ ast.Gt: operator.gt, ast.GtE: operator.ge,
190
+ }
191
+ _UNARY_OPS = {ast.UAdd: operator.pos, ast.USub: operator.neg}
192
+
193
+
194
+ def _safe_eval(node: ast.AST) -> float | bool:
195
+ if isinstance(node, ast.Expression):
196
+ return _safe_eval(node.body)
197
+ if isinstance(node, ast.Constant):
198
+ if isinstance(node.value, (int, float)):
199
+ return node.value
200
+ raise ValueError(f"不支持的字面量: {node.value!r}")
201
+ if isinstance(node, ast.BinOp):
202
+ op = _BIN_OPS.get(type(node.op))
203
+ if op is None:
204
+ raise ValueError(f"不支持的二元运算: {type(node.op).__name__}")
205
+ return op(_safe_eval(node.left), _safe_eval(node.right))
206
+ if isinstance(node, ast.UnaryOp):
207
+ op = _UNARY_OPS.get(type(node.op))
208
+ if op is None:
209
+ raise ValueError(f"不支持的一元运算: {type(node.op).__name__}")
210
+ return op(_safe_eval(node.operand))
211
+ if isinstance(node, ast.Compare):
212
+ left = _safe_eval(node.left)
213
+ for cmp_op, right_node in zip(node.ops, node.comparators):
214
+ op = _CMP_OPS.get(type(cmp_op))
215
+ if op is None:
216
+ raise ValueError(f"不支持的比较: {type(cmp_op).__name__}")
217
+ left = op(left, _safe_eval(right_node))
218
+ if not left:
219
+ return False
220
+ return True
221
+ raise ValueError(f"不支持的语法: {ast.dump(node)}")
222
+
223
+
224
+ async def tool_calculate(args: dict[str, Any]) -> str:
225
+ expr = args.get("expression", "")
226
+ if not expr:
227
+ return "❌ 缺少 expression"
228
+ try:
229
+ tree = ast.parse(expr, mode="eval")
230
+ result = _safe_eval(tree)
231
+ return f"{expr} = {result}"
232
+ except Exception as e: # noqa: BLE001
233
+ return f"❌ 计算失败: {e}"
234
+
235
+
236
+ async def tool_list_documents(args: dict[str, Any]) -> str:
237
+ docs = db.doc_list(limit=200)
238
+ if not docs:
239
+ return "暂无已上传文档"
240
+ lines = [f"- {d['id'][:8]}… {d['filename']} (chunks={d.get('chunk_count', 0)}, status={d.get('status', '?')})" for d in docs]
241
+ return "已上传文档:\n" + "\n".join(lines)
242
+
243
+
244
+ async def tool_get_current_time(args: dict[str, Any]) -> str:
245
+ tz = args.get("tz", "UTC")
246
+ try:
247
+ from zoneinfo import ZoneInfo
248
+ now = _dt.datetime.now(ZoneInfo(tz))
249
+ except Exception: # noqa: BLE001
250
+ now = _dt.datetime.utcnow()
251
+ return now.strftime("%Y-%m-%d %H:%M:%S %Z")
252
+
253
+
254
+ # ========== 工具路由表 ==========
255
+ TOOL_FUNCS = {
256
+ "summarize_document": tool_summarize_document,
257
+ "compare_documents": tool_compare_documents,
258
+ "calculate": tool_calculate,
259
+ "list_documents": tool_list_documents,
260
+ "get_current_time": tool_get_current_time,
261
+ }
262
+
263
+
264
+ async def execute_tool(name: str, args: dict[str, Any]) -> str:
265
+ """统一工具执行入口."""
266
+ fn = TOOL_FUNCS.get(name)
267
+ if fn is None:
268
+ return f"❌ 未知工具: {name}"
269
+ try:
270
+ return await fn(args)
271
+ except Exception as e: # noqa: BLE001
272
+ logger.exception("Tool %s failed", name)
273
+ return f"❌ 工具执行失败: {e}"
274
+
275
+
276
+ __all__ = ["TOOL_SCHEMAS", "TOOL_FUNCS", "execute_tool"]