Files
Orchestrator/bahn/teamlandkarte-mcp/.kiro/specs/llm-fulltext-matching/design.md
T
ankn a5f8fb49ab Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
2026-06-30 20:39:52 +02:00

37 KiB
Raw Blame History

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)

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)

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)

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.

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:

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):

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

@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)

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:

@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)

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):

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):

=== 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

_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

@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:

_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:

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:

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):

{"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:

@dataclass(frozen=True)
class MatchingConfig:
    # ... bestehende Felder ...
    default_method: str = "score"

Validierung in load_config:

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:

[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_requirementsconfirm_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

{
  "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.

# 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
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:

# 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
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).