skill-kanten-generator: Deploy-Skript für automatische Skill-Verknüpfung
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user