386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""Project Knowledge connector surface: read-only, scoped, source-backed MCP contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Iterable
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from packages.connector_kit import (
|
|
ConnectorContext,
|
|
InMemoryKnowledgeIndex,
|
|
PageRequest,
|
|
ResourcePublication,
|
|
SourceType,
|
|
ToolPublication,
|
|
decode_cursor,
|
|
encode_cursor,
|
|
)
|
|
from packages.connector_kit.knowledge import DocumentNotFound
|
|
from packages.hub_policy import Governance, governed_tool
|
|
|
|
|
|
class KnowledgeConnector:
|
|
"""Expose only registered project documents; no physical path is an input."""
|
|
|
|
connector_type = "knowledge"
|
|
version = "1.0.0"
|
|
|
|
def __init__(self, index: InMemoryKnowledgeIndex, governance: Governance | None = None) -> None:
|
|
self.index = index
|
|
self.governance = governance
|
|
|
|
def _envelope(
|
|
self,
|
|
context: ConnectorContext,
|
|
data: Any,
|
|
*,
|
|
truncated: bool = False,
|
|
next_cursor: str | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"data": data,
|
|
"meta": {
|
|
"project": context.project_slug,
|
|
"environment": context.environment,
|
|
"source": "knowledge",
|
|
"truncated": truncated,
|
|
"nextCursor": next_cursor,
|
|
"generatedAt": datetime.now(UTC).isoformat(),
|
|
},
|
|
"warnings": [],
|
|
}
|
|
|
|
@staticmethod
|
|
def _hit_payload(item: Any) -> dict[str, Any]:
|
|
return {
|
|
"uri": item.uri,
|
|
"title": item.title,
|
|
"path": item.path,
|
|
"snippet": item.snippet,
|
|
"sourceType": item.source_type,
|
|
"sourceRevision": item.source_revision,
|
|
"repositoryRef": item.repository_ref,
|
|
"repositoryCommit": item.repository_commit,
|
|
"contentHash": item.content_hash,
|
|
"modifiedAt": item.modified_at.isoformat(),
|
|
"score": item.score,
|
|
"truncated": item.truncated,
|
|
}
|
|
|
|
@governed_tool("knowledge.document.search")
|
|
def document_search(
|
|
self,
|
|
context: ConnectorContext,
|
|
query: str,
|
|
*,
|
|
source_types: Iterable[SourceType] = (),
|
|
page: PageRequest | None = None,
|
|
) -> dict[str, Any]:
|
|
request = page or PageRequest()
|
|
result = self.index.search(
|
|
context.project_id,
|
|
query,
|
|
source_types=source_types,
|
|
limit=min(request.limit, 20),
|
|
cursor=request.cursor,
|
|
)
|
|
data = {
|
|
"items": [self._hit_payload(item) for item in result.items],
|
|
"nextCursor": result.next_cursor,
|
|
"truncated": result.truncated,
|
|
}
|
|
return self._envelope(
|
|
context, data, truncated=result.truncated, next_cursor=result.next_cursor
|
|
)
|
|
|
|
@governed_tool("knowledge.document.get")
|
|
def document_get(
|
|
self, context: ConnectorContext, uri: str, *, max_bytes: int = 200_000
|
|
) -> dict[str, Any]:
|
|
if not 1 <= max_bytes <= 262_144:
|
|
raise ValueError("max_bytes is outside the connector output budget")
|
|
document = self.index.get_document(context.project_id, uri)
|
|
encoded = document.content.encode("utf-8")
|
|
truncated = len(encoded) > max_bytes
|
|
content = (
|
|
encoded[:max_bytes].decode("utf-8", errors="ignore") if truncated else document.content
|
|
)
|
|
data = {
|
|
"uri": document.uri,
|
|
"title": document.title,
|
|
"mimeType": document.mime_type,
|
|
"content": content,
|
|
"path": document.external_ref,
|
|
"sourceType": document.source_type,
|
|
"sourceRevision": document.source_revision,
|
|
"repositoryRef": document.repository_ref,
|
|
"repositoryCommit": document.repository_commit,
|
|
"contentHash": document.content_hash,
|
|
"modifiedAt": document.modified_at.isoformat(),
|
|
"truncated": truncated,
|
|
}
|
|
return self._envelope(context, data, truncated=truncated)
|
|
|
|
@governed_tool("knowledge.source.list")
|
|
def source_list(
|
|
self,
|
|
context: ConnectorContext,
|
|
*,
|
|
source_type: SourceType | None = None,
|
|
page: PageRequest | None = None,
|
|
) -> dict[str, Any]:
|
|
request = page or PageRequest(limit=25)
|
|
sources = [
|
|
source
|
|
for source in self.index.list_sources(context.project_id)
|
|
if source_type is None or source.source_type == source_type
|
|
]
|
|
cursor = decode_cursor(
|
|
request.cursor, str(context.project_id), "sources", str(source_type or "")
|
|
)
|
|
items = sources[cursor.offset : cursor.offset + min(request.limit, 100)]
|
|
offset = cursor.offset + len(items)
|
|
next_cursor = (
|
|
encode_cursor(offset, str(context.project_id), "sources", str(source_type or ""))
|
|
if offset < len(sources)
|
|
else None
|
|
)
|
|
data = {
|
|
"items": [
|
|
{
|
|
"sourceRef": item.source_ref,
|
|
"sourceType": item.source_type,
|
|
"uri": item.uri,
|
|
"status": item.status,
|
|
"sourceRevision": item.source_revision,
|
|
"lastIndexedAt": item.last_indexed_at.isoformat()
|
|
if item.last_indexed_at
|
|
else None,
|
|
"documentCount": item.document_count,
|
|
}
|
|
for item in items
|
|
],
|
|
"nextCursor": next_cursor,
|
|
"truncated": next_cursor is not None,
|
|
}
|
|
return self._envelope(
|
|
context, data, truncated=next_cursor is not None, next_cursor=next_cursor
|
|
)
|
|
|
|
@governed_tool("knowledge.index.status")
|
|
def index_status(self, context: ConnectorContext) -> dict[str, Any]:
|
|
status = self.index.status(context.project_id)
|
|
data = {
|
|
"status": status.status,
|
|
"sourceCount": status.source_count,
|
|
"documentCount": status.document_count,
|
|
"lastCompletedAt": status.last_completed_at.isoformat()
|
|
if status.last_completed_at
|
|
else None,
|
|
"lastError": status.last_error,
|
|
"generatedAt": datetime.now(UTC),
|
|
}
|
|
return self._envelope(context, data)
|
|
|
|
@governed_tool("knowledge.resource.get")
|
|
def resource_get(self, context: ConnectorContext, uri: str) -> dict[str, Any]:
|
|
if not uri.startswith("itworx-docs://"):
|
|
raise DocumentNotFound("resource URI is not registered")
|
|
return self.document_get(context, uri)
|
|
|
|
@governed_tool("knowledge.adr.list")
|
|
def adr_list(
|
|
self, context: ConnectorContext, *, page: PageRequest | None = None
|
|
) -> dict[str, Any]:
|
|
request = page or PageRequest(limit=25)
|
|
documents = self.index.documents_for_source(context.project_id, SourceType.ADR)
|
|
cursor = decode_cursor(request.cursor, str(context.project_id), "adr")
|
|
items = documents[cursor.offset : cursor.offset + min(request.limit, 100)]
|
|
offset = cursor.offset + len(items)
|
|
next_cursor = (
|
|
encode_cursor(offset, str(context.project_id), "adr")
|
|
if offset < len(documents)
|
|
else None
|
|
)
|
|
data = {
|
|
"items": [
|
|
{
|
|
"uri": doc.uri,
|
|
"number": doc.external_ref,
|
|
"title": doc.title,
|
|
"status": "indexed",
|
|
"updatedAt": doc.modified_at.isoformat(),
|
|
}
|
|
for doc in items
|
|
],
|
|
"nextCursor": next_cursor,
|
|
"truncated": next_cursor is not None,
|
|
}
|
|
return self._envelope(
|
|
context, data, truncated=next_cursor is not None, next_cursor=next_cursor
|
|
)
|
|
|
|
@governed_tool("knowledge.project.summary")
|
|
def project_summary(self, context: ConnectorContext) -> dict[str, Any]:
|
|
status = self.index.status(context.project_id)
|
|
sources = [source.uri for source in self.index.list_sources(context.project_id)]
|
|
summary = (
|
|
f"{status.document_count} indexed documents across "
|
|
f"{status.source_count} registered sources."
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"summary": summary,
|
|
"sources": sources,
|
|
"sourceRevision": status.last_completed_at.isoformat()
|
|
if status.last_completed_at
|
|
else "never",
|
|
"generatedAt": datetime.now(UTC),
|
|
},
|
|
)
|
|
|
|
def tools(self) -> tuple[ToolPublication, ...]:
|
|
return KNOWLEDGE_TOOLS
|
|
|
|
def resources(self) -> tuple[ResourcePublication, ...]:
|
|
return KNOWLEDGE_RESOURCES
|
|
|
|
def invoke(self, tool: str, context: ConnectorContext, **arguments: Any) -> dict[str, Any]:
|
|
methods: dict[str, Callable[..., dict[str, Any]]] = {
|
|
"knowledge.document.search": self.document_search,
|
|
"knowledge.document.get": self.document_get,
|
|
"knowledge.source.list": self.source_list,
|
|
"knowledge.index.status": self.index_status,
|
|
"knowledge.adr.list": self.adr_list,
|
|
"knowledge.project.summary": self.project_summary,
|
|
"knowledge.resource.get": self.resource_get,
|
|
}
|
|
method = methods.get(tool)
|
|
if method is None:
|
|
raise ValueError("unknown knowledge tool")
|
|
return method(context, **arguments)
|
|
|
|
def health(self) -> dict[str, Any]:
|
|
return {"status": "healthy", "component": "knowledge-connector"}
|
|
|
|
def readiness(self) -> dict[str, Any]:
|
|
return {"status": "ready", "component": "knowledge-connector"}
|
|
|
|
|
|
_COMMON_EMPTY_INPUT = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {},
|
|
"required": [],
|
|
}
|
|
_COMMON_PAGE = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
|
"cursor": {"type": ["string", "null"], "maxLength": 500},
|
|
},
|
|
"required": [],
|
|
}
|
|
_COMMON_OUTPUT = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"data": {"type": "object"},
|
|
"meta": {"type": "object"},
|
|
"warnings": {"type": "array"},
|
|
},
|
|
"required": ["data", "meta", "warnings"],
|
|
}
|
|
|
|
KNOWLEDGE_TOOLS = (
|
|
ToolPublication(
|
|
canonical_name="knowledge.document.search",
|
|
version="1.0.0",
|
|
connector_type="knowledge",
|
|
risk_class="R1",
|
|
input_schema={
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"query": {"type": "string", "minLength": 1, "maxLength": 1000},
|
|
"sourceTypes": {"type": "array", "maxItems": 8},
|
|
"limit": {"type": "integer", "minimum": 1, "maximum": 20},
|
|
"cursor": {"type": ["string", "null"], "maxLength": 500},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
output_schema=_COMMON_OUTPUT,
|
|
required_scopes=("project:{project}:read",),
|
|
),
|
|
ToolPublication(
|
|
canonical_name="knowledge.document.get",
|
|
version="1.0.0",
|
|
connector_type="knowledge",
|
|
risk_class="R1",
|
|
input_schema={
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"uri": {
|
|
"type": "string",
|
|
"pattern": "^itworx-docs://",
|
|
"maxLength": 2048,
|
|
}
|
|
},
|
|
"required": ["uri"],
|
|
},
|
|
output_schema=_COMMON_OUTPUT,
|
|
required_scopes=("project:{project}:read",),
|
|
),
|
|
ToolPublication(
|
|
canonical_name="knowledge.source.list",
|
|
version="1.0.0",
|
|
connector_type="knowledge",
|
|
risk_class="R1",
|
|
input_schema=_COMMON_PAGE,
|
|
output_schema=_COMMON_OUTPUT,
|
|
required_scopes=("project:{project}:read",),
|
|
),
|
|
ToolPublication(
|
|
canonical_name="knowledge.index.status",
|
|
version="1.0.0",
|
|
connector_type="knowledge",
|
|
risk_class="R1",
|
|
input_schema=_COMMON_EMPTY_INPUT,
|
|
output_schema=_COMMON_OUTPUT,
|
|
required_scopes=("project:{project}:read",),
|
|
),
|
|
ToolPublication(
|
|
canonical_name="knowledge.adr.list",
|
|
version="1.0.0",
|
|
connector_type="knowledge",
|
|
risk_class="R1",
|
|
input_schema=_COMMON_PAGE,
|
|
output_schema=_COMMON_OUTPUT,
|
|
required_scopes=("project:{project}:read",),
|
|
),
|
|
ToolPublication(
|
|
canonical_name="knowledge.project.summary",
|
|
version="1.0.0",
|
|
connector_type="knowledge",
|
|
risk_class="R1",
|
|
input_schema=_COMMON_EMPTY_INPUT,
|
|
output_schema=_COMMON_OUTPUT,
|
|
required_scopes=("project:{project}:read",),
|
|
),
|
|
)
|
|
|
|
KNOWLEDGE_RESOURCES = (
|
|
ResourcePublication(
|
|
uri_template="itworx-docs://{project}/{source}/{documentId}",
|
|
connector_type="knowledge",
|
|
risk_class="R1",
|
|
mime_types=("text/markdown", "text/plain", "application/json"),
|
|
max_bytes=262_144,
|
|
required_scopes=("project:{project}:read",),
|
|
),
|
|
)
|