from __future__ import annotations from dataclasses import asdict from typing import Any, Optional import uuid import logging from fuzzywuzzy import fuzz # type: ignore[import-untyped] from mcp.server.fastmcp import FastMCP from teamlandkarte_mcp.cache.query_cache import QueryCache from teamlandkarte_mcp.cache.search_cache import SearchCache from teamlandkarte_mcp.config import load_config from teamlandkarte_mcp.database.db_client import create_db_client from teamlandkarte_mcp.database.schema_verifier import verify_required_columns from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher from teamlandkarte_mcp.matching.matcher import Matcher from teamlandkarte_mcp.matching.profiles import ( build_capacity_profile, build_task_profile_from_requirements, ) from teamlandkarte_mcp.models import ( Capacity, Requirements, Team, TeamCompetence, TeamReference, ) from teamlandkarte_mcp.utils.dates import availability_overlaps, parse_iso_date from teamlandkarte_mcp.utils.markdown import md_table from teamlandkarte_mcp.azure.openai_client import AzureOpenAIClient from teamlandkarte_mcp.azure.cost_tracker import CostTracker from teamlandkarte_mcp.matching.similarity import SimilarityEngine from teamlandkarte_mcp.matching.vocabulary import VocabularyCache from teamlandkarte_mcp.matching.auto_tagger import AutoTagger import os LOGGER = logging.getLogger(__name__) # Allowed values for the ``matching_method`` tool parameter. # See spec: llm-fulltext-matching, requirements 1.1, 1.2, 1.5, 12.3. _ALLOWED_MATCHING_METHODS: tuple[str, ...] = ("score", "llm_fulltext") # Allowed values for the ``Profile_Type`` selector. The value is implicit # in the tool name (``find_matching_capacities`` -> ``capacity``, # ``find_matching_teams`` -> ``team``) and surfaced via the # persisted ``search_type`` field in the SearchCache. # See spec: team-profile-matching, requirement 1.1. _ALLOWED_PROFILE_TYPES: tuple[str, ...] = ("capacity", "team") def _normalize_text(s: str | None) -> str: return " ".join((s or "").strip().split()) def _task_role_text(title: str | None, description: str | None) -> str: """Title-first role inference text with description fallback.""" title_n = _normalize_text(title) if title_n: return title_n return _normalize_text(description) def _format_rationale_for_table(rationale: str, max_chars: int = 280) -> str: """Format an LLM rationale for safe inclusion in a Markdown table cell. - Replaces ``|`` with ``/`` so the Markdown table stays valid. - Replaces ``\\r`` and ``\\n`` with spaces. - Collapses whitespace. - Truncates to ``max_chars`` characters with a trailing ``…`` when the normalized text exceeds the limit. See spec: llm-fulltext-matching, requirements 8.4 and 8.5. """ text = (rationale or "").replace("|", "/").replace("\r", " ").replace("\n", " ") text = " ".join(text.split()) if len(text) > max_chars: text = text[: max_chars - 1].rstrip() + "…" return text class SessionState: """Session state for requirements capture (extract/update/confirm).""" def __init__(self): """Initialize empty session state.""" self.last_requirements: Optional[Requirements] = None self.pending_requirements: Optional[Requirements] = None self.confirmed_requirements: Optional[Requirements] = None self.last_search_id: Optional[str] = None self.guided: dict[str, Any] = { "active": False, "step": None, "description": None, "role_name": None, "date_start": None, "date_end": None, "competences": None, } self.user_confirmation_requested: bool = False def _requirements_to_dict(req: Requirements) -> dict[str, Any]: """Convert `Requirements` to a JSON-serializable dictionary.""" return { "role_name": req.role_name, "competences": req.competences, "date_start": req.date_start.isoformat() if req.date_start else None, "date_end": req.date_end.isoformat() if req.date_end else None, "description": req.description, } def _capacity_to_row(cap: Capacity) -> list[str]: """Convert a `Capacity` to a Markdown table row. Args: cap: Capacity record. Returns: Row values as strings. """ return [ str(cap.id), str(cap.owner_name), str(cap.role_name or ""), str(cap.role_level or ""), cap.begin_date.isoformat() if cap.begin_date else "", cap.end_date.isoformat() if cap.end_date else "", ", ".join(cap.competences), ] def _coerce_capacity(item: Any) -> Capacity: """Extract a `Capacity` from tool result items. The server sometimes passes around: - `Capacity` - `ScoredCapacity` - dicts produced via `asdict()` on the above Args: item: Arbitrary result item. Returns: The embedded `Capacity`. Raises: TypeError: If the object cannot be coerced. ValueError: If required fields are missing. """ if isinstance(item, Capacity): return item if isinstance(item, dict): if "capacity" in item and isinstance(item["capacity"], dict): c = item["capacity"] else: c = item raw_id = c.get("id") if raw_id is None: raise ValueError("capacity id missing in payload") return Capacity( id=int(str(raw_id)), owner_name=str(c.get("owner_name") or ""), role_name=(str(c.get("role_name")) if c.get("role_name") else None), role_level=(str(c.get("role_level")) if c.get("role_level") else None), begin_date=( parse_iso_date(c.get("begin_date")) if isinstance(c.get("begin_date"), str) else c.get("begin_date") ), end_date=( parse_iso_date(c.get("end_date")) if isinstance(c.get("end_date"), str) else c.get("end_date") ), competences=[str(x) for x in (c.get("competences") or [])], ) # ScoredCapacity-like object if hasattr(item, "capacity"): inner = getattr(item, "capacity") if isinstance(inner, Capacity): return inner raise TypeError(f"Unsupported capacity payload type: {type(item)}") def _coerce_team(item: Any) -> Team: """Extract a `Team` from tool result items. Rehydrates persisted SearchCache entries back into `Team` instances (analog zu :func:`_coerce_capacity`). Akzeptiert sowohl `Team`-Instanzen als auch verschachtelte `dict`-Repräsentationen mit oder ohne `team`- Wrapper (z.B. von ``ScoredTeam`` via ``asdict``). Args: item: Arbitrary result item. Returns: The embedded `Team`. Raises: TypeError: If the object cannot be coerced. """ if isinstance(item, Team): return item if isinstance(item, dict): d = item["team"] if isinstance(item.get("team"), dict) else item comps_raw = d.get("competences") or [] return Team( team_id=str(d["team_id"]), ouid=str(d["ouid"]), team_name=str(d.get("team_name") or ""), focus_name=str(d.get("focus_name") or ""), about_us=str(d.get("about_us") or ""), offerings=str(d.get("offerings") or ""), interests=str(d.get("interests") or ""), competences=[ TeamCompetence( name=str(c["name"]), top_competency=bool(c.get("top_competency", False)), ) for c in comps_raw ], references=[ TeamReference( partner_name=str(r.get("partner_name") or ""), projects=str(r["projects"]), ) for r in (d.get("references") or []) ], ) raise TypeError(f"Unsupported team payload type: {type(item)}") def _calculate_overlap_percentage( *, ref_start, ref_end, other_start, other_end, ) -> str: """Return overlap(ref, other) as a percentage string. - If the reference period is not fully defined, returns "". - If the other period is open-ended but the reference is defined, the overlap is still well-defined until ref_end. """ if ref_start is None or ref_end is None: return "" if other_start is None: return "" # Open-ended "other" is allowed: treat as covering until ref_end. if other_end is None: other_end = ref_end # Normalize ordering (defensive). if ref_end < ref_start or other_end < other_start: return "" overlap_start = max(ref_start, other_start) overlap_end = min(ref_end, other_end) if overlap_end < overlap_start: return "0%" ref_days = (ref_end - ref_start).days + 1 if ref_days <= 0: return "" overlap_days = (overlap_end - overlap_start).days + 1 pct = int(round((overlap_days / float(ref_days)) * 100.0)) pct = max(0, min(100, pct)) return f"{pct}%" def _task_text_full(title: str | None, description: str | None) -> str: title_n = (title or "").strip() desc_n = (description or "").strip() return (title_n + "\n\n" + desc_n).strip() if title_n else desc_n def build_server(config_path: str = "config.toml") -> FastMCP: """Build and configure the MCP server. Args: config_path: Path to the TOML configuration file. Returns: Configured FastMCP server exposing exactly 10 tools over stdio. """ mcp = FastMCP("teamlandkarte-capacity-matching") cfg = load_config(config_path) LOGGER.info("Using config: %s", config_path) LOGGER.info("Azure OpenAI endpoint: %s", cfg.azure_openai.endpoint) LOGGER.info("Similarity: BM25+LLM mode (embeddings removed)") db_client = create_db_client(cfg.database) # Strict schema verification at startup (fail-fast). # This provides an early signal when views changed and avoids serving # incorrect results. schema_expected = { "teamlandkarte_v_capacity_roles_latest": { "name", "active", "staffing_board_relevant", }, "teamlandkarte_v_capacities_latest": { "creation_date", }, "teamlandkarte_v_teams_latest": { "team_id", "ouid", "about_us", "offerings", "interests", "focus_name", }, "teamlandkarte_v_teammeter_organizational_units_latest": { "id", "name", }, "teamlandkarte_v_teammeter_team_competences_latest": { "ouid", "competence_id", "top_competency", }, "teamlandkarte_v_team_references_latest": { "ouid", "partner_id", "projects", }, } schema_issues = verify_required_columns( db=db_client, expected=schema_expected, logger=LOGGER, ) if schema_issues: details = "; ".join( f"{issue.table}: {issue.message}" for issue in schema_issues ) raise RuntimeError(f"Database schema verification failed: {details}") # Defer DB connectivity validation until first DB-backed tool call. _db_checked = False def _ensure_db() -> None: """Validate DB connectivity once (lazy) for DB-backed tools.""" nonlocal _db_checked if _db_checked: return db_client.test_connection() _db_checked = True query_cache = QueryCache[list[Capacity]]( ttl_hours=cfg.cache.db_ttl_hours, max_size=cfg.cache.max_size ) team_query_cache = QueryCache[list[Team]]( ttl_hours=cfg.cache.db_ttl_hours, max_size=cfg.cache.max_size ) search_cache = SearchCache( ttl_minutes=cfg.cache.search_ttl_minutes, max_size=cfg.cache.max_size ) session = SessionState() cost_tracker = CostTracker() azure_client = AzureOpenAIClient( endpoint=cfg.azure_openai.endpoint, api_version=cfg.azure_openai.api_version, chat_deployment=cfg.azure_openai.chat_deployment, llm_api_key=cfg.azure_openai.llm_api_key or os.getenv("AZURE_OPENAI_LLM_API_KEY", ""), cost_tracker=cost_tracker, verify_ssl=cfg.azure_openai.verify_ssl, ) # Optionally construct an LLM client + AutoTagger for BM25 auto-tagging. auto_tagger: AutoTagger | None = None if cfg.similarity.use_auto_tagging and cfg.azure_openai.chat_deployment: llm_client = AzureOpenAIClient( endpoint=cfg.azure_openai.endpoint, api_version=cfg.azure_openai.api_version, chat_deployment=cfg.azure_openai.chat_deployment, llm_api_key=cfg.azure_openai.llm_api_key, cost_tracker=cost_tracker, verify_ssl=cfg.azure_openai.verify_ssl, ) auto_tagger = AutoTagger(client=llm_client) LOGGER.info( "AutoTagger enabled (chat_deployment=%s)", cfg.azure_openai.chat_deployment, ) similarity = SimilarityEngine( client=azure_client, cost_tracker=cost_tracker, use_auto_tagging=cfg.similarity.use_auto_tagging, auto_tagger=auto_tagger, ) vocab_cache = VocabularyCache( db=db_client, client=azure_client, ) matcher = Matcher(similarity, cfg.matching) llm_fulltext_matcher = LlmFulltextMatcher( db=db_client, client=azure_client, max_concurrency=cfg.azure_openai.max_concurrency, ) def _resolve_matching_method(value: Optional[str]) -> str: """Validate and normalize the ``matching_method`` parameter. - ``None`` or an empty/whitespace-only string → use the configured ``cfg.matching.default_method``. Treating empty strings as "unset" is required because the MCP tool signatures use ``str = ""`` rather than ``Optional[str] = None`` to disable FastMCP's JSON pre-parsing of string arguments (otherwise the literal string ``"null"`` would be coerced to Python ``None`` and silently bypass validation). - Other strings are trimmed and lower-cased, then validated against ``("score", "llm_fulltext")``. - Invalid values raise ``ValueError`` whose message contains both allowed values. """ if value is None: return cfg.matching.default_method norm = str(value).strip().lower() if norm == "": return cfg.matching.default_method if norm not in _ALLOWED_MATCHING_METHODS: raise ValueError( f"Invalid matching_method: {value!r}. " f"Allowed values are 'score' and 'llm_fulltext'." ) return norm def _get_capacities_cached() -> list[Capacity]: """Return capacities from the DB cache (populated on-demand).""" _ensure_db() return query_cache.get_or_fetch( "all_capacities_with_competences", db_client.get_all_capacities_with_competences, ) def _get_teams_cached() -> list[Team]: """Return teams (with competences and references) from the DB cache. Populated on demand via ``DBClient.get_all_teams()``. Mirrors the capacity cache and uses the same ``ttl_hours``/``max_size`` from ``cfg.cache``. See spec: team-profile-matching, requirement 1.1. """ _ensure_db() return team_query_cache.get_or_fetch( "all_teams", db_client.get_all_teams, ) def _format_capacities_table( items: list[Any], *, ref_start=None, ref_end=None, ) -> str: """Format capacities or scored-capacity dicts into a Markdown table. This is used for *matching* outputs (not the raw capacity listing). """ rows: list[list[str]] = [] for item in items: cap = _coerce_capacity(item) d = item if isinstance(item, dict) else asdict(cap) avail = _calculate_overlap_percentage( ref_start=ref_start, ref_end=ref_end, other_start=cap.begin_date, other_end=cap.end_date, ) rows.append( [ str(cap.id), str(cap.owner_name), str(cap.role_name or ""), ", ".join(cap.competences), avail, f"{float(d.get('role_score', 0.0)):.3f}", f"{float(d.get('competence_score', 0.0)):.3f}", f"{float(d.get('overall_score', 0.0)):.3f}", str(d.get("category", "")), ] ) if not rows: rows = [["", "", "", "", "", "", "", "", ""]] return md_table( [ "ID", "Owner", "Role", "Competences", "Availability", "Role Score", "Competence Score", "Overall Score", "Category", ], rows, ) def _format_results_table( items: list[Any], *, search_type: str, matching_method: str, ref_start=None, ref_end=None, ) -> str: """Render either a score or LLM-fulltext result table. Routes by ``search_type`` (``"capacity_search"``, ``"task_search"`` or ``"team_search"``) and ``matching_method`` (``"score"`` or ``"llm_fulltext"``). The score-mode capacity branch reuses ``_format_capacities_table`` to keep the existing 9-column layout unchanged. For ``team_search`` the table uses the columns ``Team Name``, ``Schwerpunkt``, ``Top-Kompetenzen`` plus either the score columns or ``Category`` / ``Begründung`` depending on ``matching_method``. ``Top-Kompetenzen`` is the comma-separated list of all competence names with ``top_competency=True``. See spec: llm-fulltext-matching, requirements 7.1, 7.2, 8.1, 8.2 and 9.2; team-profile-matching, requirements 6.6, 7.8, 8.4. """ if search_type == "capacity_search": if matching_method == "llm_fulltext": rows: list[list[str]] = [] for item in items: cap = _coerce_capacity(item) d = item if isinstance(item, dict) else asdict(cap) avail = _calculate_overlap_percentage( ref_start=ref_start, ref_end=ref_end, other_start=cap.begin_date, other_end=cap.end_date, ) rows.append( [ str(cap.id), str(cap.owner_name), str(cap.role_name or ""), ", ".join(cap.competences), avail, str(d.get("category", "")), _format_rationale_for_table( str(d.get("rationale", "")) ), ] ) if not rows: rows = [["", "", "", "", "", "", ""]] return md_table( [ "ID", "Owner", "Role", "Competences", "Availability", "Category", "Begründung", ], rows, ) # Score mode: reuse the existing helper. return _format_capacities_table( items, ref_start=ref_start, ref_end=ref_end ) if search_type == "task_search": if matching_method == "llm_fulltext": rows = [] for it in items: avail = _calculate_overlap_percentage( ref_start=ref_start, ref_end=ref_end, other_start=parse_iso_date(it.get("start_date")), other_end=parse_iso_date(it.get("end_date")), ) rows.append( [ str(it.get("task_id", "")), str(it.get("title", "")), ", ".join( [ str(x) for x in ( it.get("required_competences") or [] ) ] ), avail, str(it.get("category", "")), _format_rationale_for_table( str(it.get("rationale", "")) ), ] ) if not rows: rows = [["", "", "", "", "", ""]] return md_table( [ "task_id", "Title", "Required Competences", "Availability", "Category", "Begründung", ], rows, ) # Score mode for task_search. rows = [] for it in items: avail = _calculate_overlap_percentage( ref_start=ref_start, ref_end=ref_end, other_start=parse_iso_date(it.get("start_date")), other_end=parse_iso_date(it.get("end_date")), ) rows.append( [ str(it.get("task_id", "")), str(it.get("title", "")), ", ".join( [ str(x) for x in ( it.get("required_competences") or [] ) ] ), avail, f"{float(it.get('role_score', 0.0)):.3f}", f"{float(it.get('competence_score', 0.0)):.3f}", f"{float(it.get('overall_score', 0.0)):.3f}", str(it.get("category", "")), ] ) if not rows: rows = [["", "", "", "", "", "", "", ""]] return md_table( [ "task_id", "Title", "Required Competences", "Availability", "Role Score", "Competence Score", "Overall Score", "Category", ], rows, ) if search_type == "team_search": # Team-search results use a Team-specific column layout that # omits availability columns (no availability concept for # teams) and surfaces ``Schwerpunkt`` (focus_name) and # ``Top-Kompetenzen`` (comma-separated names of competences # with ``top_competency=True``). # See spec: team-profile-matching, requirements 6.6, 7.8, 8.4. _CATEGORY_RANK_TEAM = { "Top": 0, "Good": 1, "Partial": 2, "Low": 3, "Irrelevant": 4, } if matching_method == "llm_fulltext": # Stable order within rendering: by category rank, then # team_id ascending (spec: 7.7). sorted_items = sorted( items, key=lambda it: ( _CATEGORY_RANK_TEAM.get( str( ( it.get("category") if isinstance(it, dict) else getattr(it, "category", "") ) or "Irrelevant" ), 99, ), str( ( it.get("team_id") if isinstance(it, dict) else getattr(it, "team_id", "") ) or "" ), ), ) rows = [] for item in sorted_items: team = _coerce_team(item) d = item if isinstance(item, dict) else asdict(item) top_comps = ", ".join( c.name for c in team.competences if c.top_competency ) rows.append( [ str(team.team_name), str(team.focus_name), top_comps, str(d.get("category", "")), _format_rationale_for_table( str(d.get("rationale", "")) ), ] ) if not rows: rows = [["", "", "", "", ""]] return md_table( [ "Team Name", "Schwerpunkt", "Top-Kompetenzen", "Category", "Begründung", ], rows, ) # Score mode for team_search. rows = [] for item in items: team = _coerce_team(item) d = item if isinstance(item, dict) else asdict(item) top_comps = ", ".join( c.name for c in team.competences if c.top_competency ) rows.append( [ str(team.team_name), str(team.focus_name), top_comps, f"{float(d.get('role_score', 0.0)):.3f}", f"{float(d.get('competence_score', 0.0)):.3f}", f"{float(d.get('overall_score', 0.0)):.3f}", str(d.get("category", "")), ] ) if not rows: rows = [["", "", "", "", "", "", ""]] return md_table( [ "Team Name", "Schwerpunkt", "Top-Kompetenzen", "Role Score", "Competence Score", "Overall Score", "Category", ], rows, ) raise ValueError(f"Unknown search_type: {search_type!r}") def _validate_requirements_minimum( role_name: Optional[str], competences: list[str], ) -> None: """Validate minimum required structured fields for matching. Args: role_name: Required role name. competences: Required competences list. Raises: ValueError: If role_name or competences are missing. """ if not role_name or not str(role_name).strip(): raise ValueError("role_name is required") if not competences: raise ValueError("competences is required and must be a non-empty list") def _set_pending_requirements(req: Requirements) -> None: session.pending_requirements = req # Any time requirements are updated, prior confirmation requests are no # longer valid. The assistant must re-review and re-ask the user. session.user_confirmation_requested = False # Do not clear confirmed requirements here; otherwise a follow-up # matching call after user confirmation can regress when the tool sets # pending requirements again. session.last_requirements = req def _is_confirmation_required() -> bool: return bool(getattr(cfg.matching, "require_confirmation", True)) def _require_confirmed_or_auto(req: Requirements) -> Requirements: if not _is_confirmation_required(): session.confirmed_requirements = req return req if session.confirmed_requirements is None: raise ValueError( "Requirements must be confirmed before searching. " "First call show_pending_requirements() and ask the user to " "confirm, then call confirm_requirements(confirm=true)." ) return session.confirmed_requirements def _created_date_only(dt) -> str: return dt.date().isoformat() if dt else "" def _validate_search_id(search_id: str) -> Optional[str]: """Return an error message if search_id is not a UUID; else None.""" sid = (search_id or "").strip() try: uuid.UUID(sid) except ValueError: status_meta = { "search_id": search_id, "status": "invalid_search_id_format", "normalized_search_id": sid, } meta_json = __import__("json").dumps( status_meta, ensure_ascii=False, ) return "\n".join( [ "STATUS=invalid_search_id_format", f"SEARCH_ID={sid}", "FILTER_ID=", f"META={meta_json}", "", md_table( ["Field", "Value"], [ ["status", "invalid_search_id_format"], ["search_id", sid], [ "action", ("Use SEARCH_ID from the latest tool output header."), ], ], ), ] ) return None # Phase 0 tools @mcp.tool() def show_pending_requirements() -> str: """Show the currently pending requirements as a single review table. This tool must be called *after* requirements have been captured/updated and *before* asking the user to confirm. """ if session.pending_requirements is None: return "No pending requirements." req = session.pending_requirements rows = [ ["role_name", str(req.role_name or "")], [ "competences", ", ".join([str(c) for c in (req.competences or [])]), ], [ "date_start", req.date_start.isoformat() if req.date_start else "", ], ["date_end", req.date_end.isoformat() if req.date_end else ""], ["status", "pending"], ] session.user_confirmation_requested = True return "\n".join( [ md_table(["Field", "Value"], rows), "", ( "Ask the user to confirm these requirements, then call " "confirm_requirements(confirm=true) (or confirm=false)." ), ] ) @mcp.tool() def request_requirements_confirmation() -> str: """Mark that the assistant asked the user to confirm requirements. This separates the *ask-the-user* step from the actual server-side confirmation, so agents do not auto-confirm without user interaction. """ if session.pending_requirements is None: return "No pending requirements." session.user_confirmation_requested = True return ( "Confirmation requested. Ask the user to confirm requirements. " "Then call confirm_requirements(confirm=true) or confirm=false." ) @mcp.tool() def confirm_requirements(confirm: bool = True) -> str: """Confirm or cancel the currently pending requirements. If confirmation is enabled (matching.require_confirmation=true), matching tools will refuse to run until requirements are confirmed. Safety: Agents MUST request explicit user confirmation first (via show_pending_requirements() or request_requirements_confirmation()). """ if session.pending_requirements is None: return "No pending requirements." # Confirmation must only be possible after an explicit review/ask step # for the *current* pending requirements. if not session.user_confirmation_requested: return ( "User confirmation has not been requested yet. " "Call show_pending_requirements() and ask the user to confirm " "before calling confirm_requirements()." ) if not confirm: session.pending_requirements = None session.confirmed_requirements = None session.user_confirmation_requested = False return "Cancelled. Pending requirements cleared." session.confirmed_requirements = session.pending_requirements session.user_confirmation_requested = False return ( "Requirements confirmed. You can now call " "find_matching_capacities() or continue with " "filtering/pagination tools." ) @mcp.tool() def list_open_tasks(limit: int = 20) -> str: """List published (open) tasks from the database. Returns: Markdown table with task_id, title, created date (date-only) and time range. """ _ensure_db() tasks = db_client.get_open_tasks(limit=limit) rows: list[list[str]] = [] for task in tasks[: max(0, int(limit))]: created = _created_date_only(getattr(task, "created_date", None)) start = task.start_date.isoformat() if task.start_date else "(missing)" end = task.end_date.isoformat() if task.end_date else "(missing)" # Always show the DB primary key column value (`Task.id`). # Some clients key off the first column; keep it stable and # non-empty. task_id = getattr(task, "id", None) rows.append( [ str(task_id or ""), str(task.name or ""), str(task.title), created, f"{start} .. {end}", ] ) # Force a strict table string even when empty to prevent clients from # rewriting the output into a list. if not rows: rows = [["", "", "", "", ""]] return md_table( ["task_id", "Name", "Title", "Created", "Time Range"], rows, ) @mcp.tool() async def infer_primary_role( task_id: Optional[str] = None, task_text: Optional[str] = None, ) -> str: """Infer the single closest role from either a DB task or free text. Exactly one of task_id or task_text must be provided. Returns: Markdown table: Role | Similarity """ if bool(task_id) == bool(task_text): return "Provide exactly one of task_id or task_text." text = (task_text or "").strip() if task_id: _ensure_db() task = db_client.get_task_by_id(task_id) if task is None: return f"Task not found or not published: {task_id}" title = (task.title or "").strip() desc = (task.description or "").strip() text = (title + "\n\n" + desc).strip() if title else desc if not text: return "Task text is empty." best = await vocab_cache.infer_primary_role(task_text=text) if best is None: rows = [["", ""]] else: role, score = best rows = [[str(role), f"{float(score):.3f}"]] return md_table(["Role", "Similarity"], rows) @mcp.tool() async def get_task_details(task_id: str) -> str: """Show a task summary as a table plus description below.""" _ensure_db() task = db_client.get_task_by_id(task_id) if task is None: task = db_client.get_task_by_name(task_id) if task is None: return f"Task not found or not published: {task_id}" title = (task.title or "").strip() desc = (task.description or "").strip() task_text = (title + "\n\n" + desc).strip() if title else desc best = await vocab_cache.infer_primary_role(task_text=task_text) inferred_role = best[0] if best else "(none)" table = md_table( [ "Task ID", "Name", "Title", "Created", "Start", "End", "Competences", "Inferred Role", ], [ [ str(task.id), str(task.name or ""), str(task.title), _created_date_only(task.created_date), (task.start_date.isoformat() if task.start_date else "(missing)"), (task.end_date.isoformat() if task.end_date else "(missing)"), ", ".join(task.skills) if task.skills else "(none)", inferred_role, ] ], ) parts = [table, "", "## Description", task.description or "(empty)"] return "\n".join(parts) @mcp.tool() async def validate_task_requirements(task_id: str) -> str: """Validate task fields using LLM-based inference. This tool is meant for *debugging and transparency*: - It shows DB fields for the task. - It infers the primary role using LLM. Notes on refined matching semantics: - **Role inference uses title-first text with description fallback**. - Competence inference uses the **full task text** (title + description). - Competence output shows all inferred competences returned by `inference.max_competences` (no additional hard limit in formatting). Output: - Table-first task view (DB fields) - Inferred primary role table: Role | Similarity - Inferred competences table: Competence | Similarity """ _ensure_db() task = db_client.get_task_by_id(task_id) if task is None: return f"Task not found or not published: {task_id}" title = (task.title or "").strip() desc = (task.description or "").strip() task_text = (title + "\n\n" + desc).strip() if title else desc if not task_text: task_text = "(empty)" best_role = await vocab_cache.infer_primary_role(task_text=task_text) inferred_role_name = best_role[0] if best_role else None inferred_comps = await vocab_cache.infer_competences(task_text=task_text) db_skills = sorted({s.strip() for s in (task.skills or []) if s and s.strip()}) role_table = ( md_table(["Role", "Similarity"], [["", ""]]) if best_role is None else md_table( ["Role", "Similarity"], [[str(best_role[0]), f"{float(best_role[1]):.3f}"]], ) ) comp_rows = [[c, f"{float(s):.3f}"] for c, s in inferred_comps] if not comp_rows: comp_rows = [["", ""]] comps_table = md_table(["Competence", "Similarity"], comp_rows) table = md_table( [ "Task ID", "Title", "Time range (DB)", "Competences (DB)", ], [ [ str(task.id), str(task.title), ( f"{task.start_date or '(missing)'} .. {task.end_date or '(missing)'}" ), ", ".join(db_skills) if db_skills else "(none)", ] ], ) summary = md_table( ["Field", "Value"], [ ["inferred_primary_role", str(inferred_role_name or "(none)")], ["inferred_competences", str(len(inferred_comps))], ], ) parts = [ table, "", "## Summary", "", summary, "", "## Inferred Primary Role", "", role_table, "", "## Inferred Competences", "", comps_table, ] return "\n".join(parts) # Guided capture tools @mcp.tool() def start_guided_capture() -> str: """Start step-by-step guided capture for requirements.""" session.guided.update( { "active": True, "step": "description", "description": None, "role_name": None, "date_start": None, "date_end": None, "competences": None, } ) return "Step 1/4: Provide the task description using guided_set_description()." @mcp.tool() def guided_set_description(description: str) -> str: """Step 1/4 of guided capture: store the task description. Must be called after ``start_guided_capture()``. Advances the guided flow to the role step. Args: description: Free-text scope/goal of the task. Returns: Hint for the next guided step. """ if not session.guided.get("active"): return "Guided capture is not active. Call start_guided_capture() first." session.guided["description"] = description session.guided["step"] = "role" return "Step 2/4: Provide the role using guided_set_role(role_name)." @mcp.tool() def guided_set_role(role_name: str) -> str: """Step 2/4 of guided capture: store the required role. Must be called after ``guided_set_description()``. Advances the guided flow to the time-range step. Args: role_name: Required role (e.g. ``"Backend Developer"``). Returns: Hint for the next guided step. """ if not session.guided.get("active"): return "Guided capture is not active. Call start_guided_capture() first." session.guided["role_name"] = role_name session.guided["step"] = "time_range" return ( "Step 3/4: Provide the time range using " "guided_set_time_range(date_start?, date_end?). " "Open-ended ranges are allowed (omit start or end)." ) @mcp.tool() def guided_set_time_range( date_start: Optional[str] = None, date_end: Optional[str] = None, ) -> str: """Step 3/4 of guided capture: store the optional availability window. Must be called after ``guided_set_role()``. Open-ended ranges are allowed (omit ``date_start`` or ``date_end``). Advances the guided flow to the competences step. Args: date_start: Optional ISO date (YYYY-MM-DD) for window start. date_end: Optional ISO date (YYYY-MM-DD) for window end. Returns: Hint for the next guided step. """ if not session.guided.get("active"): return "Guided capture is not active. Call start_guided_capture() first." session.guided["date_start"] = parse_iso_date(date_start) session.guided["date_end"] = parse_iso_date(date_end) session.guided["step"] = "competences" return ( "Step 4/4: Provide competences using guided_set_competences(competences)." ) @mcp.tool() def guided_set_competences(competences: list[str]) -> str: """Step 4/4 of guided capture: store competences and finalize. Must be called after ``guided_set_role()`` (and optionally ``guided_set_time_range()``). Combines the captured fields into ``Requirements`` and stores them as pending requirements. Ends the guided flow. Args: competences: Non-empty list of required competences. Returns: Markdown with the pending requirements as a JSON payload and a hint for the next step (confirmation or matching). """ if not session.guided.get("active"): return "Guided capture is not active. Call start_guided_capture() first." comps = [c.strip() for c in (competences or []) if c and c.strip()] if not comps: return "competences must be a non-empty list" req = Requirements( role_name=(str(session.guided.get("role_name") or "").strip() or None), competences=comps, date_start=session.guided.get("date_start"), date_end=session.guided.get("date_end"), ) if not req.role_name: return "role_name is required. Call guided_set_role(role_name) first." _set_pending_requirements(req) session.guided["active"] = False session.guided["step"] = None payload = {"requirements": _requirements_to_dict(req)} text = ( "## Pending Requirements\n\n```json\n" + __import__("json").dumps(payload, ensure_ascii=False, indent=2) + "\n```\n" ) if _is_confirmation_required(): text += ( "\nCall confirm_requirements(confirm=true) to proceed with matching." ) else: text += ( "\nConfirmation is disabled by configuration; you can run matching now." ) return text # Phase 1 tools @mcp.tool() async def extract_requirements( task_description: str, confirm_requirements: bool = True ) -> str: """Extract structured requirements from free-text. Args: task_description: Free-text description. confirm_requirements: If True, include next-step hint. Returns: Markdown with JSON payload + inferred primary role. """ desc = (task_description or "").strip() if not desc: return "task_description must be non-empty" # Primary role inference (LLM-based). best = await vocab_cache.infer_primary_role(task_text=desc) inferred_role = best[0] if best else None inferred_competences = await vocab_cache.infer_competences(task_text=desc) req = Requirements( role_name=inferred_role, competences=[c for c, _sim in inferred_competences], date_start=None, date_end=None, description=desc, ) _set_pending_requirements(req) payload = { "requirements": _requirements_to_dict(req), "primary_role_similarity": (float(best[1]) if best else None), } text = ( "## Pending Requirements\n\n```json\n" + __import__("json").dumps(payload, ensure_ascii=False, indent=2) + "\n```" ) text += "\n\n## Inferred Primary Role\n\n" if best is None: text += md_table(["Role", "Similarity"], [["", ""]]) else: text += md_table( ["Role", "Similarity"], [[str(best[0]), f"{float(best[1]):.3f}"]], ) text += "\n\n## Inferred Competences\n\n" if not inferred_competences: text += md_table(["Competence", "Similarity"], [["", ""]]) else: text += md_table( ["Competence", "Similarity"], [[str(c), f"{float(sim):.3f}"] for c, sim in inferred_competences], ) text += ( "\n\nDates are not extracted automatically anymore. " "If you need date_start/date_end, provide them via " "collect_structured_requirement_data(...) or guided capture." ) if confirm_requirements: if _is_confirmation_required(): text += ( "\n\nNext steps: call show_pending_requirements(), " "ask the user to confirm (Yes/No), then call " "confirm_requirements(confirm=true) and finally run " "find_matching_capacities(role_name, competences, " "date_start?, date_end?)." ) else: text += ( "\n\nConfirmation is disabled by configuration; you can " "run find_matching_capacities(role_name, competences, " "date_start?, date_end?) now." ) return text @mcp.tool() def collect_structured_requirement_data( description: Optional[str] = None, role_name: Optional[str] = None, competences: Optional[list[str]] = None, date_start: Optional[str] = None, date_end: Optional[str] = None, confirm_requirements: bool = True, ) -> str: """Collect structured requirements from explicit parameters. Args: description: Concrete task/topic description (scope/goal). Optional, but strongly recommended. role_name: Required role. competences: Required competences. date_start: Optional filter start (YYYY-MM-DD). date_end: Optional filter end (YYYY-MM-DD, may be omitted). confirm_requirements: If True, include next-step hint. Returns: Markdown with JSON payload or a message describing missing fields. """ desc = (description or "").strip() or None comps = [c.strip() for c in (competences or []) if c and c.strip()] rn = role_name.strip() if role_name else None missing = [] if not rn: missing.append("role_name") if not comps: missing.append("competences") if missing: example = "role_name='Backend Developer', competences=['Python', 'FastAPI']" return ( "Missing required fields: " + ", ".join(missing) + "\nProvide at least role_name and competences. Example: " + example ) req = Requirements( role_name=rn, competences=comps, date_start=parse_iso_date(date_start), date_end=parse_iso_date(date_end), description=desc, ) _set_pending_requirements(req) payload = {"requirements": _requirements_to_dict(req)} text = ( "## Pending Requirements\n\n```json\n" + __import__("json").dumps(payload, ensure_ascii=False, indent=2) + "\n```" ) if not desc: text += ( "\n\nMissing: task/topic description. Please describe the " "scope/goal (what will be built/done). A single skill like " "'JavaScript' is not sufficient. You can call this tool " "again with description=... or use guided capture." ) if confirm_requirements: if _is_confirmation_required(): text += ( "\n\nNext steps: call show_pending_requirements(), " "ask the user to confirm (Yes/No), then call " "confirm_requirements(confirm=true) and finally run " "find_matching_capacities(role_name, competences, " "date_start?, date_end?)." ) else: text += ( "\n\nConfirmation is disabled by configuration; you can " "run find_matching_capacities(role_name, competences, " "date_start?, date_end?) now." ) return text @mcp.tool() async def update_requirements( change_description: str, confirm_requirements: bool = True ) -> str: """Deprecated. Returns a guidance message pointing to alternatives. The LLM-driven update flow has been removed. Use ``collect_structured_requirement_data(...)`` to set the final ``role_name`` / ``competences`` / dates explicitly, or re-run ``extract_requirements(...)`` and override fields as needed. Args: change_description: Ignored. Kept for backwards compatibility. confirm_requirements: Ignored. Kept for backwards compatibility. Returns: A short guidance message describing the supported alternatives. """ return ( "update_requirements is no longer available (Azure chat removed). " "Please call collect_structured_requirement_data(...) with the final " "role/competences/dates, or re-run extract_requirements(...) and " "override fields as needed." ) # Phase 2 tool @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: str = "", ) -> str: """Run ad-hoc capacity matching with structured inputs (task→capacity). This tool performs a **capacity search** ("capacity_search"): - Input is a role + competences (+ optional date range). - Output is a list of capacities, either scored (``score`` mode) or LLM-categorised (``llm_fulltext`` mode). Matching methods (parameter ``matching_method``): - ``"score"`` (default unless overridden in config): Numeric scoring with split **Role Score**, **Competence Score**, and **Overall Score**. - ``"llm_fulltext"``: An LLM evaluates each capacity's full-text profile against the task profile and returns a category plus a short German rationale. Output schema differences: - In ``score`` mode the result table includes the columns ``Role Score``, ``Competence Score``, ``Overall Score`` and ``Category`` (unchanged behaviour). - In ``llm_fulltext`` mode there are **no numeric score columns**; instead the table includes a ``Category`` column and a ``Begründung`` column with the LLM rationale (truncated for the table; the persisted payload keeps the rationale ungekürzt). Categories in both modes: - Results are grouped into ``Top``, ``Good``, ``Partial``, ``Low``, ``Irrelevant``. Notes: Date inputs are used for *availability filtering* and *availability percentage display*. They do not change the similarity scoring or the LLM categorisation. Examples: - Find capacities (default scoring): `find_matching_capacities(role_name="Backend Engineer", competences=["Python"], date_start="2025-03-01", date_end="2025-06-30")` - Find capacities via LLM full-text matching: `find_matching_capacities(role_name="Backend Engineer", competences=["Python"], matching_method="llm_fulltext")` - Then filter the search: `filter_search_results(search_id="...", is_fully_available=true)` - Then browse categories/pages: `get_results_by_category(search_id="...", category="Irrelevant", page=1, page_size=20)` Args: role_name: Required role. competences: Required competences. date_start: Optional filter start (YYYY-MM-DD). date_end: Optional filter end (YYYY-MM-DD, may be omitted). matching_method: Optional matching method. Allowed values: ``"score"`` (default) and ``"llm_fulltext"``. Returns: Markdown summary + top results and a `search_id`. """ # Validate matching_method up-front, before any DB or LLM call # (spec: requirements 1.1, 1.5). try: method = _resolve_matching_method(matching_method) except ValueError as exc: return ( f"Error: {exc}\n\n" "Allowed values for `matching_method`: 'score', 'llm_fulltext'." ) # Normalize role_name for clients that send an empty string. rn = (role_name or "").strip() if not rn: rn = "Beliebige Rolle" comps = [c.strip() for c in competences if c and c.strip()] _validate_requirements_minimum(rn, comps) req = Requirements( role_name=rn, competences=comps, date_start=parse_iso_date(date_start), date_end=parse_iso_date(date_end), ) # If confirmation is required, validate against the currently confirmed # requirements *before* overwriting session state. try: req_to_use = _require_confirmed_or_auto(req) except ValueError: _set_pending_requirements(req) return ( "Requirements must be confirmed before searching. " "First call show_pending_requirements() and ask the user to " "confirm, then call confirm_requirements(confirm=true)." ) _set_pending_requirements(req) capacities = _get_capacities_cached() ref_start = req_to_use.date_start ref_end = req_to_use.date_end if method == "llm_fulltext": # Apply the same availability prefilter as the score path # (Matcher.match filters by availability internally). filtered_capacities = [ c for c in capacities if availability_overlaps( c.begin_date, c.end_date, req_to_use.date_start, req_to_use.date_end, ) ] task_profile = build_task_profile_from_requirements(req_to_use) llm_result = await llm_fulltext_matcher.match_capacities( task_profile=task_profile, capacities=filtered_capacities, ) by_category_payload: dict[str, list[dict[str, Any]]] = {} for cat, items in llm_result.by_category.items(): lst: list[dict[str, Any]] = [] for it in items: merged = dict(it.raw) merged["category"] = it.category merged["rationale"] = it.rationale lst.append(merged) by_category_payload[cat] = lst summary = {k: len(v) for k, v in by_category_payload.items()} errors_list = [ {"item_id": e.item_id, "error": e.error} for e in llm_result.errors ] results_payload: dict[str, Any] = { "search_type": "capacity_search", "matching_method": "llm_fulltext", "reference": { "availability_date_start": ( req_to_use.date_start.isoformat() if req_to_use.date_start else None ), "availability_date_end": ( req_to_use.date_end.isoformat() if req_to_use.date_end else None ), }, "summary": summary, "by_category": by_category_payload, "errors": errors_list, } else: match = await matcher.match(capacities, req_to_use) results_payload = { "search_type": "capacity_search", "matching_method": "score", "reference": { "availability_date_start": ( req_to_use.date_start.isoformat() if req_to_use.date_start else None ), "availability_date_end": ( req_to_use.date_end.isoformat() if req_to_use.date_end else None ), }, "summary": Matcher.summary_counts(match.by_category), "by_category": { cat: [ { **asdict(s.capacity), "competence_score": s.competence_score, "role_score": s.role_score, "overall_score": s.overall_score, "category": s.category, } for s in scored ] for cat, scored in match.by_category.items() }, } errors_list = [] search_id = search_cache.store_search( task_id=None, requirements=_requirements_to_dict(req), results=results_payload, ) session.last_search_id = search_id summary = results_payload["summary"] # Default to Top, otherwise first non-empty in desired order. shown_category = "Top" for cat in ("Top", "Good", "Partial", "Low", "Irrelevant"): if results_payload["by_category"].get(cat): shown_category = cat break shown_items = results_payload["by_category"].get(shown_category, []) if method == "llm_fulltext": shown_table = _format_results_table( shown_items, search_type="capacity_search", matching_method="llm_fulltext", ref_start=ref_start, ref_end=ref_end, ) else: shown_table = _format_capacities_table( shown_items, ref_start=ref_start, ref_end=ref_end, ) meta = { "search_id": search_id, "filter_id": None, "default_category": shown_category, "matching_method": method, } meta_json = __import__("json").dumps(meta, ensure_ascii=False) summary_rows = [ [k, str(summary.get(k, 0))] for k in ("Top", "Good", "Partial", "Low", "Irrelevant") ] summary_table = md_table(["Category", "Count"], summary_rows) parts = [ f"Using SEARCH_ID={search_id}", f"SEARCH_ID={search_id}", f"META={meta_json}", f"search_id: `{search_id}`", "", "## Summary", summary_table, "", f"## {shown_category} Results", shown_table, ] if method == "llm_fulltext" and errors_list: err_rows = [[e["item_id"], e["error"]] for e in errors_list] err_table = md_table(["item_id", "error"], err_rows) parts.append("") parts.append("## Errors") parts.append(err_table) return "\n".join(parts) @mcp.tool() async def find_matching_teams( role_name: str, competences: list[str], matching_method: str = "", ) -> str: """Run ad-hoc team matching with structured inputs (task→team). This tool performs a **team search** ("team_search"): - Input is a role + competences. - Output is a list of teams, either scored (``score`` mode) or LLM-categorised (``llm_fulltext`` mode). Matching methods (parameter ``matching_method``): - ``"score"`` (default unless overridden in config): Numeric scoring with split **Role Score**, **Competence Score**, and **Overall Score**. Top-Kompetenzen werden mit dem Faktor ``cfg.matching.team.top_competency_weight`` (Default ``1.5``) höher gewichtet. - ``"llm_fulltext"``: An LLM evaluates each team's full-text profile (Schwerpunkt, Über uns, Leistungen, Interessen, Kompetenzen, Referenzen) against the task profile and returns a category plus a short German rationale. Output schema differences: - In ``score`` mode the result table includes ``Team Name``, ``Schwerpunkt``, ``Top-Kompetenzen``, ``Role Score``, ``Competence Score``, ``Overall Score`` und ``Category``. - In ``llm_fulltext`` mode the table includes ``Team Name``, ``Schwerpunkt``, ``Top-Kompetenzen``, ``Category`` und ``Begründung``. Categories in both modes: - Results are grouped into ``Top``, ``Good``, ``Partial``, ``Low``, ``Irrelevant``. Notes: Verfügbarkeitsfilter sind für Team-Suchen nicht wirksam (Teams haben keinen Verfügbarkeitszeitraum). ``find_matching_teams`` akzeptiert deshalb keine ``date_start``/``date_end``-Parameter und persistiert die Reference-Daten als ``null``. Args: role_name: Required role (used as role similarity stand-in against ``team.focus_name``). competences: Required competences. matching_method: Optional matching method. Allowed values: ``"score"`` (default) and ``"llm_fulltext"``. Returns: Markdown summary + top results and a `search_id`. """ # Validate matching_method up-front, before any DB or LLM call # (spec: requirements 1.5, 1.6). try: method = _resolve_matching_method(matching_method) except ValueError as exc: return ( f"Error: {exc}\n\n" "Allowed values for `matching_method`: 'score', 'llm_fulltext'." ) # Normalize role_name for clients that send an empty string. rn = (role_name or "").strip() if not rn: rn = "Beliebige Rolle" comps = [c.strip() for c in competences if c and c.strip()] _validate_requirements_minimum(rn, comps) req = Requirements( role_name=rn, competences=comps, date_start=None, date_end=None, ) # Confirm-Gate (spec: requirement 1.5). try: req_to_use = _require_confirmed_or_auto(req) except ValueError: _set_pending_requirements(req) return ( "Requirements must be confirmed before searching. " "First call show_pending_requirements() and ask the user to " "confirm, then call confirm_requirements(confirm=true)." ) _set_pending_requirements(req) teams = _get_teams_cached() errors_list: list[dict[str, Any]] = [] if method == "llm_fulltext": task_profile = build_task_profile_from_requirements(req_to_use) llm_result = await llm_fulltext_matcher.match_teams( task_profile=task_profile, teams=teams, ) # Index teams by id for quick lookup so we can merge the team # payload (asdict(team)) into the LLM item, mirroring the # capacity LLM path which uses ``it.raw`` as the base team # dict (spec: requirements 7.1, 8.4). teams_by_id: dict[str, Team] = { str(t.team_id): t for t in teams } by_category_payload: dict[str, list[dict[str, Any]]] = {} for cat, items in llm_result.by_category.items(): lst: list[dict[str, Any]] = [] for it in items: team_obj = teams_by_id.get(str(it.item_id)) base = asdict(team_obj) if team_obj is not None else dict(it.raw) base["category"] = it.category base["rationale"] = it.rationale lst.append(base) by_category_payload[cat] = lst summary = {k: len(v) for k, v in by_category_payload.items()} errors_list = [ {"item_id": e.item_id, "error": e.error} for e in llm_result.errors ] results_payload: dict[str, Any] = { "search_type": "team_search", "matching_method": "llm_fulltext", "reference": { "availability_date_start": None, "availability_date_end": None, }, "summary": summary, "by_category": by_category_payload, "errors": errors_list, } else: match = await matcher.match_teams( teams, req_to_use, top_competency_weight=cfg.matching.team.top_competency_weight, ) results_payload = { "search_type": "team_search", "matching_method": "score", "reference": { "availability_date_start": None, "availability_date_end": None, }, "summary": Matcher.summary_counts(match.by_category), "by_category": { cat: [ { **asdict(s.team), "competence_score": s.competence_score, "role_score": s.role_score, "overall_score": s.overall_score, "category": s.category, } for s in scored ] for cat, scored in match.by_category.items() }, } errors_list = [] search_id = search_cache.store_search( task_id=None, requirements=_requirements_to_dict(req), results=results_payload, ) session.last_search_id = search_id summary = results_payload["summary"] # Default to Top, otherwise first non-empty in desired order. shown_category = "Top" for cat in ("Top", "Good", "Partial", "Low", "Irrelevant"): if results_payload["by_category"].get(cat): shown_category = cat break shown_items = results_payload["by_category"].get(shown_category, []) shown_table = _format_results_table( shown_items, search_type="team_search", matching_method=method, ) meta = { "search_id": search_id, "filter_id": None, "default_category": shown_category, "matching_method": method, "search_type": "team_search", } meta_json = __import__("json").dumps(meta, ensure_ascii=False) summary_rows = [ [k, str(summary.get(k, 0))] for k in ("Top", "Good", "Partial", "Low", "Irrelevant") ] summary_table = md_table(["Category", "Count"], summary_rows) parts = [ f"Using SEARCH_ID={search_id}", f"SEARCH_ID={search_id}", f"META={meta_json}", f"search_id: `{search_id}`", "", "## Summary", summary_table, "", f"## {shown_category} Results", shown_table, ] if method == "llm_fulltext" and errors_list: err_rows = [[e["item_id"], e["error"]] for e in errors_list] err_table = md_table(["item_id", "error"], err_rows) parts.append("") parts.append("## Errors") parts.append(err_table) return "\n".join(parts) # Phase 3 tools @mcp.tool() def filter_search_results( search_id: str, role_filter: Optional[str] = None, competence_filter: Optional[list[str]] = None, availability_date_start: Optional[str] = None, availability_date_end: Optional[str] = None, is_fully_available: bool = False, task_competence_filter: Optional[list[str]] = None, task_text_filter: Optional[str] = None, min_similarity: float | None = None, ) -> str: """Filter an existing search and return a `filter_id`. This tool works for both search directions: - **capacity_search** (task→capacity): created by `find_matching_capacities()` - **task_search** (capacity→task): created by `find_matching_tasks()` General filters (apply to both search types): - `role_filter`: fuzzy match against the result's role field - `competence_filter`: fuzzy match against the result's competences - `availability_date_start` / `availability_date_end`: override the reference window for availability filtering - `is_fully_available`: - for capacity_search: capacity must fully cover the reference window - for task_search: task must be fully inside the reference window If availability dates are omitted, the tool uses the reference dates that were stored in the original search payload. Task-search-only filters (ignored for capacity_search): - `task_text_filter`: case-insensitive substring search in task title and description - `task_competence_filter`: case-insensitive match against task `required_competences` (inferred) and `skills` (DB) Mode-specific behaviour: - In ``score`` mode the score-similarity threshold ``min_similarity`` drives fuzzy role/competence comparison and results are sorted by ``overall_score`` descending. - In ``llm_fulltext`` mode there are no numeric scores; ``min_similarity`` is **ignored** and recorded in the ``Applied Filters`` table with the note ``"ignored: not applicable in llm_fulltext mode"``. Results are sorted stably by ``(category_rank, item_id)`` and the preview table uses the LLM-mode column layout (``Begründung`` instead of score columns). Examples: - Filter a capacity_search to only fully available capacities: `filter_search_results(search_id="...", is_fully_available=true)` - Filter a task_search by task text: `filter_search_results(search_id="...", task_text_filter="typescript")` - Filter a task_search by task competences: `filter_search_results(search_id="...", task_competence_filter=["python"])` Returns: Markdown with `FILTER_ID=...`, a preview table (top 20), and the filtered total count. """ err = _validate_search_id(search_id) if err: return err entry = search_cache.get(search_id) if entry is None: current = session.last_search_id status_meta = { "search_id": search_id, "status": "unknown_or_expired", "current_search_id": current, } meta_json = __import__("json").dumps(status_meta, ensure_ascii=False) status_rows = [["status", "unknown_or_expired"], ["search_id", search_id]] if current: status_rows.append(["current_search_id", current]) status_rows.append( [ "action", "Run find_matching_capacities() or find_matching_tasks() again.", ] ) return "\n".join( [ "STATUS=unknown_or_expired", f"SEARCH_ID={search_id}", "FILTER_ID=", f"META={meta_json}", "", md_table(["Field", "Value"], status_rows), ] ) base: dict[str, Any] = entry.results search_type = str(base.get("search_type") or "capacity_search") # ``matching_method`` may be missing on older cached entries; fall # back to ``"score"`` for backwards compatibility (spec: 9.5). matching_method = str(base.get("matching_method") or "score") any_filter = any( [ role_filter, competence_filter and any(competence_filter), availability_date_start, availability_date_end, is_fully_available, task_competence_filter and any(task_competence_filter), (task_text_filter or "").strip(), ] ) if not any_filter: return "At least one filter must be provided." # Reference dates: explicit params override stored reference. ref = base.get("reference") or {} ref_start = parse_iso_date( availability_date_start or ref.get("availability_date_start") ) ref_end = parse_iso_date( availability_date_end or ref.get("availability_date_end") ) required_comps = [ c.strip() for c in (competence_filter or []) if c and c.strip() ] # In LLM-fulltext mode the score-similarity threshold has no # meaning; ignore it and remember the original input so it can be # surfaced in ``Applied Filters`` (spec: 9.4 / 11.1). min_similarity_ignored = ( matching_method == "llm_fulltext" and min_similarity is not None ) if matching_method == "llm_fulltext": threshold: float | None = None else: threshold = 0.7 if min_similarity is None else float(min_similarity) task_comp_filter = [ c.strip().lower() for c in (task_competence_filter or []) if c and c.strip() ] task_text_q = (task_text_filter or "").strip().lower() # Gather all items across categories. all_items: list[dict[str, Any]] = [] for items in (base.get("by_category") or {}).values(): all_items.extend(list(items or [])) def _parse_date(x): return parse_iso_date(x) if isinstance(x, str) else x def role_ok(item: dict[str, Any]) -> bool: if not role_filter: return True if search_type == "capacity_search": item_role = str(item.get("role_name") or "") elif search_type == "team_search": # Teams have no role; the focus_name acts as the role # surrogate (spec: team-profile-matching, requirement 6.7 # / 8.5). item_role = str(item.get("focus_name") or "") else: item_role = str(item.get("role") or item.get("inferred_role") or "") if threshold is None: return role_filter.strip().lower() in item_role.strip().lower() return (fuzz.token_set_ratio(role_filter, item_role) / 100.0) >= threshold def competence_ok(item: dict[str, Any]) -> bool: if not required_comps: return True if search_type == "team_search": # Team competences are stored as ``[{"name", "top_competency"}]``. # Filter values with a trailing ``(Top)`` suffix are # restricted to top competences (suffix is stripped before # comparison). See spec: team-profile-matching, # requirement 8.5. team_comps_raw = item.get("competences") or [] all_names: list[str] = [] top_names: list[str] = [] for c in team_comps_raw: if not isinstance(c, dict): continue name = str(c.get("name") or "") if not name.strip(): continue all_names.append(name) if bool(c.get("top_competency", False)): top_names.append(name) top_suffix = "(Top)" for req in required_comps: req_stripped = req.strip() if req_stripped.lower().endswith(top_suffix.lower()): target_name = req_stripped[: -len(top_suffix)].strip() target_pool = top_names else: target_name = req_stripped target_pool = all_names if threshold is None: norm = { c.strip().lower() for c in target_pool if c.strip() } if target_name.lower() not in norm: return False else: best = max( ( fuzz.token_set_ratio(target_name, c) for c in target_pool ), default=0, ) if (best / 100.0) < threshold: return False return True if search_type == "capacity_search": item_comps = [str(x) for x in (item.get("competences") or [])] else: item_comps = [str(x) for x in (item.get("required_competences") or [])] if threshold is None: norm = {c.strip().lower() for c in item_comps if c and str(c).strip()} for req in required_comps: if req.strip().lower() not in norm: return False return True for req in required_comps: best = max( (fuzz.token_set_ratio(req, c) for c in item_comps), default=0, ) if (best / 100.0) < threshold: return False return True def task_specific_ok(item: dict[str, Any]) -> bool: if search_type != "task_search": return True if task_comp_filter: comps = [ str(x).strip().lower() for x in (item.get("required_competences") or []) ] skills = [str(x).strip().lower() for x in (item.get("skills") or [])] merged = set([c for c in comps if c] + [s for s in skills if s]) if not any(c in merged for c in task_comp_filter): return False if task_text_q: title = str(item.get("title") or "").lower() desc = str(item.get("description") or "").lower() if task_text_q not in title and task_text_q not in desc: return False return True def availability_ok(item: dict[str, Any]) -> bool: if ref_start is None and ref_end is None: return True if search_type == "team_search": # Teams have no availability concept; availability filters # are silently ignored at the item level. The # ``Applied Filters`` table surfaces the values with a # ``team_search``/``nicht wirksam`` hint (spec: # team-profile-matching, requirements 6.7, 8.6). return True if search_type == "capacity_search": b = _parse_date(item.get("begin_date")) e = _parse_date(item.get("end_date")) if not is_fully_available: return availability_overlaps(b, e, ref_start, ref_end) # Full coverage: capacity covers entire [ref_start, ref_end]. if ref_start is None or ref_end is None: return False if b is None: return False if b > ref_start: return False if e is not None and e < ref_end: return False return True # task_search: item is a task. ts = _parse_date(item.get("start_date")) te = _parse_date(item.get("end_date")) if not is_fully_available: return availability_overlaps(ts, te, ref_start, ref_end) # Full coverage: task fits fully within capacity window. if ref_start is None or ref_end is None: return False if ts is None or te is None: return False if ts < ref_start: return False if te > ref_end: return False return True filtered = [ it for it in all_items if role_ok(it) and competence_ok(it) and task_specific_ok(it) and availability_ok(it) ] # Enrich with category. In score mode the category is recomputed # from ``overall_score``; in LLM mode the category is set by the # LLM matcher and must be preserved (no numeric scores exist). # Sorting is mode-dependent (spec: 11.2). _CATEGORY_RANK = { "Top": 0, "Good": 1, "Partial": 2, "Low": 3, "Irrelevant": 4, } enriched: list[dict[str, Any]] = [] for it in filtered: it2 = dict(it) if matching_method == "llm_fulltext": # Preserve persisted LLM category; default to ``Irrelevant`` # if missing (defensive). it2["category"] = str(it2.get("category") or "Irrelevant") else: it2["category"] = _category_for( float(it2.get("overall_score", 0.0)) ) enriched.append(it2) if matching_method == "llm_fulltext": def _llm_sort_key(x: dict[str, Any]) -> tuple[int, str]: rank = _CATEGORY_RANK.get( str(x.get("category", "Irrelevant")), 99 ) if search_type == "capacity_search": item_id = x.get("id") elif search_type == "team_search": item_id = x.get("team_id") or x.get("id") else: item_id = x.get("task_id") or x.get("id") return (rank, str(item_id or "")) enriched.sort(key=_llm_sort_key) else: enriched.sort( key=lambda x: float(x.get("overall_score", 0.0)), reverse=True, ) payload = {"total": len(enriched), "results": enriched} filter_id = search_cache.add_filter( search_id, filtered_results=payload, filter_meta={ "role_filter": role_filter, "competence_filter": required_comps, "availability_date_start": availability_date_start, "availability_date_end": availability_date_end, "is_fully_available": bool(is_fully_available), "task_competence_filter": task_competence_filter or [], "task_text_filter": task_text_filter or "", "min_similarity": threshold, }, ) session.last_search_id = search_id meta: dict[str, object] = { "search_id": search_id, "filter_id": filter_id, "status": "ok", "total": payload["total"], "search_type": search_type, "matching_method": matching_method, } meta_json = __import__("json").dumps(meta, ensure_ascii=False) if min_similarity_ignored: # ``min_similarity`` is guaranteed not None when # ``min_similarity_ignored`` is True (see threshold handling # above), but help the type checker out explicitly. assert min_similarity is not None min_similarity_value = ( f"{float(min_similarity)} " "(ignored: not applicable in llm_fulltext mode)" ) else: min_similarity_value = ( "" if threshold is None else str(threshold) ) # For ``team_search`` availability-related filters are not # applicable: teams have no availability windows. The values are # ignored at the item level (see ``availability_ok`` above) and # surfaced in the ``Applied Filters`` table with a substring # ``team_search`` and the marker ``nicht wirksam`` (spec: # team-profile-matching, requirements 6.7, 8.6). team_search_hint = ( " (ignored: not applicable for team_search; nicht wirksam)" ) if search_type == "team_search": avail_start_value = (availability_date_start or "") + ( team_search_hint if availability_date_start else "" ) avail_end_value = (availability_date_end or "") + ( team_search_hint if availability_date_end else "" ) is_fully_available_value = ( str(bool(is_fully_available)).lower() + (team_search_hint if is_fully_available else "") ) else: avail_start_value = availability_date_start or "" avail_end_value = availability_date_end or "" is_fully_available_value = str(bool(is_fully_available)).lower() filter_rows = [ ["role_filter", role_filter or ""], ["competence_filter", ", ".join(required_comps)], ["availability_date_start", avail_start_value], ["availability_date_end", avail_end_value], ["is_fully_available", is_fully_available_value], ["task_competence_filter", ", ".join(task_comp_filter)], ["task_text_filter", task_text_filter or ""], ["min_similarity", min_similarity_value], ] # Preview table (top 20). Uses the shared helper so score and # LLM-fulltext modes share a consistent column layout (spec: 11.3). preview = _format_results_table( enriched[: min(len(enriched), 20)], search_type=search_type, matching_method=matching_method, ref_start=ref_start, ref_end=ref_end, ) parts = [ f"Using SEARCH_ID={search_id}", f"SEARCH_ID={search_id}", f"FILTER_ID={filter_id}", f"META={meta_json}", f"search_id: `{search_id}`", f"filter_id: `{filter_id}`", "", "## Applied Filters", md_table(["Filter", "Value"], filter_rows), "", f"Filtered total results: {payload['total']}", "", preview, "", "Note: Showing the top 20 rows only. Use get_results_by_category(...) to browse.", ] return "\n".join(parts) @mcp.tool() def get_results_by_category( search_id: str, category: str = "Top", page: int = 1, page_size: int = 20, filter_id: str | None = None, ) -> str: """Fetch a single category page for a search (optionally filtered). This is the primary pagination/browsing tool. It supports: - Both search directions (capacity_search and task_search) - The refined category set: `Top`, `Good`, `Partial`, `Low`, `Irrelevant` - Paging via `page` and `page_size` - Both matching methods (``score`` and ``llm_fulltext``); the column layout is selected based on the persisted ``matching_method``. Output schema differences: - In ``score`` mode the table includes the columns ``Role Score``, ``Competence Score``, ``Overall Score`` and ``Category`` (unchanged behaviour). - In ``llm_fulltext`` mode there are no numeric score columns; instead the table includes a ``Category`` column and a ``Begründung`` column with the LLM rationale (the persisted payload keeps the rationale ungekürzt; the table truncates at 280 characters). If `filter_id` is provided, the tool pages over the filtered result set produced by `filter_search_results()`. Examples: - Browse the 2nd page of Good results: `get_results_by_category(search_id="...", category="Good", page=2, page_size=20)` - Browse filtered results: `get_results_by_category(search_id="...", category="Top", filter_id="...")` Returns: Markdown with paging metadata and the result table. """ err = _validate_search_id(search_id) if err: return err entry = search_cache.get(search_id) if entry is None: return "unknown_or_expired" data: dict[str, Any] = entry.results if filter_id: fdata = entry.filters.get(filter_id) if not fdata: return f"Unknown filter_id for this search_id: {filter_id}" data = fdata["results"] base = entry.results search_type = str(base.get("search_type") or "capacity_search") # ``matching_method`` may be missing on older cached entries; fall # back to ``"score"`` for backwards compatibility (spec: 9.5). matching_method = str(base.get("matching_method") or "score") ref = base.get("reference") or {} ref_start = parse_iso_date(ref.get("availability_date_start")) ref_end = parse_iso_date(ref.get("availability_date_end")) cat = category.capitalize() allowed = ("Top", "Good", "Partial", "Low", "Irrelevant") if cat not in allowed: return md_table( ["Field", "Value"], [["status", "invalid_category"], ["allowed", ", ".join(allowed)]], ) if isinstance(data, dict) and "by_category" in data: items: list[dict[str, Any]] = list(data.get("by_category", {}).get(cat, [])) else: all_items: list[dict[str, Any]] = list(data.get("results", [])) items = [ x for x in all_items if str(x.get("category", "")).capitalize() == cat ] total = len(items) if page <= 0: return "page must be >= 1" if page_size <= 0 or page_size > 200: return "page_size must be between 1 and 200" start = (page - 1) * page_size end = start + page_size page_items = items[start:end] # Render via the shared helper so score / LLM-fulltext modes get # consistent column layouts (``Begründung`` instead of score # columns in LLM mode). See spec: 9.1, 9.2, 10.2. table = _format_results_table( page_items, search_type=search_type, matching_method=matching_method, ref_start=ref_start, ref_end=ref_end, ) session.last_search_id = search_id meta = { "search_id": search_id, "filter_id": filter_id, "category": cat, "page": page, "page_size": page_size, "total": total, "search_type": search_type, "matching_method": matching_method, } meta_json = __import__("json").dumps(meta, ensure_ascii=False) return "\n".join( [ f"Using SEARCH_ID={search_id}", f"SEARCH_ID={search_id}", f"FILTER_ID={filter_id or ''}", f"META={meta_json}", f"search_id: `{search_id}`" + (f" (filter_id: `{filter_id}`)" if filter_id else ""), f"Category: {cat}", f"Page: {page} (page_size={page_size})", f"Total items in category: {total}", "", table, ] ) # NOTE: Intentionally no `get_last_search_id` tool. def _format_capacity_availability(cap: Capacity) -> str: begin = cap.begin_date.isoformat() if cap.begin_date else "(missing)" end = cap.end_date.isoformat() if cap.end_date else "(missing)" return f"{begin} .. {end}" _CAP_TABLE_HEADERS = [ "capacity_id", "Owner/Team", "Role", "Competences", "Availability", ] @mcp.tool() def list_free_capacities(limit: int = 20) -> str: """List most recent free capacities (creation_date DESC).""" _ensure_db() caps = db_client.get_recent_free_capacities(limit=int(limit)) rows: list[list[str]] = [] for cap in caps: competences = ", ".join(cap.competences) if cap.competences else "(none)" rows.append( [ str(cap.id), str(cap.owner_name), str(cap.role_name or ""), competences, _format_capacity_availability(cap), ] ) if not rows: rows = [["", "", "", "", ""]] return md_table(_CAP_TABLE_HEADERS, rows) @mcp.tool() def get_capacity_details(capacity_id: int | str) -> str: """Show one capacity as a table plus next steps.""" _ensure_db() cap = db_client.get_capacity_by_id(capacity_id) if cap is None: return f"Capacity not found: {capacity_id}" competences = ", ".join(cap.competences) if cap.competences else "(none)" table = md_table( _CAP_TABLE_HEADERS, [ [ str(cap.id), str(cap.owner_name), str(cap.role_name or ""), competences, _format_capacity_availability(cap), ] ], ) # Fetch enrichment data description = db_client.get_capacity_description(capacity_id) references = db_client.get_capacity_references(capacity_id) certificates = db_client.get_capacity_certificates(capacity_id) # Format Beschreibung section if description: beschreibung_section = f"## Beschreibung\n\n{description}" else: beschreibung_section = "## Beschreibung\n\nBeschreibung: (keine)" # Format Referenzen section if references: ref_lines = ["## Referenzen", ""] for ref in references: partner = ref["partner_name"] projects = ref["projects"] if partner: ref_lines.append(f"- **{partner}**: {projects}") else: ref_lines.append(f"- {projects}") referenzen_section = "\n".join(ref_lines) else: referenzen_section = "## Referenzen\n\nReferenzen: (keine)" # Format Zertifizierungen section if certificates: cert_lines = ["## Zertifizierungen", ""] for cert in certificates: cert_lines.append(f"- {cert}") zertifizierungen_section = "\n".join(cert_lines) else: zertifizierungen_section = "## Zertifizierungen\n\nZertifizierungen: (keine)" next_steps = "\n".join( [ "## Next steps", "", ( "Call find_matching_tasks(capacity_id=...) to see matching open tasks." ), ] ) return "\n\n".join([ table, beschreibung_section, referenzen_section, zertifizierungen_section, next_steps, ]) _TEAM_TABLE_HEADERS = [ "Team Id", "Team Name", "Schwerpunkt", "Anzahl Kompetenzen", "Anzahl Referenzen", ] def _team_summary_row(team: Team) -> list[str]: return [ str(team.team_id), str(team.team_name), str(team.focus_name or ""), str(len(team.competences)), str(len(team.references)), ] @mcp.tool() def list_teams(limit: int = 20) -> str: """List teams with their summary fields as a Markdown table. Columns: ``Team Id``, ``Team Name``, ``Schwerpunkt``, ``Anzahl Kompetenzen``, ``Anzahl Referenzen``. The ordering follows the DB-side ordering used by ``DBClient.get_all_teams`` (typically team name ascending). The result is sliced to ``limit`` items. See spec: team-profile-matching, requirement 9.1. """ teams = _get_teams_cached() try: n = int(limit) except (TypeError, ValueError): n = 20 if n < 0: n = 0 rows: list[list[str]] = [_team_summary_row(t) for t in teams[:n]] if not rows: rows = [["", "", "", "", ""]] return md_table(_TEAM_TABLE_HEADERS, rows) @mcp.tool() def get_team_details(team_id: str) -> str: """Show one team as a table plus description sections and next steps. Layout: * Markdown summary table (same columns as ``list_teams``). * ``## Über uns`` with ``team.about_us``. * ``## Leistungen`` with ``team.offerings``. * ``## Interessen`` with ``team.interests``. * ``## Kompetenzen`` as a bullet list. Top competences are marked with the suffix `` (Top)``. * ``## Referenzen`` as a bullet list. Entries with a non-empty ``partner_name`` are rendered as ``****: ``; entries without a partner are rendered as just ```` (no placeholder). * ``## Next steps`` hint. The ordering of competences and references follows the DB-side ordering returned by ``DBClient.get_team_by_id`` and is preserved here (no re-sorting). See spec: team-profile-matching, requirements 9.2, 9.3, 9.4. """ _ensure_db() team = db_client.get_team_by_id(str(team_id)) if team is None: return f"Team not found: {team_id}" table = md_table(_TEAM_TABLE_HEADERS, [_team_summary_row(team)]) # Description sections. about_us_section = ( f"## Über uns\n\n{team.about_us}" if team.about_us else "## Über uns\n\n(keine)" ) offerings_section = ( f"## Leistungen\n\n{team.offerings}" if team.offerings else "## Leistungen\n\n(keine)" ) interests_section = ( f"## Interessen\n\n{team.interests}" if team.interests else "## Interessen\n\n(keine)" ) # Kompetenzen section: bullet list with ``(Top)`` marker for top # competences. Order preserved. if team.competences: comp_lines = ["## Kompetenzen", ""] for comp in team.competences: if comp.top_competency: comp_lines.append(f"- {comp.name} (Top)") else: comp_lines.append(f"- {comp.name}") kompetenzen_section = "\n".join(comp_lines) else: kompetenzen_section = "## Kompetenzen\n\nKompetenzen: (keine)" # Referenzen section: bullet list. Bold partner name with projects; # if partner_name is empty, render only projects (no placeholder). if team.references: ref_lines = ["## Referenzen", ""] for ref in team.references: if ref.partner_name: ref_lines.append(f"- **{ref.partner_name}**: {ref.projects}") else: ref_lines.append(f"- {ref.projects}") referenzen_section = "\n".join(ref_lines) else: referenzen_section = "## Referenzen\n\nReferenzen: (keine)" next_steps = "\n".join( [ "## Next steps", "", ( "Call find_matching_teams(role_name=..., competences=[...]) " "to find similar teams or matching tasks for this team's " "profile." ), ] ) return "\n\n".join( [ table, about_us_section, offerings_section, interests_section, kompetenzen_section, referenzen_section, next_steps, ] ) def _category_for(score: float) -> str: score = max(0.0, min(1.0, float(score))) if score >= cfg.matching.thresholds.top: return "Top" if score >= cfg.matching.thresholds.good: return "Good" if score >= cfg.matching.thresholds.partial: return "Partial" if score >= 0.1: return "Low" return "Irrelevant" def _score_capacity_to_task( *, cap_role: str | None, cap_competences: list[str], task_role: str | None, task_competences: list[str], ) -> tuple[float, float, float]: """Return (role_score, competence_score, overall_score).""" role_score = ( 1.0 if cap_role and task_role and str(cap_role) == str(task_role) else 0.0 ) cap_set = { c.strip().lower() for c in (cap_competences or []) if c and c.strip() } task_set = { c.strip().lower() for c in (task_competences or []) if c and c.strip() } if not task_set: comp_score = 0.0 else: comp_score = len(cap_set.intersection(task_set)) / float(len(task_set)) overall = float(cfg.matching.role_weight) * float(role_score) + float( cfg.matching.competence_weight ) * float(comp_score) overall = max(0.0, min(1.0, float(overall))) return float(role_score), float(comp_score), float(overall) @mcp.tool() async def find_matching_tasks( capacity_id: int | str, matching_method: str = "", ) -> str: """Find matching tasks for a capacity (capacity→task direction). This tool performs a **task search** ("task_search"): - Input is a `capacity_id`. - Output is a list of open tasks, either scored (``score`` mode) or LLM-categorised (``llm_fulltext`` mode). Matching methods (parameter ``matching_method``): - ``"score"`` (default unless overridden in config): Numeric scoring with split **Role Score**, **Competence Score**, and **Overall Score**. - ``"llm_fulltext"``: An LLM evaluates each task's full-text profile against the capacity's full-text profile (description, competences, references, certificates) and returns a category plus a short German rationale. Output schema differences: - In ``score`` mode the result table includes the columns ``Role Score``, ``Competence Score``, ``Overall Score`` and ``Category`` (unchanged behaviour). - In ``llm_fulltext`` mode there are **no numeric score columns**; instead the table includes a ``Category`` column and a ``Begründung`` column with the LLM rationale (truncated for the table; the persisted payload keeps the rationale ungekürzt). Categories in both modes: - Results are grouped into ``Top``, ``Good``, ``Partial``, ``Low``, ``Irrelevant``. Args: capacity_id: Capacity identifier to match against. matching_method: Optional matching method. Allowed values: ``"score"`` (default) and ``"llm_fulltext"``. Next steps: Use `filter_search_results()` to refine results (task_text_filter, task_competence_filter, availability filters), then page/browse with `get_results_by_category()`. """ # Validate matching_method up-front, before any DB or LLM call # (spec: requirements 1.1, 1.5). try: method = _resolve_matching_method(matching_method) except ValueError as exc: return ( f"Error: {exc}\n\n" "Allowed values for `matching_method`: 'score', 'llm_fulltext'." ) _ensure_db() cap = db_client.get_capacity_by_id(capacity_id) if cap is None: return f"Capacity not found: {capacity_id}" tasks = db_client.get_open_tasks(limit=0) ref_start = cap.begin_date ref_end = cap.end_date errors_list: list[dict[str, Any]] = [] if method == "llm_fulltext": description = db_client.get_capacity_description(cap.id) certificates = db_client.get_capacity_certificates(cap.id) references = db_client.get_capacity_references(cap.id) capacity_profile = build_capacity_profile( cap, description=description, certificates=certificates, references=references, ) llm_result = await llm_fulltext_matcher.match_tasks( capacity_profile=capacity_profile, tasks=tasks, ) by_cat: dict[str, list[dict[str, Any]]] = { "Top": [], "Good": [], "Partial": [], "Low": [], "Irrelevant": [], } for cat, items in llm_result.by_category.items(): lst: list[dict[str, Any]] = [] for it in items: merged = dict(it.raw) # ``asdict(task)`` leaves ``start_date``/``end_date`` as # ``date`` objects. Persist them as ISO strings so the # downstream renderer (``parse_iso_date``) and the JSON # cache treat them like the score-mode payload. for date_field in ("start_date", "end_date"): val = merged.get(date_field) if hasattr(val, "isoformat"): merged[date_field] = val.isoformat() # Task.id from asdict; preserve also as task_id for table merged.setdefault("task_id", merged.get("id", "")) # Reuse skills as required_competences for the LLM-mode # table (no separate competence inference in this mode). merged.setdefault( "required_competences", merged.get("skills", []) ) merged["category"] = it.category merged["rationale"] = it.rationale lst.append(merged) by_cat[cat] = lst errors_list = [ {"item_id": e.item_id, "error": e.error} for e in llm_result.errors ] results_payload: dict[str, Any] = { "search_type": "task_search", "matching_method": "llm_fulltext", "reference": { "availability_date_start": ( ref_start.isoformat() if ref_start else None ), "availability_date_end": ( ref_end.isoformat() if ref_end else None ), }, "summary": {k: len(by_cat.get(k, [])) for k in by_cat}, "by_category": by_cat, "errors": errors_list, } else: scored: list[dict[str, Any]] = [] for t in tasks: full_text = _task_text_full(t.title, t.description) inferred_role: str | None = None inferred_comp_names: list[str] = [] # Role inference is title-first with description fallback (LLM-based). role_text = _task_role_text(t.title, t.description) if role_text: best_role = await vocab_cache.infer_primary_role( task_text=role_text ) inferred_role = best_role[0] if best_role else None if full_text: # Competence inference via LLM with fallback to DB skills. inferred_comp_tuples = await vocab_cache.infer_competences(task_text=full_text) inferred_comp_names = [name for name, _conf in inferred_comp_tuples] if not inferred_comp_names: inferred_comp_names = [str(x) for x in (getattr(t, "skills", None) or []) if x] role_score, comp_score, overall = _score_capacity_to_task( cap_role=cap.role_name, cap_competences=cap.competences, task_role=inferred_role, task_competences=inferred_comp_names, ) scored.append( { "task_id": t.id, "title": t.title, "description": t.description, "skills": [str(x) for x in (getattr(t, "skills", None) or [])], "required_competences": inferred_comp_names, "start_date": (t.start_date.isoformat() if t.start_date else None), "end_date": (t.end_date.isoformat() if t.end_date else None), "role": inferred_role, "role_score": float(role_score), "competence_score": float(comp_score), "overall_score": float(overall), "category": _category_for(float(overall)), } ) scored.sort(key=lambda x: float(x.get("overall_score", 0.0)), reverse=True) by_cat = { "Top": [], "Good": [], "Partial": [], "Low": [], "Irrelevant": [], } for item in scored: by_cat.setdefault(str(item.get("category") or "Low"), []).append(item) results_payload = { "search_type": "task_search", "matching_method": "score", "reference": { "availability_date_start": ( ref_start.isoformat() if ref_start else None ), "availability_date_end": (ref_end.isoformat() if ref_end else None), }, "summary": {k: len(by_cat.get(k, [])) for k in by_cat}, "by_category": by_cat, } search_id = search_cache.store_search( task_id=None, requirements={"capacity_id": str(cap.id)}, results=results_payload, ) session.last_search_id = search_id summary_rows = [ [k, str(results_payload["summary"].get(k, 0))] for k in ("Top", "Good", "Partial", "Low", "Irrelevant") ] summary_table = md_table(["Category", "Count"], summary_rows) shown_category = "Top" for cat in ("Top", "Good", "Partial", "Low", "Irrelevant"): if by_cat.get(cat): shown_category = cat break shown_items = list(by_cat.get(shown_category, []))[:10] if method == "llm_fulltext": results_table = _format_results_table( shown_items, search_type="task_search", matching_method="llm_fulltext", ref_start=ref_start, ref_end=ref_end, ) else: shown_rows: list[list[str]] = [] for it in shown_items: avail = _calculate_overlap_percentage( ref_start=ref_start, ref_end=ref_end, other_start=parse_iso_date(it.get("start_date")), other_end=parse_iso_date(it.get("end_date")), ) shown_rows.append( [ str(it.get("task_id", "")), str(it.get("title", "")), ", ".join([str(x) for x in (it.get("required_competences") or [])]), avail, f"{float(it.get('role_score', 0.0)):.3f}", f"{float(it.get('competence_score', 0.0)):.3f}", f"{float(it.get('overall_score', 0.0)):.3f}", str(it.get("category", "")), ] ) if not shown_rows: shown_rows = [["", "", "", "", "", "", "", ""]] results_table = md_table( [ "task_id", "Title", "Required Competences", "Availability", "Role Score", "Competence Score", "Overall Score", "Category", ], shown_rows, ) meta = { "search_id": search_id, "filter_id": None, "default_category": shown_category, "matching_method": method, } meta_json = __import__("json").dumps(meta, ensure_ascii=False) parts = [ f"Using SEARCH_ID={search_id}", f"SEARCH_ID={search_id}", f"META={meta_json}", f"search_id: `{search_id}`", "", "## Summary", summary_table, "", f"## {shown_category} Results", results_table, ] if method == "llm_fulltext" and errors_list: err_rows = [[e["item_id"], e["error"]] for e in errors_list] err_table = md_table(["item_id", "error"], err_rows) parts.append("") parts.append("## Errors") parts.append(err_table) return "\n".join(parts) return mcp