Files
Orchestrator/bahn/teamlandkarte-mcp/.kiro/specs/remove-embedding-competence-similarity/design.md
T
ankn a5f8fb49ab Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
2026-06-30 20:39:52 +02:00

22 KiB
Raw Blame History

Design: Entfernung Embedding-basierter Similarity Umstellung auf BM25 + LLM

Übersicht

Dieses Design beschreibt die vollständige Entfernung der Embedding-Infrastruktur aus dem Teamlandkarte-MCP-System. Das System wird von einem hybriden Embedding/BM25-Ansatz auf eine reine BM25 + LLM-Architektur umgestellt:

  • Kompetenz-Matching: Ausschließlich BM25+RRF (bereits implementiert, nur Conditional-Logik entfernen)
  • Rollen-Similarity: LLM Chat Completion (neu, ersetzt Embedding-Cosine-Similarity)
  • Rollen-Inferenz: LLM Chat Completion (neu, ersetzt Embedding-basierte Nearest-Neighbor-Suche)
  • AutoTagger: Bleibt unverändert (bereits LLM-basiert)

Entfernt werden: EmbeddingCache, _embed, prefetch_embeddings, _per_skill_similarity, _aggregate_similarity, Embedding-API-Methoden im AzureOpenAIClient, alle Embedding-Konfigurationsfelder, und der Startup-Preload.

Architektur

Vorher (Hybrid)

graph TD
    A[MCP Server Startup] --> B[Preload Embeddings]
    B --> C[VocabularyCache: Role + Competence Embeddings]
    B --> D[Task Embeddings]
    
    E[Matching Request] --> F{use_bm25_search?}
    F -->|true| G[BM25+RRF Competence Similarity]
    F -->|false| H[Embedding Cosine Competence Similarity]
    
    E --> I[Embedding Cosine Role Similarity]
    
    J[infer_primary_role] --> K[Task Embedding → Cosine vs Role Vocab]

Nachher (BM25 + LLM)

graph TD
    A[MCP Server Startup] --> B[DB Schema Verification]
    A --> C[AzureOpenAIClient: nur Chat Completion]
    
    E[Matching Request] --> G[BM25+RRF Competence Similarity]
    E --> I[LLM Role Similarity mit In-Run Cache]
    
    J[infer_primary_role] --> K[LLM Chat Completion: Task Text → Role Selection]

Datenfluss Matching (Nachher)

sequenceDiagram
    participant Client
    participant Matcher
    participant SimilarityEngine
    participant BM25
    participant LLM as AzureOpenAIClient (Chat)

    Client->>Matcher: match(capacities, requirements)
    Matcher->>Matcher: Filter by availability
    Matcher->>BM25: Build global index (all candidate competences)
    
    loop Per Candidate
        Matcher->>SimilarityEngine: compute_competence_similarity(required, candidate, global_index)
        SimilarityEngine->>BM25: rank per required competence
        SimilarityEngine-->>Matcher: {req → {score, best_match, rationale}}
        
        Matcher->>SimilarityEngine: compute_role_similarity(required_role, candidate_role)
        SimilarityEngine->>SimilarityEngine: Check in-run cache
        alt Cache Miss
            SimilarityEngine->>LLM: chat_completion(role_similarity_prompt)
            LLM-->>SimilarityEngine: {"similarity": 0.85}
            SimilarityEngine->>SimilarityEngine: Cache result
        end
        SimilarityEngine-->>Matcher: float score
    end
    
    Matcher-->>Client: MatchResult

Komponenten und Schnittstellen

1. SimilarityEngine (refactored)

Datei: src/teamlandkarte_mcp/matching/similarity.py

Entfernte Methoden:

  • prefetch_embeddings
  • _embed
  • get_embedding_for_cache_key
  • get_embeddings_for_cache_keys
  • _aggregate_similarity
  • _per_skill_similarity

Entfernte Properties:

  • embedding_model
  • embedding_dimensions
  • use_bm25_search

