Files
Orchestrator/bahn/teamlandkarte-mcp/.kiro/specs/team-profile-matching/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

54 KiB
Raw Blame History

Designdokument: Team-Profil-Matching

Übersicht

Dieses Designdokument beschreibt die Erweiterung des Teamlandkarte MCP-Servers um eine zweite Profilart: das Team-Profil. Heute kann das System nur Aufgaben gegen Kapazitätsprofile matchen (Capacity aus teamlandkarte_v_capacities_latest und Folge-Views). Künftig wählt der Nutzer pro Suchanfrage über einen Profile_Type (capacity | team), ob er gegen Personen oder gegen Teams matcht.

Das neue Feature führt parallel zu den bestehenden Tools find_matching_capacities, list_free_capacities und get_capacity_details drei neue MCP-Tools ein:

  • find_matching_teams Aufgabe → Team-Matching, mit den gleichen matching_method-Werten (score, llm_fulltext).
  • list_teams Listet die ersten N Teams als Tabelle.
  • get_team_details Zeigt ein Team-Profil als Tabelle plus Beschreibungstexte und Listen.

Auf Datenebene werden vier neue Views in database/types.py::DBClient und database/trino_client.py::TrinoClient ergänzt (teamlandkarte_v_teams_latest, teamlandkarte_v_teammeter_organizational_units_latest, teamlandkarte_v_teammeter_team_competences_latest, teamlandkarte_v_team_references_latest). Die bestehende Read-Only-Guard _ensure_select_only und die Retry-Infrastruktur (_retry) werden unverändert wiederverwendet. Ein neues Frozen-Dataclass-Trio (Team, TeamCompetence, TeamReference) ergänzt Capacity in models.py. Die LLM-Eingabe für Teams wird über einen neuen TeamProfile-Builder und einen neuen serialize_team_profile-Serializer in matching/profiles.py erzeugt, der die in den Anforderungen festgelegten deutschen Überschriften (Teamname:, Schwerpunkt:, Über uns:, Leistungen:, Interessen:, Kompetenzen:, Referenzen:) verwendet.

Das Score-basierte Matching (Matcher) wird so verallgemeinert, dass es Teams mit focus_name als Rollen-Stellvertreter und gewichteten Top-Kompetenzen (matching.team.top_competency_weight, Default 1.5) bewerten kann, ohne den bestehenden Capacity-Pfad zu verändern. Das LLM-Volltext-Matching (LlmFulltextMatcher) erhält eine neue match_teams-Methode, die das gleiche JSON-Schema ({"category", "rationale"}) und die gleiche Fehler- und Sortier-Semantik wie match_capacities/match_tasks verwendet.

Verfügbarkeitsfilter (availability_date_start, availability_date_end, is_fully_available) sind für Team-Suchen fachlich nicht definiert und werden ignoriert, aber in der Applied Filters-Tabelle als nicht wirksam markiert. Der SearchCache-Eintrag erhält ein neues Feld search_type = "team_search" (parallel zu capacity_search und task_search), sodass filter_search_results und get_results_by_category die Team-spezifische Spaltenkonfiguration auswählen können.

Beim Serverstart prüft die bestehende Schema-Verifikation (database/schema_verifier.py) zusätzlich die vier neuen Views inklusive ihrer relevanten Spalten. Die Konfiguration in config.toml wird um den optionalen Block [matching.team] mit dem Schlüssel top_competency_weight (Default 1.5) erweitert; ungültige Werte führen zu einem ConfigError beim Start.

Die wichtigsten Designentscheidungen sind:

  1. Wiederverwendung statt Parallelaufbau: find_matching_teams reuses Matcher/LlmFulltextMatcher über schmale Adapter, statt eigene Score-/LLM-Pipelines aufzubauen. Damit bleibt die Kategorisierungs- und Konfidenzlogik identisch.
  2. Determinismus: Team ist frozen, TeamProfile wird deterministisch serialisiert (feste Feldreihenfolge, stabile (top desc, name asc)-Sortierung der Kompetenzen, (partner_name asc, projects asc)-Sortierung der Referenzen). Das ist Grundlage für die Round-Trip-/Idempotenz-Eigenschaft (Anforderung 13) und für stabile LLM-Eingaben.
  3. Keine Änderung des Capacity-Pfads: Bestehende Tools, Tests, Caches und Tabellenformate für Kapazitäten bleiben unverändert. Neue Logik wird ausschließlich additiv eingeführt.
  4. Saubere Trennung der Such-Typen: search_type im persistierten Cache-Eintrag ist die Single Source of Truth für die nachgelagerte Renderlogik. Bestehende Cache-Einträge (ohne search_type) bleiben kompatibel und werden weiterhin als capacity_search interpretiert (existierender Fallback).

Architektur

High-Level-Datenfluss

flowchart LR
    User[Nutzer]
    Agent[Teamlandkarte_Agent]
    MCP[MCP_Server\nmcp_server.py]
    DB[DBClient / TrinoClient]
    SC[SearchCache]
    M[Matcher\nScore]
    LFM[LlmFulltextMatcher]
    LLM[Azure OpenAI]
    Trino[(Trino / Open Data Lake)]

    User -- "natürliche Sprache" --> Agent
    Agent -- "find_matching_teams\nmatching_method" --> MCP
    MCP -- "Team-Stammdaten + Kompetenzen + Referenzen" --> DB
    DB -- "SELECT (read-only)" --> Trino
    MCP -- "score" --> M
    MCP -- "llm_fulltext" --> LFM
    LFM -- "chat completion (JSON)" --> LLM
    MCP -- "store_search\nsearch_type=team_search" --> SC
    SC --> MCP
    MCP -- "Markdown-Tabelle\n+ search_id + META" --> Agent
    Agent --> User

Schichtenmodell

Schicht Bestehende Komponenten Neue / erweiterte Komponenten
Tool-Surface (MCP) find_matching_capacities, list_free_capacities, get_capacity_details, find_matching_tasks, filter_search_results, get_results_by_category find_matching_teams, list_teams, get_team_details; _format_results_table erweitert um search_type="team_search"; filter_search_results/get_results_by_category erweitert um Team-Spalten und Filter-Ignore-Hinweise
Matching Matcher (Score), LlmFulltextMatcher (LLM-JSON), SimilarityEngine, Bm25Index Matcher.match_teams(...) (oder Adapter _score_teams(...)) für Top-Kompetenz-Gewichtung; LlmFulltextMatcher.match_teams(...) für Team-Profile
Profile/Serialisierung CapacityProfile, TaskProfile, serialize_capacity_profile, serialize_task_profile, build_capacity_profile TeamProfile, TeamCompetenceEntry, TeamReferenceEntry, build_team_profile, serialize_team_profile
Models Capacity, Task, Requirements, ScoredCapacity Team, TeamCompetence, TeamReference, ScoredTeam
DB DBClient Protocol + TrinoClient, _ensure_select_only, _retry, ConnectionPool Neue Methoden: get_all_teams, get_team_by_id, get_team_competences, batch_get_team_competences, get_team_references, batch_get_team_references
Cache QueryCache[list[Capacity]], SearchCache QueryCache[list[Team]] (parallele Cache-Keys: all_teams); SearchCache mit search_type="team_search"
Konfiguration MatchingConfig, MatchingThresholds Neuer TeamMatchingConfig (top_competency_weight: float = 1.5); MatchingConfig.team: TeamMatchingConfig
Schema-Verifikation verify_required_columns Erweiterte schema_expected-Map um die vier neuen Views
Dokumentation/Agenten docs/architecture.md, README.md, .github/agents/teamlandkarte_agent.md, .kiro/agents/teamlandkarte.md Erweiterung um Profile_Type, Team-Tools, neue Views, fehlende Verfügbarkeitsfilter

