Initial monorepo structure
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user