Entfernte Constructor-Parameter:

  • cache (EmbeddingCache)
  • embedding_model
  • embedding_dimensions
  • strategy
  • use_bm25_search

Neuer Constructor:

class SimilarityEngine:
    """Compute similarity scores using BM25+RRF and LLM."""

    def __init__(
        self,
        *,
        client: AzureOpenAIClient,
        cost_tracker: CostTracker | None = None,
        use_auto_tagging: bool = False,
        auto_tagger: AutoTagger | None = None,
    ) -> None:
        self._client = client
        self._cost_tracker = cost_tracker
        self._use_auto_tagging = use_auto_tagging
        self._auto_tagger = auto_tagger
        # Per-run cache for LLM role similarity: (role_a, role_b) → score
        self._role_similarity_cache: dict[tuple[str, str], float] = {}

Geänderte Methode compute_competence_similarity:

async def compute_competence_similarity(
    self,
    required: list[str],
    candidate: list[str],
    global_index: Bm25Index | None = None,
) -> dict[str, dict[str, object]]:
    """BM25+RRF competence similarity (einziger Pfad)."""
    working = list(candidate)
    if self._use_auto_tagging and self._auto_tagger is not None:
        working = await self._auto_tagger.expand_competences(required, candidate)
    return self._bm25_rrf_similarity(required, working, global_index=global_index)

Neue Methode compute_role_similarity (LLM-basiert):

_ROLE_SIMILARITY_SYSTEM_PROMPT = (
    "You are a job-role similarity expert. Given two role names, "
    "determine their semantic similarity on a scale from 0.0 to 1.0. "
    "0.0 means completely unrelated roles, 1.0 means identical or "
    "interchangeable roles. Consider synonyms, hierarchy, and domain overlap. "
    'Respond with a JSON object: {"similarity": <float>}'
)

async def compute_role_similarity(
    self,
    required_role: str | None,
    candidate_role: str | None,
) -> float:
    """LLM-basierte Rollen-Similarity."""
    if self._is_bad_role(required_role) or self._is_bad_role(candidate_role):
        return 0.0

    req_norm = str(required_role).strip().lower()
    cand_norm = str(candidate_role).strip().lower()

    if req_norm == cand_norm:
        return 1.0

    # Symmetrischer Cache-Key
    cache_key = tuple(sorted((req_norm, cand_norm)))
    if cache_key in self._role_similarity_cache:
        return self._role_similarity_cache[cache_key]

    try:
        raw = await self._client.chat_completion(
            system=_ROLE_SIMILARITY_SYSTEM_PROMPT,
            user=f"Role A: {required_role}\nRole B: {candidate_role}",
        )
        payload = json.loads(raw)
        score = float(payload.get("similarity", 0.0))
        score = max(0.0, min(1.0, score))
    except Exception:
        score = 0.0

    self._role_similarity_cache[cache_key] = score
    return score

Neue Methode clear_role_cache:

def clear_role_cache(self) -> None:
    """Cache zwischen Matching-Runs leeren."""
    self._role_similarity_cache.clear()

2. VocabularyCache (refactored)

Datei: src/teamlandkarte_mcp/matching/vocabulary.py

Entfernte Methoden:

  • preload
  • _preload_role_vocab
  • _preload_competence_vocab
  • ensure_task_embedding
  • infer_primary_role (alte Signatur mit task_embedding)
  • infer_competences

Entfernte Properties:

  • roles
  • competences

Neuer Constructor:

class VocabularyCache:
    """LLM-basierte Rollen-Inferenz aus Task-Text."""

    def __init__(
        self,
        *,
        db: DBClient,
        client: AzureOpenAIClient,
    ) -> None:
        self._db = db
        self._client = client

Neue Methode infer_primary_role (LLM-basiert):

_ROLE_INFERENCE_SYSTEM_PROMPT = (
    "You are a role classification expert. Given a task description and a list "
    "of available roles, select the single most appropriate role for the task. "
    "You MUST select exactly one role from the provided list. "
    "Respond with a JSON object: "
    '{"role": "<selected role name>", "confidence": <float 0.0-1.0>}'
)