Runtime-View: Aufgabe → Team (score)

sequenceDiagram
    participant U as User/Agent
    participant S as MCP_Server
    participant DB as DBClient
    participant Q as QueryCache
    participant M as Matcher (Score)
    participant SC as SearchCache

    U->>S: find_matching_teams(role_name, competences, matching_method="score")
    S->>S: _resolve_matching_method() / _validate_requirements_minimum()
    S->>S: _require_confirmed_or_auto(req)
    S->>Q: get_or_fetch("all_teams", db.get_all_teams_with_competences_and_references)
    Q-->>DB: get_all_teams + batch_get_team_competences + batch_get_team_references
    DB-->>Q: list[Team]
    Q-->>S: list[Team]
    S->>M: match_teams(teams, requirements, top_competency_weight)
    M-->>S: MatchResult[ScoredTeam] mit by_category
    S->>SC: store_search(search_type="team_search", matching_method="score", results)
    SC-->>S: search_id
    S-->>U: Markdown (Summary + Top Results) + SEARCH_ID + META

Runtime-View: Aufgabe → Team (llm_fulltext)

sequenceDiagram
    participant U as User/Agent
    participant S as MCP_Server
    participant DB as DBClient
    participant LFM as LlmFulltextMatcher
    participant LLM as Azure OpenAI
    participant SC as SearchCache

    U->>S: find_matching_teams(role_name, competences, matching_method="llm_fulltext")
    S->>S: _resolve_matching_method() / Confirm-Gate
    S->>DB: get_all_teams_with_competences_and_references()
    DB-->>S: list[Team]
    S->>S: build_task_profile_from_requirements(req)
    S->>LFM: match_teams(task_profile, teams)
    par per Team (max_concurrency=cfg.azure_openai.max_concurrency)
        LFM->>LFM: build_team_profile + serialize_team_profile
        LFM->>LLM: chat completion (system + user)
        LLM-->>LFM: JSON {"category", "rationale"}
        LFM->>LFM: normalize_category() / Fehlerbehandlung
    end
    LFM-->>S: LlmFulltextResult{by_category, errors}
    S->>SC: store_search(search_type="team_search", matching_method="llm_fulltext")
    SC-->>S: search_id
    S-->>U: Markdown + SEARCH_ID + META + ggf. ## Errors

Schema-Verifikation beim Start

Die schema_expected-Map in mcp_server.build_server wird additiv erweitert:

schema_expected = {
    # bestehend ...
    "teamlandkarte_v_teams_latest": {
        "team_id", "ouid", "about_us", "offerings", "interests", "focus_name",
    },
    "teamlandkarte_v_teammeter_organizational_units_latest": {
        "id", "name",  # Spaltenname für Team_Name siehe Anforderung 12.2
    },
    "teamlandkarte_v_teammeter_team_competences_latest": {
        "ouid", "competence_id", "top_competency",
    },
    "teamlandkarte_v_team_references_latest": {
        "ouid", "partner_id", "projects",
    },
}

Die exakten Spaltennamen für name in ..._organizational_units_latest werden bei Implementierung gegen die Live-DB abgeglichen; das Feld in der expected-Map ist die Single Source of Truth für die Verifikation und wird beim Start gegen get_table_columns(...) geprüft.


Komponenten und Schnittstellen

models.py neue Frozen Dataclasses

Parallel zu Capacity werden drei neue, immutable Datentypen ergänzt:

@dataclass(frozen=True)
class TeamCompetence:
    """Eine Team-Kompetenz mit aufgelöstem Namen und Top-Markierung."""
    name: str
    top_competency: bool


@dataclass(frozen=True)
class TeamReference:
    """Eine Team-Referenz mit aufgelöstem Partner-Namen und Projekttext."""
    partner_name: str        # leer, wenn partner_id NULL oder Join leer
    projects: str            # bereits getrimmt; nie leer (Filter im DBClient)


@dataclass(frozen=True)
class Team:
    """Aggregiertes Team-Stammdatum aus dem Data Lake."""
    team_id: str
    ouid: str
    team_name: str
    focus_name: str
    about_us: str
    offerings: str
    interests: str
    competences: list[TeamCompetence] = field(default_factory=list)
    references: list[TeamReference] = field(default_factory=list)


@dataclass(frozen=True)
class ScoredTeam:
    """Team plus Matching-Scores und Diagnose-Listen (nur Score-Modus)."""
    team: Team
    competence_score: float
    role_score: float
    overall_score: float
    category: str
    matched_competences: list[str] = field(default_factory=list)
    missing_competences: list[str] = field(default_factory=list)

Begründung: Frozen Dataclasses sind im Codebase Standard (Capacity, ScoredCapacity, Task). Die Felder spiegeln die Anforderungen 5.1 und 6.1 wider. competences/references werden bewusst als Listen typisierter Records geführt, damit der Serializer und der Score-Matcher direkt top_competency bzw. partner_name lesen können, ohne Dict-Konventionen.

database/types.py DBClient-Erweiterungen

Es werden sechs neue Methoden ergänzt, die das gleiche Konventions-Set wie die Capacity-Pendants verwenden (TypedDict für DB-Rohzeilen, Batch-Variante mit dict über alle Eingabe-IDs, deterministische Sortierung in der DB-Schicht):

class TeamCompetenceRow(TypedDict):
    """Eine Zeile aus teamlandkarte_v_teammeter_team_competences_latest
    inkl. aufgelöstem Kompetenz-Namen.

    `top_competency` wird in der DB-Schicht aus NULL → False normalisiert.
    """
    name: str
    top_competency: bool


class TeamReferenceRow(TypedDict):
    """Eine Zeile aus teamlandkarte_v_team_references_latest inkl. Partner-Namen
    aus dem LEFT JOIN auf teamlandkarte_v_partners_latest.

    `partner_name` ist leer, wenn `partner_id` NULL ist oder kein Join-Treffer
    existiert. Whitespace-only `projects` werden in der DB-Schicht gefiltert.
    """
    partner_name: str
    projects: str


