"""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", ]