async def infer_primary_role(
    self,
    *,
    task_text: str,
) -> tuple[str, float] | None:
    """Inferiere die passendste Rolle für einen Task-Text via LLM."""
    role_names = self._db.get_all_role_names()
    if not role_names:
        return None

    text = (task_text or "").strip()
    if not text:
        return None

    user_prompt = (
        f"Task: {text}\n\n"
        f"Available roles: {json.dumps(list(role_names), ensure_ascii=False)}"
    )

    try:
        raw = await self._client.chat_completion(
            system=_ROLE_INFERENCE_SYSTEM_PROMPT,
            user=user_prompt,
        )
        payload = json.loads(raw)
        role = str(payload.get("role", "")).strip()
        confidence = float(payload.get("confidence", 0.0))
        confidence = max(0.0, min(1.0, confidence))

        # Validierung: Rolle muss in der DB-Liste existieren
        if role not in set(role_names):
            return None

        return role, confidence
    except Exception:
        return None

3. AzureOpenAIClient (vereinfacht)

Datei: src/teamlandkarte_mcp/azure/openai_client.py

Entfernte Methoden:

  • embeddings
  • get_embeddings_batch

Entfernte Constructor-Parameter:

  • embedding_api_key
  • embedding_deployment
  • embedding_batch_size

Neuer Constructor:

class AzureOpenAIClient:
    """Azure OpenAI Chat Completion Client."""

    def __init__(
        self,
        *,
        endpoint: str,
        api_version: str,
        chat_deployment: str,
        llm_api_key: str,
        timeout_s: float = 30.0,
        max_retries: int = 5,
        cost_tracker: CostTracker | None = None,
    ) -> None:
        self._chat_deployment = chat_deployment
        self._max_retries = max_retries
        self._timeout_s = timeout_s
        self._cost_tracker = cost_tracker

        self._chat = AsyncAzureOpenAI(
            api_key=llm_api_key,
            azure_endpoint=endpoint,
            api_version=api_version,
        )

Die chat_completion-Methode bleibt unverändert, außer dass die Guard-Clause für fehlende Konfiguration entfällt (Chat ist jetzt immer konfiguriert).

4. Matcher (minimal geändert)

Datei: src/teamlandkarte_mcp/matching/matcher.py

Änderungen:

  • Entfernung der if self._sim.use_bm25_search-Bedingung beim BM25-Index-Aufbau
  • Der globale BM25-Index wird immer gebaut (unconditional)
# Vorher:
global_bm25_index: Bm25Index | None = None
if self._sim.use_bm25_search and filtered:
    global_corpus = list(...)
    global_bm25_index = Bm25Index(corpus=global_corpus)

# Nachher:
global_bm25_index: Bm25Index | None = None
if filtered:
    global_corpus = list(
        {c for cap in filtered for c in cap.competences if c.strip()}
    )
    global_bm25_index = Bm25Index(corpus=global_corpus)

5. MCP Server (vereinfacht)

Datei: src/teamlandkarte_mcp/mcp_server.py

Entfernte Elemente:

  • _startup_preload_embeddings Funktion
  • _deferred_preload Funktion und _preload_started Flag
  • _ensure_preloaded Funktion
  • EmbeddingCache-Instanziierung
  • emb_cache-Variable
  • Alle await _ensure_preloaded()-Aufrufe in Tools
  • Import von EmbeddingCache
  • Import von VocabularyCache (wird direkt mit AzureOpenAIClient konstruiert)

Geänderte Konstruktion:

# Vorher: Embedding-Client + separater LLM-Client
azure_client = AzureOpenAIClient(
    endpoint=..., embedding_api_key=..., embedding_deployment=..., ...
)

# Nachher: Nur noch ein LLM-Client
azure_client = AzureOpenAIClient(
    endpoint=cfg.azure_openai.endpoint,
    api_version=cfg.azure_openai.api_version,
    chat_deployment=cfg.azure_openai.chat_deployment,
    llm_api_key=cfg.azure_openai.llm_api_key,
    cost_tracker=cost_tracker,
)