class DBClient(Protocol):
    # ... bestehende Methoden ...

    def get_all_teams(self) -> list[Team]:
        """Liefert alle Teams (INNER JOIN auf organizational_units_latest
        für Team_Name) inklusive ihrer Kompetenzen und Referenzen.

        Sammelt Stammdaten + Team_Name in einer Query, ruft dann
        batch_get_team_competences + batch_get_team_references mit den
        OUIDs der gefundenen Teams auf und kombiniert die Ergebnisse zu
        `Team`-Instanzen.

        Reihenfolge: Teams nach team_name aufsteigend (deterministisch).
        Empty about_us/offerings/interests/focus_name werden als "" geliefert.
        """

    def get_team_by_id(self, team_id: str) -> Team | None:
        """Einzelnes Team über team_id (oder ouid) inkl. Kompetenzen + Referenzen.

        Liefert None, wenn weder team_id noch ouid einem Team entspricht
        (oder der INNER JOIN keinen Eintrag in
        teamlandkarte_v_teammeter_organizational_units_latest findet).
        """

    def get_team_competences(self, ouid: str) -> list[TeamCompetenceRow]:
        """1:n Kompetenzen für eine ouid mit aufgelöstem Namen.

        Reihenfolge: top_competency=True zuerst, dann name aufsteigend.
        Einträge ohne auflösbaren competence_id-Namen werden gefiltert.
        """

    def batch_get_team_competences(
        self, ouids: list[str]
    ) -> dict[str, list[TeamCompetenceRow]]:
        """Batch-Variante für mehrere OUIDs in einer einzigen SQL-Query.
        Jede angefragte ouid ist im Ergebnis vorhanden (leere Liste, falls keine
        Kompetenzen).
        """

    def get_team_references(self, ouid: str) -> list[TeamReferenceRow]:
        """1:n Referenzen für eine ouid mit Partner-Name (LEFT JOIN partners).

        Reihenfolge: partner_name aufsteigend, dann projects aufsteigend.
        Einträge mit leerem/whitespace-only `projects` werden gefiltert.
        Einträge ohne Partner werden mit partner_name="" beibehalten.
        """

    def batch_get_team_references(
        self, ouids: list[str]
    ) -> dict[str, list[TeamReferenceRow]]:
        """Batch-Variante mit einem einzigen SELECT (LEFT JOIN partners
        ist Teil derselben Query, kein separater Roundtrip)."""

Begründung: Die Schnittstelle spiegelt die existierenden get_capacity_references / batch_get_capacity_references-Pendants wider, damit der LLM-Fulltext-Matcher konsistent gegen DBClient programmieren kann. Die deterministische Sortierung erfüllt Anforderungen 3.7 und 4.7 und ist Grundlage für die Determinismus-Eigenschaft der Profil-Serialisierung (Anforderung 13).

database/trino_client.py konkrete SQL-Implementierung

Die SQL-Statements folgen dem bestehenden Stil (?-Platzhalter, COALESCE für NULL-Defaults, _ensure_select_only, _retry, log_query, with self._cursor() as cur). Drei zentrale Queries:

1. get_all_teams Stammdaten + Teamname (INNER JOIN, Anforderung 2):

SELECT
    t.team_id,
    t.ouid,
    ou.name AS team_name,
    COALESCE(t.focus_name, '') AS focus_name,
    COALESCE(t.about_us, '')  AS about_us,
    COALESCE(t.offerings, '') AS offerings,
    COALESCE(t.interests, '') AS interests
FROM teamlandkarte_v_teams_latest t
INNER JOIN teamlandkarte_v_teammeter_organizational_units_latest ou
    ON t.team_id = ou.id
ORDER BY ou.name ASC, t.team_id ASC

Die Spalte für den Teamnamen (name) wird in der Schema-Verifikation geprüft; falls der Live-DB-Spaltenname abweicht, wird sie an genau einer Stelle in schema_expected und der SQL-Query angepasst.

2. batch_get_team_competences (Anforderung 3):

SELECT
    tc.ouid,
    c.name           AS competence_name,
    COALESCE(tc.top_competency, FALSE) AS top_competency
FROM teamlandkarte_v_teammeter_team_competences_latest tc
JOIN teamlandkarte_v_competences_latest c
    ON tc.competence_id = c.id
WHERE tc.ouid IN (?, ?, ..., ?)
ORDER BY tc.ouid ASC,
         CASE WHEN COALESCE(tc.top_competency, FALSE) THEN 0 ELSE 1 END ASC,
         c.name ASC

Der Join auf teamlandkarte_v_competences_latest ist analog zum bestehenden Capacity-Pfad und filtert implizit Einträge ohne auflösbaren Namen (Anforderung 3.6).

3. batch_get_team_references (Anforderung 4):

SELECT
    r.ouid,
    r.projects,
    COALESCE(p.name, '') AS partner_name
FROM teamlandkarte_v_team_references_latest r
LEFT JOIN teamlandkarte_v_partners_latest p
    ON r.partner_id = p.id
WHERE r.ouid IN (?, ?, ..., ?)
ORDER BY r.ouid ASC, partner_name ASC, r.projects ASC

Die Whitespace-Filterung von projects erfolgt nach dem Fetch in Python (projects.strip() und Skip bei Leerstring), exakt wie in batch_get_capacity_references.

get_all_teams ist intern wie folgt strukturiert (vgl. get_all_capacities_with_competences):

  1. Stammdaten + Team_Name laden.
  2. Einmal batch_get_team_competences(ouids), einmal batch_get_team_references(ouids).
  3. In Python zu Team-Instanzen aggregieren, Reihenfolge der inneren Listen aus dem ORDER BY der DB-Query.

matching/profiles.py TeamProfile und Serialisierung

Drei neue Strukturen werden ergänzt, parallel zu CapacityProfile:

@dataclass(frozen=True)
class TeamCompetenceEntry:
    name: str
    top_competency: bool


@dataclass(frozen=True)
class TeamReferenceEntry:
    partner_name: str   # darf "" sein
    projects: str


@dataclass(frozen=True)
class TeamProfile:
    id: str             # team_id (Stringform)
    ouid: str
    team_name: str
    focus_name: str
    about_us: str
    offerings: str
    interests: str
    competences: list[TeamCompetenceEntry]
    references: list[TeamReferenceEntry]


def build_team_profile(team: Team) -> TeamProfile: ...
def serialize_team_profile(profile: TeamProfile) -> str: ...

serialize_team_profile produziert eine deterministische, mehrzeilige Darstellung mit fester Feld-Reihenfolge und festen deutschen Überschriften. Empty Werte werden nicht weggelassen, sondern als (keine) bzw. leerer String nach der Überschrift gerendert (analog zu serialize_capacity_profile):

Teamname: <team_name>
Schwerpunkt: <focus_name>
Über uns: <about_us>
Leistungen: <offerings>
Interessen: <interests>
Kompetenzen:
- <name1> (Top)
- <name2>
- ...
Referenzen:
- Partner: <partner_name>  Projekte: <projects>
- Projekte: <projects>   (wenn partner_name leer)
- ...

Regeln:

  • Top-Kompetenzen werden mit Suffix (Top) markiert (Anforderung 5.5).
  • Bei leerem partner_name entfällt der Partner: -Token komplett, es wird kein Platzhalter eingefügt (Anforderung 5.3 / 5.6).
  • Empty Listen werden als Kompetenzen: (keine) bzw. Referenzen: (keine) auf einer Zeile dargestellt (analog serialize_capacity_profile).
  • Die Reihenfolge der Listen entspricht exakt der vom DBClient gelieferten Reihenfolge (Anforderung 13.4); der Serializer sortiert nicht erneut.

matching/matcher.py Score-Matching für Teams

Der bestehende Matcher wird um einen Team-Pfad ergänzt. Da Capacity und Team unterschiedliche Felder haben (role_name vs. focus_name, competences: list[str] vs. competences: list[TeamCompetence]) und das Top-Kompetenz-Gewicht nur bei Teams gilt, gibt es zwei Optionen:

Variante A (gewählt): Eigene match_teams-Methode am Matcher.

class Matcher:
    async def match_teams(
        self,
        teams: list[Team],
        requirements: Requirements,
        *,
        top_competency_weight: float,
    ) -> "TeamMatchResult":
        ...

Vorteile:

  • Klare Trennung der Pfade, kein versehentlicher Drift im Capacity-Pfad.
  • Der Top-Kompetenz-Gewichtungsfaktor bleibt explizit am Aufruf, statt über interne Konfigflags zu fließen.

