union-py-app 1.0.0

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.
Files changed (67) hide show
  1. package/app/__init__.py +1 -0
  2. package/app/agent/__init__.py +1 -0
  3. package/app/agent/capabilities.py +387 -0
  4. package/app/agent/coordinator/__init__.py +1 -0
  5. package/app/agent/coordinator/definition.py +50 -0
  6. package/app/agent/coordinator/output_guard.py +29 -0
  7. package/app/agent/graph.py +95 -0
  8. package/app/agent/guardrails.py +30 -0
  9. package/app/agent/routing.py +81 -0
  10. package/app/agent/runtime/__init__.py +1 -0
  11. package/app/agent/runtime/activity.py +393 -0
  12. package/app/agent/runtime/delegation.py +80 -0
  13. package/app/agent/runtime/deps.py +34 -0
  14. package/app/agent/runtime/execution.py +381 -0
  15. package/app/agent/runtime/model.py +47 -0
  16. package/app/agent/runtime/model_errors.py +40 -0
  17. package/app/agent/runtime/session.py +156 -0
  18. package/app/agent/specialists/__init__.py +1 -0
  19. package/app/agent/specialists/behavior_risk/__init__.py +1 -0
  20. package/app/agent/specialists/behavior_risk/definition.py +54 -0
  21. package/app/agent/specialists/build.py +94 -0
  22. package/app/agent/specialists/knowledge/__init__.py +1 -0
  23. package/app/agent/specialists/knowledge/definition.py +38 -0
  24. package/app/agent/specialists/personal_memory/__init__.py +1 -0
  25. package/app/agent/specialists/personal_memory/definition.py +35 -0
  26. package/app/agent/specialists/personal_memory/output_guard.py +55 -0
  27. package/app/agent/specialists/running_analysis/__init__.py +1 -0
  28. package/app/agent/specialists/running_analysis/definition.py +46 -0
  29. package/app/agent/specialists/running_analysis/output_guard.py +38 -0
  30. package/app/agent/specialists/scheduled_task_draft/__init__.py +8 -0
  31. package/app/agent/specialists/scheduled_task_draft/definition.py +142 -0
  32. package/app/agent/specialists/scheduled_task_draft/output_guard.py +24 -0
  33. package/app/asgi.py +148 -0
  34. package/app/config/__init__.py +1 -0
  35. package/app/config/settings.py +67 -0
  36. package/app/memory/__init__.py +1 -0
  37. package/app/memory/store.py +154 -0
  38. package/app/service/rag_service.py +365 -0
  39. package/app/skills/full-chain-quality-analysis/SKILL.md +22 -0
  40. package/app/tools/__init__.py +1 -0
  41. package/app/tools/business.py +183 -0
  42. package/app/utils/__init__.py +1 -0
  43. package/app/utils/api_client.py +108 -0
  44. package/app/utils/control_auth.py +50 -0
  45. package/app/utils/request_logging.py +63 -0
  46. package/app/utils/state_client.py +68 -0
  47. package/app/views/__init__.py +1 -0
  48. package/app/views/auth.py +208 -0
  49. package/app/views/errors.py +29 -0
  50. package/app/views/routes.py +25 -0
  51. package/app/views/run_context.py +33 -0
  52. package/app/views/streaming_runs.py +350 -0
  53. package/app/views/sync_runs.py +180 -0
  54. package/deploy/autoconf/templates/env.j2 +23 -0
  55. package/deploy/autoconf.yml +15 -0
  56. package/deploy/scripts/healthcheck.sh +12 -0
  57. package/deploy/scripts/start.sh +80 -0
  58. package/deploy/scripts/stop.sh +35 -0
  59. package/knowledge/000036-scenario-offline-function-call-mock-v1.md +134 -0
  60. package/package.json +21 -0
  61. package/requirements.txt +10 -0
  62. package/scripts/healthcheck.sh +4 -0
  63. package/scripts/start-BJ11.sh +1 -0
  64. package/scripts/start-BJ12.sh +1 -0
  65. package/scripts/start-SH20.sh +1 -0
  66. package/scripts/start-SZ31.sh +1 -0
  67. package/scripts/stop.sh +4 -0