similarity = SimilarityEngine(
    client=azure_client,
    cost_tracker=cost_tracker,
    use_auto_tagging=cfg.similarity.use_auto_tagging,
    auto_tagger=auto_tagger,
)

vocab_cache = VocabularyCache(
    db=db_client,
    client=azure_client,
)

Geändertes infer_primary_role-Tool:

@mcp.tool()
async def infer_primary_role(
    task_id: Optional[str] = None,
    task_text: Optional[str] = None,
) -> str:
    """Infer the single closest role from either a DB task or free text."""
    if bool(task_id) == bool(task_text):
        return "Provide exactly one of task_id or task_text."

    text = (task_text or "").strip()
    if task_id:
        _ensure_db()
        task = db_client.get_task_by_id(task_id)
        if task is None:
            return f"Task not found or not published: {task_id}"
        title = (task.title or "").strip()
        desc = (task.description or "").strip()
        text = (title + "\n\n" + desc).strip() if title else desc

    if not text:
        return "Task text is empty."

    best = await vocab_cache.infer_primary_role(task_text=text)
    if best is None:
        rows = [["", ""]]
    else:
        role, score = best
        rows = [[str(role), f"{float(score):.3f}"]]

    return md_table(["Role", "Confidence"], rows)

Entferntes Tool validate_task_requirements: Dieses Tool basiert vollständig auf Embedding-Inferenz (infer_competences, ensure_task_embedding). Es wird entfernt oder durch eine vereinfachte Version ersetzt, die nur DB-Felder anzeigt.

6. Config (vereinfacht)

Datei: src/teamlandkarte_mcp/config.py

Entfernte Dataclasses:

  • EmbeddingCacheConfig
  • InferenceConfig

Geänderte Dataclasses:

@dataclass(frozen=True)
class AzureOpenAIConfig:
    """Azure OpenAI configuration (nur Chat Completion)."""
    endpoint: str
    api_version: str = "2024-02-15-preview"
    chat_deployment: str = ""
    llm_api_key: str = ""
    show_costs_in_output: bool = False

@dataclass(frozen=True)
class SimilarityConfig:
    """Similarity engine configuration."""
    use_auto_tagging: bool = False

@dataclass(frozen=True)
class MatchingConfig:
    """Matching weights and thresholds."""
    competence_weight: float = 0.8
    role_weight: float = 0.2
    require_confirmation: bool = True
    thresholds: MatchingThresholds = MatchingThresholds()
    fuzzy: FuzzyConfig = FuzzyConfig()
    # inference entfällt

@dataclass(frozen=True)
class AppConfig:
    """Top-level application configuration."""
    database: DatabaseConfig
    matching: MatchingConfig
    cache: CacheConfig
    azure_openai: AzureOpenAIConfig
    similarity: SimilarityConfig
    # embedding_cache entfällt

Entfernte Felder aus SimilarityConfig:

  • embedding_model
  • embedding_dimensions
  • strategy
  • use_bm25_search

Entfernte Felder aus AzureOpenAIConfig:

  • embedding_deployment
  • embedding_batch_size

Entfernte Validierungen in load_config:

  • AZURE_OPENAI_EMBEDDING_API_KEY Prüfung
  • embedding_dimensions == 3072 Prüfung
  • strategy Validierung
  • inference Parsing

Neue Validierung:

  • AZURE_OPENAI_LLM_API_KEY ist jetzt immer erforderlich (nicht nur bei auto_tagging)
  • chat_deployment ist jetzt erforderlich

7. Entfernte Dateien

Datei Grund
src/teamlandkarte_mcp/cache/embedding_cache.py Keine Embeddings mehr
Zugehörige Tests für EmbeddingCache Keine Embeddings mehr

8. config.toml Änderungen

Entfernte Sektionen:

  • [embedding_cache] komplett