Algorithmus (Anforderung 6):

  1. Kompetenz-Liste erzeugen: Für jedes Team eine Score-Eingabeliste der Form [name1, name2, ...] aus team.competences (Reihenfolge wie in Team). Die Top-Markierung wird nicht in den Namen gemischt, sondern in einem parallelen Top-Set festgehalten.
  2. BM25/RRF-Similarity wie bisher gegen die requirements.competences rechnen (SimilarityEngine.compute_competence_similarity). Pro Required-Kompetenz wird das beste Match (best_match) zurückgeliefert.
  3. Top-Kompetenz-Gewichtung: Pro Required-Kompetenz wird der Roh-Score s mit top_competency_weight (Default 1.5) multipliziert, wenn best_match zu einer Top-Kompetenz des Teams gehört, sonst mit 1.0. Anschließend wird auf [0, 1] geclampt:
    weighted = min(1.0, s * (top_weight if best_match in top_set else 1.0))
    
    Die Mittelung über die Required-Kompetenzen ergibt die competence_score.
  4. Role Score über SimilarityEngine.compute_role_similarity( requirements.role_name, team.focus_name) (Anforderung 6.3). Dadurch wird focus_name als Rollen-Stellvertreter für Teams genutzt und nutzt den bestehenden symmetrischen LLM-Cache.
  5. Overall Score über das bestehende compute_overall(...) aus matching/scorer.py (gleiche Gewichte competence_weight/role_weight und gleiche Thresholds). Die Kategorisierung erfolgt über categorize(...) und liefert Top|Good|Partial|Low|Irrelevant.
  6. Verfügbarkeitsfilter werden ignoriert (Anforderung 6.7): Der Pfad ruft availability_overlaps(...) nicht auf.

Ausgabe ist ein neues TeamMatchResult-Pendant zu MatchResult:

@dataclass
class TeamMatchResult:
    scored: list[ScoredTeam]
    by_category: dict[str, list[ScoredTeam]]

Matcher.summary_counts(...) wird so generalisiert, dass es sowohl dict[str, list[ScoredCapacity]] als auch dict[str, list[ScoredTeam]] akzeptiert (strukturell identisch, nur len-basiert).

matching/llm_fulltext_matcher.py match_teams

Der LlmFulltextMatcher erhält parallel zu match_capacities und match_tasks eine neue match_teams-Methode:

class LlmFulltextMatcher:
    async def match_teams(
        self,
        *,
        task_profile: TaskProfile,
        teams: list[Team],
    ) -> "LlmFulltextResult":
        ...

Implementierungsdetails:

  • Es wird kein zusätzlicher DB-Roundtrip benötigt, da Team bereits competences und references mitführt (im Gegensatz zu Capacity, wo description/certificates/references via Batch-Methoden nachgeladen werden).

  • Pro Team wird build_team_profile + serialize_team_profile aufgerufen, der User-Prompt wird wie in _build_user_prompt_capacity_for_task strukturiert:

    === Aufgabe ===
    <serialize_task_profile(task_profile)>
    
    === Team ===
    ID: <team_id>
    <serialize_team_profile(team_profile)>
    
  • System-Prompt: dasselbe Schema wie für Kapazitäten (Top/Good/Partial/Low/Irrelevant, JSON-Antwort {"category": ..., "rationale": ...}). Da der Bewertungsmaßstab inhaltlich identisch ist (Passung Aufgabe ↔ Profil), wird der bestehende _SYSTEM_PROMPT mit einer minimalen Wortwahl-Anpassung (KapazitätsprofilProfil) verwendet, ohne neuen Prompt einzuführen, der die Kategoriesemantik ändert. Die Implementierung kann bei Bedarf einen _SYSTEM_PROMPT_TEAM einführen, falls A/B-Tests dies nahelegen.

  • Concurrency-Steuerung über die existierende asyncio.Semaphore-Logik (max_concurrency=cfg.azure_openai.max_concurrency).

  • Fehlerbehandlung: identisch zu match_capacities; ungültige Kategorien werden über normalize_category auf Irrelevant gemappt mit Hinweis-Suffix in der Rationale (Anforderung 7.5). LLM-Fehler landen in errors-Liste und nicht im by_category-Bucket (Anforderung 7.6).

  • Sortierung: pro Kategorie nach item_id aufsteigend (team_id), wie in den Capacity- und Task-Pfaden bereits erfolgt (Anforderung 7.7).

mcp_server.py neue Tools

find_matching_teams

Signatur (parallel zu find_matching_capacities):

@mcp.tool()
async def find_matching_teams(
    role_name: str,
    competences: list[str],
    matching_method: str = "",
) -> str:
    ...

Wesentliche Schritte:

  1. _resolve_matching_method(matching_method) (Anforderung 1.5/1.6).
  2. role_name-Normalisierung wie im Capacity-Pfad ("Beliebige Rolle" als Fallback). competences muss non-empty sein (_validate_requirements_minimum).
  3. Confirm-Gate über _require_confirmed_or_auto(req).
  4. Teams aus _get_teams_cached() holen (QueryCache[list[Team]]).
  5. Score-Pfad: matcher.match_teams(teams, req, top_competency_weight=cfg.matching.team.top_competency_weight).
  6. LLM-Pfad: task_profile = build_task_profile_from_requirements(req)llm_fulltext_matcher.match_teams(task_profile=task_profile, teams=teams).
  7. Persistenz im SearchCache mit results_payload["search_type"] = "team_search" und matching_method = ... (Anforderung 8.2 / 8.3).
  8. Ausgabe: SEARCH_ID-Header + Summary-Tabelle + Top-Kategorie-Tabelle, exakt wie in find_matching_capacities. Tabellenformat siehe nächster Abschnitt.

Tabellenformat für Team-Suchen

_format_results_table wird um search_type="team_search" erweitert:

Modus Spalten
team_search × score Team Name, Schwerpunkt, Top-Kompetenzen, Role Score, Competence Score, Overall Score, Category
team_search × llm_fulltext Team Name, Schwerpunkt, Top-Kompetenzen, Category, Begründung

Top-Kompetenzen ist die kommaseparierte Liste aller competences mit top_competency=True (Anforderung 6.6/7.8). Die Begründung-Spalte verwendet denselben Truncation-Helper _format_rationale_for_table.

Der Helper _team_to_row(team_dict, *, matching_method) extrahiert aus dem persistierten dict-Eintrag (siehe _coerce_team weiter unten) die nötigen Felder und nutzt team["category"] bzw. die Score-Felder.

list_teams und get_team_details

@mcp.tool()
def list_teams(limit: int = 20) -> str:
    """Markdown-Tabelle: Team Id | Team Name | Schwerpunkt
    | Anzahl Kompetenzen | Anzahl Referenzen.

    Nutzt _get_teams_cached() und schneidet auf `limit` ab. Empty-Fallback
    wie bei list_free_capacities.
    """

@mcp.tool()
def get_team_details(team_id: str) -> str:
    """Markdown-Tabelle für ein Team plus Beschreibungssektionen.

    Layout (analog get_capacity_details):
      | Team Id | Team Name | Schwerpunkt | Anzahl Kompetenzen | Anzahl Referenzen |

      ## Über uns
      ...

      ## Leistungen
      ...

      ## Interessen
      ...

      ## Kompetenzen
      - <name> (Top)
      - <name>
      - ...

      ## Referenzen
      - **<partner_name>**: <projects>     (wenn partner_name vorhanden)
      - <projects>                          (wenn partner_name leer)

      ## Next steps
      Call find_matching_teams(role_name=..., competences=[...])
      to find matching tasks for this team's profile.
    """

