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

1301 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: <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`.
```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 ===
<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ä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
- <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`):
```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.<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)
```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": "<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 `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`).