@@ -0,0 +1,67 @@
1
+ """读取并校验应用运行所需的环境变量配置。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+
9
+ def _integer(name: str, default: int) -> int:
10
+ try:
11
+ return int(os.getenv(name, str(default)))
12
+ except ValueError:
13
+ return default
14
+
15
+
16
+ def _floating(name: str, default: float) -> float:
17
+ try:
18
+ return float(os.getenv(name, str(default)))
19
+ except ValueError:
20
+ return default
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class AgentSettings:
25
+ union_base_url: str
26
+ llm_url: str
27
+ llm_key: str
28
+ llm_model: str
29
+ llm_context_window: int
30
+ request_limit: int
31
+ tool_calls_limit: int
32
+ input_tokens_limit: int
33
+ output_tokens_limit: int
34
+ tool_timeout_seconds: float
35
+ subagent_timeout_seconds: float
36
+ max_run_seconds: float
37
+ required_permission: str
38
+ rag_enabled: bool
39
+ rag_knowledge_dir: str
40
+ rag_collection: str
41
+ rag_embedding_model: str
42
+ rag_top_k: int
43
+ rag_chunk_size: int
44
+
45
+ @classmethod
46
+ def from_env(cls) -> AgentSettings:
47
+ return cls(
48
+ union_base_url=os.getenv("UNION_BASE_URL", "http://127.0.0.1:8080").rstrip("/"),
49
+ llm_url=os.getenv("LLM_URL", "").rstrip("/"),
50
+ llm_key=os.getenv("LLM_KEY", ""),
51
+ llm_model=os.getenv("LLM_MODEL", ""),
52
+ llm_context_window=_integer("LLM_CONTEXT_WINDOW", 131072),
53
+ request_limit=_integer("AGENT_REQUEST_LIMIT", 30),
54
+ tool_calls_limit=_integer("AGENT_TOOL_CALLS_LIMIT", 40),
55
+ input_tokens_limit=_integer("AGENT_INPUT_TOKENS_LIMIT", 120000),
56
+ output_tokens_limit=_integer("AGENT_OUTPUT_TOKENS_LIMIT", 16000),
57
+ tool_timeout_seconds=_floating("AGENT_TOOL_TIMEOUT_SECONDS", 300.0),
58
+ subagent_timeout_seconds=_floating("SUBAGENT_TIMEOUT_SECONDS", 900.0),
59
+ max_run_seconds=_floating("AGENT_MAX_RUN_SECONDS", 900.0),
60
+ required_permission=os.getenv("PERMISSIONS", ""),
61
+ rag_enabled=os.getenv("RAG_ENABLED", "true").lower() in {"1", "true", "yes", "on"},
62
+ rag_knowledge_dir=os.getenv("RAG_KNOWLEDGE_DIR", "knowledge"),
63
+ rag_collection=os.getenv("RAG_COLLECTION", "ops_knowledge"),
64
+ rag_embedding_model=os.getenv("RAG_EMBEDDING_MODEL", "embedding-3"),
65
+ rag_top_k=_integer("RAG_TOP_K", 5),
66
+ rag_chunk_size=_integer("RAG_CHUNK_SIZE", 1200),
67
+ )
@@ -0,0 +1 @@
1
+ """存放 Agent 个人记忆的存储适配实现。"""
@@ -0,0 +1,154 @@
1
+ """将远端记忆接口适配为 Harness 记忆存储协议。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic_ai_harness.memory import (
8
+ MemoryConflictError,
9
+ MemoryFile,
10
+ MemoryMutation,
11
+ MemoryOperation,
12
+ MemoryOperationConflictError,
13
+ MemorySearchMatch,
14
+ MemorySearchResult,
15
+ )
16
+
17
+ from app.utils.state_client import AgentStateClient, AgentStateError
18
+
19
+
20
+ class AgentMemoryStore:
21
+ """HTTP adapter for the official SearchableMemoryStore protocol."""
22
+
23
+ def __init__(self, state_client: AgentStateClient) -> None:
24
+ self._state_client = state_client
25
+
26
+ async def _call(self, action: str, payload: dict[str, Any]) -> dict[str, Any]:
27
+ body = await self._state_client.post(f"/agent/memoryStore/{action}", payload)
28
+ code = body.get("errorCode")
29
+ if code == "version_conflict":
30
+ raise MemoryConflictError(body.get("errorMsg", "memory version conflict"))
31
+ if code == "operation_conflict":
32
+ raise MemoryOperationConflictError(body.get("errorMsg", "memory operation conflict"))
33
+ if body.get("success") is False:
34
+ raise AgentStateError(body.get("errorMsg", "memory store request failed"))
35
+ return body
36
+
37
+ async def read(self, path: str, *, max_chars: int) -> MemoryFile | None:
38
+ body = await self._call("read", {"path": path, "maxChars": max_chars})
39
+ item = body.get("file")
40
+ if item is None:
41
+ return None
42
+ return MemoryFile(
43
+ content=str(item["content"]),
44
+ version=str(item["version"]),
45
+ operation_id=item.get("operationId"),
46
+ truncated=bool(item.get("truncated", False)),
47
+ )
48
+
49
+ async def list_paths(self, prefix: str = "", *, limit: int) -> list[str]:
50
+ body = await self._call("list", {"prefix": prefix, "limit": limit})
51
+ return [str(path) for path in body.get("paths", [])]
52
+
53
+ async def get_operation(self, operation: MemoryOperation) -> MemoryMutation | None:
54
+ body = await self._call(
55
+ "operation",
56
+ {"operationId": operation.id, "fingerprint": operation.fingerprint},
57
+ )
58
+ return self._mutation(body.get("mutation"))
59
+
60
+ async def write(
61
+ self,
62
+ path: str,
63
+ content: str,
64
+ *,
65
+ expected_version: str | None,
66
+ operation: MemoryOperation | None = None,
67
+ ) -> MemoryMutation:
68
+ return self._required_mutation(
69
+ await self._call(
70
+ "write",
71
+ {
72
+ "path": path,
73
+ "content": content,
74
+ "expectedVersion": expected_version,
75
+ "operation": self._operation(operation),
76
+ },
77
+ )
78
+ )
79
+
80
+ async def delete(
81
+ self,
82
+ path: str,
83
+ *,
84
+ expected_version: str | None,
85
+ operation: MemoryOperation | None = None,
86
+ ) -> MemoryMutation:
87
+ return self._required_mutation(
88
+ await self._call(
89
+ "delete",
90
+ {
91
+ "path": path,
92
+ "expectedVersion": expected_version,
93
+ "operation": self._operation(operation),
94
+ },
95
+ )
96
+ )
97
+
98
+ async def search(
99
+ self,
100
+ prefix: str,
101
+ query: str,
102
+ *,
103
+ limit: int,
104
+ max_files: int,
105
+ max_chars: int,
106
+ max_file_chars: int,
107
+ ) -> MemorySearchResult:
108
+ body = await self._call(
109
+ "search",
110
+ {
111
+ "prefix": prefix,
112
+ "query": query,
113
+ "limit": limit,
114
+ "maxFiles": max_files,
115
+ "maxChars": max_chars,
116
+ "maxFileChars": max_file_chars,
117
+ },
118
+ )
119
+ result = body.get("result") or {}
120
+ return MemorySearchResult(
121
+ matches=[
122
+ MemorySearchMatch(
123
+ path=str(item["path"]),
124
+ snippet=str(item["snippet"]),
125
+ score=float(item["score"]),
126
+ )
127
+ for item in result.get("matches", [])
128
+ ],
129
+ scanned=int(result.get("scanned", 0)),
130
+ truncated=bool(result.get("truncated", False)),
131
+ )
132
+
133
+ @staticmethod
134
+ def _operation(operation: MemoryOperation | None) -> dict[str, str] | None:
135
+ if operation is None:
136
+ return None
137
+ return {"id": operation.id, "fingerprint": operation.fingerprint}
138
+
139
+ @staticmethod
140
+ def _mutation(value: Any) -> MemoryMutation | None:
141
+ if value is None:
142
+ return None
143
+ return MemoryMutation(
144
+ version=str(value["version"]) if value.get("version") is not None else None,
145
+ replayed=bool(value.get("replayed", False)),
146
+ existed=bool(value.get("existed", False)),
147
+ )
148
+
149
+ @classmethod
150
+ def _required_mutation(cls, body: dict[str, Any]) -> MemoryMutation:
151
+ mutation = cls._mutation(body.get("mutation"))
152
+ if mutation is None:
153
+ raise AgentStateError("state service omitted memory mutation result")
154
+ return mutation
@@ -0,0 +1,365 @@
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, item_metadata),
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, metadata: dict) -> str:
343
+ fingerprint = json.dumps([str(path), index, chunk, metadata], sort_keys=True)
344
+ return hashlib.sha256(fingerprint.encode()).hexdigest()
345
+
346
+ def _source(self, metadata: dict) -> dict:
347
+ return {
348
+ key: metadata.get(key, "")
349
+ for key in (
350
+ "kb_id",
351
+ "title",
352
+ "doc_type",
353
+ "domain",
354
+ "category",
355
+ "category_keywords",
356
+ "source_doc_description",
357
+ "subcategory",
358
+ "related_items",
359
+ "related_categories",
360
+ "relation_notes",
361
+ "source_doc",
362
+ "source_section",
363
+ "file_path",
364
+ )
365
+ }
@@ -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 调用的业务工具。"""