Validierung: Wenn db_client.get_team_by_id(team_id) None liefert, wird f"Team not found: {team_id}" zurückgegeben (Anforderung 9.3, parallel zu get_capacity_details).

_coerce_team

Analog zu _coerce_capacity werden persistierte SearchCache-Einträge zurück in Team-Instanzen rehydratisiert (für Tabellen-Rendering nach get_results_by_category oder filter_search_results):

def _coerce_team(item: Any) -> Team:
    if isinstance(item, Team):
        return item
    if isinstance(item, dict):
        d = item["team"] if isinstance(item.get("team"), dict) else item
        comps_raw = d.get("competences") or []
        return Team(
            team_id=str(d["team_id"]),
            ouid=str(d["ouid"]),
            team_name=str(d.get("team_name") or ""),
            focus_name=str(d.get("focus_name") or ""),
            about_us=str(d.get("about_us") or ""),
            offerings=str(d.get("offerings") or ""),
            interests=str(d.get("interests") or ""),
            competences=[
                TeamCompetence(
                    name=str(c["name"]),
                    top_competency=bool(c.get("top_competency", False)),
                )
                for c in comps_raw
            ],
            references=[
                TeamReference(
                    partner_name=str(r.get("partner_name") or ""),
                    projects=str(r["projects"]),
                )
                for r in (d.get("references") or [])
            ],
        )
    raise TypeError(f"Unsupported team payload type: {type(item)}")

filter_search_results und get_results_by_category

filter_search_results (Anforderung 8.5/8.6) wird so erweitert, dass für search_type == "team_search":

  • role_filter matcht gegen team.focus_name (statt cap.role_name).
  • competence_filter matcht gegen die Liste der Kompetenz-Namen ([c["name"] for c in item["competences"]]).
  • Ein neuer optionaler Filter top_competency_only: bool = False wird im Tool nicht eingeführt; stattdessen kann competence_filter auf Top-Kompetenzen beschränkt werden, indem das Tabellenfeld Top-Kompetenzen genutzt wird. Eine echte Top-Filter-Funktion wird über die existierende competence_filter-Logik realisiert: Wenn ein Filterwert mit Suffix (Top) versehen ist, wird das Suffix entfernt und nur Top-Kompetenzen werden geprüft. Detail-Verfeinerung erfolgt in Tasks; das Tool-Surface bleibt unverändert.
  • availability_date_* und is_fully_available werden ignoriert; die Werte fließen in die Applied Filters-Tabelle mit Suffix "(ignored: not applicable for team_search)" (Anforderung 8.6 / 6.7).
  • task_*-Filter (task_text_filter, task_competence_filter) sind weiterhin nur für task_search aktiv und werden für team_search ignoriert (kein zusätzlicher Hinweis nötig, da bereits heute so dokumentiert).

get_results_by_category benötigt nur eine kleine Erweiterung in _format_results_table, da der Branch bereits über search_type und matching_method routet. Das LLM-Mode-Sortierkey für team_search ist (category_rank, item.get("team_id") or ""), parallel zur bestehenden Capacity-Logik.

Konfigurationserweiterung

config.py wird um TeamMatchingConfig und das Feld MatchingConfig.team erweitert. Außerdem wird in load_config das neue TOML-Sub-Table [matching.team] geparst:

@dataclass(frozen=True)
class TeamMatchingConfig:
    top_competency_weight: float = 1.5


@dataclass(frozen=True)
class MatchingConfig:
    # ... bestehende Felder ...
    team: TeamMatchingConfig = TeamMatchingConfig()

Validierung in load_config (Anforderung 12.6):

team_raw = matching_raw.get("team", {}) or {}
top_w_raw = team_raw.get("top_competency_weight", 1.5)
try:
    top_w = float(top_w_raw)
except (TypeError, ValueError) as exc:
    raise ConfigError(
        "matching.team.top_competency_weight must be a number, "
        f"got {top_w_raw!r}"
    ) from exc
if top_w < 1.0:
    raise ConfigError(
        "matching.team.top_competency_weight must be >= 1.0, "
        f"got {top_w}"
    )

config.toml.example wird um den neuen Abschnitt erweitert:

[matching.team]
# Gewichtungsfaktor für Top-Kompetenzen im Score-basierten Team-Matching.
# Default: 1.5. Muss numerisch und >= 1.0 sein.
top_competency_weight = 1.5

Dokumentations- und Agenten-Updates

Die folgenden Dateien werden im Rahmen der Implementierungstasks aktualisiert (Anforderung 10/11):

  • docs/architecture.md: Neuer Unterabschnitt "Team Profile" im Datenmodell-Kapitel, Datenquellen-Tabelle wird um vier Views ergänzt, Tool-Surface-Tabelle um find_matching_teams/list_teams/get_team_details, Runtime-View für Aufgabe → Team in beiden Modi.
  • README.md: Quick-Start- und Usage-Abschnitt um Profile_Type-Wahl, neue Views, fehlende Verfügbarkeitsfilter und neue Spalten ergänzen.
  • .github/agents/teamlandkarte_agent.md und .kiro/agents/teamlandkarte.md:
    • Profile_Type-Werte capacity/team mit Bedeutung dokumentieren.
    • Ablauf-Frage "Profile_Type" zur initialen Captures-Phase ergänzen.
    • Hinweis: Verfügbarkeitsfilter wirken in team-Suchen nicht, Ergebnisspalten weichen ab.
    • Erwähnung von find_matching_teams, list_teams, get_team_details.
    • Bestätigungs-Workflow bleibt für beide Profile_Type-Werte gleich.

Datenmodelle

Profile_Type-Enum (logisch)

Profile_Type ist bewusst kein enum.Enum im Code, sondern eine String-Konstante in mcp_server.py (parallel zu _ALLOWED_MATCHING_METHODS):

_ALLOWED_PROFILE_TYPES: tuple[str, ...] = ("capacity", "team")

Begründung: MCP-Tools übergeben Strings, und der Profile_Type ergibt sich implizit aus dem Tool-Namen (find_matching_capacitiescapacity, find_matching_teamsteam). Ein expliziter Parameter würde redundant zur Tool-Identität werden. Im persistierten Suchergebnis wird das Feld als search_type ∈ {"capacity_search","task_search","team_search"} geführt.

Team und Folge-Records

Feld Typ Quelle Bemerkungen
team_id str teamlandkarte_v_teams_latest.team_id Stringform für SearchCache-Persistenz
ouid str teamlandkarte_v_teams_latest.ouid Join-Schlüssel für Kompetenzen + Referenzen
team_name str teamlandkarte_v_teammeter_organizational_units_latest.<name> INNER JOIN über team_id = id
focus_name str teamlandkarte_v_teams_latest.focus_name NULL → "", dient als Rollen-Stellvertreter
about_us str teamlandkarte_v_teams_latest.about_us NULL → ""
offerings str teamlandkarte_v_teams_latest.offerings NULL → ""
interests str teamlandkarte_v_teams_latest.interests NULL → ""
competences list[TeamCompetence] teamlandkarte_v_teammeter_team_competences_latest Sortiert: Top zuerst, dann Name
references list[TeamReference] teamlandkarte_v_team_references_latest Sortiert: partner_name, dann projects

