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.
3243 lines
117 KiB
Python
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
|