# 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
Capacity_Profile + Task_Profile] F --> CL[AzureOpenAIClient.chat_completion] F --> SC[(SearchCache
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: – Projekte: ` wenn partner_name nicht leer - `Projekte: ` 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": "", "rationale": ""} ``` User-Prompt (Beispiel Aufgabe→Kapazität): ```text === Aufgabe === === Kapazität === ID: Owner: ``` 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: ]"` (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": "", # 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: ` als auch `Projekte: ` 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: "` | | LLM-Antwort enthält `category` außerhalb der Menge | Item in Kategorie `Irrelevant`; Rationale erhält Hinweis `[Hinweis: ungültige LLM-Kategorie: ]` | | 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).