TeamCompetence: (name: str, top_competency: bool) mit top_competency aus COALESCE(top_competency, FALSE) (Anforderung 3.5).

TeamReference: (partner_name: str, projects: str) partner_name darf "" sein, projects ist non-empty (whitespace-only wird in der DB-Schicht gefiltert, Anforderung 4.6).

Persistiertes Such-Ergebnis (SearchCache)

{
    "search_type": "team_search",
    "matching_method": "score",            // oder "llm_fulltext"
    "reference": {
        "availability_date_start": null,    // immer null bei team_search
        "availability_date_end": null
    },
    "summary": {
        "Top": 0, "Good": 0, "Partial": 0, "Low": 0, "Irrelevant": 0
    },
    "by_category": {
        "Top": [
            {
                "team_id": "...",
                "ouid": "...",
                "team_name": "...",
                "focus_name": "...",
                "about_us": "...",
                "offerings": "...",
                "interests": "...",
                "competences": [
                    {"name": "Python", "top_competency": true},
                    {"name": "FastAPI", "top_competency": false}
                ],
                "references": [
                    {"partner_name": "DB Cargo", "projects": "..."}
                ],
                // Score-Modus:
                "competence_score": 0.81,
                "role_score": 0.5,
                "overall_score": 0.74,
                // LLM-Modus:
                "category": "Good",
                "rationale": "..."
            }
        ],
        "...": []
    },
    "errors": []                              // nur llm_fulltext
}

asdict(team) erzeugt diese Struktur weitgehend automatisch; lediglich die verschachtelten TeamCompetence/TeamReference-Dataclasses werden ebenfalls über asdict rekursiv flach gemacht. Der Server-Code stellt sicher, dass category/Score-Felder über dict | asdict(team)-Merge ergänzt werden, exakt wie im Capacity-Pfad.

META-JSON in der Tool-Antwort

{
    "search_id": "<uuid>",
    "filter_id": null,
    "default_category": "Top",
    "matching_method": "score",
    "search_type": "team_search"   // neu für find_matching_teams
}

Begründung: search_type wird zusätzlich zu matching_method ausgegeben, damit der Agent in seiner UI klar zwischen Capacity- und Team-Suchen unterscheiden kann (Anforderung 1.4 / 8.3).


Korrektheits-Eigenschaften

Eine Eigenschaft ist ein Verhalten, das für alle gültigen Ausführungen des Systems gelten muss eine formale Aussage darüber, was die Software tun soll. Eigenschaften überbrücken die Lücke zwischen menschenlesbaren Anforderungen und maschinell prüfbaren Korrektheits-Garantien. Jede Eigenschaft enthält einen expliziten "für alle"-Quantifikator und referenziert die Anforderungen, die sie validiert.

Die folgenden Eigenschaften ergeben sich aus der Vorprüfung der EARS-Akzeptanzkriterien und wurden via Reflexion auf logisch eigenständige Aussagen reduziert. Akzeptanzkriterien, die als reine Beispiele klassifiziert wurden (Tool-Existenz, Markdown-Doku-Strings, spezifische Fehlermeldungen für ungültige team_id), werden über klassische Unit-Tests abgedeckt und sind nicht als Properties aufgeführt.

Property 1: matching_method-Validierung in find_matching_teams

Für jeden String m, der nicht (case-insensitiv getrimmt) in {"score", "llm_fulltext"} liegt und nicht leer/None ist, gibt find_matching_teams(role_name, competences, matching_method=m) eine Fehlermeldung zurück, die sowohl 'score' als auch 'llm_fulltext' als erlaubte Werte enthält, und führt keinen DB- oder LLM-Aufruf aus.

Validates: Requirements 1.5, 1.6

Property 2: SELECT-Only-Eigenschaft aller neuen Trino-Queries

Für alle Aufrufe von TrinoClient.get_all_teams, get_team_by_id, get_team_competences, batch_get_team_competences, get_team_references und batch_get_team_references (mit beliebigen gültigen Eingaben) ist die ausgeführte SQL-Anfrage ein SELECT- oder WITH-Statement, sodass _ensure_select_only(sql) ohne Fehler durchläuft.

Validates: Requirements 2.6

Property 3: Stammdaten-Konsistenz und INNER-JOIN-Filter

Für jede Stub-DB-Antwort (Listen von Teams- und OrganizationalUnits-Zeilen mit beliebigen NULL/Empty-Verteilungen) gilt: get_all_teams()

  1. enthält ausschließlich Teams, deren team_id in der OrganizationalUnits-Liste über id einen Treffer hat (Anforderung 2.3),
  2. liefert für jede der Spalten about_us, offerings, interests, focus_name einen leeren String, wenn die Quelle NULL oder leer ist, und niemals None (Anforderung 2.4),
  3. setzt Team.team_name auf den Namen aus der OU-Zeile, der dem team_id-Match entspricht (Anforderung 2.2),
  4. liefert für jede gefundene team_id ein konsistentes Ergebnis mit get_team_by_id(team_id) (Anforderung 2.5).

Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5

Property 4: Kompetenz-Batch ist konsistent zur Einzel-Variante

Für jede Liste von OUIDs ouids und jede Stub-DB-Antwort gilt:

batch_get_team_competences(ouids)[ouid] ist für jedes ouid ∈ ouids gleich get_team_competences(ouid). Außerdem gilt für jeden Eintrag der Ergebnisliste:

  • top_competency ist False, wenn die Quellzeile NULL enthielt (Anforderung 3.5),
  • Einträge ohne auflösbaren Kompetenz-Namen sind nicht enthalten (Anforderung 3.6),
  • die Reihenfolge ist (top_competency desc, name asc) und damit deterministisch (Anforderung 3.7),
  • für ouid-Werte ohne Kompetenzen ist die Liste leer (Anforderung 3.4).

Validates: Requirements 3.1, 3.3, 3.4, 3.5, 3.6, 3.7

Property 5: Referenz-Batch ist konsistent zur Einzel-Variante

Für jede Liste von OUIDs ouids und jede Stub-DB-Antwort gilt:

batch_get_team_references(ouids)[ouid] ist für jedes ouid ∈ ouids gleich get_team_references(ouid). Außerdem gilt für jeden Eintrag:

  • Bei partner_id IS NULL oder leerem Partner-Join ist partner_name == "", der Eintrag bleibt aber in der Liste erhalten (Anforderung 4.5),
  • Einträge mit leerem oder ausschließlich Whitespace gefülltem projects sind nicht enthalten (Anforderung 4.6),
  • die Reihenfolge ist (partner_name asc, projects asc) und damit deterministisch (Anforderung 4.7),
  • für ouid-Werte ohne Referenzen ist die Liste leer (Anforderung 4.4).

Zusätzlich ruft die Batch-Methode genau einen cur.execute(...)-Call gegen teamlandkarte_v_team_references_latest ab (Anforderung 4.3).

Validates: Requirements 4.1, 4.3, 4.4, 4.5, 4.6, 4.7

Property 6: Determinismus und Vollständigkeit der Team-Profil-Serialisierung

