Initial monorepo structure

This commit is contained in:
2026-06-30 20:37:40 +02:00
commit 2f2b295531
121 changed files with 39171 additions and 0 deletions
@@ -0,0 +1,44 @@
"""Wissensspeicher-Modul für das Monorepo-CLI.
Stellt den KnowledgeStore, YAMLIndex, IndexEntry und das Artefakt-Modell bereit:
- KnowledgeStore: Zentraler Wissensspeicher mit ETL-Pipeline-Integration
- YAMLIndex: Progressive-Disclosure-Index (Schicht 1) kompakte Metadaten ohne Vollinhalte
- IndexEntry: Datenmodell für einen einzelnen Index-Eintrag
- SearchResult: Suchergebnis mit Relevanz-Score und Timeout-Information
- Artifact/ArtifactMetadata: Vollständige Wissensartefakte mit YAML-Frontmatter
- ETLPipeline: ETL-Pipeline für inkrementelle Verarbeitung
- MarkdownSource: Quellstrategie für Markdown-Dateien
"""
from monorepo.knowledge.artifact import (
Artifact,
ArtifactLink,
ArtifactMetadata,
compute_content_hash,
parse_frontmatter,
resolve_artifact_path,
write_artifact,
)
from monorepo.knowledge.etl import ETLPipeline, IngestError, IngestResult
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource, SourceError
from monorepo.knowledge.store import KnowledgeStore, SearchResult
__all__ = [
"Artifact",
"ArtifactLink",
"ArtifactMetadata",
"ETLPipeline",
"IndexEntry",
"IngestError",
"IngestResult",
"KnowledgeStore",
"MarkdownSource",
"SearchResult",
"SourceError",
"YAMLIndex",
"compute_content_hash",
"parse_frontmatter",
"resolve_artifact_path",
"write_artifact",
]
@@ -0,0 +1,295 @@
"""Wissensartefakt-Modell mit YAML-Frontmatter.
Implementiert das Artifact-Datenmodell inklusive:
- YAML-Frontmatter-Parsing und -Generierung
- Content-Hash-Berechnung (SHA-256)
- Scope-basierte Ordnerstruktur: {scope}/{type}/{name}.md
Requirements: 3.4, 3.6, 3.11, 3.17, 3.19
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path
from typing import Any
import yaml
# ---------------------------------------------------------------------------
# Datenmodelle
# ---------------------------------------------------------------------------
@dataclass
class ArtifactLink:
"""Gerichtete Verknüpfung zu einem anderen Artefakt (Graph-Kante)."""
target: str
relation: str
@dataclass
class ArtifactMetadata:
"""Metadaten eines Wissensartefakts (YAML-Frontmatter).
Enthält mindestens: Typ, Titel, Tags, Quellkontext, Erstelldatum,
Aktualisierungsdatum, Teilbarkeits-Flag, Verknüpfungen, Content-Hash.
"""
type: str
title: str
tags: list[str] = field(default_factory=list)
source_context: str = ""
created: date | None = None
updated: date | None = None
shareable: bool = False
links: list[ArtifactLink] = field(default_factory=list)
content_hash: str = ""
@dataclass
class Artifact:
"""Ein vollständiges Wissensartefakt: Metadaten + Inhalt + Dateipfad.
Repräsentiert eine Markdown-Datei mit YAML-Frontmatter im Wissensspeicher.
"""
metadata: ArtifactMetadata
content: str
file_path: Path
# ---------------------------------------------------------------------------
# Content-Hash
# ---------------------------------------------------------------------------
def compute_content_hash(content: str) -> str:
"""Berechnet den SHA-256-Hash des Inhalts.
Args:
content: Der Textinhalt des Artefakts.
Returns:
Hash im Format "sha256:<hex-digest>".
"""
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
return f"sha256:{digest}"
# ---------------------------------------------------------------------------
# Pfadauflösung
# ---------------------------------------------------------------------------
def resolve_artifact_path(scope: str, artifact_type: str, name: str) -> Path:
"""Berechnet den Scope-basierten Pfad für ein Artefakt.
Struktur: {scope}/{type}/{name}.md
Args:
scope: Der Arbeitskontext/Scope (z.B. "bahn", "privat").
artifact_type: Der Artefakttyp (z.B. "decision", "note").
name: Der Dateiname ohne Erweiterung (z.B. "api-design").
Returns:
Relativer Pfad zum Artefakt.
"""
# Sicherstellen, dass der Name keine .md-Endung enthält
if name.endswith(".md"):
name = name[:-3]
return Path(scope) / artifact_type / f"{name}.md"
# ---------------------------------------------------------------------------
# YAML-Frontmatter Parsing
# ---------------------------------------------------------------------------
# Pattern zum Erkennen von YAML-Frontmatter: beginnt mit --- am Dateianfang
_FRONTMATTER_PATTERN = re.compile(
r"\A---\s*\n(.*?)^---\s*$\n?",
re.DOTALL | re.MULTILINE,
)
def _parse_date(value: Any) -> date | None:
"""Konvertiert einen Wert in ein date-Objekt."""
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError:
return None
return None
def _parse_links(raw_links: Any) -> list[ArtifactLink]:
"""Parsed die links-Liste aus dem YAML-Frontmatter."""
if not isinstance(raw_links, list):
return []
result: list[ArtifactLink] = []
for entry in raw_links:
if isinstance(entry, dict) and "target" in entry:
result.append(
ArtifactLink(
target=str(entry["target"]),
relation=str(entry.get("relation", "")),
)
)
return result
def parse_frontmatter(file_path: Path) -> Artifact:
"""Parsed eine Markdown-Datei mit YAML-Frontmatter.
Erwartet das Format:
---
type: decision
title: "Titel"
...
---
# Markdown-Inhalt
Args:
file_path: Pfad zur Markdown-Datei.
Returns:
Ein Artifact mit geparsten Metadaten und Inhalt.
Raises:
FileNotFoundError: Wenn die Datei nicht existiert.
ValueError: Wenn kein gültiges YAML-Frontmatter gefunden wird.
"""
text = file_path.read_text(encoding="utf-8")
match = _FRONTMATTER_PATTERN.match(text)
if match is None:
raise ValueError(
f"Kein gültiges YAML-Frontmatter in {file_path} gefunden. "
"Datei muss mit '---' beginnen und ein schließendes '---' enthalten."
)
yaml_text = match.group(1)
content = text[match.end():]
# YAML parsen
raw: dict[str, Any] = yaml.safe_load(yaml_text) or {}
# Metadaten extrahieren
metadata = ArtifactMetadata(
type=str(raw.get("type", "")),
title=str(raw.get("title", "")),
tags=[str(t) for t in raw.get("tags", [])] if isinstance(raw.get("tags"), list) else [],
source_context=str(raw.get("source_context", "")),
created=_parse_date(raw.get("created")),
updated=_parse_date(raw.get("updated")),
shareable=bool(raw.get("shareable", False)),
links=_parse_links(raw.get("links")),
content_hash=str(raw.get("content_hash", "")),
)
return Artifact(metadata=metadata, content=content, file_path=file_path)
# ---------------------------------------------------------------------------
# YAML-Frontmatter Generierung & Schreiben
# ---------------------------------------------------------------------------
def _serialize_metadata(metadata: ArtifactMetadata) -> dict[str, Any]:
"""Konvertiert ArtifactMetadata in ein YAML-serialisierbares dict."""
data: dict[str, Any] = {
"type": metadata.type,
"title": metadata.title,
}
if metadata.tags:
data["tags"] = metadata.tags
if metadata.source_context:
data["source_context"] = metadata.source_context
if metadata.created is not None:
data["created"] = metadata.created.isoformat()
if metadata.updated is not None:
data["updated"] = metadata.updated.isoformat()
data["shareable"] = metadata.shareable
if metadata.links:
data["links"] = [
{"target": link.target, "relation": link.relation}
for link in metadata.links
]
if metadata.content_hash:
data["content_hash"] = metadata.content_hash
return data
def _generate_frontmatter(metadata: ArtifactMetadata) -> str:
"""Generiert YAML-Frontmatter-String aus Metadaten."""
data = _serialize_metadata(metadata)
yaml_str = yaml.dump(
data,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
return f"---\n{yaml_str}---\n"
def write_artifact(artifact: Artifact, base_path: Path) -> Path:
"""Schreibt ein Artefakt in die Scope-basierte Ordnerstruktur.
Berechnet den Zielpfad als {base_path}/{scope}/{type}/{name}.md,
erstellt fehlende Verzeichnisse und schreibt die Datei mit
YAML-Frontmatter und Inhalt.
Der Content-Hash wird automatisch aktualisiert.
Args:
artifact: Das zu schreibende Artefakt.
base_path: Basis-Verzeichnis des Wissensspeichers.
Returns:
Der vollständige Pfad der geschriebenen Datei.
"""
# Content-Hash aktualisieren
artifact.metadata.content_hash = compute_content_hash(artifact.content)
# Zielpfad berechnen
name = artifact.file_path.stem if artifact.file_path.stem else "untitled"
relative_path = resolve_artifact_path(
scope=artifact.metadata.source_context,
artifact_type=artifact.metadata.type,
name=name,
)
full_path = base_path / relative_path
# Verzeichnisse erstellen
full_path.parent.mkdir(parents=True, exist_ok=True)
# Frontmatter + Content zusammenbauen
frontmatter = _generate_frontmatter(artifact.metadata)
file_content = frontmatter + artifact.content
# Schreiben
full_path.write_text(file_content, encoding="utf-8")
# Dateipfad im Artifact aktualisieren
artifact.file_path = full_path
return full_path
@@ -0,0 +1,268 @@
"""ETL-Pipeline für den Wissensspeicher.
Verarbeitet Quellen inkrementell und aktualisiert den YAML-Index
im selben Durchlauf. Basiert auf der DB-Wissensdatenbank-ETL-Architektur.
Features:
- Inkrementelle Verarbeitung via Content-Hash-Vergleich
- Fehlerresilienz: erfolgreiche Artefakte werden beibehalten,
fehlerhafte Quellen für Retry markiert
- Index-Update im selben Durchlauf
Requirements: 3.2, 3.6, 3.10, 3.12, 3.20, 3.21
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from monorepo.knowledge.artifact import (
Artifact,
ArtifactMetadata,
compute_content_hash,
write_artifact,
)
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource, SourceError
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Ergebnis-Datenmodell
# ---------------------------------------------------------------------------
@dataclass
class IngestError:
"""Fehler bei der Ingestion eines einzelnen Artefakts."""
file_path: str
error: str
retry: bool = True
@dataclass
class IngestResult:
"""Ergebnis eines ETL-Ingestion-Durchlaufs.
Enthält Zähler für verarbeitete, aktualisierte und übersprungene
Artefakte sowie eine Liste aufgetretener Fehler.
"""
processed: int = 0
updated: int = 0
skipped: int = 0
errors: list[IngestError] = field(default_factory=list)
@property
def success(self) -> bool:
"""True wenn mindestens ein Artefakt verarbeitet wurde und keine fatalen Fehler."""
return self.processed > 0 or (self.processed == 0 and len(self.errors) == 0)
# ---------------------------------------------------------------------------
# ETL-Pipeline
# ---------------------------------------------------------------------------
class ETLPipeline:
"""ETL-Pipeline für den Wissensspeicher.
Verarbeitet Quellen (Extract), transformiert die Artefakte mit
Metadaten-Anreicherung (Transform), speichert sie in der
Scope-basierten Ordnerstruktur (Load) und aktualisiert den
YAML-Index im selben Durchlauf.
Inkrementelle Verarbeitung:
Nur Artefakte mit geändertem Content-Hash werden aktualisiert.
Unveränderte Artefakte werden übersprungen.
Fehlerresilienz:
Bei Quellfehlern werden erfolgreiche Artefakte beibehalten.
Fehlgeschlagene Quellen werden für Retry markiert.
"""
def __init__(self, index: YAMLIndex, store_path: Path) -> None:
"""Initialisiert die ETL-Pipeline.
Args:
index: Der YAML-Index für Progressive Disclosure.
store_path: Basispfad des Wissensspeichers (für Artefakt-Ablage).
"""
self.index = index
self.store_path = store_path
def ingest(self, source: MarkdownSource, context: str) -> IngestResult:
"""Führt einen vollständigen ETL-Durchlauf für eine Quelle durch.
1. Extract: Artefakte aus der Quelle extrahieren
2. Transform: Metadaten anreichern (Scope, Datum, Content-Hash)
3. Load: Nur geänderte Artefakte in die Ordnerstruktur schreiben
4. Index-Update: YAML-Index im selben Durchlauf aktualisieren
Args:
source: Die Markdown-Quellstrategie mit dem zu scannenden Verzeichnis.
context: Der Arbeitskontext der Quelle (z.B. "bahn", "privat").
Returns:
IngestResult mit Statistiken und aufgetretenen Fehlern.
"""
result = IngestResult()
# --- Extract ---
try:
artifacts = source.extract()
except Exception as e:
logger.error(
"Fataler Fehler bei der Extraktion aus %s: %s",
source.directory,
e,
)
result.errors.append(
IngestError(
file_path=str(source.directory),
error=f"Extraktion fehlgeschlagen: {e}",
retry=True,
)
)
return result
# Quellfehler als IngestErrors übernehmen
for src_error in source.errors:
result.errors.append(
IngestError(
file_path=src_error.file_path,
error=src_error.error,
retry=src_error.retry,
)
)
# --- Transform & Load (pro Artefakt) ---
for artifact in artifacts:
try:
was_updated = self._process_artifact(artifact, context)
result.processed += 1
if was_updated:
result.updated += 1
else:
result.skipped += 1
except Exception as e:
logger.error(
"Fehler bei der Verarbeitung von %s: %s",
artifact.file_path,
e,
)
result.errors.append(
IngestError(
file_path=str(artifact.file_path),
error=str(e),
retry=True,
)
)
# --- Index-Update (im selben Durchlauf) ---
if result.processed > 0:
try:
self.index.save()
except Exception as e:
logger.error("Fehler beim Speichern des Index: %s", e)
result.errors.append(
IngestError(
file_path=str(self.index.index_path),
error=f"Index-Speicherung fehlgeschlagen: {e}",
retry=True,
)
)
return result
def _process_artifact(self, artifact: Artifact, context: str) -> bool:
"""Verarbeitet ein einzelnes Artefakt (Transform + Load + Index).
Returns:
True wenn das Artefakt aktualisiert wurde,
False wenn es übersprungen wurde (unverändert).
"""
# --- Transform: Metadaten anreichern ---
self._enrich_metadata(artifact, context)
# Content-Hash berechnen
new_hash = compute_content_hash(artifact.content)
# Artefakt-ID bestimmen
artifact_id = self._compute_artifact_id(artifact, context)
# --- Inkrementelle Verarbeitung: Hash-Vergleich ---
existing_entry = self.index.get_entry(artifact_id)
if existing_entry is not None and existing_entry.content_hash == new_hash:
# Inhalt unverändert → überspringen
return False
# --- Load: Artefakt in Ordnerstruktur schreiben ---
artifact.metadata.content_hash = new_hash
written_path = write_artifact(artifact, self.store_path)
# --- Index-Update ---
entry = IndexEntry(
id=artifact_id,
title=artifact.metadata.title,
type=artifact.metadata.type,
tags=artifact.metadata.tags,
scope=context,
summary=self._generate_summary(artifact.content),
path=str(written_path.relative_to(self.store_path)),
content_hash=new_hash,
links=[link.target for link in artifact.metadata.links],
)
self.index.update_entry(entry)
return True
def _enrich_metadata(self, artifact: Artifact, context: str) -> None:
"""Reichert die Artefakt-Metadaten mit Kontext und Datum an.
Setzt source_context und created/updated-Datum falls nicht vorhanden.
"""
if not artifact.metadata.source_context:
artifact.metadata.source_context = context
today = date.today()
if artifact.metadata.created is None:
artifact.metadata.created = today
if artifact.metadata.updated is None:
artifact.metadata.updated = today
else:
# Bei Update das Aktualisierungsdatum auf heute setzen
artifact.metadata.updated = today
def _compute_artifact_id(self, artifact: Artifact, context: str) -> str:
"""Berechnet die eindeutige ID eines Artefakts.
Format: {context}/{type}/{filename_without_extension}
"""
name = artifact.file_path.stem if artifact.file_path.stem else "untitled"
artifact_type = artifact.metadata.type or "note"
return f"{context}/{artifact_type}/{name}"
def _generate_summary(self, content: str, max_length: int = 150) -> str:
"""Erzeugt eine Kurzbeschreibung aus dem Inhalt.
Nimmt die erste nicht-leere Zeile (ohne Markdown-Heading-Marker)
und kürzt sie auf max_length Zeichen.
"""
for line in content.splitlines():
stripped = line.strip()
if not stripped:
continue
# Heading-Marker entfernen
if stripped.startswith("#"):
stripped = stripped.lstrip("#").strip()
if stripped:
if len(stripped) > max_length:
return stripped[:max_length - 3] + "..."
return stripped
return ""
@@ -0,0 +1,263 @@
"""YAML-Index für den Wissensspeicher (Progressive Disclosure Schicht 1).
Der Index enthält ausschließlich kompakte Metadaten-Einträge (Titel, Tags,
Beziehungen, Kurzbeschreibungen, Pfade) niemals den vollständigen
Dokumentinhalt. Agenten lesen zuerst den Index und laden bei Bedarf
gezielt einzelne Dateien nach.
Format:
version: "1.0"
last_updated: "2025-01-15T10:30:00Z"
artifacts:
- id: "bahn/decisions/api-design"
title: "..."
type: decision
tags: [...]
scope: bahn
summary: "..."
path: "..."
content_hash: "sha256:..."
links: [...]
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import yaml
@dataclass
class IndexEntry:
"""Ein einzelner Eintrag im YAML-Index (Progressive Disclosure).
Enthält nur kompakte Metadaten kein vollständiger Dokumentinhalt.
"""
id: str
title: str
type: str
tags: list[str] = field(default_factory=list)
scope: str = ""
summary: str = ""
path: str = ""
content_hash: str = ""
links: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, object]:
"""Serialisiert den Eintrag als dict für YAML-Export."""
return {
"id": self.id,
"title": self.title,
"type": self.type,
"tags": self.tags,
"scope": self.scope,
"summary": self.summary,
"path": self.path,
"content_hash": self.content_hash,
"links": self.links,
}
@classmethod
def from_dict(cls, data: dict[str, object]) -> IndexEntry:
"""Erstellt einen IndexEntry aus einem dict (YAML-Import)."""
return cls(
id=str(data.get("id", "")),
title=str(data.get("title", "")),
type=str(data.get("type", "")),
tags=list(data.get("tags", [])), # type: ignore[arg-type]
scope=str(data.get("scope", "")),
summary=str(data.get("summary", "")),
path=str(data.get("path", "")),
content_hash=str(data.get("content_hash", "")),
links=list(data.get("links", [])), # type: ignore[arg-type]
)
class YAMLIndex:
"""Progressive-Disclosure-Index für den Wissensspeicher.
Verwaltet eine YAML-Datei mit kompakten Metadaten-Einträgen.
Erzwingt, dass niemals vollständiger Dokumentinhalt im Index landet.
"""
VERSION = "1.0"
def __init__(self, index_path: Path) -> None:
"""Initialisiert den YAMLIndex.
Args:
index_path: Pfad zur YAML-Index-Datei (_index.yaml).
"""
self.index_path = index_path
self._version: str = self.VERSION
self._last_updated: str = ""
self._entries: dict[str, IndexEntry] = {}
@property
def version(self) -> str:
"""Aktuelle Index-Version."""
return self._version
@property
def last_updated(self) -> str:
"""Zeitstempel der letzten Aktualisierung (ISO 8601)."""
return self._last_updated
@property
def entries(self) -> dict[str, IndexEntry]:
"""Alle Index-Einträge, indexiert nach ID."""
return dict(self._entries)
def load(self) -> None:
"""Lädt den YAML-Index von der Festplatte.
Wenn die Datei nicht existiert, wird ein leerer Index initialisiert.
Raises:
yaml.YAMLError: Wenn die YAML-Datei nicht geparst werden kann.
ValueError: Wenn das Format ungültig ist.
"""
if not self.index_path.exists():
self._entries = {}
self._last_updated = ""
self._version = self.VERSION
return
with open(self.index_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
self._entries = {}
self._last_updated = ""
self._version = self.VERSION
return
if not isinstance(data, dict):
raise ValueError(
f"Ungültiges Index-Format: Erwartet dict, erhalten {type(data).__name__}"
)
self._version = str(data.get("version", self.VERSION))
self._last_updated = str(data.get("last_updated", ""))
self._entries = {}
for artifact_data in data.get("artifacts", []):
if isinstance(artifact_data, dict):
entry = IndexEntry.from_dict(artifact_data)
self._entries[entry.id] = entry
def save(self) -> None:
"""Persistiert den Index auf die Festplatte.
Aktualisiert den last_updated-Zeitstempel und schreibt alle
Einträge im definierten YAML-Format.
"""
self._last_updated = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
data = {
"version": self._version,
"last_updated": self._last_updated,
"artifacts": [entry.to_dict() for entry in self._entries.values()],
}
# Sicherstellen, dass das Verzeichnis existiert
self.index_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
def update_entry(self, entry: IndexEntry) -> None:
"""Fügt einen Eintrag hinzu oder aktualisiert einen bestehenden.
Progressive Disclosure wird erzwungen: Der Eintrag darf nur
kompakte Metadaten enthalten (kein vollständiger Inhalt).
Args:
entry: Der hinzuzufügende oder zu aktualisierende IndexEntry.
Raises:
ValueError: Wenn der Eintrag keine gültige ID hat.
"""
if not entry.id:
raise ValueError("IndexEntry muss eine nicht-leere ID haben.")
self._entries[entry.id] = entry
def search(self, query: str, allowed_scopes: list[str]) -> list[IndexEntry]:
"""Textsuche über den Index mit Scope-Filterung.
Durchsucht Titel, Tags, Summary und ID nach dem Suchbegriff.
Liefert nur Einträge aus erlaubten Scopes zurück Einträge
aus nicht-autorisierten Scopes werden weder zurückgegeben
noch wird deren Existenz offengelegt.
Args:
query: Suchbegriff (case-insensitive Teilstring-Suche).
allowed_scopes: Liste der Scopes, aus denen Ergebnisse
zurückgegeben werden dürfen.
Returns:
Liste passender IndexEntry-Objekte, gefiltert nach Scope.
"""
if not query:
return []
query_lower = query.lower()
results: list[IndexEntry] = []
for entry in self._entries.values():
# Scope-Filter: Nur autorisierte Scopes
if entry.scope not in allowed_scopes:
continue
# Textsuche über relevante Felder
searchable_text = " ".join([
entry.title,
entry.id,
entry.summary,
" ".join(entry.tags),
]).lower()
if query_lower in searchable_text:
results.append(entry)
return results
def get_by_scope(self, scope: str) -> list[IndexEntry]:
"""Liefert alle Einträge eines bestimmten Scopes.
Args:
scope: Der Scope, nach dem gefiltert wird (z.B. "bahn", "privat").
Returns:
Liste aller IndexEntry-Objekte mit dem angegebenen Scope.
"""
return [entry for entry in self._entries.values() if entry.scope == scope]
def remove_entry(self, entry_id: str) -> bool:
"""Entfernt einen Eintrag aus dem Index.
Args:
entry_id: Die ID des zu entfernenden Eintrags.
Returns:
True wenn der Eintrag entfernt wurde, False wenn er nicht existierte.
"""
if entry_id in self._entries:
del self._entries[entry_id]
return True
return False
def get_entry(self, entry_id: str) -> IndexEntry | None:
"""Liefert einen einzelnen Eintrag nach ID.
Args:
entry_id: Die ID des gesuchten Eintrags.
Returns:
Der IndexEntry oder None, wenn nicht gefunden.
"""
return self._entries.get(entry_id)
@@ -0,0 +1,486 @@
"""NoteGraph-Migration für den Wissensspeicher.
Migriert eine bestehende NoteGraph-Verzeichnisstruktur (decisions, inbox,
meetings, people, projects) in den KnowledgeStore. Nutzt die ETLPipeline
für die Ingestion und erweitert Artefakte mit fehlenden Metadaten.
Scope-Konfiguration: Mapping Arbeitskontext → Scope wird aus scopes.yaml geladen.
Requirements: 3.5, 3.14
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from monorepo.knowledge.artifact import (
Artifact,
ArtifactMetadata,
parse_frontmatter,
)
from monorepo.knowledge.etl import ETLPipeline, IngestResult
from monorepo.knowledge.index import YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ScopeConfig
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# NoteGraph-Verzeichnis → ArtifactType Mapping
# ---------------------------------------------------------------------------
NOTEGRAPH_DIR_TYPE_MAP: dict[str, str] = {
"decisions": "decision",
"inbox": "note",
"meetings": "meeting",
"people": "reference",
"projects": "project",
}
"""Standard-NoteGraph-Verzeichnisse und ihr zugeordneter Artefakt-Typ."""
# ---------------------------------------------------------------------------
# Ergebnis-Datenmodelle
# ---------------------------------------------------------------------------
@dataclass
class MigrationResult:
"""Ergebnis einer NoteGraph-Migration.
Attributes:
migrated_count: Anzahl erfolgreich migrierter Artefakte.
skipped_count: Anzahl übersprungener Artefakte (unverändert oder ohne Frontmatter).
errors: Liste der aufgetretenen Fehler mit Details.
"""
migrated_count: int = 0
skipped_count: int = 0
errors: list[str] = field(default_factory=list)
@property
def success(self) -> bool:
"""True wenn Migration durchgeführt wurde ohne fatale Fehler."""
return self.migrated_count > 0 or (
self.migrated_count == 0 and len(self.errors) == 0
)
@dataclass
class ValidationDetail:
"""Detail zu einem fehlenden oder gefundenen Artefakt bei der Validierung."""
file_name: str
expected_type: str
found_in_index: bool
artifact_id: str = ""
@dataclass
class ValidationResult:
"""Ergebnis der Migrations-Validierung.
Attributes:
valid: True wenn alle Artefakte im Index auffindbar sind.
total: Gesamtanzahl erwarteter Artefakte.
found_in_index: Anzahl der im Index gefundenen Artefakte.
missing: Liste fehlender Artefakt-IDs.
details: Detaillierte Ergebnisse pro Artefakt.
"""
valid: bool = True
total: int = 0
found_in_index: int = 0
missing: list[str] = field(default_factory=list)
details: list[ValidationDetail] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Scope-Konfiguration laden
# ---------------------------------------------------------------------------
def load_scope_config(scope_config_path: Path) -> ScopeConfig:
"""Lädt die Scope-Konfiguration aus einer YAML-Datei.
Die Datei definiert das Mapping Arbeitskontext → Scope für den
Wissensspeicher (Requirement 3.14).
Args:
scope_config_path: Pfad zur scopes.yaml Datei.
Returns:
ScopeConfig mit dem Scope-Mapping.
Raises:
FileNotFoundError: Wenn die Datei nicht existiert.
ValueError: Wenn das Format ungültig ist.
"""
if not scope_config_path.exists():
raise FileNotFoundError(
f"Scope-Konfiguration nicht gefunden: {scope_config_path}"
)
with open(scope_config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "scopes" not in data:
raise ValueError(
f"Ungültiges Scope-Konfigurationsformat in {scope_config_path}"
)
scopes: dict[str, dict[str, object]] = {}
for entry in data["scopes"]:
if isinstance(entry, dict) and "name" in entry:
context = str(entry.get("context", entry["name"]))
scopes[context] = {
"scope": str(entry["name"]),
"description": str(entry.get("description", "")),
"knowledge_path": str(entry.get("knowledge_path", "")),
}
return ScopeConfig(scopes=scopes)
# ---------------------------------------------------------------------------
# NoteGraph-Migrator
# ---------------------------------------------------------------------------
class NoteGraphMigrator:
"""Migriert NoteGraph-Verzeichnisstrukturen in den KnowledgeStore.
Der NoteGraph hat folgende Verzeichnisstruktur:
decisions/ → Typ: decision
inbox/ → Typ: note
meetings/ → Typ: meeting
people/ → Typ: reference
projects/ → Typ: project
Jedes Verzeichnis enthält .md-Dateien mit optionalem YAML-Frontmatter.
Der Migrator:
- Mappt NoteGraph-Verzeichnisse auf Artefakttypen
- Nutzt die ETLPipeline für die Ingestion
- Ergänzt fehlendes Frontmatter (Typ, source_context)
- Bewahrt vorhandene Metadaten
- Validiert, dass alle Artefakte im Zielsystem vorhanden und auffindbar sind
Requirements: 3.5, 3.14
"""
def __init__(self, store: KnowledgeStore, scope_config_path: Path) -> None:
"""Initialisiert den NoteGraphMigrator.
Args:
store: Der KnowledgeStore, in den migriert wird.
scope_config_path: Pfad zur scopes.yaml für Scope-Mapping.
"""
self.store = store
self.scope_config_path = scope_config_path
self.scope_config = load_scope_config(scope_config_path)
def migrate(self, source_path: Path, context: str) -> MigrationResult:
"""Migriert eine NoteGraph-Verzeichnisstruktur in den KnowledgeStore.
Durchsucht die NoteGraph-Verzeichnisse (decisions, inbox, meetings,
people, projects) und migriert alle .md-Dateien. Dateien ohne
YAML-Frontmatter werden mit generiertem Frontmatter versehen.
Args:
source_path: Pfad zum NoteGraph-Wurzelverzeichnis.
context: Arbeitskontext für die migrierten Artefakte
(z.B. "privat", "bahn").
Returns:
MigrationResult mit Statistiken und Fehlerdetails.
"""
result = MigrationResult()
if not source_path.exists():
result.errors.append(
f"NoteGraph-Verzeichnis existiert nicht: {source_path}"
)
return result
if not source_path.is_dir():
result.errors.append(
f"NoteGraph-Pfad ist kein Verzeichnis: {source_path}"
)
return result
# Jedes NoteGraph-Unterverzeichnis verarbeiten
for dir_name, artifact_type in NOTEGRAPH_DIR_TYPE_MAP.items():
sub_dir = source_path / dir_name
if not sub_dir.exists() or not sub_dir.is_dir():
logger.debug(
"NoteGraph-Unterverzeichnis existiert nicht, überspringe: %s",
sub_dir,
)
continue
sub_result = self._migrate_directory(sub_dir, context, artifact_type)
result.migrated_count += sub_result.migrated_count
result.skipped_count += sub_result.skipped_count
result.errors.extend(sub_result.errors)
return result
def _migrate_directory(
self, directory: Path, context: str, artifact_type: str
) -> MigrationResult:
"""Migriert alle .md-Dateien eines NoteGraph-Unterverzeichnisses.
Bereitet Dateien ohne Frontmatter vor und nutzt dann die ETLPipeline.
Args:
directory: Das zu migrierende Verzeichnis.
context: Arbeitskontext.
artifact_type: Der zugeordnete Artefakt-Typ.
Returns:
MigrationResult für dieses Verzeichnis.
"""
result = MigrationResult()
md_files = sorted(directory.rglob("*.md"))
if not md_files:
return result
# Dateien ohne Frontmatter vorbereiten (temporär Frontmatter hinzufügen)
prepared_files = self._prepare_files(md_files, context, artifact_type)
# ETLPipeline für die vorbereiteten Dateien verwenden
source = MarkdownSource(directory=directory)
# Wir nutzen den Store.ingest direkt, da die Dateien nun Frontmatter haben
ingest_result = self.store.ingest(source, context)
result.migrated_count = ingest_result.updated
result.skipped_count = ingest_result.skipped
for error in ingest_result.errors:
result.errors.append(f"{error.file_path}: {error.error}")
return result
def _prepare_files(
self,
md_files: list[Path],
context: str,
artifact_type: str,
) -> list[Path]:
"""Bereitet .md-Dateien für die Migration vor.
Dateien ohne YAML-Frontmatter erhalten generiertes Frontmatter
mit dem korrekten Typ und Kontext. Dateien mit existierendem
Frontmatter erhalten fehlende Metadaten (type, source_context).
Args:
md_files: Liste der zu verarbeitenden Markdown-Dateien.
context: Arbeitskontext.
artifact_type: Der Artefakt-Typ basierend auf dem NoteGraph-Verzeichnis.
Returns:
Liste der vorbereiteten Dateipfade.
"""
prepared: list[Path] = []
for md_file in md_files:
try:
content = md_file.read_text(encoding="utf-8")
except OSError as e:
logger.warning("Kann Datei nicht lesen: %s %s", md_file, e)
continue
updated_content = self._ensure_frontmatter(
content, md_file.stem, context, artifact_type
)
if updated_content != content:
md_file.write_text(updated_content, encoding="utf-8")
prepared.append(md_file)
return prepared
def _ensure_frontmatter(
self,
content: str,
file_stem: str,
context: str,
artifact_type: str,
) -> str:
"""Stellt sicher, dass der Inhalt gültiges YAML-Frontmatter enthält.
Wenn kein Frontmatter vorhanden ist, wird eines generiert.
Wenn Frontmatter existiert, werden fehlende Felder ergänzt
(type, source_context).
Args:
content: Der aktuelle Dateiinhalt.
file_stem: Dateiname ohne Erweiterung (für den Titel).
context: Arbeitskontext.
artifact_type: Der Artefakt-Typ.
Returns:
Der (möglicherweise aktualisierte) Dateiinhalt.
"""
if content.startswith("---"):
# Frontmatter existiert fehlende Felder ergänzen
return self._enrich_existing_frontmatter(
content, context, artifact_type
)
else:
# Kein Frontmatter generieren
return self._generate_frontmatter(
content, file_stem, context, artifact_type
)
def _enrich_existing_frontmatter(
self, content: str, context: str, artifact_type: str
) -> str:
"""Ergänzt fehlende Felder in existierendem YAML-Frontmatter.
Setzt 'type' und 'source_context' falls nicht vorhanden.
Args:
content: Dateiinhalt mit existierendem Frontmatter.
context: Arbeitskontext.
artifact_type: Der zu setzende Artefakt-Typ.
Returns:
Aktualisierter Dateiinhalt.
"""
import re
pattern = re.compile(
r"\A---\s*\n(.*?)^---\s*$\n?",
re.DOTALL | re.MULTILINE,
)
match = pattern.match(content)
if not match:
return content
yaml_text = match.group(1)
body = content[match.end():]
try:
metadata: dict[str, Any] = yaml.safe_load(yaml_text) or {}
except yaml.YAMLError:
return content
modified = False
# Typ ergänzen falls fehlend
if not metadata.get("type"):
metadata["type"] = artifact_type
modified = True
# source_context ergänzen falls fehlend
if not metadata.get("source_context"):
metadata["source_context"] = context
modified = True
if not modified:
return content
# Frontmatter neu generieren
new_yaml = yaml.dump(
metadata,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
return f"---\n{new_yaml}---\n{body}"
def _generate_frontmatter(
self, content: str, file_stem: str, context: str, artifact_type: str
) -> str:
"""Generiert neues YAML-Frontmatter für eine Datei ohne Frontmatter.
Erstellt minimales Frontmatter mit Typ, Titel und Quellkontext.
Args:
content: Der Dateiinhalt (ohne Frontmatter).
file_stem: Dateiname ohne Erweiterung.
context: Arbeitskontext.
artifact_type: Der Artefakt-Typ.
Returns:
Dateiinhalt mit vorangestelltem Frontmatter.
"""
# Titel aus Dateiname ableiten (kebab-case → Title Case)
title = file_stem.replace("-", " ").replace("_", " ").title()
metadata = {
"type": artifact_type,
"title": title,
"tags": [],
"source_context": context,
"shareable": False,
}
yaml_str = yaml.dump(
metadata,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
return f"---\n{yaml_str}---\n{content}"
def validate(self, context: str) -> ValidationResult:
"""Validiert, dass alle migrierten Artefakte im Index auffindbar sind.
Prüft für den angegebenen Kontext:
- Alle erwarteten Artefakte sind im YAML-Index vorhanden
- Metadaten (Typ, Scope) sind korrekt gesetzt
- Verknüpfungen sind intakt
Args:
context: Der Arbeitskontext, für den validiert wird.
Returns:
ValidationResult mit detaillierten Ergebnissen.
"""
result = ValidationResult()
# Alle Einträge für diesen Scope aus dem Index holen
index_entries = self.store.get_index(scope=context)
index_ids = {entry.id for entry in index_entries}
result.total = len(index_entries)
result.found_in_index = len(index_entries)
# Prüfe ob jeder Index-Eintrag eine gültige Datei referenziert
for entry in index_entries:
artifact_path = self.store.base_path / entry.path if entry.path else None
file_exists = artifact_path.exists() if artifact_path else False
detail = ValidationDetail(
file_name=entry.path,
expected_type=entry.type,
found_in_index=True,
artifact_id=entry.id,
)
result.details.append(detail)
if not file_exists:
result.missing.append(entry.id)
result.valid = False
# Zusätzlich prüfen: Haben alle Dateien einen korrekten Scope?
for entry in index_entries:
if entry.scope != context:
result.missing.append(
f"{entry.id} (falscher Scope: {entry.scope}, erwartet: {context})"
)
result.valid = False
return result
@@ -0,0 +1,11 @@
"""Quellstrategien für die ETL-Pipeline des Wissensspeichers.
Jede Quellstrategie implementiert das Extrahieren von Wissensartefakten
aus einem bestimmten Format/Medium (Markdown, Confluence, PDF, etc.).
"""
from monorepo.knowledge.sources.markdown import MarkdownSource
__all__ = [
"MarkdownSource",
]
@@ -0,0 +1,111 @@
"""Markdown-Quellstrategie für die ETL-Pipeline.
Scannt ein Verzeichnis rekursiv nach .md-Dateien mit YAML-Frontmatter
und liefert eine Liste von Artifacts zurück.
Requirements: 3.2, 3.10, 3.12, 3.19
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from monorepo.knowledge.artifact import Artifact, parse_frontmatter
logger = logging.getLogger(__name__)
@dataclass
class SourceError:
"""Fehler bei der Verarbeitung einer einzelnen Quelldatei."""
file_path: str
error: str
retry: bool = True
@dataclass
class MarkdownSource:
"""Quellstrategie: Rekursives Scannen eines Verzeichnisses nach Markdown-Dateien.
Findet alle .md-Dateien im angegebenen Verzeichnis (rekursiv),
parsed jede Datei mit parse_frontmatter und liefert die resultierenden
Artifacts. Dateien ohne gültiges YAML-Frontmatter werden übersprungen
und als Warning geloggt.
"""
directory: Path
errors: list[SourceError] = field(default_factory=list)
def extract(self) -> list[Artifact]:
"""Extrahiert alle Artefakte aus dem Quellverzeichnis.
Durchsucht das Verzeichnis rekursiv nach .md-Dateien,
parsed jede Datei und sammelt Fehler ohne abzubrechen.
Returns:
Liste erfolgreich geparster Artifacts.
"""
self.errors = []
artifacts: list[Artifact] = []
if not self.directory.exists():
logger.warning(
"Quellverzeichnis existiert nicht: %s", self.directory
)
self.errors.append(
SourceError(
file_path=str(self.directory),
error=f"Verzeichnis existiert nicht: {self.directory}",
retry=True,
)
)
return artifacts
if not self.directory.is_dir():
logger.warning(
"Quellpfad ist kein Verzeichnis: %s", self.directory
)
self.errors.append(
SourceError(
file_path=str(self.directory),
error=f"Pfad ist kein Verzeichnis: {self.directory}",
retry=False,
)
)
return artifacts
md_files = sorted(self.directory.rglob("*.md"))
for md_file in md_files:
try:
artifact = parse_frontmatter(md_file)
artifacts.append(artifact)
except ValueError as e:
logger.warning(
"Überspringe Datei ohne gültiges Frontmatter: %s %s",
md_file,
e,
)
self.errors.append(
SourceError(
file_path=str(md_file),
error=str(e),
retry=False,
)
)
except OSError as e:
logger.error(
"Fehler beim Lesen der Datei: %s %s", md_file, e
)
self.errors.append(
SourceError(
file_path=str(md_file),
error=str(e),
retry=True,
)
)
return artifacts
@@ -0,0 +1,352 @@
"""KnowledgeStore Zentraler Wissensspeicher mit ETL-Pipeline.
Basiert auf der DB-Wissensdatenbank-ETL-Architektur und bietet:
- Ingestion von Wissensartefakten aus verschiedenen Quellen
- Progressive-Disclosure-Index (kompakte YAML-Metadaten, Schicht 1)
- Volltextsuche mit Scope-basierter Zugriffskontrolle und Relevanz-Sortierung
- Graph-Verknüpfungen zwischen Artefakten
- Timeout-Handling für Suchanfragen (5 Sekunden)
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from monorepo.knowledge.artifact import ArtifactLink, parse_frontmatter, write_artifact
from monorepo.knowledge.etl import ETLPipeline, IngestResult
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource
from monorepo.models import ScopeConfig
logger = logging.getLogger(__name__)
# Default-Timeout für Suchanfragen in Sekunden
SEARCH_TIMEOUT_SECONDS: float = 5.0
@dataclass
class SearchResult:
"""Ergebnis einer Volltextsuche im Wissensspeicher.
Enthält den gefundenen Index-Eintrag, einen Relevanz-Score und
eine Information, ob die Suche wegen Timeout abgebrochen wurde.
Attributes:
entry: Der gefundene IndexEntry mit kompakten Metadaten.
relevance_score: Relevanz-Score zwischen 0.0 und 1.0.
Höhere Werte bedeuten bessere Treffer.
Scoring-Hierarchie: title > tags > summary > id.
partial_results: True wenn die Suche wegen Timeout abgebrochen
wurde und möglicherweise nicht alle Ergebnisse enthalten sind.
"""
entry: IndexEntry
relevance_score: float = 0.0
partial_results: bool = False
def _compute_relevance(query_lower: str, entry: IndexEntry) -> float:
"""Berechnet den Relevanz-Score für einen Index-Eintrag.
Scoring-Hierarchie (von hoch nach niedrig):
1. Title-Match (0.8 - 1.0): Query kommt im Titel vor
2. Tag-Match (0.6 - 0.79): Query kommt in einem Tag vor
3. Summary-Match (0.4 - 0.59): Query kommt im Summary vor
4. ID-Match (0.2 - 0.39): Query kommt in der ID vor
Innerhalb jeder Kategorie wird ein Bonus für exakte Übereinstimmung
oder einen höheren Anteil des Matchs vergeben.
Args:
query_lower: Suchbegriff in Kleinbuchstaben.
entry: Der zu bewertende IndexEntry.
Returns:
Relevanz-Score zwischen 0.0 und 1.0.
"""
title_lower = entry.title.lower()
tags_lower = [t.lower() for t in entry.tags]
summary_lower = entry.summary.lower()
id_lower = entry.id.lower()
score = 0.0
# 1. Title match (highest priority: 0.8 - 1.0)
if query_lower in title_lower:
# Bonus for exact match or high coverage
if title_lower == query_lower:
score = max(score, 1.0)
else:
coverage = len(query_lower) / max(len(title_lower), 1)
score = max(score, 0.8 + coverage * 0.19)
# 2. Tag match (0.6 - 0.79)
for tag in tags_lower:
if query_lower in tag:
if tag == query_lower:
score = max(score, 0.79)
else:
score = max(score, 0.6 + len(query_lower) / max(len(tag), 1) * 0.18)
# 3. Summary match (0.4 - 0.59)
if query_lower in summary_lower:
coverage = len(query_lower) / max(len(summary_lower), 1)
score = max(score, 0.4 + coverage * 0.19)
# 4. ID match (0.2 - 0.39)
if query_lower in id_lower:
coverage = len(query_lower) / max(len(id_lower), 1)
score = max(score, 0.2 + coverage * 0.19)
return min(score, 1.0)
class KnowledgeStore:
"""Zentraler Wissensspeicher mit ETL-Pipeline.
Der KnowledgeStore verwaltet Wissensartefakte aus allen Arbeitskontexten,
indexiert sie im YAML-Index und stellt Suchmechanismen bereit.
Progressive Disclosure:
- Schicht 1 (Index): Kompakte Metadaten (Titel, Tags, Pfade, Summary)
- Schicht 2 (Dokument): Vollständige Markdown-Datei mit YAML-Frontmatter
Agenten lesen zuerst den Index und laden nur bei Bedarf einzelne Dokumente.
"""
INDEX_FILENAME = "_index.yaml"
def __init__(self, base_path: Path, scope_config: ScopeConfig) -> None:
"""Initialisiert den KnowledgeStore.
Args:
base_path: Basispfad des Wissensspeichers
(z.B. shared/knowledge-store/).
scope_config: Konfiguration des Scope-Mappings
(Arbeitskontext → Scope).
"""
self.base_path = base_path
self.scope_config = scope_config
self.index = YAMLIndex(base_path / self.INDEX_FILENAME)
# Index beim Start laden, sofern vorhanden
self.index.load()
def ingest(self, source: MarkdownSource, context: str) -> IngestResult:
"""Verarbeitet eine Quelle und legt Artefakte im Wissensspeicher ab.
Delegiert an die ETLPipeline, die:
- Artefakte aus der Quelle extrahiert
- Metadaten anreichert (Scope, Datum, Content-Hash)
- Inkrementell verarbeitet (nur geänderte Artefakte aktualisiert)
- Den YAML-Index im selben Durchlauf aktualisiert
- Bei Fehlern erfolgreiche Artefakte behält und Fehler protokolliert
Args:
source: Die Markdown-Quellstrategie mit dem zu scannenden Verzeichnis.
context: Der Arbeitskontext, aus dem die Quelle stammt
(z.B. "bahn", "privat").
Returns:
IngestResult mit Statistiken (processed, updated, skipped, errors).
"""
pipeline = ETLPipeline(index=self.index, store_path=self.base_path)
return pipeline.ingest(source, context)
def search(
self,
query: str,
allowed_scopes: list[str],
timeout: float | None = None,
) -> list[SearchResult]:
"""Volltextsuche über den Index mit Relevanz-Sortierung und Timeout.
Durchsucht den YAML-Index nach dem Suchbegriff und liefert
nur Ergebnisse aus autorisierten Scopes, sortiert nach Relevanz.
Nicht-autorisierte Artefakte werden weder zurückgegeben noch
wird deren Existenz offengelegt.
Relevanz-Scoring-Hierarchie (höchste zuerst):
1. Title-Match
2. Tag-Match
3. Summary-Match
4. ID-Match
Timeout-Handling:
Die Suche bricht nach dem konfigurierten Timeout (Standard:
5 Sekunden) ab und liefert die bis dahin gefundenen
Teilergebnisse mit partial_results=True.
Args:
query: Suchbegriff für die Volltextsuche.
allowed_scopes: Liste der Scopes, aus denen Ergebnisse
geliefert werden dürfen.
timeout: Timeout in Sekunden (None = SEARCH_TIMEOUT_SECONDS).
Returns:
Liste von SearchResult-Objekten, sortiert nach Relevanz
(höchster Score zuerst). Jedes Ergebnis enthält nur Pfade
und Metadaten kein vollständiger Inhalt (Progressive Disclosure).
Note:
Requirements: 3.3, 3.7, 3.9
"""
if not query:
return []
effective_timeout = timeout if timeout is not None else SEARCH_TIMEOUT_SECONDS
query_lower = query.lower()
results: list[SearchResult] = []
timed_out = False
start_time = time.monotonic()
for entry in self.index.entries.values():
# Timeout-Check
elapsed = time.monotonic() - start_time
if elapsed >= effective_timeout:
timed_out = True
break
# Scope-Filter: Nur autorisierte Scopes (niemals Existenz
# nicht-autorisierter Artefakte offenlegen)
if entry.scope not in allowed_scopes:
continue
# Textsuche über relevante Felder
searchable_text = " ".join([
entry.title,
entry.id,
entry.summary,
" ".join(entry.tags),
]).lower()
if query_lower in searchable_text:
relevance = _compute_relevance(query_lower, entry)
results.append(SearchResult(
entry=entry,
relevance_score=relevance,
partial_results=False,
))
# Bei Timeout: Alle bisherigen Ergebnisse als partial markieren
if timed_out:
for result in results:
result.partial_results = True
# Nach Relevanz sortieren (höchster Score zuerst)
results.sort(key=lambda r: r.relevance_score, reverse=True)
return results
def get_index(self, scope: str | None = None) -> list[IndexEntry]:
"""Liefert den kompakten YAML-Index (Progressive Disclosure Schicht 1).
Gibt nur Metadaten-Einträge zurück niemals den vollständigen
Dokumentinhalt. Agenten können über die Pfade in den Einträgen
bei Bedarf einzelne Dokumente nachladen.
Args:
scope: Optionaler Scope-Filter. Wenn None, werden alle
Einträge zurückgegeben.
Returns:
Liste von IndexEntry-Objekten mit kompakten Metadaten.
"""
if scope is not None:
return self.index.get_by_scope(scope)
return list(self.index.entries.values())
def link_artifacts(self, source_id: str, target_id: str, relation: str) -> None:
"""Verknüpft zwei Artefakte als gerichtete Graph-Kante.
Erstellt eine bidirektionale Verknüpfung zwischen zwei Artefakten:
- Im Quell-Artefakt wird die Beziehung zum Ziel eingetragen
- Im Ziel-Artefakt wird die inverse Beziehung zur Quelle eingetragen
- Der YAML-Index wird mit den neuen Link-Informationen aktualisiert
- Die YAML-Frontmatter beider Artefakt-Dateien werden aktualisiert
Args:
source_id: ID des Quell-Artefakts.
target_id: ID des Ziel-Artefakts.
relation: Art der Beziehung (z.B. "implements", "references").
Raises:
ValueError: Wenn source_id oder target_id nicht im Index existiert.
"""
source_entry = self.index.get_entry(source_id)
target_entry = self.index.get_entry(target_id)
if source_entry is None:
raise ValueError(f"Quell-Artefakt nicht im Index gefunden: {source_id}")
if target_entry is None:
raise ValueError(f"Ziel-Artefakt nicht im Index gefunden: {target_id}")
# Bidirektionale Verlinkung im Index
if target_id not in source_entry.links:
source_entry.links.append(target_id)
self.index.update_entry(source_entry)
if source_id not in target_entry.links:
target_entry.links.append(source_id)
self.index.update_entry(target_entry)
# YAML-Frontmatter beider Artefakt-Dateien aktualisieren
self._update_artifact_frontmatter_link(
source_entry, target_entry.path, relation
)
self._update_artifact_frontmatter_link(
target_entry, source_entry.path, relation
)
def _update_artifact_frontmatter_link(
self, entry: IndexEntry, link_target_path: str, relation: str
) -> None:
"""Aktualisiert das YAML-Frontmatter einer Artefakt-Datei mit einem neuen Link.
Liest die Artefakt-Datei, prüft ob der Link bereits vorhanden ist,
fügt ihn bei Bedarf hinzu und schreibt die Datei zurück.
Wenn die Datei nicht existiert, wird nur der Index aktualisiert
(kein Fehler die Datei wird ggf. später erstellt).
Args:
entry: Der IndexEntry des Artefakts, dessen Datei aktualisiert werden soll.
link_target_path: Der Pfad des Ziel-Artefakts (für den links-Eintrag).
relation: Art der Beziehung.
"""
if not entry.path:
return
artifact_path = self.base_path / entry.path
if not artifact_path.exists():
logger.debug(
"Artefakt-Datei existiert nicht, überspringe Frontmatter-Update: %s",
artifact_path,
)
return
try:
artifact = parse_frontmatter(artifact_path)
except (ValueError, FileNotFoundError) as e:
logger.warning(
"Konnte Artefakt-Datei nicht parsen, überspringe Frontmatter-Update: %s (%s)",
artifact_path,
e,
)
return
# Prüfe ob Link bereits vorhanden (Idempotenz)
for existing_link in artifact.metadata.links:
if existing_link.target == link_target_path and existing_link.relation == relation:
return # Link existiert bereits
# Neuen Link hinzufügen
artifact.metadata.links.append(
ArtifactLink(target=link_target_path, relation=relation)
)
# Datei zurückschreiben (mit aktualisiertem Frontmatter)
write_artifact(artifact, self.base_path)