260 lines
8.2 KiB
Python
260 lines
8.2 KiB
Python
|
|
"""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",
|
||
|
|
]
|