Für alle TeamProfile-Instanzen p (inklusive solcher mit allen Pflichtfeldern leer, leeren Listen und Referenzen mit leerem partner_name) gilt:

  1. serialize_team_profile(p) == serialize_team_profile(p) (Determinismus, Anforderung 13.1).
  2. serialize_team_profile(build_team_profile(team)) == serialize_team_profile(build_team_profile(team)) für jedes Team (Idempotenz der Profil-Bildung, Anforderung 13.2).
  3. Die Ausgabe enthält jede der sieben Überschriften Teamname:, Schwerpunkt:, Über uns:, Leistungen:, Interessen:, Kompetenzen:, Referenzen: mindestens einmal in genau dieser Reihenfolge (Anforderungen 5.4, 5.7, 13.3).
  4. Für jede Kompetenz mit top_competency=True enthält die Ausgabe das Marker-Suffix (Top) direkt am Kompetenz-Namen (Anforderung 5.5).
  5. Für jede Referenz mit partner_name != "" enthält die Ausgabe sowohl partner_name als auch projects als Substring (Anforderung 5.6); für Referenzen mit partner_name == "" enthält die zugehörige Zeile kein Partner: -Token (Anforderung 5.3).
  6. Die Reihenfolge der Listen-Elemente in der Serialisierung entspricht exakt der Reihenfolge in profile.competences bzw. profile.references (Anforderung 13.4).

Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 13.1, 13.2, 13.3, 13.4

Property 7: Top-Kompetenz-Monotonie im Score-Matching

Für alle Requirements, alle Listen cs von Kompetenz-Namen und alle Konfigurationswerte top_weight ≥ 1.0 gilt:

Matcher.match_teams([T_top], req, top_competency_weight=top_weight) ergibt für T_top einen competence_score, der größer oder gleich dem competence_score von T_plain ist, wobei

  • T_plain ein Team mit competences=[TeamCompetence(c, top_competency=False) for c in cs] ist,
  • T_top ein Team mit identischer competences-Liste, jedoch top_competency=True für alle Einträge ist.

Außerdem ist bei top_weight == 1.0 die Differenz exakt 0.0 (Anforderung 6.4: Top-Markierung ohne Gewicht-Effekt).

Validates: Requirements 6.4

Property 8: Score-Pfad liefert gültige Score- und Kategorie-Werte

Für jede Liste teams: list[Team] und alle gültigen Requirements req liefert Matcher.match_teams(teams, req, top_competency_weight=cfg.matching.team.top_competency_weight) ein TeamMatchResult, in dem für jedes ScoredTeam gilt:

  • 0.0 <= competence_score <= 1.0, 0.0 <= role_score <= 1.0, 0.0 <= overall_score <= 1.0,
  • category ∈ {"Top","Good","Partial","Low","Irrelevant"},
  • category ist konsistent mit categorize(overall_score, cfg.matching) (gleiche Schwellwerte wie für Kapazitäten),
  • die Verfügbarkeitsfelder von req (date_start, date_end) ändern weder die Liste noch die Scores (Anforderung 6.7).

Validates: Requirements 6.1, 6.2, 6.3, 6.5, 6.7

Property 9: LLM-Pfad ist eine vollständige Partition mit gültigen Kategorien

Für jede Liste teams: list[Team], jedes task_profile: TaskProfile und jede deterministische LLM-Maske (mask: list[bool] mit len(mask) == len(teams), wobei True einen LLM-Fehler simuliert) liefert LlmFulltextMatcher.match_teams(task_profile=..., teams=teams) ein LlmFulltextResult, für das gilt:

  1. sum(len(items) for items in result.by_category.values()) + len(result.errors) == len(teams) (Vollständigkeit, Anforderung 7.1, 7.6).
  2. Alle team_id-Werte über by_category und errors sind paarweise eindeutig (kein Team verschwindet oder dupliziert sich).
  3. Jede LlmFulltextItem.category ∈ {"Top","Good","Partial","Low","Irrelevant"} (Anforderung 7.2).
  4. Innerhalb jeder Kategorie sind die Items aufsteigend nach item_id (team_id) sortiert (Anforderung 7.7).
  5. Bei erfolgreichem LLM-Aufruf ist rationale ein nicht-leerer String; bei fehlerhaftem LLM-Aufruf erscheint das Team in errors und nicht in by_category (Anforderung 7.3, 7.6).

Validates: Requirements 7.1, 7.2, 7.3, 7.6, 7.7

Property 10: Ungültige LLM-Kategorien fallen auf Irrelevant zurück

Für jeden String c, der (nach normalize_category) nicht in {"Top","Good","Partial","Low","Irrelevant"} liegt, und jedes LLM-Antwort-JSON {"category": c, "rationale": r} mit beliebigem r gilt: das resultierende LlmFulltextItem hat category == "Irrelevant" und rationale enthält einen Hinweis-Text auf die ungültige LLM-Antwort.

Validates: Requirements 7.5

Property 11: META und SearchCache markieren Team-Suchen korrekt

Für jede erfolgreiche find_matching_teams-Antwort gilt:

  1. Die Ausgabe enthält eine Zeile SEARCH_ID=<uuid> mit <uuid> als gültigem UUID-String (Anforderung 8.1).
  2. Die Ausgabe enthält eine Zeile META=<json> mit <json>["search_type"] == "team_search" und <json>["matching_method"] ∈ {"score","llm_fulltext"} (Anforderungen 1.4, 8.3).
  3. Der zugehörige SearchCache-Eintrag hat entry.results["search_type"] == "team_search" und entry.results["matching_method"] == <json>["matching_method"] (Anforderung 8.2).
  4. get_results_by_category(search_id, ...) liefert für team_search eine Tabelle, die je nach matching_method die in _format_results_table definierten Team-Header enthält (Team Name, Schwerpunkt, Top-Kompetenzen, ... Anforderung 8.4).

Validates: Requirements 1.4, 8.1, 8.2, 8.3, 8.4

Property 12: Verfügbarkeitsfilter werden in Team-Suchen ignoriert

Für jeden filter_search_results-Aufruf auf einen team_search-Cache-Eintrag mit beliebigen Werten für availability_date_start, availability_date_end und is_fully_available gilt:

  1. Die Menge der gefilterten Items ist die gleiche wie bei einem Aufruf ohne Verfügbarkeitsfilter (gleiche role_filter/competence_filter).
  2. Die Ausgabe enthält in der Applied Filters-Tabelle für jeden Verfügbarkeitswert einen Hinweis, der die Zeichenkette "team_search" enthält und kennzeichnet, dass der Filter nicht wirksam ist (Anforderungen 6.7, 8.5, 8.6).

Validates: Requirements 6.7, 8.5, 8.6

Property 13: Konfig-Validierung von top_competency_weight

Für jeden Wert v, der entweder

  1. ein Float ≥ 1.0 ist (positiv): load_config(...) liefert eine AppConfig mit cfg.matching.team.top_competency_weight == float(v), oder
  2. nicht-numerisch oder < 1.0 (negativ): load_config(...) wirft einen ConfigError, dessen Meldung sowohl den Schlüsselnamen matching.team.top_competency_weight als auch den fehlerhaften Wert enthält.

Wenn der Schlüssel im TOML fehlt, ist top_competency_weight == 1.5.

Validates: Requirements 12.5, 12.6

Property 14: Schema-Verifikation deckt fehlende Spalten in den vier neuen Views auf

Für jede Mock-DB-Antwort, die in einer der vier Views (teamlandkarte_v_teams_latest, teamlandkarte_v_teammeter_organizational_units_latest, teamlandkarte_v_teammeter_team_competences_latest, teamlandkarte_v_team_references_latest) eine erwartete Spalte weglässt, liefert verify_required_columns(...) für die in mcp_server.build_server konfigurierte schema_expected-Erweiterung einen SchemaIssue mit dem betroffenen Tabellennamen und enthält die fehlende Spalte in message. Wenn alle erwarteten Spalten vorhanden sind, ist das Issue-Set für diese vier Views leer.

