Files
mission-control-v2/deploy/skill-kanten-generator.py
T

380 lines
13 KiB
Python

#!/usr/bin/env python3
"""
skill-kanten-generator.py
Generiert Kanten für den Skills-Journey-Graphen basierend auf:
- Kanal A: Manuelle Kanten (aus Memory)
- Kanal B: SKILL.md-Metadaten (gleiche Kategorie, related_skills)
- Kanal C: Co-Nutzung (Session-Analyse, TODO: noch nicht implementiert)
Ergebnis: journey-graph.json im Werkstatt-Skills-Verzeichnis.
"""
from __future__ import annotations
import json
import os
import re
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
# Pfade
HERMES_WORKSPACE = Path.home() / ".hermes" / "profiles" / "werkstatt"
SKILLS_DIR = HERMES_WORKSPACE / "skills"
USAGE_FILE = SKILLS_DIR / ".usage.json"
MEMORY_FILE = HERMES_WORKSPACE / "memories" / "MEMORY.md"
JOURNEY_GRAPH_FILE = SKILLS_DIR / "journey-graph.json"
# Thresholds für Kanal C (Co-Nutzung)
MIN_CO_USE = 2
MIN_USE_COUNT = 3
JACCARD_THRESHOLD = 0.3
# Gewichte für Priorisierung
WEIGHT_MANUAL = 1.0
WEIGHT_RELATED_DECLARED = 0.9
WEIGHT_CATEGORY_MATCH = 0.7
WEIGHT_TAG_OVERLAP = 0.5
@dataclass
class Edge:
"""Repräsentiert eine Kante im Journey-Graphen."""
skill_a: str
skill_b: str
weight: float
edge_type: str
source: str
def key(self) -> Tuple[str, str]:
return (self.skill_a, self.skill_b) if self.skill_a < self.skill_b else (self.skill_b, self.skill_a)
def to_dict(self) -> Dict:
return {
"skill_a": self.skill_a,
"skill_b": self.skill_b,
"weight": self.weight,
"type": self.edge_type,
"source": self.source,
}
@dataclass
class SkillInfo:
"""Zusammengeführte Informationen zu einem Skill."""
name: str
category: Optional[str] = None
related_skills: List[str] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
use_count: int = 0
state: str = "active"
def load_manual_edges_from_memory() -> List[Edge]:
"""Liest manuelle Kanten aus dem MEMORY.md-Eintrag 'Skills-Kanten (Journey-Graph)'."""
edges: List[Edge] = []
if not MEMORY_FILE.exists():
print(f"Warnung: MEMORY.md nicht gefunden unter {MEMORY_FILE}")
return edges
content = MEMORY_FILE.read_text(encoding="utf-8")
# Suche nach dem Eintrag "Skills-Kanten (Journey-Graph)"
pattern = r"Skills-Kanten \(Journey-Graph\):\s*(.+?)(?=\n\n|\Z)"
match = re.search(pattern, content, re.DOTALL)
if not match:
print("Keine manuellen Skills-Kanten im MEMORY.md gefunden.")
return edges
kanten_text = match.group(1).strip()
# Parso die Kanten: "skill-a ↔ skill-b (Begründung)"
# Trennzeichen ist " | " für mehrere Kanten
kanten_liste = re.split(r"\s*\|\s*", kanten_text)
for kante in kanten_liste:
kante = kante.strip()
if not kante:
continue
# Format: skill-a ↔ skill-b (Begründung)
parts = kante.split("")
if len(parts) != 2:
print(f"Warnung: Ungültiges Kanten-Format: {kante}")
continue
skill_a = parts[0].strip()
skill_b_and_reason = parts[1].strip()
# Trenne Skill-B und Begründung (in Klammern)
reason_match = re.match(r"(.+?)\s*\((.+?)\)\s*$", skill_b_and_reason)
if reason_match:
skill_b = reason_match.group(1).strip()
# reason = reason_match.group(2).strip() # nicht verwendet, aber verfügbar
else:
skill_b = skill_b_and_reason
print(f"Warnung: Keine Begründung in Kante: {kante}")
edges.append(Edge(
skill_a=skill_a,
skill_b=skill_b,
weight=WEIGHT_MANUAL,
edge_type="manual",
source="memory"
))
print(f"Manuelle Kanten geladen: {len(edges)}")
return edges
def build_skill_index() -> Dict[str, SkillInfo]:
"""Erstellt einen Index aller Skills aus .usage.json und SKILL.md-Dateien."""
skills: Dict[str, SkillInfo] = {}
# 1. Lade .usage.json für use_count und state
if not USAGE_FILE.exists():
print(f"Warnung: .usage.json nicht gefunden unter {USAGE_FILE}")
return skills
usage_data = json.loads(USAGE_FILE.read_text(encoding="utf-8"))
# Extrahiere Skill-Namen aus keys (es gibt auch "Mission Control Model Management" etc.)
for skill_name, info in usage_data.items():
# Normalisiere Namen (ersetze Leerzeichen, Sonderzeichen für Verzeichnisnamen)
normalized_name = skill_name.lower().replace(" ", "-").replace("_", "-")
skill = SkillInfo(
name=skill_name,
use_count=info.get("use_count", 0),
state=info.get("state", "active")
)
skills[normalized_name] = skill
# 2. Lade SKILL.md Metadaten für Kategorien, Tags, related_skills
# Suche in allen Kategorien-Verzeichnissen
for category_dir in SKILLS_DIR.iterdir():
if not category_dir.is_dir() or category_dir.name.startswith("."):
continue
for skill_dir in category_dir.iterdir():
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
continue
content = skill_md.read_text(encoding="utf-8")
# YAML-Frontmatter extrahieren (alles vor der ersten leeren Zeile nach ---)
frontmatter_match = re.match(r"---\s*(.+?)\s*---", content, re.DOTALL)
if not frontmatter_match:
continue
frontmatter = frontmatter_match.group(1)
# Parse YAML-Felder (einfache Regex-basierte Parsers)
category_match = re.search(r"category:\s*(['\"]?)(\w+)\1", frontmatter)
tags_match = re.search(r"tags:\s*\[(.*?)\]", frontmatter, re.DOTALL)
related_match = re.search(r"related_skills:\s*\[(.*?)\]", frontmatter, re.DOTALL)
# Extrahiere den Skill-Namen aus dem Frontmatter
name_match = re.search(r"name:\s*['\"]?([^\n'\"]+)['\"]?", frontmatter)
if name_match:
skill_name = name_match.group(1).strip().lower().replace(" ", "-")
if skill_name in skills:
if category_match:
skills[skill_name].category = category_match.group(2).lower()
if tags_match:
tags_str = tags_match.group(1)
tags = [t.strip().strip("'\"") for t in tags_str.split(",") if t.strip()]
skills[skill_name].tags.extend(tags)
if related_match:
related_str = related_match.group(1)
related = [r.strip().strip("'\"") for r in related_str.split(",") if r.strip()]
skills[skill_name].related_skills.extend(related)
print(f"Skill-Index erstellt: {len(skills)} Skills")
return skills
def generate_metadata_edges(skills: Dict[str, SkillInfo]) -> List[Edge]:
"""Generiert Kanten aus SKILL.md-Metadaten (Kategorie, related_skills, Tags)."""
edges: List[Edge] = []
skill_list = list(skills.values())
# Kategorien und Tags für schnellen Zugriff
category_map: Dict[str, List[str]] = defaultdict(list)
tag_map: Dict[str, List[str]] = defaultdict(list)
for skill in skill_list:
if skill.category:
category_map[skill.category].append(skill.name)
for tag in skill.tags:
tag_map[tag].append(skill.name)
# 1. Kategorie-basierte Kanten
for category, skill_names in category_map.items():
if len(skill_names) < 2:
continue
for i, skill_a in enumerate(skill_names):
for skill_b in skill_names[i + 1:]:
if skill_a != skill_b:
edges.append(Edge(
skill_a=skill_a,
skill_b=skill_b,
weight=WEIGHT_CATEGORY_MATCH,
edge_type="category",
source="category_match"
))
# 2. Related skills Kanten (gerichtet, aber als ungerichtete Kante gespeichert)
for skill in skill_list:
for related in skill.related_skills:
if related in skills:
edges.append(Edge(
skill_a=skill.name,
skill_b=related,
weight=WEIGHT_RELATED_DECLARED,
edge_type="declared",
source="related_declared"
))
# 3. Tag-Überlappung (mindestens 2 gemeinsame Tags)
for skill_a in skill_list:
for skill_b in skill_list:
if skill_a.name >= skill_b.name: # Vermeide Doppelt-Generierung
continue
common_tags = set(skill_a.tags) & set(skill_b.tags)
if len(common_tags) >= 2:
edges.append(Edge(
skill_a=skill_a.name,
skill_b=skill_b.name,
weight=WEIGHT_TAG_OVERLAP,
edge_type="tag_overlap",
source="tag_overlap"
))
print(f"Metadaten-Kanten generiert: {len(edges)}")
return edges
def generate_co_use_edges(skills: Dict[str, SkillInfo]) -> List[Edge]:
"""
Generiert Kanten basierend auf Co-Nutzungsmustern aus Sessions.
HINWEIS: Aktuell nicht implementiert, da Sessions keine Skill-IDs speichern.
Gibt eine leere Liste zurück.
"""
print("Co-Nutzung-Analyse: SKIPPED (Sessions speichern keine Skill-IDs)")
return []
def deduplicate_edges(edges: List[Edge]) -> List[Edge]:
"""
Entfernt doppelte Kanten zwischen denselben Skills.
Wenn mehrere Kanten zwischen selbem Paar existieren, behält die mit höchstem Gewicht.
PRIORITY: manual > declared > category > co_use
"""
# Gruppiere nach Schlüssel (ungereichtes Paar)
edge_groups: Dict[Tuple[str, str], List[Edge]] = defaultdict(list)
for edge in edges:
edge_groups[edge.key()].append(edge)
# Wähle die beste Kante pro Gruppe
result: List[Edge] = []
for key, group in edge_groups.items():
# Sortiere nach Gewicht (höchste zuerst), dann nach Typ-Priorität
type_priority = {"manual": 4, "declared": 3, "category": 2, "tag_overlap": 1, "co_use": 0}
group.sort(key=lambda e: (e.weight, type_priority.get(e.edge_type, -1)), reverse=True)
result.append(group[0])
print(f"Nach Deduplizierung: {len(result)} eindeutige Kanten")
return result
def generate_journey_graph() -> Dict:
"""Generiert den Journey-Graphen mit Knoten und Kanten."""
# 1. Manuelle Kanten laden
manual_edges = load_manual_edges_from_memory()
# 2. Skill-Index bauen
skills = build_skill_index()
# 3. Metadaten-Kanten generieren
metadata_edges = generate_metadata_edges(skills)
# 4. Co-Nutzung (zurzeit leer)
co_use_edges = generate_co_use_edges(skills)
# 5. Alle Kanten zusammenfassen
all_edges = manual_edges + metadata_edges + co_use_edges
# 6. Deduplizieren
final_edges = deduplicate_edges(all_edges)
# 7. Graph strukturieren
graph = {
"generated_at": None, # Wird unten gesetzt
"version": "1.0.0",
"nodes": [
{
"id": skill.name,
"label": skill.name.replace("-", " ").title(),
"category": skill.category,
"state": skill.state,
}
for skill in skills.values() if skill.state == "active"
],
"edges": [edge.to_dict() for edge in final_edges],
}
# 8. Timestamp hinzufügen
from datetime import datetime, timezone
graph["generated_at"] = datetime.now(timezone.utc).isoformat()
return graph
def main() -> int:
"""Hauptfunktion."""
print("=== Skill-Kanten-Generator ===")
print()
# Graph generieren
graph = generate_journey_graph()
# Ausgabe
print()
print(f"Knoten: {len(graph['nodes'])}")
print(f"Kanten: {len(graph['edges'])}")
# Protokoll
edge_types = defaultdict(int)
for edge in graph["edges"]:
edge_types[edge["type"]] += 1
print()
print("Kanten nach Typ:")
for t, count in sorted(edge_types.items(), key=lambda x: -x[1]):
print(f" {t}: {count}")
# Speichern
JOURNEY_GRAPH_FILE.write_text(
json.dumps(graph, indent=2, ensure_ascii=False),
encoding="utf-8"
)
print()
print(f"Graph gespeichert unter: {JOURNEY_GRAPH_FILE}")
return 0
if __name__ == "__main__":
import sys
sys.exit(main())