union-app-chat-stream 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/__init__.py +1 -0
- package/app/agent/__init__.py +1 -0
- package/app/agent/capabilities.py +388 -0
- package/app/agent/coordinator/__init__.py +1 -0
- package/app/agent/coordinator/definition.py +50 -0
- package/app/agent/coordinator/output_guard.py +29 -0
- package/app/agent/graph.py +95 -0
- package/app/agent/guardrails.py +30 -0
- package/app/agent/routing.py +81 -0
- package/app/agent/runtime/__init__.py +1 -0
- package/app/agent/runtime/activity.py +393 -0
- package/app/agent/runtime/delegation.py +80 -0
- package/app/agent/runtime/deps.py +34 -0
- package/app/agent/runtime/execution.py +368 -0
- package/app/agent/runtime/model.py +47 -0
- package/app/agent/runtime/model_errors.py +24 -0
- package/app/agent/runtime/session.py +154 -0
- package/app/agent/specialists/__init__.py +1 -0
- package/app/agent/specialists/behavior_risk/__init__.py +1 -0
- package/app/agent/specialists/behavior_risk/definition.py +54 -0
- package/app/agent/specialists/build.py +94 -0
- package/app/agent/specialists/knowledge/__init__.py +1 -0
- package/app/agent/specialists/knowledge/definition.py +38 -0
- package/app/agent/specialists/personal_memory/__init__.py +1 -0
- package/app/agent/specialists/personal_memory/definition.py +35 -0
- package/app/agent/specialists/personal_memory/output_guard.py +55 -0
- package/app/agent/specialists/running_analysis/__init__.py +1 -0
- package/app/agent/specialists/running_analysis/definition.py +46 -0
- package/app/agent/specialists/running_analysis/output_guard.py +38 -0
- package/app/agent/specialists/scheduled_task_draft/__init__.py +8 -0
- package/app/agent/specialists/scheduled_task_draft/definition.py +142 -0
- package/app/agent/specialists/scheduled_task_draft/output_guard.py +81 -0
- package/app/asgi.py +139 -0
- package/app/config/__init__.py +1 -0
- package/app/config/settings.py +67 -0
- package/app/memory/__init__.py +1 -0
- package/app/memory/store.py +154 -0
- package/app/service/rag_service.py +364 -0
- package/app/skills/full-chain-quality-analysis/SKILL.md +22 -0
- package/app/tools/__init__.py +1 -0
- package/app/tools/business.py +183 -0
- package/app/utils/__init__.py +1 -0
- package/app/utils/api_client.py +76 -0
- package/app/utils/control_auth.py +35 -0
- package/app/utils/state_client.py +60 -0
- package/app/views/__init__.py +1 -0
- package/app/views/auth.py +189 -0
- package/app/views/errors.py +19 -0
- package/app/views/routes.py +25 -0
- package/app/views/run_context.py +33 -0
- package/app/views/streaming_runs.py +340 -0
- package/app/views/sync_runs.py +152 -0
- package/deploy/autoconf/templates/env.j2 +23 -0
- package/deploy/autoconf.yml +15 -0
- package/deploy/scripts/healthcheck.sh +12 -0
- package/deploy/scripts/start.sh +80 -0
- package/deploy/scripts/stop.sh +35 -0
- package/knowledge/000036-scenario-offline-function-call-mock-v1.md +134 -0
- package/package.json +21 -0
- package/requirements.txt +10 -0
- package/scripts/healthcheck.sh +4 -0
- package/scripts/start-BJ11.sh +1 -0
- package/scripts/start-BJ12.sh +1 -0
- package/scripts/start-SH20.sh +1 -0
- package/scripts/start-SZ31.sh +1 -0
- package/scripts/stop.sh +4 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""管理本地知识文档的 Chroma 索引、检查、重建与检索。"""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
import chromadb
|
|
12
|
+
import yaml
|
|
13
|
+
from chromadb.errors import NotFoundError
|
|
14
|
+
from chromadb.utils.embedding_functions import OpenAIEmbeddingFunction
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RagService:
|
|
20
|
+
"""Markdown knowledge retrieval backed by Chroma's public API."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config):
|
|
23
|
+
self._enabled = config["RAG_ENABLED"]
|
|
24
|
+
self._top_k = self._positive_int(config["RAG_TOP_K"])
|
|
25
|
+
self._root = Path(__file__).resolve().parents[2]
|
|
26
|
+
self._persist_dir = self._root / ".chroma"
|
|
27
|
+
self._knowledge_dir = self._root / config["RAG_KNOWLEDGE_DIR"]
|
|
28
|
+
self._collection_name = config["RAG_COLLECTION"]
|
|
29
|
+
self._embedding_url = config["LLM_URL"]
|
|
30
|
+
self._embedding_key = config["LLM_KEY"]
|
|
31
|
+
self._embedding_model = config["RAG_EMBEDDING_MODEL"]
|
|
32
|
+
self._chunk_size = self._positive_int(config["RAG_CHUNK_SIZE"])
|
|
33
|
+
self._chroma = None
|
|
34
|
+
self._collection = None
|
|
35
|
+
self._embedding_function = None
|
|
36
|
+
self._embedding_incompatible = False
|
|
37
|
+
if not self._enabled:
|
|
38
|
+
return
|
|
39
|
+
missing = [
|
|
40
|
+
name
|
|
41
|
+
for name, value in {
|
|
42
|
+
"LLM_URL": self._embedding_url,
|
|
43
|
+
"LLM_KEY": self._embedding_key,
|
|
44
|
+
"RAG_EMBEDDING_MODEL": self._embedding_model,
|
|
45
|
+
}.items()
|
|
46
|
+
if not value
|
|
47
|
+
]
|
|
48
|
+
if missing:
|
|
49
|
+
raise RuntimeError(f"RAG embedding 缺少配置: {', '.join(missing)}")
|
|
50
|
+
self._embedding_function = OpenAIEmbeddingFunction(
|
|
51
|
+
api_key=self._embedding_key,
|
|
52
|
+
api_base=self._embedding_url,
|
|
53
|
+
model_name=self._embedding_model,
|
|
54
|
+
api_key_env_var="LLM_KEY",
|
|
55
|
+
)
|
|
56
|
+
self._init_collection()
|
|
57
|
+
status = self.check()
|
|
58
|
+
logger.info(
|
|
59
|
+
"RAG 知识库状态检查完成 collection=%s status=%s is_synced=%s "
|
|
60
|
+
"needs_rebuild=%s item_counts=%s",
|
|
61
|
+
self._collection_name,
|
|
62
|
+
status.get("status"),
|
|
63
|
+
status.get("is_synced"),
|
|
64
|
+
status["needs_rebuild"],
|
|
65
|
+
status["item_counts"],
|
|
66
|
+
)
|
|
67
|
+
needs_rebuild = (
|
|
68
|
+
self._embedding_incompatible
|
|
69
|
+
or status["needs_rebuild"]
|
|
70
|
+
or status["item_counts"]["vector_chunks"] == 0
|
|
71
|
+
)
|
|
72
|
+
if needs_rebuild:
|
|
73
|
+
logger.info(
|
|
74
|
+
"RAG 知识库需要重建 collection=%s embedding_incompatible=%s",
|
|
75
|
+
self._collection_name,
|
|
76
|
+
self._embedding_incompatible,
|
|
77
|
+
)
|
|
78
|
+
self.rebuild()
|
|
79
|
+
else:
|
|
80
|
+
logger.info("RAG 知识库无需重建 collection=%s", self._collection_name)
|
|
81
|
+
|
|
82
|
+
def search(self, question: str, top_k: int | None = None) -> tuple[str, list[dict]]:
|
|
83
|
+
question = str(question or "").strip()
|
|
84
|
+
if not question or not self._ensure_collection():
|
|
85
|
+
return "", []
|
|
86
|
+
count = self._collection.count()
|
|
87
|
+
if count == 0:
|
|
88
|
+
return "", []
|
|
89
|
+
limit = min(self._parse_optional_top_k(top_k) or self._top_k, count)
|
|
90
|
+
result = self._collection.query(
|
|
91
|
+
query_texts=[question],
|
|
92
|
+
n_results=limit,
|
|
93
|
+
include=["documents", "metadatas", "distances"],
|
|
94
|
+
)
|
|
95
|
+
documents = result.get("documents", [[]])[0]
|
|
96
|
+
metadatas = result.get("metadatas", [[]])[0]
|
|
97
|
+
distances = result.get("distances", [[]])[0]
|
|
98
|
+
hits = [
|
|
99
|
+
{
|
|
100
|
+
"document": self._strip_search_prefix(document),
|
|
101
|
+
"metadata": metadata or {},
|
|
102
|
+
"distance": distances[index] if index < len(distances) else None,
|
|
103
|
+
}
|
|
104
|
+
for index, (document, metadata) in enumerate(zip(documents, metadatas))
|
|
105
|
+
]
|
|
106
|
+
return self._format_evidence_context(hits), [self._source(hit["metadata"]) for hit in hits]
|
|
107
|
+
|
|
108
|
+
def knowledge_search(self, query: str, top_k: Any | None = None) -> tuple[dict[str, Any], str]:
|
|
109
|
+
query = str(query or "").strip()
|
|
110
|
+
if not query:
|
|
111
|
+
return {"context": "", "sources": [], "related_tools": []}, "query_empty"
|
|
112
|
+
context, sources = self.search(query, top_k=self._parse_optional_top_k(top_k))
|
|
113
|
+
return {
|
|
114
|
+
"query": query,
|
|
115
|
+
"context": context,
|
|
116
|
+
"sources": sources,
|
|
117
|
+
"related_tools": self._extract_related_tools(sources),
|
|
118
|
+
}, "success"
|
|
119
|
+
|
|
120
|
+
def rebuild(self) -> int:
|
|
121
|
+
if not self._enabled or self._chroma is None:
|
|
122
|
+
return 0
|
|
123
|
+
documents = self._load_documents()
|
|
124
|
+
logger.info(
|
|
125
|
+
"RAG 知识库开始重建(重建中) collection=%s doc_chunks=%s",
|
|
126
|
+
self._collection_name,
|
|
127
|
+
len(documents),
|
|
128
|
+
)
|
|
129
|
+
temporary_name = f"{self._collection_name}-rebuild-{uuid4().hex}"
|
|
130
|
+
new_collection = self._chroma.get_or_create_collection(
|
|
131
|
+
temporary_name,
|
|
132
|
+
embedding_function=self._embedding_function,
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
if documents:
|
|
136
|
+
new_collection.upsert(
|
|
137
|
+
ids=[document["id"] for document in documents],
|
|
138
|
+
documents=[document["content"] for document in documents],
|
|
139
|
+
metadatas=[document["metadata"] for document in documents],
|
|
140
|
+
)
|
|
141
|
+
except Exception:
|
|
142
|
+
try:
|
|
143
|
+
self._chroma.delete_collection(temporary_name)
|
|
144
|
+
except Exception:
|
|
145
|
+
logger.exception("RAG 临时集合清理失败 collection=%s", temporary_name)
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
self._chroma.delete_collection(self._collection_name)
|
|
150
|
+
except NotFoundError:
|
|
151
|
+
pass
|
|
152
|
+
except Exception:
|
|
153
|
+
try:
|
|
154
|
+
self._chroma.delete_collection(temporary_name)
|
|
155
|
+
except Exception:
|
|
156
|
+
logger.exception("RAG 临时集合清理失败 collection=%s", temporary_name)
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
self._collection = new_collection
|
|
160
|
+
new_collection.modify(name=self._collection_name)
|
|
161
|
+
self._embedding_incompatible = False
|
|
162
|
+
logger.info("RAG 知识库重建完成 doc_chunks=%s", len(documents))
|
|
163
|
+
return len(documents)
|
|
164
|
+
|
|
165
|
+
def check(self) -> dict[str, Any]:
|
|
166
|
+
documents = self._load_documents()
|
|
167
|
+
expected_ids = {document["id"] for document in documents}
|
|
168
|
+
result = {
|
|
169
|
+
"enabled": self._enabled,
|
|
170
|
+
"status": "disabled" if not self._enabled else "unavailable",
|
|
171
|
+
"collection": self._collection_name,
|
|
172
|
+
"is_synced": False,
|
|
173
|
+
"needs_rebuild": bool(self._enabled),
|
|
174
|
+
"item_counts": {
|
|
175
|
+
"source_files": len(list(self._knowledge_dir.rglob("*.md"))),
|
|
176
|
+
"expected_chunks": len(expected_ids),
|
|
177
|
+
"vector_chunks": 0,
|
|
178
|
+
"missing_chunks": len(expected_ids),
|
|
179
|
+
"stale_chunks": 0,
|
|
180
|
+
},
|
|
181
|
+
"config": {
|
|
182
|
+
"embedding_model": self._embedding_model,
|
|
183
|
+
"top_k": self._top_k,
|
|
184
|
+
"chunk_size": self._chunk_size,
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
if not self._ensure_collection():
|
|
188
|
+
return result
|
|
189
|
+
count = self._collection.count()
|
|
190
|
+
stored_ids = set(self._collection.get(include=[]).get("ids", [])) if count else set()
|
|
191
|
+
missing = expected_ids - stored_ids
|
|
192
|
+
stale = stored_ids - expected_ids
|
|
193
|
+
synced = not missing and not stale and count == len(expected_ids)
|
|
194
|
+
result.update({
|
|
195
|
+
"status": "ready" if count else "empty",
|
|
196
|
+
"is_synced": synced,
|
|
197
|
+
"needs_rebuild": not synced,
|
|
198
|
+
})
|
|
199
|
+
result["item_counts"].update({
|
|
200
|
+
"vector_chunks": count,
|
|
201
|
+
"missing_chunks": len(missing),
|
|
202
|
+
"stale_chunks": len(stale),
|
|
203
|
+
})
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
def _init_collection(self) -> None:
|
|
207
|
+
self._chroma = chromadb.PersistentClient(path=str(self._persist_dir))
|
|
208
|
+
try:
|
|
209
|
+
existing = self._chroma.get_collection(self._collection_name)
|
|
210
|
+
except NotFoundError:
|
|
211
|
+
pass
|
|
212
|
+
else:
|
|
213
|
+
config = existing.configuration_json or {}
|
|
214
|
+
persisted = config.get("embedding_function") or {}
|
|
215
|
+
current = self._embedding_function.get_config()
|
|
216
|
+
vector_config_keys = (
|
|
217
|
+
"model_name", "api_base", "api_type", "api_version", "deployment_id", "dimensions",
|
|
218
|
+
)
|
|
219
|
+
incompatible = persisted.get("name") != self._embedding_function.name() or any(
|
|
220
|
+
(persisted.get("config") or {}).get(key) != current.get(key)
|
|
221
|
+
for key in vector_config_keys
|
|
222
|
+
)
|
|
223
|
+
if persisted and incompatible:
|
|
224
|
+
self._embedding_incompatible = True
|
|
225
|
+
self._collection = existing
|
|
226
|
+
return
|
|
227
|
+
self._collection = self._chroma.get_or_create_collection(
|
|
228
|
+
self._collection_name,
|
|
229
|
+
embedding_function=self._embedding_function,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _ensure_collection(self) -> bool:
|
|
233
|
+
if self._collection is not None:
|
|
234
|
+
return True
|
|
235
|
+
if not self._enabled:
|
|
236
|
+
return False
|
|
237
|
+
self._init_collection()
|
|
238
|
+
return self._collection is not None
|
|
239
|
+
|
|
240
|
+
def _load_documents(self) -> list[dict[str, Any]]:
|
|
241
|
+
documents = []
|
|
242
|
+
for path in sorted(self._knowledge_dir.rglob("*.md")):
|
|
243
|
+
metadata, body = self._read_markdown(path)
|
|
244
|
+
if metadata.get("status", "active") != "active":
|
|
245
|
+
continue
|
|
246
|
+
for index, chunk in enumerate(self._split(body)):
|
|
247
|
+
item_metadata = self._clean_metadata({
|
|
248
|
+
**metadata,
|
|
249
|
+
"file_path": path.relative_to(self._root).as_posix(),
|
|
250
|
+
"chunk_index": index,
|
|
251
|
+
})
|
|
252
|
+
documents.append({
|
|
253
|
+
"id": self._chunk_id(path, index, chunk),
|
|
254
|
+
"content": self._searchable_content(item_metadata, chunk),
|
|
255
|
+
"metadata": item_metadata,
|
|
256
|
+
})
|
|
257
|
+
return documents
|
|
258
|
+
|
|
259
|
+
def _parse_optional_top_k(self, value: Any | None) -> int | None:
|
|
260
|
+
if value in (None, ""):
|
|
261
|
+
return None
|
|
262
|
+
return self._positive_int(value)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _positive_int(value) -> int:
|
|
266
|
+
parsed = int(value)
|
|
267
|
+
if parsed < 1:
|
|
268
|
+
raise ValueError(f"配置值必须为正整数: {value}")
|
|
269
|
+
return parsed
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _extract_related_tools(sources: list[dict]) -> list[dict[str, str]]:
|
|
273
|
+
names = set()
|
|
274
|
+
for source in sources:
|
|
275
|
+
raw = source.get("related_items", "")
|
|
276
|
+
text = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False)
|
|
277
|
+
names.update(re.findall(r"\b[a-zA-Z][a-zA-Z0-9_]*_[a-zA-Z0-9_]+\b", text))
|
|
278
|
+
return [
|
|
279
|
+
{"name": name, "reason": "知识库关联能力提示;是否可执行以当前工具定义为准。"}
|
|
280
|
+
for name in sorted(names)
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _format_evidence_context(hits: list[dict[str, Any]]) -> str:
|
|
285
|
+
blocks = []
|
|
286
|
+
for index, hit in enumerate(hits, 1):
|
|
287
|
+
metadata = hit["metadata"]
|
|
288
|
+
blocks.append(
|
|
289
|
+
f"[{index}] 标题:{metadata.get('title', '')}\n"
|
|
290
|
+
f"来源:{metadata.get('source_doc', '')} {metadata.get('source_section', '')}\n"
|
|
291
|
+
f"内容:{hit['document']}"
|
|
292
|
+
)
|
|
293
|
+
return "\n\n".join(blocks)
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _searchable_content(metadata: dict[str, Any], chunk: str) -> str:
|
|
297
|
+
prefix = "\n".join(
|
|
298
|
+
f"{label}:{metadata.get(key, '')}"
|
|
299
|
+
for label, key in (
|
|
300
|
+
("标题", "title"),
|
|
301
|
+
("标签", "tags"),
|
|
302
|
+
("知识领域", "domain"),
|
|
303
|
+
("知识大类", "category"),
|
|
304
|
+
)
|
|
305
|
+
if metadata.get(key)
|
|
306
|
+
)
|
|
307
|
+
return f"{prefix}\n\n---CONTENT---\n{chunk}" if prefix else chunk
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _strip_search_prefix(document: str) -> str:
|
|
311
|
+
return document.split("---CONTENT---\n", 1)[-1]
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _read_markdown(path: Path) -> tuple[dict, str]:
|
|
315
|
+
text = path.read_text(encoding="utf-8")
|
|
316
|
+
match = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
|
|
317
|
+
if not match:
|
|
318
|
+
return {"title": path.stem, "status": "active"}, text
|
|
319
|
+
return yaml.safe_load(match.group(1)) or {}, match.group(2).strip()
|
|
320
|
+
|
|
321
|
+
def _split(self, text: str) -> list[str]:
|
|
322
|
+
chunks = []
|
|
323
|
+
for section in re.split(r"\n(?=##\s+)", text):
|
|
324
|
+
section = section.strip()
|
|
325
|
+
chunks.extend(
|
|
326
|
+
section[index:index + self._chunk_size]
|
|
327
|
+
for index in range(0, len(section), self._chunk_size)
|
|
328
|
+
)
|
|
329
|
+
return [chunk for chunk in chunks if chunk]
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _clean_metadata(metadata: dict) -> dict:
|
|
333
|
+
return {
|
|
334
|
+
key: json.dumps(value, ensure_ascii=False) if isinstance(value, (list, dict))
|
|
335
|
+
else "" if value is None
|
|
336
|
+
else value if isinstance(value, (str, int, float, bool))
|
|
337
|
+
else str(value)
|
|
338
|
+
for key, value in metadata.items()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _chunk_id(path: Path, index: int, chunk: str) -> str:
|
|
343
|
+
return hashlib.sha256(f"{path}:{index}:{chunk}".encode()).hexdigest()
|
|
344
|
+
|
|
345
|
+
def _source(self, metadata: dict) -> dict:
|
|
346
|
+
return {
|
|
347
|
+
key: metadata.get(key, "")
|
|
348
|
+
for key in (
|
|
349
|
+
"kb_id",
|
|
350
|
+
"title",
|
|
351
|
+
"doc_type",
|
|
352
|
+
"domain",
|
|
353
|
+
"category",
|
|
354
|
+
"category_keywords",
|
|
355
|
+
"source_doc_description",
|
|
356
|
+
"subcategory",
|
|
357
|
+
"related_items",
|
|
358
|
+
"related_categories",
|
|
359
|
+
"relation_notes",
|
|
360
|
+
"source_doc",
|
|
361
|
+
"source_section",
|
|
362
|
+
"file_path",
|
|
363
|
+
)
|
|
364
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: full-chain-quality-analysis
|
|
3
|
+
description: 当用户要求分析全链路、端到端或整体链路在某个周期内的运行质量、风险、影响因素或原因时使用;普通指标查询、单一机构查询或知识库问答不要使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 全链路运行质量分析
|
|
7
|
+
|
|
8
|
+
先明确分析日期范围;相对日期按 Asia/Shanghai 的业务日期计算,并在结论中写明实际起止日期。
|
|
9
|
+
|
|
10
|
+
使用全链路运行指标建立事实基础。为了定位影响因素,还应取得同一日期范围内全部成员机构的运行指标,从中识别需要进一步核查的机构。机构变更和故障查询要求可信 orgCode:只能使用机构指标结果中已经返回的编码,或先通过机构解析工具取得编码,不得使用 `ALL` 等虚构编码。
|
|
11
|
+
|
|
12
|
+
对需要核查的机构查询同一日期范围内的变更和故障。不要为没有证据的问题编造事件或根因。
|
|
13
|
+
|
|
14
|
+
输出结构:
|
|
15
|
+
|
|
16
|
+
1. 分析周期和证据范围。
|
|
17
|
+
2. 全链路指标趋势与关键数值。
|
|
18
|
+
3. 成员机构对照和问题事件。
|
|
19
|
+
4. 有证据支持的影响因素、尚不能确认的风险点。
|
|
20
|
+
5. 可执行建议。
|
|
21
|
+
|
|
22
|
+
工具结果没有阈值、SLA、历史基线或对照数据时,只陈述指标事实,不判定正常、异常、良好或可接受。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""存放可供 Agent 调用的业务工具。"""
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""定义知识检索和运行分析 Agent 可调用的业务工具。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
from pydantic_ai import RunContext, Tool
|
|
12
|
+
from pydantic_ai.toolsets import FunctionToolset
|
|
13
|
+
|
|
14
|
+
from app.agent.runtime.deps import RunDeps
|
|
15
|
+
from app.utils.api_client import ToolError, ToolResult
|
|
16
|
+
|
|
17
|
+
QueryDate = Annotated[str, Field(pattern=r"^\d{8}$")]
|
|
18
|
+
_BIGDATA_REQUEST_TIMEOUT_SECONDS = 180
|
|
19
|
+
_BIGDATA_TIMEOUT_ATTEMPTS = 3
|
|
20
|
+
_BIGDATA_TOOL_TIMEOUT_SECONDS = 600
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def knowledge_search(
|
|
24
|
+
ctx: RunContext[RunDeps],
|
|
25
|
+
query: Annotated[str, Field(min_length=1, max_length=2000)],
|
|
26
|
+
top_k: Annotated[int | None, Field(ge=1, le=20)] = None,
|
|
27
|
+
) -> ToolResult:
|
|
28
|
+
"""检索共享知识库中的制度、SOP、机制和名词解释证据。"""
|
|
29
|
+
if ctx.deps.rag_service is None:
|
|
30
|
+
raise ToolError("知识库当前不可用。")
|
|
31
|
+
try:
|
|
32
|
+
data, status = await anyio.to_thread.run_sync(
|
|
33
|
+
ctx.deps.rag_service.knowledge_search,
|
|
34
|
+
query,
|
|
35
|
+
top_k,
|
|
36
|
+
abandon_on_cancel=True,
|
|
37
|
+
)
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
raise ToolError("知识库检索失败。") from exc
|
|
40
|
+
if status != "success":
|
|
41
|
+
raise ToolError("知识库检索失败。")
|
|
42
|
+
return ToolResult(status="success", data=data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def running_analysis_resolve_member_org(
|
|
46
|
+
ctx: RunContext[RunDeps],
|
|
47
|
+
org_name: Annotated[str, Field(min_length=1, max_length=128)],
|
|
48
|
+
) -> ToolResult:
|
|
49
|
+
"""根据机构名称、简称或别名解析可信 orgCode。"""
|
|
50
|
+
return await ctx.deps.api_client.call(
|
|
51
|
+
path="/agent/getOrgInfo",
|
|
52
|
+
payload={"orgName": org_name},
|
|
53
|
+
timeout=10,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def running_analysis_query_member_metrics(
|
|
58
|
+
ctx: RunContext[RunDeps],
|
|
59
|
+
start_date: QueryDate,
|
|
60
|
+
end_date: QueryDate,
|
|
61
|
+
org_code_list: Annotated[list[str], Field(min_length=1, max_length=100)],
|
|
62
|
+
) -> ToolResult:
|
|
63
|
+
"""查询指定成员机构在日期范围内的每日运行指标。"""
|
|
64
|
+
_validate_metric_end_date(end_date)
|
|
65
|
+
return await ctx.deps.api_client.call(
|
|
66
|
+
path="/agent/queryBigData",
|
|
67
|
+
payload={
|
|
68
|
+
"interfaceName": "runing_cnt.bank",
|
|
69
|
+
"params": {
|
|
70
|
+
"startDate": start_date,
|
|
71
|
+
"endDate": end_date,
|
|
72
|
+
"orgCodeList": org_code_list,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
timeout=_BIGDATA_REQUEST_TIMEOUT_SECONDS,
|
|
76
|
+
timeout_attempts=_BIGDATA_TIMEOUT_ATTEMPTS,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def running_analysis_query_all_member_metrics(
|
|
81
|
+
ctx: RunContext[RunDeps],
|
|
82
|
+
start_date: QueryDate,
|
|
83
|
+
end_date: QueryDate,
|
|
84
|
+
) -> ToolResult:
|
|
85
|
+
"""查询全部成员机构在日期范围内的每日运行指标。"""
|
|
86
|
+
_validate_metric_end_date(end_date)
|
|
87
|
+
return await ctx.deps.api_client.call(
|
|
88
|
+
path="/agent/queryBigData",
|
|
89
|
+
payload={
|
|
90
|
+
"interfaceName": "runing_cnt.bank",
|
|
91
|
+
"params": {"startDate": start_date, "endDate": end_date},
|
|
92
|
+
},
|
|
93
|
+
timeout=_BIGDATA_REQUEST_TIMEOUT_SECONDS,
|
|
94
|
+
timeout_attempts=_BIGDATA_TIMEOUT_ATTEMPTS,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def running_analysis_query_chain_metrics(
|
|
99
|
+
ctx: RunContext[RunDeps],
|
|
100
|
+
start_date: QueryDate,
|
|
101
|
+
end_date: QueryDate,
|
|
102
|
+
) -> ToolResult:
|
|
103
|
+
"""查询全链路在日期范围内的每日运行指标。"""
|
|
104
|
+
_validate_metric_end_date(end_date)
|
|
105
|
+
return await ctx.deps.api_client.call(
|
|
106
|
+
path="/agent/queryBigData",
|
|
107
|
+
payload={
|
|
108
|
+
"interfaceName": "runing_cnt.full_link",
|
|
109
|
+
"params": {"startDate": start_date, "endDate": end_date},
|
|
110
|
+
},
|
|
111
|
+
timeout=_BIGDATA_REQUEST_TIMEOUT_SECONDS,
|
|
112
|
+
timeout_attempts=_BIGDATA_TIMEOUT_ATTEMPTS,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def running_analysis_query_changes(
|
|
117
|
+
ctx: RunContext[RunDeps],
|
|
118
|
+
org_code: Annotated[str, Field(min_length=1, max_length=64)],
|
|
119
|
+
start_date: QueryDate,
|
|
120
|
+
end_date: QueryDate,
|
|
121
|
+
) -> ToolResult:
|
|
122
|
+
"""查询机构在日期范围内的变更通知、状态、评价和影响范围。"""
|
|
123
|
+
return await ctx.deps.api_client.call(
|
|
124
|
+
path="/agent/announceList",
|
|
125
|
+
payload={
|
|
126
|
+
"org_code": org_code,
|
|
127
|
+
"planned_start_time": start_date,
|
|
128
|
+
"planned_start_time_end": end_date,
|
|
129
|
+
},
|
|
130
|
+
timeout=10,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def running_analysis_query_faults(
|
|
135
|
+
ctx: RunContext[RunDeps],
|
|
136
|
+
org_code: Annotated[str, Field(min_length=1, max_length=64)],
|
|
137
|
+
start_date: QueryDate,
|
|
138
|
+
end_date: QueryDate,
|
|
139
|
+
) -> ToolResult:
|
|
140
|
+
"""查询机构在日期范围内的 Jira 故障、影响、原因及状态。"""
|
|
141
|
+
return await ctx.deps.api_client.call(
|
|
142
|
+
path="/agent/getJiraInfo",
|
|
143
|
+
payload={"orgCode": org_code, "startDate": start_date, "endDate": end_date},
|
|
144
|
+
timeout=10,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _validate_metric_end_date(end_date: str) -> None:
|
|
149
|
+
today = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
|
|
150
|
+
if end_date > today:
|
|
151
|
+
raise ToolError(f"运行指标结束日期不得晚于当前业务日期 {today}。")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def knowledge_toolset(timeout: float) -> FunctionToolset[RunDeps]:
|
|
155
|
+
return FunctionToolset[RunDeps](
|
|
156
|
+
[knowledge_search],
|
|
157
|
+
timeout=timeout,
|
|
158
|
+
max_retries=2,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def running_analysis_toolset(timeout: float) -> FunctionToolset[RunDeps]:
|
|
163
|
+
return FunctionToolset[RunDeps](
|
|
164
|
+
[
|
|
165
|
+
running_analysis_resolve_member_org,
|
|
166
|
+
Tool(
|
|
167
|
+
running_analysis_query_member_metrics,
|
|
168
|
+
timeout=_BIGDATA_TOOL_TIMEOUT_SECONDS,
|
|
169
|
+
),
|
|
170
|
+
Tool(
|
|
171
|
+
running_analysis_query_all_member_metrics,
|
|
172
|
+
timeout=_BIGDATA_TOOL_TIMEOUT_SECONDS,
|
|
173
|
+
),
|
|
174
|
+
Tool(
|
|
175
|
+
running_analysis_query_chain_metrics,
|
|
176
|
+
timeout=_BIGDATA_TOOL_TIMEOUT_SECONDS,
|
|
177
|
+
),
|
|
178
|
+
running_analysis_query_changes,
|
|
179
|
+
running_analysis_query_faults,
|
|
180
|
+
],
|
|
181
|
+
timeout=timeout,
|
|
182
|
+
max_retries=2,
|
|
183
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""存放不依赖 Agent 定义的通用业务 API 辅助实现。"""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""封装工具调用所需的认证 HTTP 请求、结果校验与数据边界。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
from pydantic_ai import ModelRetry, ToolFailed
|
|
10
|
+
|
|
11
|
+
from app.utils.control_auth import ControlAuth
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolResult(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="forbid")
|
|
16
|
+
|
|
17
|
+
status: Literal["success"]
|
|
18
|
+
data: Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolError(ModelRetry):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiClient:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
http: httpx.AsyncClient,
|
|
30
|
+
base_url: str,
|
|
31
|
+
auth: ControlAuth,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._http = http
|
|
34
|
+
self._base_url = base_url.rstrip("/")
|
|
35
|
+
self._auth = auth
|
|
36
|
+
|
|
37
|
+
async def call(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
path: str,
|
|
41
|
+
payload: dict[str, Any],
|
|
42
|
+
timeout: float,
|
|
43
|
+
timeout_attempts: int = 1,
|
|
44
|
+
) -> ToolResult:
|
|
45
|
+
if timeout_attempts < 1:
|
|
46
|
+
raise ValueError("timeout_attempts must be positive")
|
|
47
|
+
for attempt in range(1, timeout_attempts + 1):
|
|
48
|
+
try:
|
|
49
|
+
response = await self._http.post(
|
|
50
|
+
f"{self._base_url}{path}",
|
|
51
|
+
json=payload,
|
|
52
|
+
headers=self._auth.headers(),
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
)
|
|
55
|
+
break
|
|
56
|
+
except httpx.TimeoutException as exc:
|
|
57
|
+
if attempt == timeout_attempts:
|
|
58
|
+
raise ToolFailed(
|
|
59
|
+
f"上游查询连续 {timeout_attempts} 次超时,请稍后重试。"
|
|
60
|
+
) from exc
|
|
61
|
+
return _tool_result(response)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tool_result(response: httpx.Response) -> ToolResult:
|
|
65
|
+
if response.status_code >= 400:
|
|
66
|
+
raise ToolError(f"上游查询失败,HTTP {response.status_code}。")
|
|
67
|
+
try:
|
|
68
|
+
body = response.json()
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise ToolError("上游没有返回有效 JSON。") from exc
|
|
71
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
72
|
+
if isinstance(data, dict) and "data" in data:
|
|
73
|
+
data = data["data"]
|
|
74
|
+
if data is None:
|
|
75
|
+
raise ToolError("上游没有返回可用数据。")
|
|
76
|
+
return ToolResult(status="success", data=data)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""封装访问 Control 时互斥且不可回显的运行认证凭证。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ControlAuth:
|
|
11
|
+
"""A server-created CAS or scheduled credential, never both."""
|
|
12
|
+
|
|
13
|
+
authentication_type: Literal["CAS", "SCHEDULED"]
|
|
14
|
+
_cas_session_id: str | None = field(default=None, repr=False)
|
|
15
|
+
_scheduled_token: str | None = field(default=None, repr=False)
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
if bool(self._cas_session_id) == bool(self._scheduled_token):
|
|
19
|
+
raise ValueError("exactly one Control credential is required")
|
|
20
|
+
expected = "CAS" if self._cas_session_id else "SCHEDULED"
|
|
21
|
+
if self.authentication_type != expected:
|
|
22
|
+
raise ValueError("authentication type does not match credential")
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def cas(cls, session_id: str) -> ControlAuth:
|
|
26
|
+
return cls("CAS", _cas_session_id=session_id)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def scheduled(cls, token: str) -> ControlAuth:
|
|
30
|
+
return cls("SCHEDULED", _scheduled_token=token)
|
|
31
|
+
|
|
32
|
+
def headers(self) -> dict[str, str]:
|
|
33
|
+
if self.authentication_type == "CAS":
|
|
34
|
+
return {"Cookie": f"CASSESSIONID={self._cas_session_id}"}
|
|
35
|
+
return {"Authorization": f"Scheduled {self._scheduled_token}"}
|