Validates: Requirements 12.1, 12.2, 12.3, 12.4


Fehlerbehandlung

Fehlerquelle Verhalten Referenz
matching_method ungültig find_matching_teams gibt String mit Fehlermeldung + erlaubten Werten zurück, ohne DB-/LLM-Aufruf. 1.5/1.6, Property 1
competences leer / nur Whitespace _validate_requirements_minimum wirft ValueError, der vom Tool als Fehlermeldung formatiert wird (parallel zum Capacity-Pfad). 1.5 (Defaults wie Capacities)
Bestätigung fehlt (require_confirmation=true) Tool gibt Hinweis zurück und ruft Matching nicht auf. 1.5
team_id unbekannt get_team_details liefert f"Team not found: {team_id}", kein Stack-Trace. 9.3
INNER JOIN ohne Treffer Team wird nicht in get_all_teams aufgenommen; get_team_by_id liefert None. 2.3 / 9.3
competence_id nicht auflösbar Eintrag wird in der DB-Schicht herausgefiltert. 3.6
partner_id NULL / Join leer partner_name = "", Referenz bleibt erhalten. 4.5
projects leer / Whitespace Eintrag wird ausgeschlossen. 4.6
LLM-JSON nicht parsebar LlmFulltextError(item_id=team_id, error="invalid JSON: ...") in errors-Liste; Team nicht in by_category. 7.4, 7.6
LLM-Fehler (AzureAPIError, generische Exception) LlmFulltextError(error=type+message) in errors-Liste. 7.6
Ungültige LLM-Kategorie category = "Irrelevant", Hinweis im rationale-Suffix. 7.5
Ungültiger top_competency_weight (TOML) ConfigError beim Start. 12.6
Schema-Spalte fehlt verify_required_columns erzeugt SchemaIssue; build_server wirft RuntimeError("Database schema verification failed: ...") und der Server startet nicht. 12.1-12.4
Unbekannte/abgelaufene search_id filter_search_results und get_results_by_category geben STATUS=unknown_or_expired zurück (existierender Pfad). 8.1

Sicherheitsrelevant ist insbesondere die Read-Only-Eigenschaft aller neuen Queries. Die zentrale Stelle dafür ist die bestehende _ensure_select_only-Guard, die in allen sechs neuen TrinoClient-Methoden vor dem cur.execute(...)-Aufruf greift.


Teststrategie

Dualer Ansatz

Die Tests verwenden den im Projekt etablierten dualen Ansatz aus Unit-Tests (für konkrete Beispiele und Edge Cases) und Property-Based Tests (für universell quantifizierte Eigenschaften). Das eingesetzte PBT-Framework ist Hypothesis, parallel zu den bestehenden tests/test_*_pbt.py-Modulen. Property-Based-Tests werden nicht von Hand reimplementiert.

Konfiguration

  • Mindestens max_examples=100 pro Property-Test (analog zu existierenden PBT-Tests).
  • deadline=None für Tests mit asyncio-Loop.
  • Jeder PBT-Test trägt einen Tag-Kommentar im Format: # Feature: team-profile-matching, Property <N>: <kurze Beschreibung>
  • Jede in Korrektheits-Eigenschaften definierte Property wird durch genau einen Property-Based Test implementiert.

Unit-Test-Schwerpunkte

Unit-Tests decken die als yes - example markierten Akzeptanzkriterien ab und ergänzen die Properties:

  • Profile_Type-Konstante existiert und enthält "capacity" und "team" (Anforderung 1.1).
  • Tools find_matching_teams, list_teams, get_team_details sind über FastMCP registriert (Anforderungen 1.3, 9.1, 9.2).
  • find_matching_capacities-Regression: Snapshot-Test, dass Outputs für einen festen Input nach dem Refactoring unverändert bleiben (Anforderung 1.2).
  • Markdown-Outputs von list_teams und get_team_details enthalten die geforderten Header und Sektionen (Anforderungen 9.1, 9.2, 9.4).
  • Fehler-Beispiel: get_team_details("unknown") liefert eine Fehlermeldung, die unknown enthält (Anforderung 9.3).
  • Doku-Snapshot-Tests: docs/architecture.md, README.md, .kiro/agents/teamlandkarte.md, .github/agents/teamlandkarte_agent.md enthalten die definierten Stichworte (Profile_Type, find_matching_teams, team_search, …) (Anforderungen 10.1-10.6, 11.1-11.9).

Property-Based-Tests (Übersicht)

Test-Datei (vorgeschlagen) Property Hypothesis-Strategie
test_team_matching_method_validation_pbt.py 1 st.text() ohne whitelisted Werte
test_team_trino_select_only_pbt.py 2 Stub-Cursor, der _ensure_select_only jeden generierten SQL-String prüfen lässt
test_team_master_data_pbt.py 3 Strategy für Listen aus Teams- und OU-Stub-Zeilen mit beliebigen NULL-Verteilungen
test_team_competences_batch_pbt.py 4 Strategy aus Mengen von OUIDs + zufälligen Top/Name-Verteilungen
test_team_references_batch_pbt.py 5 Strategy für Referenzzeilen mit/ohne Partner und Whitespace-projects
test_team_profile_serialization_pbt.py 6 Strategy für TeamProfile analog zu _capacity_profile in test_profile_serialization_pbt.py
test_team_score_top_weight_pbt.py 7 Strategy für Kompetenz-Listen + top_weight ∈ [1.0, 5.0]
test_team_score_pipeline_pbt.py 8 Strategy für Team-Listen mit injizierter Stub-SimilarityEngine
test_team_llm_fulltext_partition_pbt.py 9 Strategy: mask: list[bool] analog zu test_batch_completeness_pbt.py
test_team_llm_invalid_category_pbt.py 10 Strategy: zufällige Strings + JSON-Wrapper
test_team_meta_search_type_pbt.py 11 Strategy: zufällige Inputs + Snapshot-Parser für META-JSON
test_team_filter_ignores_availability_pbt.py 12 Strategy: zufällige Datumswerte + is_fully_available
test_team_config_top_weight_pbt.py 13 Strategy: zufällige numerische und non-numerische Werte
test_team_schema_verification_pbt.py 14 Strategy: Powerset der erwarteten Spalten pro View, jeweils minus eine Spalte

Stubs und Fakes

  • DB-Stub: Ein In-Memory-DBClient-Double (analog zu _StubDb in tests/test_batch_completeness_pbt.py) liefert deterministische Team-/Kompetenz-/Referenzlisten und zählt SQL-Aufrufe für die Batch-Konsistenz-Property.
  • LLM-Fake: Maskenbasiertes Fake (_MaskLlm-Pattern aus existierenden Tests) erzeugt deterministische chat_completion-Antworten plus einstreuende AzureAPIErrors.
  • SimilarityEngine-Stub: Liefert deterministische BM25-Scores für die Top-Kompetenz-Monotonie-Property; verhindert echte LLM-Aufrufe in den Score-Pfad-Tests.

Integrationstests

Bestehende Integrationstests (test_integration_matching.py, test_llm_fulltext_integration.py) werden um team_search-Pendants ergänzt: ein Smoke-Test pro matching_method, der mit einem gemockten Trino-Cursor und einem gemockten Azure-Client den vollen Pfad durchläuft (find_matching_teamsSearchCacheget_results_by_categoryfilter_search_results).