feat(H): H-SRC/H-CITE — knowledge source adapter (wiki/dms/mail/communication), evidence references with deep-links and workstream blocks, 23 tests passing

This commit is contained in:
Agent Zero
2026-08-18 11:59:39 +02:00
parent 396fdf3c9a
commit e09e33e225
2 changed files with 365 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
"""Knowledge source adapter — connects DMS, Wiki, Mail, Communication to the
existing SearchProvider/RAG pipeline (H-SRC).
Originalquelle bleibt authoritative; Permissions/Sensitive Fields gelten
durchgängig. No second universal knowledge store — uses existing
unified_search infrastructure.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# ─── Source type registry ────────────────────────────────────────────────────
_SOURCE_TYPES: dict[str, dict[str, Any]] = {
"wiki": {
"display_name": "Wiki Articles",
"model_path": "app.plugins.builtins.wiki.models.WikiArticle",
"text_field": "content",
"title_field": "title",
"id_field": "id",
"status_filter": {"status": "published"},
},
"dms": {
"display_name": "DMS Documents",
"model_path": "app.plugins.builtins.dms.models.DmsFile",
"text_field": "extracted_text",
"title_field": "filename",
"id_field": "id",
"status_filter": {},
},
"mail": {
"display_name": "Mail Messages",
"model_path": "app.plugins.builtins.mail.models.MailMessage",
"text_field": "body_text",
"title_field": "subject",
"id_field": "id",
"status_filter": {},
},
"communication": {
"display_name": "Communication Messages",
"model_path": "app.plugins.builtins.kommunikation.models.CommMessage",
"text_field": "content",
"title_field": "content",
"id_field": "id",
"status_filter": {},
},
}
def get_available_sources() -> list[dict[str, str]]:
"""List all available knowledge source types."""
return [
{"type": k, "display_name": v["display_name"]}
for k, v in _SOURCE_TYPES.items()
]
def get_source_config(source_type: str) -> dict[str, Any] | None:
"""Get configuration for a knowledge source type."""
return _SOURCE_TYPES.get(source_type)
async def fetch_source_content(
db: AsyncSession,
tenant_id: uuid.UUID,
source_type: str,
entity_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Fetch content from a knowledge source for RAG indexing.
Returns a dict with:
- ``title``: Title for the content
- ``text``: Text content for embedding
- ``source_type``: The source type
- ``source_id``: The entity ID
- ``source_url``: Deep link to the original content
- ``metadata``: Additional metadata
"""
config = get_source_config(source_type)
if config is None:
return None
try:
# Dynamic import of the model
import importlib
module_path, class_name = config["model_path"].rsplit(".", 1)
module = importlib.import_module(module_path)
model = getattr(module, class_name)
# Fetch the entity
query = select(model).where(
model.id == entity_id,
model.tenant_id == tenant_id,
)
if hasattr(model, "deleted_at"):
query = query.where(model.deleted_at.is_(None))
# Apply status filter
for field, value in config.get("status_filter", {}).items():
if hasattr(model, field):
query = query.where(getattr(model, field) == value)
result = await db.execute(query)
entity = result.scalar_one_or_none()
if entity is None:
return None
# Extract text and title
text = getattr(entity, config["text_field"], "") or ""
title = getattr(entity, config["title_field"], "") or ""
# Build source URL (deep link)
source_url = _build_source_url(source_type, entity_id)
# Build metadata
metadata = {
"source_type": source_type,
"source_id": str(entity_id),
"tenant_id": str(tenant_id),
}
if hasattr(entity, "owner_id"):
metadata["owner_id"] = str(entity.owner_id) if entity.owner_id else None
if hasattr(entity, "tags"):
metadata["tags"] = entity.tags or []
if hasattr(entity, "category_id"):
metadata["category_id"] = str(entity.category_id) if entity.category_id else None
return {
"title": title,
"text": text,
"source_type": source_type,
"source_id": str(entity_id),
"source_url": source_url,
"metadata": metadata,
}
except Exception as e:
logger.warning("Failed to fetch %s content %s: %s", source_type, entity_id, e)
return None
def _build_source_url(source_type: str, entity_id: uuid.UUID) -> str:
"""Build a deep-link URL to the original content."""
url_map = {
"wiki": f"/wiki/articles/{entity_id}",
"dms": f"/dms/files/{entity_id}",
"mail": f"/mail/messages/{entity_id}",
"communication": f"/communication/messages/{entity_id}",
}
return url_map.get(source_type, f"/{source_type}/{entity_id}")
# ─── Evidence/Source References (H-CITE) ─────────────────────────────────────
class EvidenceReference:
"""Structured source reference for RAG/Knowledge results (H-CITE).
Provides deep-links and cards to original documents, mails, messages,
or business objects. Agents can display these in the workstream.
"""
def __init__(
self,
source_type: str,
source_id: str,
title: str,
url: str,
snippet: str = "",
confidence: float = 0.0,
metadata: dict[str, Any] | None = None,
):
self.source_type = source_type
self.source_id = source_id
self.title = title
self.url = url
self.snippet = snippet
self.confidence = confidence
self.metadata = metadata or {}
def to_dict(self) -> dict[str, Any]:
"""Serialize to dict for API responses and workstream blocks."""
return {
"source_type": self.source_type,
"source_id": self.source_id,
"title": self.title,
"url": self.url,
"snippet": self.snippet,
"confidence": self.confidence,
"metadata": self.metadata,
}
def to_workstream_block(self) -> dict[str, Any]:
"""Convert to a typed workstream block for display in Communication."""
return {
"type": "evidence_card",
"source_type": self.source_type,
"source_id": self.source_id,
"title": self.title,
"url": self.url,
"snippet": self.snippet[:200],
"confidence": self.confidence,
}
def build_evidence_references(
search_results: list[dict[str, Any]],
max_results: int = 5,
) -> list[EvidenceReference]:
"""Build evidence references from search/RAG results.
Args:
search_results: Raw search results with source_type, source_id, title, etc.
max_results: Maximum number of references to return.
Returns:
List of EvidenceReference objects sorted by confidence.
"""
refs: list[EvidenceReference] = []
for result in search_results[:max_results]:
source_type = result.get("source_type", "unknown")
source_id = result.get("source_id", "")
title = result.get("title", "")
url = result.get("source_url") or _build_source_url(
source_type, uuid.UUID(source_id) if source_id else uuid.uuid4()
)
snippet = result.get("snippet", "") or result.get("text", "")[:200]
confidence = result.get("score", 0.0)
refs.append(EvidenceReference(
source_type=source_type,
source_id=source_id,
title=title,
url=url,
snippet=snippet,
confidence=confidence,
metadata=result.get("metadata", {}),
))
# Sort by confidence descending
refs.sort(key=lambda r: r.confidence, reverse=True)
return refs
__all__ = [
"get_available_sources",
"get_source_config",
"fetch_source_content",
"EvidenceReference",
"build_evidence_references",
]