Files
Orchestrator/bahn/teamlandkarte-mcp/src/teamlandkarte_mcp/mcp_server.py
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

3243 lines
117 KiB
Python

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 ``**<partner>**: <projects>``;
entries without a partner are rendered as just ``<projects>``
(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