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:
@@ -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).
|
||||
Reference in New Issue
Block a user