Entfernte Felder aus [matching.similarity]:

  • embedding_model
  • embedding_dimensions
  • strategy
  • use_bm25_search

Entfernte Felder aus [azure_openai]:

  • embedding_deployment
  • embedding_batch_size

Entfernte Felder aus [matching]:

  • [matching.inference] komplett

Neue Pflichtfelder in [azure_openai]:

  • chat_deployment (bereits vorhanden als optionales Feld, wird Pflicht)

Neue Umgebungsvariable (Pflicht):

  • AZURE_OPENAI_LLM_API_KEY (ersetzt AZURE_OPENAI_EMBEDDING_API_KEY)

Entfernte Umgebungsvariable:

  • AZURE_OPENAI_EMBEDDING_API_KEY

Datenmodelle

LLM Role Similarity Request/Response

// System Prompt: _ROLE_SIMILARITY_SYSTEM_PROMPT
// User Message:
"Role A: Software Engineer\nRole B: Backend Developer"

// Expected Response:
{"similarity": 0.75}

LLM Role Inference Request/Response

// System Prompt: _ROLE_INFERENCE_SYSTEM_PROMPT  
// User Message:
"Task: Implementierung einer REST-API für Benutzerverwaltung\n\nAvailable roles: [\"Backend Developer\", \"Frontend Developer\", \"DevOps Engineer\", \"Data Engineer\"]"

// Expected Response:
{"role": "Backend Developer", "confidence": 0.92}

In-Run Role Similarity Cache

# Symmetrischer Cache innerhalb eines Matching-Runs
# Key: tuple(sorted((role_a_lower, role_b_lower)))
# Value: float (0.0 - 1.0)
_role_similarity_cache: dict[tuple[str, str], float] = {}

Der Cache wird pro SimilarityEngine-Instanz gehalten und lebt für die Dauer des Server-Prozesses. Da Rollennamen stabil sind (aus der DB), ist kein TTL nötig.

Correctness Properties

A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.

Property 1: BM25 Zero-Score für fehlenden Token-Overlap

For any set of required competences and candidate competences where no candidate shares any token with a required competence, compute_competence_similarity shall return a score of 0.0 for that required competence.

Validates: Requirements 1.1

Property 2: Matcher baut BM25-Index bedingungslos

For any non-empty list of filtered candidates with competences, the Matcher shall always build a global BM25 index and use it for competence scoring, producing results where candidates with no token overlap receive score 0.0.

Validates: Requirements 2.6

Property 3: infer_primary_role Ausgabe-Validität

For any non-empty task text and non-empty role list from the database, if infer_primary_role returns a non-None result, the returned role name must be an element of the database role list and the confidence must be a float in [0.0, 1.0].

Validates: Requirements 3.1, 3.4

Property 4: Graceful Degradation bei LLM-Fehler

For any LLM call that raises an exception, compute_role_similarity shall return 0.0 and infer_primary_role shall return None, without propagating the exception to the caller.

Validates: Requirements 3.5, 5.6

Property 5: compute_role_similarity Wertebereich

For any two non-empty, non-"(unknown)" role names, compute_role_similarity shall return a float in the closed interval [0.0, 1.0]. For identical role names (case-insensitive), it shall return 1.0.

Validates: Requirements 5.1

Property 6: Rollen-Similarity-Cache ist symmetrisch und idempotent

For any role pair (A, B), calling compute_role_similarity(A, B) and then compute_role_similarity(B, A) shall return the same score, and the second call shall not invoke the LLM (cache hit). Repeated calls with the same pair shall always return the same cached value.

Validates: Requirements 5.8

Error Handling

LLM-Fehler in compute_role_similarity

  • Bei jeder Exception (Timeout, API-Fehler, JSON-Parse-Fehler) wird 0.0 zurückgegeben
  • Fehler wird geloggt (LOGGER.warning)
  • Kein Eintrag im Cache für fehlgeschlagene Aufrufe (Retry bei nächstem Aufruf möglich)

