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.
1301 lines
54 KiB
Markdown
1301 lines
54 KiB
Markdown
# 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`).
|
||
|