# 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 ```mermaid 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`) ```mermaid 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`) ```mermaid 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: ```python 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: ```python @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): ```python 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):** ```sql 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):** ```sql 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):** ```sql 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`: ```python @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`): ```text Teamname: Schwerpunkt: Über uns: Leistungen: Interessen: Kompetenzen: - (Top) - - ... Referenzen: - Partner: – Projekte: - Projekte: (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`. ```python 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: ```python 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`: ```python @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: ```python 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: ```text === Aufgabe === === Team === ID: ``` - 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ätsprofil` → `Profil`) 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`): ```python @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` ```python @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 - (Top) - - ... ## Referenzen - ****: (wenn partner_name vorhanden) - (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`): ```python 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: ```python @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): ```python 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: ```toml [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`): ```python _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_capacities` → `capacity`, `find_matching_teams` → `team`). 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.` | 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) ```jsonc { "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 ```jsonc { "search_id": "", "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=` mit `` als gültigem UUID-String (Anforderung 8.1). 2. Die Ausgabe enthält eine Zeile `META=` mit `["search_type"] == "team_search"` und `["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"] == ["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 : ` - 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 `AzureAPIError`s. - **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_teams` → `SearchCache` → `get_results_by_category` → `filter_search_results`).