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.
This commit is contained in:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
@@ -0,0 +1,798 @@
# Design: LLM-Volltext-Matching als zweites Verfahren
## Übersicht
Dieses Design beschreibt die Einführung eines zweiten Matching-Verfahrens neben dem bestehenden Score-basierten Matching: einen **LLM-basierten Volltext-Vergleich** (`llm_fulltext`), der Kapazitäten und Aufgaben anhand von ganzen Profiltexten bewertet und jedes Ergebnis direkt einer der bestehenden Kategorien (`Top`, `Good`, `Partial`, `Low`, `Irrelevant`) zuordnet. Es gibt keine numerischen Scores mehr im neuen Modus, dafür eine Begründung (Rationale) pro Treffer.
Das neue Verfahren erweitert den Datenraum um:
- die Capacity-Beschreibung (`teamlandkarte_v_capacities_latest.description`)
- die Capacity-Zertifikate (`teamlandkarte_v_capacity_certificates_latest.description`)
- die Capacity-Referenzen (`teamlandkarte_v_capacity_references_latest.projects`)
- den Partner-Namen je Referenz aus `teamlandkarte_v_partners_latest.name`, verknüpft über `teamlandkarte_v_capacity_references_latest.partner_id = teamlandkarte_v_partners_latest.id`
Eine Capacity_Reference besteht damit aus dem Tupel (`partner_name`, `projects`); `partner_name` kann leer sein, wenn `partner_id` `NULL` ist oder der Join keinen Treffer liefert.
Auf Aufgabenseite werden die bestehenden Felder (`title`, `description`, `skills`) genutzt.
Der Nutzer wählt das Verfahren über den neuen Tool-Parameter `matching_method` (`score` | `llm_fulltext`); der Standardwert ist konfigurierbar (`config.toml: matching.default_method`). Beide Verfahren teilen sich Suchcache, Pagination, Filtertools und Bestätigungs-Workflow.
## Architektur
### Komponenten-Überblick (nachher)
```mermaid
flowchart TD
A[MCP Tool: find_matching_capacities] -->|matching_method| R{Routing}
B[MCP Tool: find_matching_tasks] -->|matching_method| R
R -->|score| M[Matcher BM25+LLM Role]
R -->|llm_fulltext| F[LLM_Fulltext_Matcher]
F --> P1[Profile Builder<br/>Capacity_Profile + Task_Profile]
F --> CL[AzureOpenAIClient.chat_completion]
F --> SC[(SearchCache<br/>category + rationale)]
P1 --> DB[(TrinoClient)]
DB --> V1[teamlandkarte_v_capacities_latest.description]
DB --> V2[teamlandkarte_v_capacity_certificates_latest]
DB --> V3[teamlandkarte_v_capacity_references_latest]
V3 -->|partner_id = id| V4[teamlandkarte_v_partners_latest.name]
M --> SC
```
Der `LLM_Fulltext_Matcher` ist eine neue Komponente in der Business-Logic-Layer und wird beim Server-Start instanziiert. Er greift auf den bestehenden `AzureOpenAIClient`, den `DBClient` und den `SearchCache` zu. Der bestehende `Matcher` bleibt unverändert; die Auswahl erfolgt im MCP-Tool.
### Runtime: find_matching_capacities (LLM-Volltext)
```mermaid
sequenceDiagram
participant Client
participant Server as MCP_Server
participant FM as LLM_Fulltext_Matcher
participant DB as TrinoClient
participant LLM as AzureOpenAIClient
Client->>Server: find_matching_capacities(role_name, competences, dates, matching_method="llm_fulltext")
Server->>Server: Validate matching_method, confirmation gate
Server->>DB: get_all_capacities_with_competences()
Server->>Server: Vorfilter (Verfügbarkeit) wie bei score
Server->>FM: match_capacities(task_profile, filtered_capacities)
FM->>DB: batch_get_capacity_descriptions(ids)
FM->>DB: batch_get_capacity_certificates(ids)
FM->>DB: batch_get_capacity_references(ids)
FM->>FM: build Task_Profile + Capacity_Profile pro Kandidat
loop pro Kapazität
FM->>LLM: chat_completion(system_prompt, user_prompt)
LLM-->>FM: {"category": "...", "rationale": "..."}
FM->>FM: validate(category) sonst Irrelevant + Hinweis
end
FM-->>Server: {by_category, errors}
Server->>SC: store_search(results=payload mit matching_method)
Server-->>Client: Markdown (Summary + Begründungs-Spalte)
```
### Runtime: find_matching_tasks (LLM-Volltext)
```mermaid
sequenceDiagram
participant Client
participant Server as MCP_Server
participant FM as LLM_Fulltext_Matcher
participant DB as TrinoClient
participant LLM as AzureOpenAIClient
Client->>Server: find_matching_tasks(capacity_id, matching_method="llm_fulltext")
Server->>DB: get_capacity_by_id(capacity_id)
Server->>DB: get_open_tasks(limit=0)
Server->>FM: match_tasks(capacity_profile, tasks)
FM->>DB: get_capacity_description(capacity_id)
FM->>DB: get_capacity_certificates(capacity_id)
FM->>DB: get_capacity_references(capacity_id)
FM->>FM: build Capacity_Profile + Task_Profile pro Aufgabe
loop pro Aufgabe
FM->>LLM: chat_completion(system_prompt, user_prompt)
LLM-->>FM: {"category": "...", "rationale": "..."}
end
FM-->>Server: {by_category, errors}
Server->>SC: store_search(results)
Server-->>Client: Markdown (Summary + Begründungs-Spalte)
```
## Komponenten und Schnittstellen
### 1. DBClient (`database/types.py`) neue Methoden
Eine Capacity_Reference wird als strukturierter Eintrag mit den Feldern `partner_name` und `projects` modelliert. `partner_name` kann eine leere Zeichenkette sein (NULL `partner_id` oder Join-Mismatch, vgl. Anforderung 2.8); `projects` enthält den Inhalt der Spalte `projects`.
```python
class CapacityReferenceRow(TypedDict):
partner_name: str # leer, wenn partner_id NULL ist oder kein Partner gefunden wurde
projects: str
class DBClient(Protocol):
# ... bestehende Methoden ...
def get_capacity_description(self, capacity_id: int | str) -> str | None:
"""Return description from teamlandkarte_v_capacities_latest."""
raise NotImplementedError
def get_capacity_certificates(self, capacity_id: int | str) -> list[str]:
"""Return certificate descriptions joined via capacity_id."""
raise NotImplementedError
def get_capacity_references(
self, capacity_id: int | str
) -> list[CapacityReferenceRow]:
"""Return reference entries joined via capacity_id.
Each entry contains the project text (`projects`) and the partner
name from `teamlandkarte_v_partners_latest` (joined via
`partner_id = id`). `partner_name` may be an empty string if
`partner_id` is NULL or no matching partner exists.
"""
raise NotImplementedError
def batch_get_capacity_descriptions(
self, capacity_ids: list[int | str]
) -> dict[str, str | None]:
"""Batch variant: one SELECT for many capacity_ids."""
raise NotImplementedError
def batch_get_capacity_certificates(
self, capacity_ids: list[int | str]
) -> dict[str, list[str]]:
"""Batch variant: one SELECT, grouped per capacity_id."""
raise NotImplementedError
def batch_get_capacity_references(
self, capacity_ids: list[int | str]
) -> dict[str, list[CapacityReferenceRow]]:
"""Batch variant: one SELECT with LEFT JOIN on partners,
grouped per capacity_id. `partner_name` may be empty per entry."""
raise NotImplementedError
```
Schlüssel der Batch-Rückgaben sind die `capacity_id` als String, damit die Aufrufer unabhängig vom Quelltyp (`int`/`str`) deterministisch zugreifen können.
### 2. TrinoClient (`database/trino_client.py`)
Alle neuen Methoden nutzen `_ensure_select_only`, den Pool und `_retry`. Beispiel für die Batch-Variante:
```python
def batch_get_capacity_descriptions(
self, capacity_ids: list[int | str]
) -> dict[str, str | None]:
if not capacity_ids:
return {}
placeholders = ", ".join(["?"] * len(capacity_ids))
query = (
"SELECT capacity_id, description "
"FROM teamlandkarte_v_capacities_latest "
f"WHERE capacity_id IN ({placeholders})"
)
_ensure_select_only(query)
params = [str(c) for c in capacity_ids]
def _run():
with self._cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
rows = self._retry(_run)
out: dict[str, str | None] = {str(c): None for c in capacity_ids}
for row in rows:
cap_id = str(row[0])
desc = row[1]
out[cap_id] = (desc.strip() if isinstance(desc, str) and desc.strip() else None)
return out
```
`batch_get_capacity_certificates` ist analog aufgebaut, gruppiert n:1 (`defaultdict(list)`), filtert leere Strings und liefert stabile, fehlende IDs als leere Liste zurück.
`batch_get_capacity_references` führt zusätzlich einen `LEFT JOIN` auf `teamlandkarte_v_partners_latest` aus, damit der Partner-Name in derselben Abfrage zurückgegeben wird (Anforderung 2.4: keine zusätzliche SQL-Abfrage für den Partner-Join):
```python
def batch_get_capacity_references(
self, capacity_ids: list[int | str]
) -> dict[str, list[CapacityReferenceRow]]:
if not capacity_ids:
return {}
placeholders = ", ".join(["?"] * len(capacity_ids))
query = (
"SELECT r.capacity_id, r.projects, COALESCE(p.name, '') AS partner_name "
"FROM teamlandkarte_v_capacity_references_latest r "
"LEFT JOIN teamlandkarte_v_partners_latest p ON r.partner_id = p.id "
f"WHERE r.capacity_id IN ({placeholders})"
)
_ensure_select_only(query)
params = [str(c) for c in capacity_ids]
def _run():
with self._cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
rows = self._retry(_run)
out: dict[str, list[CapacityReferenceRow]] = {str(c): [] for c in capacity_ids}
for row in rows:
cap_id = str(row[0])
projects = row[1]
partner_name = row[2] or "" # NULL/COALESCE → ""
if not (isinstance(projects, str) and projects.strip()):
continue
out.setdefault(cap_id, []).append(
{"partner_name": str(partner_name), "projects": projects.strip()}
)
return out
```
Hinweise:
- `COALESCE(p.name, '')` deckt sowohl NULL `partner_id` (kein Join-Match) als auch existierende Partner ohne Namen ab und garantiert einen leeren String statt `None` (Anforderung 2.8).
- Der LEFT JOIN ist Bestandteil derselben Referenz-Abfrage; es entsteht keine zusätzliche SQL-Abfrage. Damit bleibt das Drei-Abfragen-Limit pro Quelle (Beschreibung, Zertifikate, Referenzen) erhalten (Anforderung 2.4).
Alle Methoden führen genau **eine** SQL-Abfrage pro Quelle aus (Anforderung 2.4) und nutzen Parameter-Bindung gegen Injection.
### 3. Datenmodelle: Capacity_Profile und Task_Profile
```python
@dataclass(frozen=True)
class CapacityReferenceEntry:
"""Strukturierter Referenz-Eintrag im CapacityProfile.
`partner_name` darf leer sein (NULL `partner_id` oder Join-Mismatch);
in diesem Fall wird die Referenz dennoch im Profil geführt und
ausschließlich `projects` in der Serialisierung dargestellt.
"""
partner_name: str # leer, wenn nicht zuordenbar
projects: str
@dataclass(frozen=True)
class CapacityProfile:
id: str
owner_name: str
role_name: str
competences: list[str]
description: str # leer wenn None/leer in DB
references: list[CapacityReferenceEntry]
certificates: list[str]
@dataclass(frozen=True)
class TaskProfile:
id: str
title: str
description: str
skills: list[str] # gesuchte Kompetenzen
```
Beide Profile haben deterministische Serialisierungen (siehe `serialize`). Leere Felder erzeugen leere Zeichenkette/leere Liste, das Profil wird nie verworfen (Anforderung 3.2/4.2).
### 4. LLM_Fulltext_Matcher (`matching/llm_fulltext_matcher.py`)
```python
class LlmFulltextMatcher:
"""LLM-based full-text matching between capacities and tasks."""
def __init__(
self,
*,
db: DBClient,
client: AzureOpenAIClient,
rationale_max_chars: int = 280,
) -> None:
self._db = db
self._client = client
self._rationale_max_chars = rationale_max_chars
async def match_capacities(
self,
*,
task_profile: TaskProfile,
capacities: list[Capacity],
) -> "LlmFulltextResult": ...
async def match_tasks(
self,
*,
capacity_profile: CapacityProfile,
tasks: list[Task],
) -> "LlmFulltextResult": ...
```
Ergebnistyp:
```python
@dataclass
class LlmFulltextItem:
item_id: str # capacity_id oder task_id
category: str # Top|Good|Partial|Low|Irrelevant
rationale: str # ungekürzt, von LLM
raw: dict # ursprüngliches Item-Payload für Cache (asdict(Capacity)/Task)
@dataclass
class LlmFulltextError:
item_id: str
error: str
@dataclass
class LlmFulltextResult:
by_category: dict[str, list[LlmFulltextItem]]
errors: list[LlmFulltextError]
```
#### Profil-Serialisierung (deterministisch)
```python
def _format_reference(entry: CapacityReferenceEntry) -> str:
"""Format a single reference deterministically.
- `Partner: <name> Projekte: <projects>` wenn partner_name nicht leer
- `Projekte: <projects>` wenn partner_name leer (kein Platzhalter)
"""
projects = entry.projects.strip()
if entry.partner_name:
return f"Partner: {entry.partner_name} Projekte: {projects}"
return f"Projekte: {projects}"
def serialize_capacity_profile(p: CapacityProfile) -> str:
refs = [_format_reference(r) for r in p.references]
return "\n".join([
f"Rolle: {p.role_name}",
"Kompetenzen: " + ", ".join(p.competences),
f"Beschreibung: {p.description}",
"Referenzen:" + ("\n- " + "\n- ".join(refs) if refs else " (keine)"),
"Zertifikate:" + ("\n- " + "\n- ".join(p.certificates) if p.certificates else " (keine)"),
])
def serialize_task_profile(p: TaskProfile) -> str:
return "\n".join([
f"Titel: {p.title}",
f"Beschreibung: {p.description}",
"Gesuchte Kompetenzen: " + ", ".join(p.skills),
])
```
Die Reihenfolge der Felder ist über alle Profile konstant, und die Reihenfolge der Referenzen entspricht der Reihenfolge aus dem DBClient (DB-stabil sortiert), sodass auch `partner_name` deterministisch erscheint (Anforderung 3.4 / 3.7 / 4.4). Ist `partner_name` leer, entfällt das Partner-Token in der Ausgabe; die Referenz selbst bleibt erhalten (Anforderung 3.4 / 3.6).
#### LLM-Prompt-Design
System-Prompt (deutschsprachig, deterministisch):
```text
Du bist ein erfahrener Personal- und Skill-Matcher der DB Systel.
Du erhältst ein Aufgabenprofil und ein Kapazitätsprofil.
Bewerte, wie gut die Kapazität zur Aufgabe passt, und wähle GENAU EINE Kategorie aus:
- Top: passt fachlich und in den Kompetenzen praktisch vollständig
- Good: passt gut, mit kleinen Lücken
- Partial: passt teilweise, mehrere relevante Lücken
- Low: schwacher Bezug, nur einzelne Berührungspunkte
- Irrelevant: kein erkennbarer fachlicher Bezug
Begründe deine Wahl in 1-2 prägnanten deutschen Sätzen
(maximal ~280 Zeichen, keine Aufzählungspunkte, keine Zeilenumbrüche).
Antworte AUSSCHLIESSLICH als gültiges JSON-Objekt mit den Feldern:
{"category": "<Top|Good|Partial|Low|Irrelevant>", "rationale": "<Begründung>"}
```
User-Prompt (Beispiel Aufgabe→Kapazität):
```text
=== Aufgabe ===
<serialize_task_profile(...)>
=== Kapazität ===
ID: <capacity.id>
Owner: <capacity.owner_name>
<serialize_capacity_profile(...)>
```
Die Antwort wird über `chat_completion(system, user)` (bereits mit `response_format=json_object`) angefordert und mit `json.loads` geparst.
#### Kategorie-Normalisierung
```python
_ALLOWED = ("Top", "Good", "Partial", "Low", "Irrelevant")
_ALIAS = {x.lower(): x for x in _ALLOWED}
def normalize_category(value: str | None) -> tuple[str, bool]:
"""Return (category, is_valid). Invalid → ("Irrelevant", False)."""
if not isinstance(value, str):
return "Irrelevant", False
norm = _ALIAS.get(value.strip().lower())
if norm is None:
return "Irrelevant", False
return norm, True
```
Bei ungültiger Kategorie wird das Item nach `Irrelevant` einsortiert und in der Rationale wird angehängt: `"[Hinweis: ungültige LLM-Kategorie: <wert>]"` (Anforderung 5.6 / 6.6).
#### Sortierung
Innerhalb jeder Kategorie werden die Items deterministisch nach `item_id` aufsteigend (lexikographisch als String) sortiert (Anforderung 5.8 / 6.8).
### 5. MCP_Server erweiterte Tool-Signaturen
```python
@mcp.tool()
async def find_matching_capacities(
role_name: str,
competences: list[str],
date_start: Optional[str] = None,
date_end: Optional[str] = None,
matching_method: Optional[str] = None,
) -> str: ...
@mcp.tool()
async def find_matching_tasks(
capacity_id: int | str,
matching_method: Optional[str] = None,
) -> str: ...
```
Validierung von `matching_method`:
```python
_ALLOWED_METHODS = ("score", "llm_fulltext")
def _resolve_method(value: Optional[str]) -> str:
if value is None:
return cfg.matching.default_method
norm = str(value).strip().lower()
if norm not in _ALLOWED_METHODS:
raise ValueError(
f"Invalid matching_method: {value!r}. "
f"Allowed: {', '.join(_ALLOWED_METHODS)}"
)
return norm
```
Bei ungültigem Wert gibt das Tool eine Fehlermeldung zurück und führt keine Suche aus (Anforderung 1.5).
### 6. Persistenz im SearchCache
Das Ergebnis-Payload behält den bestehenden Aufbau (`search_type`, `reference`, `summary`, `by_category`), wird aber pro Modus unterschiedlich befüllt:
```python
results_payload = {
"search_type": "capacity_search", # oder "task_search"
"matching_method": "llm_fulltext", # NEU im Score-Modus "score"
"reference": {...},
"summary": {...}, # Counter pro Kategorie
"by_category": {
"Top": [
{
**asdict(capacity), # bzw. Task-Felder
"category": "Top",
"rationale": "<unverkürzt>",
# KEIN role_score / competence_score / overall_score
},
...
],
...
},
"errors": [ # nur im LLM-Modus, sonst weglassen
{"item_id": "...", "error": "..."}
],
}
```
Im Score-Modus bleibt das bisherige Schema (mit Score-Feldern, ohne `rationale`/`errors`) unverändert. Damit ist das Schema rückwärtskompatibel: bestehende Filter- und Pagination-Tools lesen `by_category` weiterhin korrekt.
### 7. Ausgabe-Tabellen
#### Score-Modus (unverändert)
Spalten: `ID | Owner | Role | Competences | Availability | Role Score | Competence Score | Overall Score | Category`.
#### LLM-Volltext-Modus
`find_matching_capacities`:
`ID | Owner | Role | Competences | Availability | Category | Begründung`
`find_matching_tasks`:
`task_id | Title | Required Competences | Availability | Category | Begründung`
Eine Hilfsfunktion kürzt Rationales konsistent:
```python
def _format_rationale_for_table(rationale: str, max_chars: int = 280) -> str:
text = (rationale or "").replace("|", "/").replace("\r", " ").replace("\n", " ")
text = " ".join(text.split()) # collapse whitespace
if len(text) > max_chars:
text = text[: max_chars - 1].rstrip() + ""
return text
```
- Pipes (`|`) werden zu `/`, Zeilenumbrüche zu Leerzeichen ersetzt (Anforderung 8.4).
- Bei > 280 Zeichen wird gekürzt und mit `…` abgeschlossen (Anforderung 8.5).
- Die ungekürzte Rationale steht im SearchCache (Anforderung 8.6).
Die Summary-Tabelle bleibt in beiden Modi gleich (Counter je Kategorie).
Im META-JSON jeder Tool-Antwort wird `matching_method` zusätzlich aufgenommen (Anforderung 1.6 / 9.5):
```json
{"search_id": "...", "filter_id": null, "default_category": "Top", "matching_method": "llm_fulltext"}
```
### 8. Filter- und Pagination-Tools
#### get_results_by_category
Erkennt das Modus-Schema am Feld `matching_method` im SearchEntry und rendert entweder die Score-Tabelle (bisheriges Verhalten) oder die LLM-Tabelle mit `Begründung`-Spalte. Das Routing kapselt eine neue Hilfsfunktion `_format_results_table(items, *, search_type, matching_method, ref_start, ref_end)`.
#### filter_search_results
Erkennt den Modus ebenfalls am persistierten `matching_method`. Im LLM-Volltext-Modus:
- Bestehende Filter (Rollen-, Kompetenz-, Verfügbarkeits-, Aufgaben-Text-/Kompetenzfilter) bleiben aktiv (Anforderung 9.3).
- `min_similarity` wird ignoriert; in `Applied Filters` erscheint eine Zeile `min_similarity (ignored: not applicable in llm_fulltext mode)` (Anforderung 9.4).
- Die Zwischensortierung erfolgt im LLM-Modus stabil nach `(category_rank, item_id)` statt nach `overall_score`.
### 9. Konfiguration (`config.py`, `config.toml`)
Neue Felder in `MatchingConfig`:
```python
@dataclass(frozen=True)
class MatchingConfig:
# ... bestehende Felder ...
default_method: str = "score"
```
Validierung in `load_config`:
```python
allowed = {"score", "llm_fulltext"}
default_method = (raw.get("matching", {}).get("default_method") or "score").strip().lower()
if default_method not in allowed:
raise ConfigError(
f"matching.default_method must be one of {sorted(allowed)}, "
f"got: {default_method!r}"
)
```
`config.toml`:
```toml
[matching]
# Default method for new searches when callers do not pass matching_method.
# Allowed: "score" (BM25 + LLM role similarity) or "llm_fulltext"
# (LLM-based full-text matching with rationale).
default_method = "score"
```
Beim Start wird die Validierung als `ConfigError` (fail-fast) geworfen (Anforderung 12.4).
### 10. Agenten- und Dokumentationsanpassungen
- `.github/agents/teamlandkarte_agent.md` und `.kiro/agents/teamlandkarte.md`:
- Beschreibung beider Verfahren (`score`, `llm_fulltext`).
- Pflicht-Frage "Welches Verfahren soll verwendet werden?" vor `find_matching_capacities`/`find_matching_tasks`, falls nicht aus dem Verlauf bekannt.
- Hinweis: Im LLM-Volltext-Modus keine numerischen Scores; stattdessen Spalte `Begründung`.
- Bestätigungs-Workflow (`show_pending_requirements``confirm_requirements`) bleibt für beide Verfahren identisch.
- `docs/architecture.md`:
- Neue Komponente `LLM_Fulltext_Matcher` im Business-Logic-Diagramm und in der Komponentenbeschreibung.
- Erweiterung der Schema-Verifikation um `teamlandkarte_v_capacities_latest.description`, `teamlandkarte_v_capacity_certificates_latest.{capacity_id, description}`, `teamlandkarte_v_capacity_references_latest.{capacity_id, partner_id, projects}` sowie `teamlandkarte_v_partners_latest.{id, name}` einschließlich der Join-Beziehung `teamlandkarte_v_capacity_references_latest.partner_id = teamlandkarte_v_partners_latest.id`.
- Tool-Surface-Tabelle mit neuem Parameter `matching_method`.
- Runtime-View für beide Suchrichtungen ergänzt um den LLM-Volltext-Pfad.
- `README.md`:
- Quick Start: Verfahrenswahl per Tool-Parameter; Default-Konfiguration in `[matching].default_method`.
- Hinweis "keine Score-Spalten im LLM-Modus, dafür `Begründung`".
- Zusätzliche Datenbank-Views aufgelistet, einschließlich `teamlandkarte_v_partners_latest` mit Hinweis auf den LEFT JOIN über `partner_id` in der Referenz-Abfrage.
## Datenmodelle
### Übersicht der zusätzlichen DB-Felder
| Quelle | Spalte | Genutzt für |
|---|---|---|
| `teamlandkarte_v_capacities_latest` | `description` | `CapacityProfile.description` |
| `teamlandkarte_v_capacity_certificates_latest` | `capacity_id`, `description` | `CapacityProfile.certificates` |
| `teamlandkarte_v_capacity_references_latest` | `capacity_id`, `partner_id`, `projects` | `CapacityProfile.references[].projects` (Join-Schlüssel: `partner_id`) |
| `teamlandkarte_v_partners_latest` | `id`, `name` | Partner_Name in `CapacityProfile.references[].partner_name` (LEFT JOIN über `partner_id = id`) |
### LLM-Antwortschema
```json
{
"category": "Top|Good|Partial|Low|Irrelevant",
"rationale": "Kurzbegründung in 1-2 Sätzen."
}
```
### Persistierte Item-Struktur (LLM-Modus)
Im LLM-Modus enthält ein gespeichertes Item dieselben Identitäts- und Verfügbarkeitsfelder wie im Score-Modus, plus `category` und `rationale`. Falls Referenzen für nachgelagerte Anzeige zusätzlich am Item gepuffert werden sollen, werden sie als Liste strukturierter Einträge mit den Feldern `partner_name` und `projects` abgelegt (gleiche Form wie in `CapacityProfile.references`); `partner_name` darf leer sein.
```python
# capacity_search
{
"id": 12345,
"owner_name": "...",
"role_name": "...",
"begin_date": "2025-03-01",
"end_date": "2025-12-31",
"competences": ["..."],
# optional, falls Referenzen mitgepuffert werden:
# "references": [{"partner_name": "...", "projects": "..."}, ...],
"category": "Top",
"rationale": "Volltext-Begründung des LLM ...",
}
# task_search
{
"task_id": "00T...",
"title": "...",
"description": "...",
"skills": ["..."],
"required_competences": [],
"start_date": "2025-04-01",
"end_date": "2025-09-30",
"category": "Good",
"rationale": "Volltext-Begründung des LLM ...",
}
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Profil-Serialisierung ist deterministisch und feldvollständig
*For any* `CapacityProfile` (bzw. `TaskProfile`) zwei wiederholte Aufrufe von `serialize_capacity_profile` (bzw. `serialize_task_profile`) liefern denselben String, und der String enthält jede Feldüberschrift (`Rolle:`, `Kompetenzen:`, `Beschreibung:`, `Referenzen:`, `Zertifikate:` bzw. `Titel:`, `Beschreibung:`, `Gesuchte Kompetenzen:`) in einer fixen Reihenfolge. Innerhalb des Abschnitts `Referenzen:` erscheinen die Einträge in derselben Reihenfolge wie in `references`, und für jeden Eintrag mit nicht-leerem `partner_name` ist der Partner-Name in der serialisierten Darstellung deterministisch enthalten.
**Validates: Requirements 3.3, 3.4, 3.6, 3.7, 4.3, 4.4**
### Property 2: Leere/None-Felder verwerfen das Profil nicht
*For any* `Capacity` (bzw. `Task`), bei dem ein Teil der Felder `None`, leerer String oder leere Liste ist, liefert der Profil-Builder ein `CapacityProfile`/`TaskProfile`, dessen leere Felder als leerer String bzw. leere Liste erscheinen, und dessen Serialisierung weiterhin alle Feldüberschriften enthält. Dies gilt insbesondere auch für Capacity_References mit leerem `partner_name`: Die Referenz wird nicht verworfen, sondern in `references` aufgenommen; lediglich das Partner-Token entfällt in der Serialisierung.
**Validates: Requirements 3.2, 3.4, 4.2**
### Property 2b: Referenzen mit leerem Partner-Name behalten projects, ohne Partner-Token
*For any* Liste von `CapacityReferenceEntry`-Werten, in der ein Teil der Einträge `partner_name == ""` hat, ist die serialisierte `Referenzen:`-Sektion so beschaffen, dass (a) die Anzahl der ausgegebenen Referenz-Zeilen gleich der Anzahl der Einträge mit nicht-leerem `projects` ist, (b) jede Zeile zu einem Eintrag mit leerem `partner_name` mit `Projekte:` beginnt und keinen Token `Partner:` enthält, und (c) jede Zeile zu einem Eintrag mit nicht-leerem `partner_name` sowohl `Partner: <name>` als auch `Projekte: <projects>` enthält.
**Validates: Requirements 3.4, 3.6, 2.8**
### Property 3: Kategorienormalisierung bildet auf erlaubte Menge ab
*For any* String-Eingabe gibt `normalize_category` ein Tupel `(category, is_valid)` zurück, bei dem `category` immer in `{"Top","Good","Partial","Low","Irrelevant"}` liegt; `is_valid` ist genau dann `True`, wenn die getrimmte, lower-case Eingabe einer dieser Kategorien (case-insensitive) entspricht.
**Validates: Requirements 5.3, 5.6, 6.3, 6.6**
### Property 4: Ungültige LLM-Kategorie wird auf Irrelevant gemappt
*For any* LLM-Antwort `{"category": X, "rationale": R}`, bei der `X` nicht in der erlaubten Menge liegt, wird das Item in der Kategorie `Irrelevant` einsortiert, und seine gespeicherte `rationale` enthält den ursprünglichen `R` sowie einen Hinweis auf die ungültige LLM-Antwort.
**Validates: Requirements 5.6, 6.6**
### Property 5: LLM-Fehler erscheinen in der Fehlerliste, nicht als Ergebnis
*For any* Liste von Kapazitäten (bzw. Aufgaben), bei denen der LLM-Aufruf für eine Teilmenge `S` fehlschlägt, ist jedes Item aus `S` in `result.errors` enthalten und kommt in keiner Kategorie von `result.by_category` vor; alle restlichen Items befinden sich in genau einer Kategorie.
**Validates: Requirements 5.7, 6.7**
### Property 6: Ergebnisse sind innerhalb jeder Kategorie deterministisch sortiert
*For any* `LlmFulltextResult` ist innerhalb jeder Kategorie die Liste der `item_id`-Werte streng aufsteigend (lexikographisch) sortiert; Permutationen der Eingabeliste verändern die Ausgabe-Reihenfolge nicht.
**Validates: Requirements 5.8, 6.8**
### Property 7: Tabellen-Rationale ist gültiges Markdown und längenbegrenzt
*For any* String `R`, hat `_format_rationale_for_table(R)` höchstens 280 Zeichen, enthält weder `|` noch Zeilenumbrüche, und ist genau dann mit `…` abgeschlossen, wenn die normalisierte Eingabe länger als 280 Zeichen war.
**Validates: Requirements 8.4, 8.5**
### Property 8: Ungekürzte Rationale wird persistiert
*For any* erfolgreich kategorisiertes Item ist die im SearchCache gespeicherte `rationale` exakt der vom LLM gelieferte (oder durch ungültige-Kategorie-Hinweis ergänzte) String, unabhängig von der für die Tabellendarstellung verwendeten gekürzten Form.
**Validates: Requirements 8.6**
### Property 9: matching_method-Validierung lehnt unbekannte Werte ab
*For any* Eingabe `matching_method`, die nach `strip().lower()` weder `"score"` noch `"llm_fulltext"` ist, gibt das MCP-Tool eine Fehlermeldung zurück, in der beide erlaubten Werte vorkommen, und führt weder DB- noch LLM-Aufrufe aus.
**Validates: Requirements 1.5**
### Property 10: Score-Modus ist abwärtskompatibel
*For any* Aufruf von `find_matching_capacities` bzw. `find_matching_tasks` ohne den Parameter `matching_method` (oder mit `"score"`) ist das im SearchCache persistierte Ergebnis-Payload schemagleich zum bisherigen Score-Payload (Felder `role_score`, `competence_score`, `overall_score` pro Item; kein `rationale`-Feld).
**Validates: Requirements 1.2, 1.3, 7.3**
### Property 11: META enthält das verwendete Verfahren
*For any* erfolgreichen Tool-Aufruf von `find_matching_capacities`/`find_matching_tasks` enthält das META-JSON in der Antwort einen Schlüssel `matching_method`, dessen Wert genau dem verwendeten Verfahren entspricht (`"score"` oder `"llm_fulltext"`).
**Validates: Requirements 1.6, 9.5**
## Error Handling
| Szenario | Verhalten |
|---|---|
| `matching_method` ungültig | Tool gibt Fehlermeldung mit erlaubten Werten zurück, keine DB-/LLM-Aufrufe (Anforderung 1.5) |
| `matching.default_method` ungültig | `ConfigError` beim Server-Start (fail-fast) |
| LLM-Antwort kein gültiges JSON | Item landet in `errors` mit Meldung `"invalid JSON: <excerpt>"` |
| LLM-Antwort enthält `category` außerhalb der Menge | Item in Kategorie `Irrelevant`; Rationale erhält Hinweis `[Hinweis: ungültige LLM-Kategorie: <wert>]` |
| LLM-Aufruf wirft `AzureAPIError` / Timeout | Item landet in `errors` mit Meldung der Exception-Klasse + erstem Satz; kein Eintrag in `by_category` |
| `description`/`certificates`/`references` in DB leer | Profil wird mit leerem String/leerer Liste gebaut, niemals verworfen (Anforderung 3.2/4.2) |
| Capacity ohne Datenbank-Eintrag in der Batch-Antwort | Wird als `description=None` / `certificates=[]` / `references=[]` interpretiert |
| `min_similarity` im Filter angewandt im LLM-Modus | Filter wird ignoriert; Eintrag in `Applied Filters` mit Hinweis (Anforderung 9.4) |
| Rationale enthält `|` oder Zeilenumbruch | Wird vor Tabellenausgabe ersetzt; ungekürzte Original-Rationale bleibt im SearchCache |
| Rationale länger als 280 Zeichen | In Tabellenausgabe gekürzt mit `…`; ungekürzt im SearchCache |
Die Fehlerliste wird im Tool-Output nach der Ergebnistabelle als zusätzlicher Markdown-Block (`## Errors`) ausgegeben, sofern nicht leer. Der MCP-Tool-Aufruf selbst schlägt nicht fehl, solange wenigstens ein Item kategorisiert werden konnte oder die Fehlerliste vollständig ist damit ist das Verfahren robust gegen Einzelausfälle.
## Testing Strategy
### Property-Based Tests
Bibliothek: **Hypothesis**, mindestens 100 Iterationen pro Property. Jeder PBT-Test wird mit einem Kommentar getaggt:
```python
# Feature: llm-fulltext-matching, Property {N}: {title}
```
| Property | Test-Ansatz | Generatoren |
|---|---|---|
| 1: Profil-Serialisierung deterministisch | Generiere `CapacityProfile`/`TaskProfile` (inkl. `CapacityReferenceEntry` mit/ohne `partner_name`), rufe Serializer zweimal auf, prüfe Gleichheit + Vorkommen aller Feldüberschriften und Partner-Namen | `st.builds(...)` mit `st.text`/`st.lists(st.builds(CapacityReferenceEntry, ...))` |
| 2: Leere Felder verwerfen Profil nicht | Generiere `Capacity`/`Task` mit zufällig leeren Feldern (inkl. Referenzen mit leerem `partner_name`), baue Profil, prüfe Vollständigkeit | Custom Capacity/Task strategy mit `st.one_of(st.none(), st.text())` und Referenz-Strategie mit `partner_name=st.one_of(st.just(""), st.text())` |
| 2b: Leerer Partner-Name → kein Partner-Token, projects bleibt | Generiere Referenz-Listen mit gemischtem `partner_name`, serialisiere, prüfe Zeilenanzahl, Präfixe und Token-Vorkommen | `st.lists(st.builds(CapacityReferenceEntry, partner_name=st.one_of(st.just(""), st.text(min_size=1)), projects=st.text(min_size=1)))` |
| 3: Kategorienormalisierung im Wertebereich | Generiere zufällige Strings (inkl. Aliase), prüfe Output ∈ erlaubte Menge | `st.text()` und `st.sampled_from([...alias variants...])` |
| 4: Ungültige Kategorie → Irrelevant | Mocke LLM mit zufälliger ungültiger Kategorie, prüfe Item in `Irrelevant` und Hinweis in Rationale | `st.text().filter(lambda x: x.strip().lower() not in {"top",...})` |
| 5: LLM-Fehler in errors | Mocke LLM, das per Bool-Strategie eine Exception wirft, prüfe Trennung errors / by_category | `st.lists(st.booleans())` |
| 6: Sortierung deterministisch | Generiere Items, permutiere Eingabe, prüfe gleiche `by_category`-Reihenfolge | `st.permutations(...)` |
| 7: Tabellen-Rationale-Format | Generiere Strings inkl. `|`, `\n`, sehr lang, prüfe Längen- und Zeichen-Constraints | `st.text(alphabet=st.characters(blacklist_categories=()))` |
| 8: Ungekürzte Rationale persistiert | Generiere Rationale > 280 Zeichen, prüfe Cache-Eintrag == Original | `st.text(min_size=300)` |
| 9: matching_method Validierung | Generiere zufällige Strings, mocke DB+LLM, prüfe Fehlerpfad ohne Aufrufe | `st.text()` |
| 10: Score-Modus abwärtskompatibel | Vergleiche persistiertes Payload-Schema vor/nach Patch (snapshot-frei: Feldmenge je Item) | bestehende Capacity-Strategie |
| 11: META-Schlüssel | Aus Tool-Output META-JSON parsen und prüfen | bestehende Capacity-Strategie |
### Unit Tests
- **DBClient (Trino)**: Mock-Cursor-Tests für `get_capacity_description`, `get_capacity_certificates`, `get_capacity_references` und ihre Batch-Varianten. Verifiziert: genau eine SQL-Abfrage je Methode, korrekte SELECT-Only-Guard, Gruppierung n:1, Default für fehlende IDs. Für die Referenz-Methoden zusätzlich:
- LEFT JOIN auf `teamlandkarte_v_partners_latest` ist Bestandteil derselben SQL-Abfrage (kein zusätzlicher SQL-Roundtrip; Anforderung 2.4).
- Mock-Cursor liefert drei Spalten (`capacity_id`, `projects`, `partner_name`); Rückgabe enthält `CapacityReferenceRow`-Einträge mit korrektem Partner-Namen.
- Fall NULL `partner_id` bzw. fehlender Partner: `partner_name` ist leerer String (`COALESCE`), Referenz bleibt mit `projects` erhalten (Anforderung 2.8).
- **LlmFulltextMatcher**: Beispiel-basierte Tests mit gemocktem `AzureOpenAIClient`:
- Erfolgsfall (gültige Kategorie + Rationale)
- Ungültiges JSON
- Gültiges JSON mit unbekannter Kategorie
- Exception aus `chat_completion` (`AzureAPIError`)
- Vermischung mehrerer Kapazitäten/Aufgaben (Reihenfolge, Sortierung)
- **MCP-Tools** (`find_matching_capacities`, `find_matching_tasks`):
- `matching_method=None` → Default greift, Schema entspricht Score-Modus.
- `matching_method="llm_fulltext"` → keine Score-Spalten, `Begründung`-Spalte vorhanden, `META` enthält `matching_method`.
- `matching_method="bogus"` → Fehlermeldung; keine DB/LLM-Aufrufe (über Mocks verifiziert).
- **`get_results_by_category` / `filter_search_results`**:
- Im LLM-Modus rendern sie die `Begründung`-Spalte und ignorieren `min_similarity` mit Hinweis.
- Im Score-Modus bleibt das Verhalten unverändert (Regression).
- **Konfiguration**: Test, dass `matching.default_method = "irgendwas"` einen `ConfigError` beim Laden wirft, und dass das Weglassen den Default `"score"` ergibt.
### Integrationstests
- End-to-End-Lauf für beide Suchrichtungen mit gemocktem LLM-Client und gemocktem `DBClient`:
- Verfügbarkeitsfilter ist im LLM-Modus identisch zum Score-Modus.
- SearchCache enthält `matching_method`, `rationale` (ungekürzt), `errors`-Liste.
- Tabellen-Snapshot-Test (deterministisch über stabile Mock-Antworten), der die Kopfzeilen `... | Category | Begründung` für `find_matching_capacities`/`find_matching_tasks` im LLM-Modus festschreibt.
### Bewusst nicht getestet
- Inhaltliche Qualität der LLM-Begründungen (subjektiv).
- Konkrete Wahl der Kategorie durch das LLM für reale Inhalte (modellabhängig, nicht deterministisch).
- Performance/Latenz der LLM-Aufrufe (kein Unit-Test-Scope).