From e09e33e225313982de3f6d77628e917f676009dd Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 18 Aug 2026 11:59:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(H):=20H-SRC/H-CITE=20=E2=80=94=20knowledge?= =?UTF-8?q?=20source=20adapter=20(wiki/dms/mail/communication),=20evidence?= =?UTF-8?q?=20references=20with=20deep-links=20and=20workstream=20blocks,?= =?UTF-8?q?=2023=20tests=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ai/knowledge_sources.py | 259 ++++++++++++++++++++++++++++++++++++ tests/test_phase_h_wiki.py | 106 +++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 app/ai/knowledge_sources.py diff --git a/app/ai/knowledge_sources.py b/app/ai/knowledge_sources.py new file mode 100644 index 0000000..4f91547 --- /dev/null +++ b/app/ai/knowledge_sources.py @@ -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", +] diff --git a/tests/test_phase_h_wiki.py b/tests/test_phase_h_wiki.py index 4b55513..a91deed 100644 --- a/tests/test_phase_h_wiki.py +++ b/tests/test_phase_h_wiki.py @@ -169,3 +169,109 @@ class TestWikiServices: assert _slugify("Hello World") == "hello-world" assert _slugify("Überblick") == "ueberblick" assert _slugify("Test Article 123") == "test-article-123" + + +# ─── H-SRC: Knowledge Source Adapter ───────────────────────────────────────── + + +class TestKnowledgeSourceAdapter: + """Test the knowledge source adapter (H-SRC).""" + + def test_available_sources(self): + """All 4 knowledge source types are registered.""" + from app.ai.knowledge_sources import get_available_sources + + sources = get_available_sources() + types = {s["type"] for s in sources} + assert types == {"wiki", "dms", "mail", "communication"} + + def test_get_source_config_wiki(self): + """Wiki source config is correct.""" + from app.ai.knowledge_sources import get_source_config + + config = get_source_config("wiki") + assert config is not None + assert config["text_field"] == "content" + assert config["title_field"] == "title" + assert config["status_filter"] == {"status": "published"} + + def test_get_source_config_unknown(self): + """Unknown source type returns None.""" + from app.ai.knowledge_sources import get_source_config + + assert get_source_config("nonexistent") is None + + +# ─── H-CITE: Evidence References ───────────────────────────────────────────── + + +class TestEvidenceReferences: + """Test the evidence reference builder (H-CITE).""" + + def test_evidence_reference_to_dict(self): + """EvidenceReference serializes to dict correctly.""" + from app.ai.knowledge_sources import EvidenceReference + + ref = EvidenceReference( + source_type="wiki", + source_id="abc-123", + title="Test Article", + url="/wiki/articles/abc-123", + snippet="This is a test snippet", + confidence=0.95, + ) + d = ref.to_dict() + assert d["source_type"] == "wiki" + assert d["source_id"] == "abc-123" + assert d["title"] == "Test Article" + assert d["confidence"] == 0.95 + + def test_evidence_reference_to_workstream_block(self): + """EvidenceReference converts to workstream block.""" + from app.ai.knowledge_sources import EvidenceReference + + ref = EvidenceReference( + source_type="dms", + source_id="file-123", + title="Document.pdf", + url="/dms/files/file-123", + snippet="Important content here", + ) + block = ref.to_workstream_block() + assert block["type"] == "evidence_card" + assert block["source_type"] == "dms" + assert block["title"] == "Document.pdf" + + def test_build_evidence_references_from_results(self): + """build_evidence_references creates refs from search results.""" + from app.ai.knowledge_sources import build_evidence_references + + results = [ + {"source_type": "wiki", "source_id": str(uuid.uuid4()), "title": "Article 1", "score": 0.9, "snippet": "Snippet 1"}, + {"source_type": "dms", "source_id": str(uuid.uuid4()), "title": "Doc 2", "score": 0.7, "snippet": "Snippet 2"}, + {"source_type": "mail", "source_id": str(uuid.uuid4()), "title": "Mail 3", "score": 0.95, "snippet": "Snippet 3"}, + ] + refs = build_evidence_references(results) + assert len(refs) == 3 + # Sorted by confidence descending + assert refs[0].confidence == 0.95 + assert refs[1].confidence == 0.9 + assert refs[2].confidence == 0.7 + + def test_build_evidence_references_max_results(self): + """build_evidence_references respects max_results.""" + from app.ai.knowledge_sources import build_evidence_references + + results = [ + {"source_type": "wiki", "source_id": str(uuid.uuid4()), "title": f"Article {i}", "score": 0.5} + for i in range(10) + ] + refs = build_evidence_references(results, max_results=3) + assert len(refs) == 3 + + def test_build_evidence_references_empty(self): + """build_evidence_references handles empty results.""" + from app.ai.knowledge_sources import build_evidence_references + + refs = build_evidence_references([]) + assert refs == []