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.
- package/app/__init__.py +1 -0
- package/app/agent/__init__.py +1 -0
- package/app/agent/capabilities.py +387 -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 +381 -0
- package/app/agent/runtime/model.py +47 -0
- package/app/agent/runtime/model_errors.py +40 -0
- package/app/agent/runtime/session.py +156 -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 +24 -0
- package/app/asgi.py +148 -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 +365 -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 +108 -0
- package/app/utils/control_auth.py +50 -0
- package/app/utils/request_logging.py +63 -0
- package/app/utils/state_client.py +68 -0
- package/app/views/__init__.py +1 -0
- package/app/views/auth.py +208 -0
- package/app/views/errors.py +29 -0
- package/app/views/routes.py +25 -0
- package/app/views/run_context.py +33 -0
- package/app/views/streaming_runs.py +350 -0
- package/app/views/sync_runs.py +180 -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,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 调用的业务工具。"""
|