LLM-Fehler in infer_primary_role

  • Bei jeder Exception wird None zurückgegeben
  • Fehler wird geloggt (LOGGER.warning)
  • Caller (MCP-Tool) zeigt leere Tabelle an

Ungültige LLM-Antworten

  • Role Similarity: Wenn similarity nicht im JSON oder nicht parsebar → 0.0
  • Role Inference: Wenn role nicht in der DB-Liste → None
  • Role Inference: Wenn confidence nicht parsebar → 0.0 (aber Rolle wird trotzdem zurückgegeben wenn valide)

Leere/Ungültige Eingaben

  • compute_role_similarity mit None/leer/"(unknown)" → 0.0 (kein LLM-Aufruf)
  • infer_primary_role mit leerem Text → None (kein LLM-Aufruf)
  • infer_primary_role mit leerer Rollenliste aus DB → None (kein LLM-Aufruf)
  • compute_competence_similarity mit leerer Required-Liste → leeres Dict
  • compute_competence_similarity mit leerer Candidate-Liste → alle Scores 0.0

Konfigurationsfehler

  • Fehlende AZURE_OPENAI_LLM_API_KEYConfigError beim Laden (fail-fast)
  • Fehlender chat_deploymentConfigError beim Laden (fail-fast)

Testing Strategy

Property-Based Tests (fast-check / Hypothesis)

Bibliothek: Hypothesis (Python PBT-Standard)

Konfiguration: Mindestens 100 Iterationen pro Property-Test.

Jeder Property-Test wird mit einem Kommentar getaggt:

# Feature: remove-embedding-competence-similarity, Property {N}: {title}
Property Test-Ansatz Generator
1: BM25 Zero-Score Generiere disjunkte Token-Sets für required/candidate, prüfe Score == 0.0 st.lists(st.text(alphabet=st.characters(whitelist_categories=('L',)), min_size=3))
2: Matcher BM25 unconditional Generiere Capacities + Requirements, prüfe dass Ergebnis BM25-Charakteristik hat (0.0 bei no-overlap) Custom Capacity/Requirements strategies
3: infer_primary_role Validität Generiere Task-Texte + Role-Listen, mocke LLM mit zufälliger valider Antwort, prüfe Output-Constraints st.text(min_size=1), st.lists(st.text(min_size=1), min_size=1)
4: Graceful Degradation Generiere zufällige Exceptions, prüfe dass 0.0/None zurückkommt st.sampled_from([TimeoutError, RuntimeError, ValueError, json.JSONDecodeError])
5: Role Similarity Wertebereich Generiere Rollenpaare, mocke LLM mit zufälligem Score, prüfe [0.0, 1.0] und Identitäts-Case st.text(min_size=1, max_size=50)
6: Cache Symmetrie Generiere Rollenpaare, rufe in beiden Reihenfolgen auf, prüfe gleichen Score + nur 1 LLM-Call st.text(min_size=1, max_size=50)

Unit Tests

Unit Tests fokussieren auf:

  • Spezifische Beispiele: Bekannte Rollenpaare (z.B. "Backend Developer" vs "Software Engineer") mit gemocktem LLM
  • Edge Cases: Leere Strings, None-Werte, "(unknown)", Whitespace-only
  • Integration: Config-Loading ohne Embedding-Felder, Server-Startup ohne Preload
  • Regressions: Sicherstellen dass BM25+RRF-Ergebnisse identisch zum bisherigen use_bm25_search=True-Pfad sind

Integrationstests

  • End-to-End Matching-Run mit gemocktem LLM-Client
  • Config-Loading aus minimaler TOML-Datei (ohne Embedding-Sektionen)
  • Server-Startup ohne AZURE_OPENAI_EMBEDDING_API_KEY (darf nicht mehr geprüft werden)

Nicht getestet (bewusst ausgelassen)

  • Prompt-Qualität (subjektiv, erfordert manuelles Review)
  • LLM-Antwort-Genauigkeit (abhängig vom Modell, nicht deterministisch)
  • Startup-Performance-Verbesserung (Benchmark, kein Unit-Test)