Migrate all repos into monorepo context folders

Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
@@ -0,0 +1 @@
"""Teamlandkarte MCP server package."""
@@ -0,0 +1,66 @@
from __future__ import annotations
import argparse
import logging
import sys
import teamlandkarte_mcp.logging_config as logging_config
from teamlandkarte_mcp.config import ConfigError
from teamlandkarte_mcp.mcp_server import build_server
logger = logging.getLogger(__name__)
def _parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse CLI arguments.
Args:
argv: Argument list excluding the program name.
Returns:
Parsed argparse namespace.
"""
parser = argparse.ArgumentParser(prog="teamlandkarte-mcp")
parser.add_argument(
"--config",
default="config.toml",
help="Path to TOML config file (default: config.toml)",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Python logging level (default: INFO)",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> None:
"""Run the MCP server (stdio)."""
args = _parse_args(argv or sys.argv[1:])
logging_config.configure_logging(logging_config.LoggingConfig(level=args.log_level))
try:
mcp = build_server(config_path=args.config)
except ConfigError as exc:
# Stderr only; stdout must remain valid JSON-RPC for MCP.
logger.error(
"Azure OpenAI credentials not found in environment. "
"Ensure AZURE_OPENAI_LLM_API_KEY is set and "
"config.toml has a valid [azure_openai] section. "
"(details: %s)",
exc,
)
raise SystemExit(2) from exc
try:
mcp.run()
except KeyboardInterrupt:
# Keep shutdown silent for stdio use.
return
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""Azure integration modules (OpenAI client, errors, optional cost tracking)."""
@@ -0,0 +1,83 @@
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
LOGGER = logging.getLogger(__name__)
@dataclass
class CostSnapshot:
llm_requests: int = 0
llm_input_tokens: int = 0
llm_output_tokens: int = 0
class CostTracker:
"""Best-effort session cost tracker.
Notes:
This is intentionally approximate. Token counts are taken from the
Azure/OpenAI response if available. If not available, the request is
counted but token totals stay unchanged.
Pricing varies by region and contract. Constants below default to 0.0
so enabling this feature never misleads by accident.
"""
# USD per 1K tokens (set to 0.0 by default; customize if desired).
LLM_INPUT_USD_PER_1K_TOKENS: float = 0.0
LLM_OUTPUT_USD_PER_1K_TOKENS: float = 0.0
def __init__(
self,
*,
log_every_n_calls: int = 10,
log_every_seconds: float = 300.0,
) -> None:
self._snap = CostSnapshot()
self._calls_since_log = 0
self._log_every_n = max(1, int(log_every_n_calls))
self._log_every_s = max(1.0, float(log_every_seconds))
self._last_log_ts = time.time()
def log_llm_request(self, *, input_tokens: int, output_tokens: int) -> None:
self._snap.llm_requests += 1
self._snap.llm_input_tokens += max(0, int(input_tokens))
self._snap.llm_output_tokens += max(0, int(output_tokens))
self._bump_and_maybe_log()
def get_session_costs(self) -> dict[str, float | int]:
llm_in_cost = (self._snap.llm_input_tokens / 1000.0) * float(
self.LLM_INPUT_USD_PER_1K_TOKENS
)
llm_out_cost = (self._snap.llm_output_tokens / 1000.0) * float(
self.LLM_OUTPUT_USD_PER_1K_TOKENS
)
return {
"llm_requests": self._snap.llm_requests,
"llm_input_tokens": self._snap.llm_input_tokens,
"llm_output_tokens": self._snap.llm_output_tokens,
"llm_usd": float(llm_in_cost + llm_out_cost),
"total_usd": float(llm_in_cost + llm_out_cost),
}
def _bump_and_maybe_log(self) -> None:
self._calls_since_log += 1
now = time.time()
if (
self._calls_since_log < self._log_every_n
and (now - self._last_log_ts) < self._log_every_s
):
return
self._calls_since_log = 0
self._last_log_ts = now
costs = self.get_session_costs()
LOGGER.info(
"Azure OpenAI costs this session: llm=$%.4f, total=$%.4f",
float(costs["llm_usd"]),
float(costs["total_usd"]),
)
@@ -0,0 +1,100 @@
import asyncio
import logging
from typing import Optional
import httpx
from openai import AsyncAzureOpenAI
from teamlandkarte_mcp.azure.cost_tracker import CostTracker
LOGGER = logging.getLogger(__name__)
class AzureAPIError(RuntimeError):
"""Raised when Azure OpenAI calls fail."""
class AzureOpenAIClient:
"""Azure OpenAI Chat Completion Client.
This wrapper exists to:
* centralize error semantics (``AzureAPIError``)
* keep deterministic request settings
* provide a single place for retry/backoff
"""
def __init__(
self,
*,
endpoint: str,
api_version: str,
chat_deployment: str,
llm_api_key: str,
timeout_s: float = 30.0,
max_retries: int = 5,
cost_tracker: Optional[CostTracker] = None,
verify_ssl: bool = True,
) -> None:
self._chat_deployment = chat_deployment
self._max_retries = max_retries
self._timeout_s = timeout_s
self._cost_tracker = cost_tracker
client_kwargs: dict = dict(
api_key=llm_api_key,
azure_endpoint=endpoint,
api_version=api_version,
)
if not verify_ssl:
http_client = httpx.AsyncClient(verify=False)
client_kwargs["http_client"] = http_client
self._chat = AsyncAzureOpenAI(**client_kwargs)
async def chat_completion(self, system: str, user: str) -> str:
"""Call the Azure OpenAI chat completions API.
Uses ``response_format={"type": "json_object"}`` to enforce structured
JSON output. The caller is responsible for parsing the returned string.
Args:
system: System prompt text.
user: User message text.
Returns:
Raw JSON string from the first choice's message content.
Raises:
AzureAPIError: If the API call fails after all retries.
"""
last_exc: Exception | None = None
for attempt in range(self._max_retries):
try:
resp = await asyncio.wait_for(
self._chat.chat.completions.create(
model=self._chat_deployment,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
response_format={"type": "json_object"},
),
timeout=self._timeout_s,
)
content = resp.choices[0].message.content or ""
return content
except asyncio.TimeoutError as e:
last_exc = e
if attempt == self._max_retries - 1:
break
await asyncio.sleep(min(8.0, 0.5 * (2**attempt)))
except RuntimeError as e:
last_exc = e
if attempt == self._max_retries - 1:
break
await asyncio.sleep(min(8.0, 0.5 * (2**attempt)))
raise AzureAPIError(
f"Azure chat completion call failed: {last_exc}"
) from last_exc
@@ -0,0 +1,49 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Generic, TypeVar
from cachetools import TTLCache
T = TypeVar("T")
class QueryCache(Generic[T]):
"""TTL cache wrapper for expensive database queries.
The cache is intended for large, read-only DB queries (e.g. all capacities)
where results can be reused for multiple MCP tool calls.
Attributes:
stats: Cache hit/miss counters.
"""
def __init__(self, ttl_hours: int, max_size: int):
"""Initialize the cache.
Args:
ttl_hours: Time-to-live in hours.
max_size: Maximum number of cache entries.
"""
self._cache: TTLCache[str, T] = TTLCache(maxsize=max_size, ttl=ttl_hours * 3600)
self.stats: dict[str, int] = {"hits": 0, "misses": 0}
def get_or_fetch(self, key: str, fetch_fn: Callable[[], T]) -> T:
"""Return cached value for key or populate it using fetch_fn.
Args:
key: Cache key.
fetch_fn: Function that returns the value to cache.
Returns:
The cached (or freshly fetched) value.
"""
if key in self._cache:
self.stats["hits"] += 1
return self._cache[key]
self.stats["misses"] += 1
value = fetch_fn()
self._cache[key] = value
return value
@@ -0,0 +1,183 @@
from __future__ import annotations
import threading
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Optional
from cachetools import TTLCache
@dataclass
class SearchEntry:
"""In-memory stored search result payload.
Attributes:
created_at: UTC ISO timestamp.
task_id: Optional originating task id (DB workflow) or None (ad-hoc).
requirements: Structured requirements used for matching.
results: Full search results grouped by category.
filters: Map of filter_id -> {meta, results}.
next_filter_counter: Monotonic counter used to produce filter ids.
"""
created_at: str
task_id: Optional[str]
requirements: dict[str, Any]
results: dict[str, Any]
filters: dict[str, dict[str, Any]]
next_filter_counter: int
class SearchCache:
"""TTL cache storing search results and derived filters.
Search results are stored under a `search_id`. Any subsequent filtering
must return a `filter_id` while keeping the original `search_id`.
All operations are thread-safe via an internal re-entrant lock.
Attributes:
stats: Cache hit/miss counters.
"""
def __init__(
self,
ttl_minutes: int,
max_size: int,
*,
enable_diagnostics: bool = False,
):
"""Initialize the search cache.
Args:
ttl_minutes: Time-to-live in minutes.
max_size: Maximum number of cache entries.
enable_diagnostics: If true, track additional metadata helpful when
investigating cache misses/evictions.
"""
self._ttl_seconds = ttl_minutes * 60
self._cache: TTLCache[str, SearchEntry] = TTLCache(
maxsize=max_size, ttl=self._ttl_seconds
)
self.stats: dict[str, int] = {"hits": 0, "misses": 0}
self._lock = threading.RLock()
self.instance_id = str(uuid.uuid4())
self._diagnostics_enabled = bool(enable_diagnostics)
self._inserted_at: dict[str, float] = {}
def store_search(
self,
*,
task_id: Optional[str],
requirements: dict[str, Any],
results: dict[str, Any],
) -> str:
"""Store a new search entry and return a new search_id.
Args:
task_id: Optional task id (DB workflow) or None (ad-hoc workflow).
requirements: Structured requirements used for the search.
results: Full results payload.
Returns:
The generated search id (UUID string).
"""
with self._lock:
search_id = str(uuid.uuid4())
self._cache[search_id] = SearchEntry(
created_at=datetime.now(timezone.utc).isoformat(),
task_id=task_id,
requirements=requirements,
results=results,
filters={},
next_filter_counter=1,
)
if self._diagnostics_enabled:
now_ts = datetime.now(timezone.utc).timestamp()
self._inserted_at[search_id] = now_ts
return search_id
def get(self, search_id: str) -> Optional[SearchEntry]:
"""Retrieve a search entry.
Args:
search_id: Search id returned by `store_search`.
Returns:
The `SearchEntry` if found and not expired, else None.
"""
with self._lock:
entry = self._cache.get(search_id)
if entry is None:
self.stats["misses"] += 1
return None
self.stats["hits"] += 1
return entry
def diagnostics(self) -> dict[str, Any]:
"""Return a small diagnostics snapshot (safe for logs).
This is intended for unit tests and troubleshooting.
"""
with self._lock:
oldest_age_s: Optional[float] = None
if self._diagnostics_enabled and self._inserted_at:
now = datetime.now(timezone.utc).timestamp()
oldest_age_s = max(0.0, now - min(self._inserted_at.values()))
return {
"instance_id": self.instance_id,
"ttl_seconds": self._ttl_seconds,
"max_size": self._cache.maxsize,
"size": len(self._cache),
"hits": self.stats.get("hits", 0),
"misses": self.stats.get("misses", 0),
"oldest_age_seconds": oldest_age_s,
"diagnostics_enabled": self._diagnostics_enabled,
}
def _drop_diagnostics_for_missing_keys(self) -> None:
if not self._diagnostics_enabled:
return
keys = set(self._cache.keys())
for k in list(self._inserted_at.keys()):
if k not in keys:
self._inserted_at.pop(k, None)
def add_filter(
self,
search_id: str,
filtered_results: dict[str, Any],
filter_meta: dict[str, Any],
) -> str:
"""Attach filtered results to an existing search and return filter_id.
Args:
search_id: Id of the original search.
filtered_results: Results payload after filtering.
filter_meta: Filter parameters used (for display/debugging).
Returns:
The filter id (e.g. "filter-1").
Raises:
KeyError: If the search id is unknown or expired.
"""
with self._lock:
entry = self._cache.get(search_id)
if entry is None:
if self._diagnostics_enabled:
self._drop_diagnostics_for_missing_keys()
raise KeyError(search_id)
filter_id = f"filter-{entry.next_filter_counter}"
entry.next_filter_counter += 1
entry.filters[filter_id] = {
"meta": filter_meta,
"results": filtered_results,
}
return filter_id
@@ -0,0 +1,414 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import os
from dotenv import load_dotenv
class ConfigError(RuntimeError):
"""Raised when configuration is missing or invalid."""
@dataclass(frozen=True)
class DatabaseConfig:
"""Database connection configuration.
Attributes:
host: Database host.
port: Database port.
username: Login name.
password: Login password.
backend: Backend identifier (must be "trino").
http_scheme: Trino http scheme ("http" or "https").
verify_ssl: Whether to verify TLS certificates (Trino).
catalog: Trino catalog.
schema: Trino schema.
connect_timeout: Optional connect timeout (seconds).
pool_size: Maximum number of pooled DB connections.
"""
host: str
port: int
username: str
password: str
# Connection backend. DBeaver "PrestoSQL" corresponds to Trino/Presto.
backend: str = "trino" # only "trino"
# Trino/Presto settings
http_scheme: str = "https" # "http" | "https"
verify_ssl: bool = True
catalog: str = "hive"
schema: str = "tier1_open_lake"
connect_timeout: int = 10
pool_size: int = 4
@dataclass(frozen=True)
class MatchingThresholds:
"""Thresholds used to bucket scores into categories."""
top: float = 0.8
good: float = 0.6
partial: float = 0.4
low: float = 0.1
@dataclass(frozen=True)
class FuzzyConfig:
"""Fuzzy matching configuration for filters."""
min_similarity: float = 0.7
@dataclass(frozen=True)
class TeamMatchingConfig:
"""Team-specific matching configuration.
Attributes:
top_competency_weight: Multiplier applied to competence similarity
when the best-matching team competence is flagged as a top
competency. Must be a number greater than or equal to ``1.0``.
Default ``1.5``.
"""
top_competency_weight: float = 1.5
@dataclass(frozen=True)
class MatchingConfig:
"""Matching weights and thresholds.
Attributes:
competence_weight: Weight applied to competence similarity.
role_weight: Weight applied to role similarity.
require_confirmation: Hard-gate matching until the user confirms
extracted/collected requirements. If set to False, the server
auto-skips confirmation.
thresholds: Score thresholds used to bucket results into categories.
fuzzy: Fuzzy matching configuration for filters.
default_method: Default matching method used when callers do not
pass ``matching_method``. Allowed values: ``"score"`` (BM25 +
LLM role similarity) or ``"llm_fulltext"`` (LLM-based full-text
matching with rationale).
team: Team-specific matching configuration (e.g. top-competency
weight used by ``find_matching_teams``).
"""
competence_weight: float = 0.8
role_weight: float = 0.2
# Hard-gate matching until the user confirms extracted/collected
# requirements. If set to False, the server auto-skips confirmation.
require_confirmation: bool = True
thresholds: MatchingThresholds = MatchingThresholds()
fuzzy: FuzzyConfig = FuzzyConfig()
default_method: str = "score"
team: TeamMatchingConfig = TeamMatchingConfig()
@dataclass(frozen=True)
class CacheConfig:
"""Caching configuration for DB and search result caches."""
db_ttl_hours: int = 12
search_ttl_minutes: int = 60
max_size: int = 100
@dataclass(frozen=True)
class AzureOpenAIConfig:
"""Azure OpenAI configuration (nur Chat Completion).
Notes:
Chat/LLM API key is read from environment variable:
``AZURE_OPENAI_LLM_API_KEY`` (always required).
"""
endpoint: str
api_version: str = "2024-02-15-preview"
chat_deployment: str = ""
llm_api_key: str = ""
show_costs_in_output: bool = False
verify_ssl: bool = True
max_concurrency: int = 5
@dataclass(frozen=True)
class SimilarityConfig:
"""Similarity engine configuration."""
use_auto_tagging: bool = False
@dataclass(frozen=True)
class AppConfig:
"""Top-level application configuration loaded from `config.toml`."""
database: DatabaseConfig
matching: MatchingConfig
cache: CacheConfig
azure_openai: AzureOpenAIConfig
similarity: SimilarityConfig
try:
import tomllib # Python 3.11+
except ModuleNotFoundError: # pragma: no cover
import tomli as tomllib # type: ignore
def _require(mapping: dict[str, Any], key: str, ctx: str) -> Any:
"""Read a required key from a mapping.
Args:
mapping: Source mapping.
key: Key that must exist.
ctx: Human-readable context used in error messages.
Returns:
The value stored under `key`.
Raises:
ConfigError: If the key is missing.
"""
if key not in mapping:
raise ConfigError(f"Missing required config key: {ctx}.{key}")
return mapping[key]
def _parse_azure_openai(cfg: dict) -> AzureOpenAIConfig:
"""Parse Azure OpenAI configuration."""
max_concurrency = int(cfg.get("max_concurrency", 5))
if max_concurrency < 1:
raise ConfigError(
"azure_openai.max_concurrency must be >= 1, "
f"got {max_concurrency}"
)
if max_concurrency > 50:
raise ConfigError(
"azure_openai.max_concurrency must be <= 50, "
f"got {max_concurrency}"
)
return AzureOpenAIConfig(
endpoint=str(cfg.get("endpoint") or "").strip(),
api_version=str(cfg.get("api_version") or "").strip(),
show_costs_in_output=bool(cfg.get("show_costs_in_output", False)),
chat_deployment=str(cfg.get("chat_deployment") or "").strip(),
verify_ssl=bool(cfg.get("verify_ssl", True)),
max_concurrency=max_concurrency,
# llm_api_key is resolved from env in load_config, not TOML
)
def load_config(
path: str | Path = "config.toml",
) -> AppConfig:
"""Load configuration from TOML.
Args:
path: Path to a TOML config file. Defaults to `config.toml`.
Returns:
Fully parsed `AppConfig`.
Raises:
ConfigError: If the file is missing or contains invalid values.
"""
load_dotenv(override=False)
path = Path(path)
if not path.exists():
raise ConfigError(
"Missing config.toml. Copy config.toml.example to config.toml and "
"fill in settings."
)
raw = tomllib.loads(path.read_text(encoding="utf-8"))
# --- database (legacy + new) ---
db_raw = _require(raw, "database", "root")
backend = str(db_raw.get("backend", "trino"))
if backend.strip().lower() != "trino":
raise ConfigError(
f"Only database.backend='trino' is supported (got {backend!r})."
)
username = (os.getenv("DATA_LAKE_USERNAME") or "").strip()
password = (os.getenv("DATA_LAKE_PASSWORD") or "").strip()
if not username or not password:
raise ConfigError(
"Missing database credentials. Set DATA_LAKE_USERNAME and "
"DATA_LAKE_PASSWORD (e.g. in .env)."
)
database = DatabaseConfig(
host=str(_require(db_raw, "host", "database")),
port=int(_require(db_raw, "port", "database")),
username=username,
password=password,
backend="trino",
http_scheme=str(db_raw.get("http_scheme", "https")),
verify_ssl=bool(db_raw.get("verify_ssl", True)),
catalog=str(db_raw.get("catalog", "hive")),
schema=str(db_raw.get("schema", "tier1_open_lake")),
connect_timeout=int(db_raw.get("connect_timeout", 10)),
pool_size=int(db_raw.get("pool_size", 4)),
)
# --- matching ---
matching_raw = raw.get("matching", {})
thresholds_raw = matching_raw.get("thresholds", {})
fuzzy_raw = matching_raw.get("fuzzy", {})
default_method_raw = matching_raw.get("default_method")
default_method = (
str(default_method_raw).strip().lower()
if default_method_raw is not None
else "score"
)
if default_method not in ("score", "llm_fulltext"):
raise ConfigError(
"matching.default_method must be one of "
"['score', 'llm_fulltext'], "
f"got: {default_method_raw!r}"
)
team_raw = matching_raw.get("team", {}) or {}
if not isinstance(team_raw, dict):
raise ConfigError(
"matching.team must be a table/mapping, "
f"got {team_raw!r}"
)
top_w_raw = team_raw.get("top_competency_weight", 1.5)
if isinstance(top_w_raw, bool):
# Avoid bool-as-int coercion (True/False -> 1.0/0.0).
raise ConfigError(
"matching.team.top_competency_weight must be a number, "
f"got {top_w_raw!r}"
)
try:
top_competency_weight = float(top_w_raw)
except (TypeError, ValueError) as exc:
raise ConfigError(
"matching.team.top_competency_weight must be a number, "
f"got {top_w_raw!r}"
) from exc
if top_competency_weight < 1.0:
raise ConfigError(
"matching.team.top_competency_weight must be >= 1.0, "
f"got {top_competency_weight}"
)
matching = MatchingConfig(
competence_weight=float(matching_raw.get("competence_weight", 0.8)),
role_weight=float(matching_raw.get("role_weight", 0.2)),
require_confirmation=bool(
matching_raw.get("require_confirmation", True)
),
thresholds=MatchingThresholds(
top=float(thresholds_raw.get("top", 0.8)),
good=float(thresholds_raw.get("good", 0.6)),
partial=float(thresholds_raw.get("partial", 0.4)),
low=float(thresholds_raw.get("low", 0.1)),
),
fuzzy=FuzzyConfig(
min_similarity=float(fuzzy_raw.get("min_similarity", 0.7))
),
default_method=default_method,
team=TeamMatchingConfig(
top_competency_weight=top_competency_weight,
),
)
if matching.competence_weight < 0 or matching.role_weight < 0:
raise ConfigError("matching weights must be non-negative")
weight_sum = matching.competence_weight + matching.role_weight
if abs(weight_sum - 1.0) > 1e-6:
raise ConfigError(
"matching weights must sum to 1.0 "
f"(got {weight_sum:.6f}: "
f"competence={matching.competence_weight}, "
f"role={matching.role_weight})"
)
# --- cache ---
cache_raw = raw.get("cache", {})
cache = CacheConfig(
db_ttl_hours=int(cache_raw.get("db_ttl_hours", 12)),
search_ttl_minutes=int(cache_raw.get("search_ttl_minutes", 60)),
max_size=int(cache_raw.get("max_size", 100)),
)
if (
cache.db_ttl_hours <= 0
or cache.search_ttl_minutes <= 0
or cache.max_size <= 0
):
raise ConfigError(
"cache DB/search TTLs and max_size must be positive integers"
)
# --- azure openai ---
aoai_raw = raw.get("azure_openai")
if not isinstance(aoai_raw, dict):
azure_openai = AzureOpenAIConfig(endpoint="")
else:
endpoint = str(
_require(aoai_raw, "endpoint", "azure_openai")
).strip()
if not endpoint:
raise ConfigError("azure_openai.endpoint must be non-empty")
azure_openai = _parse_azure_openai(aoai_raw)
# Resolve LLM API key from env; always required.
llm_api_key = (os.getenv("AZURE_OPENAI_LLM_API_KEY") or "").strip()
if not llm_api_key:
raise ConfigError(
"Missing AZURE_OPENAI_LLM_API_KEY environment variable."
)
# Validate chat_deployment is set.
if isinstance(aoai_raw, dict) and not azure_openai.chat_deployment:
raise ConfigError(
"azure_openai.chat_deployment must be non-empty."
)
# Rebuild azure_openai with resolved llm_api_key.
if isinstance(aoai_raw, dict):
azure_openai = AzureOpenAIConfig(
endpoint=azure_openai.endpoint,
api_version=azure_openai.api_version,
show_costs_in_output=azure_openai.show_costs_in_output,
chat_deployment=azure_openai.chat_deployment,
verify_ssl=azure_openai.verify_ssl,
max_concurrency=azure_openai.max_concurrency,
llm_api_key=llm_api_key,
)
# --- similarity ---
sim_raw = matching_raw.get("similarity", {})
use_auto_tagging = bool(sim_raw.get("use_auto_tagging", False))
similarity = SimilarityConfig(
use_auto_tagging=use_auto_tagging,
)
return AppConfig(
database=database,
matching=matching,
cache=cache,
azure_openai=azure_openai,
similarity=similarity,
)
@@ -0,0 +1,32 @@
from __future__ import annotations
from teamlandkarte_mcp.config import DatabaseConfig
from teamlandkarte_mcp.database.types import DBClient
from teamlandkarte_mcp.database.trino_client import TrinoClient
def create_db_client(cfg: DatabaseConfig) -> DBClient:
"""Create a database client.
The server supports Trino/Presto connectivity only.
Args:
cfg: Database configuration loaded from `config.toml`.
Returns:
A `TrinoClient` instance implementing `DBClient`.
Raises:
ValueError: If the configured backend is not "trino".
"""
backend = (cfg.backend or "trino").strip().lower()
if backend != "trino":
raise ValueError(
(
f"Unsupported database.backend: {cfg.backend!r}. "
"Only 'trino' is supported."
)
)
return TrinoClient(cfg)
@@ -0,0 +1,103 @@
from __future__ import annotations
import contextlib
import queue
import threading
from dataclasses import dataclass
from typing import Callable, Generic, Iterator, TypeVar
TConn = TypeVar("TConn")
@dataclass(frozen=True)
class PoolConfig:
"""Configuration for `ConnectionPool`.
Attributes:
max_size: Maximum number of live connections.
"""
max_size: int = 4
class PoolExhaustedError(RuntimeError):
"""Raised when a connection cannot be obtained from the pool."""
class ConnectionPool(Generic[TConn]):
"""A small, thread-safe connection pool.
This pool is intentionally minimal and synchronous. It provides:
- Bounded maximum number of connections
- Reuse of returned connections
- A context manager interface for safe acquire/release
The implementation avoids depending on driver-specific pooling behavior.
Args:
factory: Callable that creates a new connection.
max_size: Maximum number of live connections.
"""
def __init__(self, factory: Callable[[], TConn], *, max_size: int = 4):
if max_size <= 0:
raise ValueError("max_size must be positive")
self._factory = factory
self._max_size = max_size
self._lock = threading.Lock()
self._available: "queue.LifoQueue[TConn]" = queue.LifoQueue()
self._created = 0
@contextlib.contextmanager
def connection(self) -> Iterator[TConn]:
"""Acquire a connection from the pool.
Yields:
A DB connection.
Raises:
PoolExhaustedError: If max_size is reached and no connection is free.
"""
conn: TConn | None = None
try:
try:
conn = self._available.get_nowait()
except queue.Empty:
with self._lock:
if self._created < self._max_size:
conn = self._factory()
self._created += 1
if conn is None:
raise PoolExhaustedError(
f"Connection pool exhausted (max_size={self._max_size})"
)
yield conn
finally:
if conn is not None:
self._available.put(conn)
def closeall(self) -> None:
"""Close all currently available connections.
Note:
In-use connections cannot be safely closed from here; they will be
closed when returned only if the user calls `closeall()` after all
workers are done.
"""
while True:
try:
conn = self._available.get_nowait()
except queue.Empty:
return
try:
close = getattr(conn, "close", None)
if callable(close):
close() # type: ignore[misc]
except Exception:
# Best-effort close.
pass
@@ -0,0 +1,98 @@
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Any, Iterable
@dataclass(frozen=True)
class QueryLogEvent:
"""Structured query log event.
Attributes:
query_name: Short name for the operation (e.g. "get_open_tasks").
sql: SQL statement with parameters redacted/normalized.
params: Parameters (redacted where needed).
"""
query_name: str
sql: str
params: tuple[Any, ...] | None
_SECRET_KEYS = re.compile(r"(pass(word)?|secret|token|key)", re.IGNORECASE)
def _redact_value(value: Any) -> Any:
"""Redact a single value for logging.
Args:
value: Value to redact.
Returns:
Redacted value.
"""
if value is None:
return None
if isinstance(value, (int, float, bool)):
return value
s = str(value)
if len(s) <= 4:
return "***"
return s[:2] + "***" + s[-2:]
def redact_params(params: Iterable[Any] | None) -> tuple[Any, ...] | None:
"""Redact query parameters for safe logging.
Args:
params: DB-API parameter sequence.
Returns:
Redacted parameter tuple.
"""
if params is None:
return None
return tuple(_redact_value(p) for p in params)
def normalize_sql(sql: str) -> str:
"""Normalize SQL for logging.
This removes excessive whitespace to keep log lines readable.
Args:
sql: SQL statement.
Returns:
Normalized SQL.
"""
return " ".join(sql.split())
def log_query(
*,
logger: logging.Logger,
query_name: str,
sql: str,
params: Iterable[Any] | None = None,
) -> None:
"""Emit a safe query log line.
Args:
logger: Logger to use.
query_name: Short name for the operation.
sql: SQL statement.
params: DB-API parameters.
"""
event = QueryLogEvent(
query_name=query_name,
sql=normalize_sql(sql),
params=redact_params(params),
)
logger.debug("DB query: %s", event)
@@ -0,0 +1,34 @@
from __future__ import annotations
__all__ = [
"DatabaseError",
"ensure_select_only",
"_ensure_select_only",
]
class DatabaseError(RuntimeError):
"""Raised for database connectivity/query errors."""
def ensure_select_only(query: str) -> None:
"""Ensure a SQL query is read-only.
Args:
query: SQL string.
Raises:
DatabaseError: If the query does not appear to be a read-only SELECT.
Notes:
This is a conservative guard. It intentionally allows only statements
starting with SELECT or WITH (CTE) after stripping leading whitespace.
"""
q = query.strip().lower()
if not (q.startswith("select") or q.startswith("with")):
raise DatabaseError("Only SELECT queries are allowed")
# Backwards-compatible alias (older call sites use the underscore name).
_ensure_select_only = ensure_select_only
@@ -0,0 +1,69 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Protocol
class SchemaIntrospector(Protocol):
"""Minimal protocol for DB schema introspection used by this module."""
def get_table_columns(self, table: str) -> list[str]:
"""Return column names for a table."""
...
@dataclass(frozen=True)
class SchemaIssue:
"""A single schema verification issue."""
table: str
message: str
def _normalize(name: str) -> str:
return name.strip().lower()
def verify_required_columns(
*,
db: SchemaIntrospector,
expected: dict[str, set[str]],
logger: logging.Logger | None = None,
) -> list[SchemaIssue]:
"""Verify that required tables/columns exist in the configured database.
The verifier intentionally checks only for presence (not types), because
Trino/Hive may expose slightly different type names.
Args:
db: DB-like object with `get_table_columns(table)`.
expected: Mapping of table name to required column names.
logger: Optional logger.
Returns:
A list of schema issues. Empty list means the schema matches.
Raises:
AttributeError: If the provided DB object does not support schema
introspection.
"""
log = logger or logging.getLogger(__name__)
if not hasattr(db, "get_table_columns"):
raise AttributeError("DB client does not support get_table_columns")
issues: list[SchemaIssue] = []
for table, required_cols in expected.items():
table_columns = db.get_table_columns(table)
cols = {_normalize(c) for c in table_columns}
missing = sorted({_normalize(c) for c in required_cols} - cols)
if missing:
msg = f"missing columns: {', '.join(missing)}"
issues.append(SchemaIssue(table=table, message=msg))
log.warning("Schema verify: %s: %s", table, msg)
else:
log.info("Schema verify: %s: OK", table)
return issues
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,351 @@
from __future__ import annotations
# ruff: noqa: B018
from typing import Protocol, TypedDict
from teamlandkarte_mcp.models import Capacity, Task, Team
class CapacityReferenceRow(TypedDict):
"""One row of a capacity reference returned by the DB layer.
A capacity reference comes from
`teamlandkarte_v_capacity_references_latest` and carries the project
text in `projects`. The associated partner is resolved via a LEFT
JOIN on `teamlandkarte_v_partners_latest` using
`teamlandkarte_v_capacity_references_latest.partner_id =
teamlandkarte_v_partners_latest.id`; the partner's `name` column is
exposed as `partner_name`.
`partner_name` may be an empty string when `partner_id` is `NULL`
or when the join produces no matching partner row. The reference is
still returned in that case (only `projects` is meaningful then).
"""
partner_name: str
projects: str
class TeamCompetenceRow(TypedDict):
"""Eine Zeile aus `teamlandkarte_v_teammeter_team_competences_latest`
inkl. aufgelöstem Kompetenz-Namen.
Der Name wird über einen Join auf
`teamlandkarte_v_competences_latest` (Bedingung
`team_competences_latest.competence_id = competences_latest.id`)
aufgelöst; Einträge ohne auflösbaren Kompetenz-Namen werden in der
DB-Schicht ausgefiltert.
`top_competency` wird in der DB-Schicht aus `NULL` zu `False`
normalisiert, sodass der Boolean immer gesetzt ist.
"""
name: str
top_competency: bool
class TeamReferenceRow(TypedDict):
"""Eine Zeile aus `teamlandkarte_v_team_references_latest` inkl.
Partner-Namen aus dem LEFT JOIN auf
`teamlandkarte_v_partners_latest`.
Der Partner wird über `team_references_latest.partner_id =
partners_latest.id` aufgelöst; die Spalte `name` wird als
`partner_name` übernommen. `partner_name` ist leer, wenn
`partner_id` `NULL` ist oder kein Join-Treffer existiert; die
Referenz wird in diesem Fall dennoch beibehalten.
Whitespace-only-Werte für `projects` werden in der DB-Schicht
ausgefiltert; `projects` ist daher nie leer.
"""
partner_name: str
projects: str
class DBClient(Protocol):
"""Protocol for database clients used by the MCP server.
Currently implemented by `TrinoClient`.
"""
def test_connection(self) -> None:
"""Validate database connectivity."""
raise NotImplementedError
def get_all_capacities_with_competences(self) -> list[Capacity]:
"""Fetch all capacities and their competences."""
raise NotImplementedError
def get_open_tasks(self, limit: int = 20) -> list[Task]:
"""Fetch the newest published tasks with their DB skills."""
raise NotImplementedError
def get_task_by_id(self, task_id: str) -> Task | None:
"""Fetch one published task by id with its DB skills."""
raise NotImplementedError
def get_task_by_name(self, name: str) -> Task | None:
"""Fetch one published task by its short name."""
raise NotImplementedError
def get_table_columns(self, table: str) -> list[str]:
"""Return column names for a table.
Used by schema verification utilities.
"""
raise NotImplementedError
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
"""Fetch most recent active capacities.
Ordering: `creation_date DESC`.
"""
raise NotImplementedError
def get_capacity_by_id(self, capacity_id: int | str) -> Capacity | None:
"""Fetch one capacity entry by id."""
raise NotImplementedError
def get_all_role_names(self) -> list[str]:
"""Fetch unique, non-empty role names from the role vocabulary view."""
raise NotImplementedError
def get_all_competence_names(self) -> list[str]:
"""Fetch unique, non-empty competence names.
This returns values from the configured competence source.
"""
raise NotImplementedError
def get_capacity_description(self, capacity_id: int | str) -> str | None:
"""Return the capacity description for a single capacity.
Source: `teamlandkarte_v_capacities_latest.description`.
Returns `None` when the column is `NULL` or contains only
whitespace; otherwise returns the description string.
"""
raise NotImplementedError
def get_capacity_certificates(self, capacity_id: int | str) -> list[str]:
"""Return the certificate descriptions for a single capacity.
Source: `teamlandkarte_v_capacity_certificates_latest`, joined
via `capacity_id` (1:n). The `description` column is returned
as a list of strings.
Returns an empty list when no certificates are linked to the
given capacity. Empty/whitespace-only descriptions are filtered
out.
"""
raise NotImplementedError
def get_capacity_references(
self, capacity_id: int | str
) -> list[CapacityReferenceRow]:
"""Return reference entries for a single capacity.
Source: `teamlandkarte_v_capacity_references_latest`, joined
via `capacity_id` (1:n). Each entry contains the project text
from the `projects` column and the partner name resolved via
a LEFT JOIN on `teamlandkarte_v_partners_latest`
(`partner_id = id`, column `name`).
Returns an empty list when no references are linked to the
given capacity. `partner_name` may be an empty string when
`partner_id` is `NULL` or when the partner join produces no
match; the reference is still returned in that case.
"""
raise NotImplementedError
def batch_get_capacity_descriptions(
self, capacity_ids: list[int | str]
) -> dict[str, str | None]:
"""Batch variant of `get_capacity_description` for many ids.
Source: `teamlandkarte_v_capacities_latest.description`.
Issues at most one `SELECT` against
`teamlandkarte_v_capacities_latest` and returns a mapping from
`str(capacity_id)` to either the description string or `None`
(when the column is `NULL` or whitespace-only).
Every requested id is present in the returned dict; missing
rows are pre-filled with `None`. Returns an empty dict when
`capacity_ids` is empty (no SQL is issued).
"""
raise NotImplementedError
def batch_get_capacity_certificates(
self, capacity_ids: list[int | str]
) -> dict[str, list[str]]:
"""Batch variant of `get_capacity_certificates` for many ids.
Source: `teamlandkarte_v_capacity_certificates_latest`, joined
via `capacity_id` (1:n).
Issues at most one `SELECT` and returns a mapping from
`str(capacity_id)` to the list of certificate descriptions.
Every requested id is present in the returned dict; ids
without certificates map to an empty list. Returns an empty
dict when `capacity_ids` is empty (no SQL is issued).
"""
raise NotImplementedError
def batch_get_capacity_references(
self, capacity_ids: list[int | str]
) -> dict[str, list[CapacityReferenceRow]]:
"""Batch variant of `get_capacity_references` for many ids.
Source: `teamlandkarte_v_capacity_references_latest`, joined
via `capacity_id` (1:n), with a LEFT JOIN on
`teamlandkarte_v_partners_latest`
(`partner_id = id`, column `name`) for the partner name.
Issues exactly one `SELECT` (the partner join is part of the
same statement and does not produce an additional roundtrip).
Returns a mapping from `str(capacity_id)` to the list of
reference rows. Every requested id is present in the returned
dict; ids without references map to an empty list.
`partner_name` may be an empty string per entry when
`partner_id` is `NULL` or the join produces no match; the
reference is still kept in the list. Returns an empty dict
when `capacity_ids` is empty (no SQL is issued).
"""
raise NotImplementedError
def get_all_teams(self) -> list[Team]:
"""Liefert alle Teams inklusive ihrer Kompetenzen und Referenzen.
Quelle: `teamlandkarte_v_teams_latest` mit INNER JOIN auf
`teamlandkarte_v_teammeter_organizational_units_latest`
(`teams_latest.team_id = organizational_units_latest.id`) zur
Auflösung des `team_name`. Teams ohne passenden OU-Eintrag
werden durch den INNER JOIN aus dem Ergebnis ausgeschlossen.
Sammelt Stammdaten und Team-Name in einer Query, lädt
anschließend Kompetenzen und Referenzen für die gefundenen
OUIDs in jeweils einer Batch-Query
(`batch_get_team_competences`, `batch_get_team_references`)
und aggregiert das Ergebnis zu `Team`-Instanzen.
Reihenfolge: Teams werden deterministisch nach `team_name`
aufsteigend, sekundär nach `team_id` aufsteigend zurückgegeben.
Leere oder `NULL`-Werte für `about_us`, `offerings`,
`interests` und `focus_name` werden als leere Zeichenkette
zurückgegeben (NULL-Normalisierung in der DB-Schicht).
"""
raise NotImplementedError
def get_team_by_id(self, team_id: str) -> Team | None:
"""Liefert ein einzelnes Team über `team_id` (oder `ouid`)
inklusive Kompetenzen und Referenzen.
Quelle: `teamlandkarte_v_teams_latest` mit demselben INNER JOIN
auf `teamlandkarte_v_teammeter_organizational_units_latest`
wie in `get_all_teams`. Die Suche prüft sowohl `team_id` als
auch `ouid`, um beide fachlichen Identifikatoren zu
unterstützen.
Liefert `None`, wenn weder `team_id` noch `ouid` einem Team
entsprechen oder der INNER JOIN keinen Eintrag findet.
"""
raise NotImplementedError
def get_team_competences(self, ouid: str) -> list[TeamCompetenceRow]:
"""Liefert die 1:n-Kompetenzen eines Teams für eine `ouid`.
Quelle:
`teamlandkarte_v_teammeter_team_competences_latest`, gejoint
über `competence_id` auf
`teamlandkarte_v_competences_latest` zur Auflösung des
Kompetenz-Namens (analog zum Capacity-Pfad).
Reihenfolge: deterministisch, primär nach
`top_competency = True` zuerst, sekundär nach `name`
aufsteigend.
`top_competency` wird aus `NULL` zu `False` normalisiert.
Einträge ohne auflösbaren Kompetenz-Namen (kein Join-Treffer)
werden ausgefiltert.
Liefert eine leere Liste, wenn das Team keine Kompetenzen
besitzt.
"""
raise NotImplementedError
def batch_get_team_competences(
self, ouids: list[str]
) -> dict[str, list[TeamCompetenceRow]]:
"""Batch-Variante von `get_team_competences` für mehrere OUIDs.
Quelle:
`teamlandkarte_v_teammeter_team_competences_latest`, gejoint
über `competence_id` auf
`teamlandkarte_v_competences_latest`.
Setzt höchstens eine einzige `SELECT`-Query ab und gibt ein
Mapping von `str(ouid)` auf die Liste der Kompetenz-Zeilen
zurück. Jede angefragte `ouid` ist im Ergebnis vorhanden;
OUIDs ohne Kompetenzen werden auf eine leere Liste abgebildet.
Reihenfolge innerhalb jeder Liste ist identisch zu
`get_team_competences` (Top-Kompetenzen zuerst, dann Name
aufsteigend). Liefert ein leeres Dict, wenn `ouids` leer ist
(in diesem Fall wird kein SQL abgesetzt).
"""
raise NotImplementedError
def get_team_references(self, ouid: str) -> list[TeamReferenceRow]:
"""Liefert die 1:n-Referenzen eines Teams für eine `ouid` mit
Partner-Namen.
Quelle: `teamlandkarte_v_team_references_latest` mit LEFT JOIN
auf `teamlandkarte_v_partners_latest`
(`team_references_latest.partner_id = partners_latest.id`,
Spalte `name` als `partner_name`). Der Partner-Join ist Teil
derselben Query, kein zusätzlicher Roundtrip.
Reihenfolge: deterministisch, primär nach `partner_name`
aufsteigend, sekundär nach `projects` aufsteigend.
Einträge mit leerem oder ausschließlich whitespace-haltigem
`projects` werden in der DB-Schicht ausgefiltert. Einträge
ohne Partner (`partner_id IS NULL` oder kein Join-Treffer)
werden mit `partner_name = ""` beibehalten.
Liefert eine leere Liste, wenn das Team keine Referenzen
besitzt.
"""
raise NotImplementedError
def batch_get_team_references(
self, ouids: list[str]
) -> dict[str, list[TeamReferenceRow]]:
"""Batch-Variante von `get_team_references` für mehrere OUIDs.
Quelle: `teamlandkarte_v_team_references_latest` mit LEFT JOIN
auf `teamlandkarte_v_partners_latest`
(`partner_id = id`, Spalte `name`). Der Partner-Join ist Teil
derselben Query, sodass insgesamt genau eine `SELECT`-Query
gegen die References-View abgesetzt wird (kein separater
Roundtrip für Partner).
Gibt ein Mapping von `str(ouid)` auf die Liste der
Referenz-Zeilen zurück. Jede angefragte `ouid` ist im Ergebnis
vorhanden; OUIDs ohne Referenzen werden auf eine leere Liste
abgebildet.
Reihenfolge innerhalb jeder Liste ist identisch zu
`get_team_references` (`partner_name` aufsteigend, dann
`projects` aufsteigend). Whitespace-only-`projects` werden
ausgefiltert; Einträge ohne Partner werden mit
`partner_name = ""` beibehalten. Liefert ein leeres Dict,
wenn `ouids` leer ist (in diesem Fall wird kein SQL
abgesetzt).
"""
raise NotImplementedError
@@ -0,0 +1,36 @@
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass
@dataclass(frozen=True)
class LoggingConfig:
"""Basic logging configuration.
Attributes:
level: Logging level name.
format: Log record format string.
"""
level: str = "INFO"
format: str = "%(asctime)s %(levelname)s %(name)s: %(message)s"
def configure_logging(cfg: LoggingConfig) -> None:
"""Configure Python logging.
Args:
cfg: Logging configuration.
Notes:
MCP stdio servers must not write to stdout (JSON-RPC is carried over
stdout). We explicitly route logs to stderr.
"""
logging.basicConfig(
level=getattr(logging, cfg.level.upper(), logging.INFO),
format=cfg.format,
stream=sys.stderr,
)
@@ -0,0 +1,141 @@
# filepath: src/teamlandkarte_mcp/matching/auto_tagger.py
"""LLM-based competence expander (Auto-Tagger) for BM25 pre-processing.
The :class:`AutoTagger` bridges the lexical gap between BM25 and semantic
matching by asking an LLM to identify which *required* competences are already
covered by a candidate's *existing* competences (via synonym, abbreviation, or
cross-language equivalence). The identified canonical required-competence names
are appended to the candidate's working list before BM25 scoring.
All expansions are **ephemeral**: they are used only for the current BM25
scoring call and are never written back to the database.
On any LLM or parsing error, the original candidate list is returned unchanged
(graceful degradation).
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from teamlandkarte_mcp.azure.openai_client import AzureOpenAIClient
LOGGER = logging.getLogger(__name__)
_SYSTEM_PROMPT = (
"You are a skill-taxonomy assistant. "
"Given a list of REQUIRED competences and a candidate's EXISTING competences, "
"output ONLY the canonical names from REQUIRED that are already covered by one "
"or more entries in EXISTING (via synonym, abbreviation, or cross-language "
"equivalence). Do not invent new skills. The outpust must only include REQUIRED competences "
"that are in addtion also semantically equal. For example, the existing skill Data Science "
"matches the required skill Datenanalyse, because Datenanalyse is part of Data Science. "
"Or JavaScript and TypeScript, because TypeScript is a superset of JavaScript. But you must "
" not match skills with similar wording, but highly different meaning. For example, "
"Data Science and Data Engineering both have 'Data' in their name, but they are very different competences."
'Respond with a JSON object: {"additions": ["...", ...]}'
)
class AutoTagger:
"""LLM-based competence expander for BM25 pre-processing.
Calls an Azure OpenAI chat model to identify which required competences are
already covered by the candidate's existing competences (via synonym,
abbreviation, or cross-language equivalence) and returns the canonical
required-competence names as additions.
All LLM calls use ``response_format={"type": "json_object"}`` to ensure
parseable output. On any LLM or parsing error the original candidate list
is returned unchanged (graceful degradation).
Args:
client: An :class:`~teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient`
instance configured with a chat deployment (``chat_deployment`` and
``llm_api_key``).
"""
def __init__(self, client: AzureOpenAIClient) -> None:
"""Store the Azure OpenAI client reference.
Args:
client: Configured client with chat-completion capability.
"""
self._client = client
async def expand_competences(
self,
required: list[str],
existing: list[str],
) -> list[str]:
"""Expand a candidate's competence list with covered required terms.
Sends a prompt to the LLM asking: given ``required`` competences and
the candidate's ``existing`` competences, which required competences are
already implicitly covered by the existing entries?
The returned list is ``existing + additions`` where ``additions`` are
the canonical required-competence names identified by the LLM and
validated against ``required`` (hallucination guard).
The result is **ephemeral**: it is used only for the current BM25
scoring call and is never written back to the database.
Args:
required: List of required competence names (BM25 query terms).
existing: Candidate's current competence list.
Returns:
Combined list ``existing + additions``. If the LLM call fails,
returns ``existing`` unchanged. Duplicate entries (items already
in ``existing``) are not added again.
"""
if not required or not existing:
return list(existing)
user_prompt = (
f"REQUIRED: {json.dumps(required, ensure_ascii=False)}\n"
f"EXISTING: {json.dumps(existing, ensure_ascii=False)}"
)
try:
raw = await self._client.chat_completion(
system=_SYSTEM_PROMPT,
user=user_prompt,
)
payload = json.loads(raw)
additions_raw: list[str] = payload.get("additions", [])
except Exception as exc: # noqa: BLE001
LOGGER.warning(
"AutoTagger: LLM call or JSON parse failed (%s); "
"using unmodified candidate list.",
exc,
)
return list(existing)
if not isinstance(additions_raw, list):
LOGGER.warning(
"AutoTagger: unexpected 'additions' type %s; "
"using unmodified candidate list.",
type(additions_raw).__name__,
)
return list(existing)
required_set = set(required)
existing_set = set(existing)
new_additions = [
a
for a in additions_raw
if isinstance(a, str) and a in required_set and a not in existing_set
]
if new_additions:
LOGGER.debug(
"AutoTagger: added %d term(s) to candidate list: %s",
len(new_additions),
new_additions,
)
return list(existing) + new_additions
@@ -0,0 +1,132 @@
# filepath: src/teamlandkarte_mcp/matching/bm25.py
"""BM25 index for lexical competence matching.
This module provides a thin wrapper around ``rank_bm25.BM25Okapi`` that
tokenizes candidate and query strings in a consistent way and exposes a clean
public interface for the rest of the matching pipeline.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from rank_bm25 import BM25Okapi # type: ignore[import-untyped]
def _tokenize(text: str) -> list[str]:
"""Tokenize a competence string for BM25 indexing and querying.
Lowercases the input, then splits on any sequence of non-word characters
(whitespace, hyphens, slashes, parentheses, dots, etc.). Empty tokens are
discarded.
Examples::
>>> _tokenize("Progressive Web App (PWA)")
['progressive', 'web', 'app', 'pwa']
>>> _tokenize("CI/CD Pipeline")
['ci', 'cd', 'pipeline']
>>> _tokenize("React.js")
['react', 'js']
Args:
text: Raw competence string.
Returns:
List of lowercase tokens with no empty entries.
"""
return [t for t in re.split(r"[\W_]+", text.lower()) if t]
@dataclass
class Bm25Index:
"""In-memory BM25 index over a corpus of candidate competence strings.
The index is built once during ``__post_init__`` and is thereafter
read-only. For correct IDF weights the corpus should span **all**
candidates in the matching pool, not just a single person's skills.
The :class:`~teamlandkarte_mcp.matching.matcher.Matcher` builds one
``Bm25Index`` globally before iterating over candidates; individual
scoring calls then filter the results to each candidate's own skills.
Args:
corpus: List of competence strings to index. Should be the
deduplicated union of all candidates' competences.
Notes:
- Tokenization lowercases and splits on non-word characters.
- An empty corpus produces an index that scores all queries as 0.0.
- ``Bm25Index`` is intentionally **not** frozen; it stores the index
object as a private field set during ``__post_init__``.
"""
corpus: list[str]
_bm25: BM25Okapi | None = field(default=None, init=False, repr=False)
def __post_init__(self) -> None:
"""Build the BM25 index from the corpus."""
if self.corpus:
tokenized = [_tokenize(doc) for doc in self.corpus]
self._bm25 = BM25Okapi(tokenized)
def rank(self, query: str) -> list[tuple[str, float]]:
"""Rank corpus documents for a single query string.
Negative BM25 scores (caused by terms appearing in more than half the
corpus — ubiquitous "stop-word" terms with IDF < 0) are clamped to
0.0. This is semantically correct: a ubiquitous term is
non-discriminative and should not help or hurt the match score.
Args:
query: Required competence text used as the BM25 query.
Returns:
List of ``(candidate_text, bm25_score)`` sorted by score
descending. Entries with score 0.0 are included so that callers
can inspect the full ranked list without a cutoff.
Returns an empty list for an empty corpus.
"""
if not self.corpus or self._bm25 is None:
return []
query_tokens = _tokenize(query)
if not query_tokens:
return [(doc, 0.0) for doc in self.corpus]
raw_scores: list[float] = self._bm25.get_scores(query_tokens).tolist()
pairs: list[tuple[str, float]] = [
(doc, max(0.0, float(raw)))
for doc, raw in zip(self.corpus, raw_scores, strict=True)
]
# Sort descending by score, stable by original order for ties.
pairs.sort(key=lambda t: t[1], reverse=True)
return pairs
def bm25_rank_competences(
required: str,
candidates: list[str],
) -> list[tuple[str, float]]:
"""Rank candidate competences against a single required competence.
Convenience wrapper that builds a temporary :class:`Bm25Index` over
``candidates`` and ranks them by ``required`` as the query.
.. note::
This builds an index over ``candidates`` only, so IDF weights reflect
that small corpus. In the main matching pipeline
:class:`~teamlandkarte_mcp.matching.matcher.Matcher` builds a single
global :class:`Bm25Index` across all candidates instead. This wrapper
is retained for standalone use and unit tests.
Args:
required: The required competence text (BM25 query).
candidates: Candidate competence strings to rank.
Returns:
Ranked list of ``(candidate_text, bm25_score)``, descending by score.
Returns an empty list when ``candidates`` is empty.
"""
return Bm25Index(corpus=list(candidates)).rank(required)
@@ -0,0 +1,432 @@
"""LLM-based full-text matcher between capacities and tasks."""
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import asdict, dataclass, field
from teamlandkarte_mcp.azure.openai_client import AzureAPIError, AzureOpenAIClient
from teamlandkarte_mcp.database.types import DBClient
from teamlandkarte_mcp.matching.profiles import (
CapacityProfile,
TaskProfile,
build_capacity_profile,
build_task_profile,
build_team_profile,
serialize_capacity_profile,
serialize_task_profile,
serialize_team_profile,
)
from teamlandkarte_mcp.models import Capacity, Task, Team
LOGGER = logging.getLogger(__name__)
_ALLOWED_CATEGORIES: tuple[str, ...] = (
"Top",
"Good",
"Partial",
"Low",
"Irrelevant",
)
_ALIAS: dict[str, str] = {c.lower(): c for c in _ALLOWED_CATEGORIES}
_SYSTEM_PROMPT: str = (
"Du bist ein erfahrener Personal- und Skill-Matcher der DB Systel.\n"
"Du erhältst ein Aufgabenprofil und ein Kapazitätsprofil.\n"
"Bewerte, wie gut die Kapazität zur Aufgabe passt, und wähle GENAU EINE Kategorie aus:\n"
"- Top: passt fachlich und in den Kompetenzen praktisch vollständig\n"
"- Good: passt gut, mit kleinen Lücken\n"
"- Partial: passt teilweise, mehrere relevante Lücken\n"
"- Low: schwacher Bezug, nur einzelne Berührungspunkte\n"
"- Irrelevant: kein erkennbarer fachlicher Bezug\n"
"\n"
"Begründe deine Wahl in 1-2 prägnanten deutschen Sätzen\n"
"(maximal ~280 Zeichen, keine Aufzählungspunkte, keine Zeilenumbrüche).\n"
"Antworte AUSSCHLIESSLICH als gültiges JSON-Objekt mit den Feldern:\n"
'{"category": "<Top|Good|Partial|Low|Irrelevant>", "rationale": "<Begründung>"}'
)
_SYSTEM_PROMPT_TEAM: str = _SYSTEM_PROMPT.replace("Kapazitätsprofil", "Profil")
def normalize_category(value: object) -> tuple[str, bool]:
"""Validates: Requirements 5.3, 5.6, 6.3, 6.6."""
if not isinstance(value, str):
return "Irrelevant", False
norm = _ALIAS.get(value.strip().lower())
if norm is None:
return "Irrelevant", False
return norm, True
def _format_exception(exc: BaseException) -> str:
msg = str(exc) or ""
first = msg.split(". ", 1)[0].split("\n", 1)[0].strip()
if first:
return f"{type(exc).__name__}: {first}"
return type(exc).__name__
def _build_user_prompt_capacity_for_task(
task_profile: TaskProfile,
capacity_profile: CapacityProfile,
capacity_owner: str,
) -> str:
owner_line = f"Owner: {capacity_owner}\n" if capacity_owner else ""
return (
"=== Aufgabe ===\n"
+ serialize_task_profile(task_profile)
+ "\n\n=== Kapazität ===\n"
+ f"ID: {capacity_profile.id}\n"
+ owner_line
+ serialize_capacity_profile(capacity_profile)
)
def _build_user_prompt_task_for_capacity(
capacity_profile: CapacityProfile,
task_profile: TaskProfile,
) -> str:
return (
"=== Kapazität ===\n"
+ serialize_capacity_profile(capacity_profile)
+ "\n\n=== Aufgabe ===\n"
+ f"ID: {task_profile.id}\n"
+ serialize_task_profile(task_profile)
)
def _build_user_prompt_team_for_task(
task_profile: TaskProfile,
team: Team,
) -> str:
team_profile = build_team_profile(team)
return (
"=== Aufgabe ===\n"
+ serialize_task_profile(task_profile)
+ "\n\n=== Team ===\n"
+ f"ID: {team.team_id}\n"
+ serialize_team_profile(team_profile)
)
@dataclass
class LlmFulltextItem:
item_id: str
category: str
rationale: str
raw: dict
@dataclass
class LlmFulltextError:
item_id: str
error: str
@dataclass
class LlmFulltextResult:
by_category: dict[str, list[LlmFulltextItem]] = field(default_factory=dict)
errors: list[LlmFulltextError] = field(default_factory=list)
class LlmFulltextMatcher:
"""LLM-based full-text matcher between capacities and tasks."""
def __init__(
self,
*,
db: DBClient,
client: AzureOpenAIClient,
rationale_max_chars: int = 280,
max_concurrency: int = 5,
) -> None:
self._db = db
self._client = client
self._rationale_max_chars = rationale_max_chars
self._max_concurrency = max_concurrency
self._semaphore = asyncio.Semaphore(max_concurrency)
async def _categorize_one(
self,
*,
item_id: str,
user_prompt: str,
raw: dict,
system_prompt: str = _SYSTEM_PROMPT,
) -> tuple[LlmFulltextItem | None, LlmFulltextError | None]:
try:
response_text = await self._client.chat_completion(
system_prompt, user_prompt
)
except AzureAPIError as exc:
return None, LlmFulltextError(item_id=item_id, error=_format_exception(exc))
except Exception as exc: # noqa: BLE001
return None, LlmFulltextError(item_id=item_id, error=_format_exception(exc))
try:
parsed = json.loads(response_text)
except json.JSONDecodeError:
excerpt = (response_text or "")[:120].replace("\n", " ")
return None, LlmFulltextError(item_id=item_id, error=f"invalid JSON: {excerpt}")
if not isinstance(parsed, dict):
excerpt = str(parsed)[:80].replace("\n", " ")
return None, LlmFulltextError(
item_id=item_id,
error=f"invalid JSON: not an object: {excerpt}",
)
raw_category = parsed.get("category")
raw_rationale = parsed.get("rationale")
rationale = raw_rationale if isinstance(raw_rationale, str) else ""
category, is_valid = normalize_category(raw_category)
if not is_valid:
hint = f"[Hinweis: ungültige LLM-Kategorie: {raw_category!r}]"
rationale = f"{rationale} {hint}".strip()
return (
LlmFulltextItem(
item_id=item_id,
category=category,
rationale=rationale,
raw=raw,
),
None,
)
async def _categorize_one_throttled(
self,
*,
item_id: str,
user_prompt: str,
raw: dict,
system_prompt: str = _SYSTEM_PROMPT,
) -> tuple[LlmFulltextItem | None, LlmFulltextError | None]:
"""Wrapper um _categorize_one mit Semaphore-Begrenzung."""
async with self._semaphore:
return await self._categorize_one(
item_id=item_id,
user_prompt=user_prompt,
raw=raw,
system_prompt=system_prompt,
)
async def match_capacities(
self,
*,
task_profile: TaskProfile,
capacities: list[Capacity],
) -> "LlmFulltextResult":
"""LLM-categorize each capacity against ``task_profile``."""
if not capacities:
return LlmFulltextResult(
by_category={cat: [] for cat in _ALLOWED_CATEGORIES},
errors=[],
)
capacity_ids: list[int | str] = [str(c.id) for c in capacities]
descriptions = self._db.batch_get_capacity_descriptions(capacity_ids)
certificates = self._db.batch_get_capacity_certificates(capacity_ids)
references = self._db.batch_get_capacity_references(capacity_ids)
LOGGER.info(
"Batch-Matching gestartet: %d Kandidaten, max_concurrency=%d",
len(capacities),
self._max_concurrency,
)
start_time = time.monotonic()
gather_tasks = []
for capacity in capacities:
cap_id = str(capacity.id)
profile = build_capacity_profile(
capacity,
description=descriptions.get(cap_id),
certificates=certificates.get(cap_id, []),
references=references.get(cap_id, []),
)
user_prompt = _build_user_prompt_capacity_for_task(
task_profile=task_profile,
capacity_profile=profile,
capacity_owner=capacity.owner_name or "",
)
raw = asdict(capacity)
gather_tasks.append(
self._categorize_one_throttled(
item_id=cap_id,
user_prompt=user_prompt,
raw=raw,
)
)
results = await asyncio.gather(*gather_tasks)
elapsed = time.monotonic() - start_time
by_category: dict[str, list[LlmFulltextItem]] = {
cat: [] for cat in _ALLOWED_CATEGORIES
}
errors: list[LlmFulltextError] = []
for item, error in results:
if item is not None:
by_category[item.category].append(item)
elif error is not None:
errors.append(error)
LOGGER.info(
"Batch-Matching abgeschlossen: %.1fs, %d kategorisiert, %d Fehler",
elapsed,
sum(len(v) for v in by_category.values()),
len(errors),
)
for cat in by_category:
by_category[cat].sort(key=lambda it: it.item_id)
errors.sort(key=lambda e: e.item_id)
return LlmFulltextResult(by_category=by_category, errors=errors)
async def match_tasks(
self,
*,
capacity_profile: CapacityProfile,
tasks: list[Task],
) -> "LlmFulltextResult":
"""LLM-categorize each task against ``capacity_profile``."""
if not tasks:
return LlmFulltextResult(
by_category={cat: [] for cat in _ALLOWED_CATEGORIES},
errors=[],
)
LOGGER.info(
"Batch-Matching gestartet: %d Kandidaten, max_concurrency=%d",
len(tasks),
self._max_concurrency,
)
start_time = time.monotonic()
gather_tasks = []
for task in tasks:
task_id = str(task.id)
profile = build_task_profile(task)
user_prompt = _build_user_prompt_task_for_capacity(
capacity_profile=capacity_profile,
task_profile=profile,
)
raw = asdict(task)
gather_tasks.append(
self._categorize_one_throttled(
item_id=task_id,
user_prompt=user_prompt,
raw=raw,
)
)
results = await asyncio.gather(*gather_tasks)
elapsed = time.monotonic() - start_time
by_category: dict[str, list[LlmFulltextItem]] = {
cat: [] for cat in _ALLOWED_CATEGORIES
}
errors: list[LlmFulltextError] = []
for item, error in results:
if item is not None:
by_category[item.category].append(item)
elif error is not None:
errors.append(error)
LOGGER.info(
"Batch-Matching abgeschlossen: %.1fs, %d kategorisiert, %d Fehler",
elapsed,
sum(len(v) for v in by_category.values()),
len(errors),
)
for cat in by_category:
by_category[cat].sort(key=lambda it: it.item_id)
errors.sort(key=lambda e: e.item_id)
return LlmFulltextResult(by_category=by_category, errors=errors)
async def match_teams(
self,
*,
task_profile: TaskProfile,
teams: list[Team],
) -> "LlmFulltextResult":
"""LLM-categorize each team against ``task_profile``.
Mirrors :meth:`match_capacities` and :meth:`match_tasks` but uses
:func:`build_team_profile` / :func:`serialize_team_profile` to
produce the user prompt and ranks results by ``team_id`` within
each category (see requirements 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7).
"""
if not teams:
return LlmFulltextResult(
by_category={cat: [] for cat in _ALLOWED_CATEGORIES},
errors=[],
)
LOGGER.info(
"Batch-Matching gestartet: %d Kandidaten, max_concurrency=%d",
len(teams),
self._max_concurrency,
)
start_time = time.monotonic()
gather_tasks = []
for team in teams:
team_id = str(team.team_id)
user_prompt = _build_user_prompt_team_for_task(
task_profile=task_profile,
team=team,
)
raw = asdict(team)
gather_tasks.append(
self._categorize_one_throttled(
item_id=team_id,
user_prompt=user_prompt,
raw=raw,
system_prompt=_SYSTEM_PROMPT_TEAM,
)
)
results = await asyncio.gather(*gather_tasks)
elapsed = time.monotonic() - start_time
by_category: dict[str, list[LlmFulltextItem]] = {
cat: [] for cat in _ALLOWED_CATEGORIES
}
errors: list[LlmFulltextError] = []
for item, error in results:
if item is not None:
by_category[item.category].append(item)
elif error is not None:
errors.append(error)
LOGGER.info(
"Batch-Matching abgeschlossen: %.1fs, %d kategorisiert, %d Fehler",
elapsed,
sum(len(v) for v in by_category.values()),
len(errors),
)
for cat in by_category:
by_category[cat].sort(key=lambda it: it.item_id)
errors.sort(key=lambda e: e.item_id)
return LlmFulltextResult(by_category=by_category, errors=errors)
@@ -0,0 +1,331 @@
"""Score-basiertes Matching für Kapazitäten und Teams."""
from __future__ import annotations
from dataclasses import dataclass
from teamlandkarte_mcp.config import MatchingConfig
from teamlandkarte_mcp.models import (
Capacity,
Requirements,
ScoredCapacity,
ScoredTeam,
Team,
)
from teamlandkarte_mcp.utils.dates import availability_overlaps
from teamlandkarte_mcp.matching.bm25 import Bm25Index
from teamlandkarte_mcp.matching.scorer import compute_overall
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
@dataclass
class MatchResult:
"""Result of a single matching run.
Attributes:
scored: Flat list of scored capacities, sorted by overall score.
by_category: Mapping of category (Top/Good/Partial/Low/Irrelevant)
to items.
"""
scored: list[ScoredCapacity]
by_category: dict[str, list[ScoredCapacity]]
@dataclass
class TeamMatchResult:
"""Result of a single team-matching run.
Attributes:
scored: Flat list of scored teams, sorted by overall score
(descending).
by_category: Mapping of category (Top/Good/Partial/Low/Irrelevant)
to scored teams.
"""
scored: list[ScoredTeam]
by_category: dict[str, list[ScoredTeam]]
class Matcher:
"""Matches capacities and teams against requirements."""
def __init__(self, similarity: SimilarityEngine, cfg: MatchingConfig):
"""Initialize the matcher.
Args:
similarity: SimilarityEngine used for competence + role similarity.
cfg: Matching configuration (weights and thresholds).
"""
self._sim = similarity
self._cfg = cfg
async def match(
self, capacities: list[Capacity], requirements: Requirements
) -> MatchResult:
"""Compute matches for a list of capacities.
Args:
capacities: Input capacities.
requirements: Structured matching requirements.
Returns:
MatchResult containing per-capacity scores and category groups.
"""
filtered = [
c
for c in capacities
if availability_overlaps(
c.begin_date,
c.end_date,
requirements.date_start,
requirements.date_end,
)
]
by_category: dict[str, list[ScoredCapacity]] = {
"Top": [],
"Good": [],
"Partial": [],
"Low": [],
"Irrelevant": [],
}
scored_all: list[ScoredCapacity] = []
required_comps = requirements.competences
global_bm25_index: Bm25Index | None = None
if filtered:
global_corpus = list(
{c for cap in filtered for c in cap.competences if c.strip()}
)
global_bm25_index = Bm25Index(corpus=global_corpus)
for cap in filtered:
sim = await self._sim.compute_competence_similarity(
required_comps,
cap.competences,
global_index=global_bm25_index,
)
per_scores: list[float] = []
matched: list[str] = []
missing: list[str] = []
for req in required_comps:
entry = sim.get(req)
if not entry:
per_scores.append(0.0)
missing.append(req)
continue
raw_score = entry.get("score", 0.0)
try:
score = float(raw_score)
except (TypeError, ValueError):
score = 0.0
score = max(0.0, min(1.0, score))
per_scores.append(score)
if score >= 0.5:
matched.append(req)
else:
missing.append(req)
if per_scores:
competence_score = sum(per_scores) / len(per_scores)
else:
competence_score = 0.0
role_score = await self._sim.compute_role_similarity(
requirements.role_name,
cap.role_name,
)
breakdown = compute_overall(
competence_score,
role_score,
self._cfg,
)
sc = ScoredCapacity(
capacity=cap,
competence_score=breakdown.competence_score,
role_score=breakdown.role_score,
overall_score=breakdown.overall_score,
category=breakdown.category,
matched_competences=matched,
missing_competences=missing,
)
scored_all.append(sc)
by_category.setdefault(sc.category, []).append(sc)
scored_all.sort(key=lambda s: s.overall_score, reverse=True)
for cat in by_category:
by_category[cat].sort(key=lambda s: s.overall_score, reverse=True)
return MatchResult(scored=scored_all, by_category=by_category)
async def match_teams(
self,
teams: list[Team],
requirements: Requirements,
*,
top_competency_weight: float,
) -> TeamMatchResult:
"""Compute score-based matches for a list of teams.
Mirrors :meth:`match` for capacities, but applies team-specific
semantics:
* A team's competence list is derived from ``team.competences``
(the resolved competence names) plus a parallel top-set used to
boost top competencies.
* The role similarity uses ``team.focus_name`` as the role
stand-in (teams have no role in the capacity sense).
* Each per-required-competence raw score is multiplied by
``top_competency_weight`` when the BM25 ``best_match`` is a top
competency of the team, otherwise multiplied by ``1.0``. The
result is clamped to ``[0.0, 1.0]``.
* Availability is **not** evaluated; any date range on
``requirements`` is ignored on this path.
Args:
teams: Input teams.
requirements: Structured matching requirements (date fields are
ignored).
top_competency_weight: Multiplicative weight applied to raw
competence similarity when the best match is a top
competency of the team. Expected to be ``>= 1.0``.
Returns:
TeamMatchResult containing per-team scores sorted by overall
score (descending) and grouped by category.
"""
by_category: dict[str, list[ScoredTeam]] = {
"Top": [],
"Good": [],
"Partial": [],
"Low": [],
"Irrelevant": [],
}
scored_all: list[ScoredTeam] = []
required_comps = requirements.competences
global_bm25_index: Bm25Index | None = None
if teams:
global_corpus = list(
{
tc.name
for team in teams
for tc in team.competences
if tc.name and tc.name.strip()
}
)
global_bm25_index = Bm25Index(corpus=global_corpus)
try:
top_weight = float(top_competency_weight)
except (TypeError, ValueError):
top_weight = 1.0
for team in teams:
comp_names = [
tc.name
for tc in team.competences
if tc.name and tc.name.strip()
]
top_set = {
tc.name
for tc in team.competences
if tc.top_competency and tc.name and tc.name.strip()
}
sim = await self._sim.compute_competence_similarity(
required_comps,
comp_names,
global_index=global_bm25_index,
)
per_scores: list[float] = []
matched: list[str] = []
missing: list[str] = []
for req in required_comps:
entry = sim.get(req)
if not entry:
per_scores.append(0.0)
missing.append(req)
continue
raw_score = entry.get("score", 0.0)
try:
score = float(raw_score)
except (TypeError, ValueError):
score = 0.0
score = max(0.0, min(1.0, score))
best_match = entry.get("best_match")
factor = top_weight if best_match in top_set else 1.0
weighted = min(1.0, score * factor)
per_scores.append(weighted)
if weighted >= 0.5:
matched.append(req)
else:
missing.append(req)
if per_scores:
competence_score = sum(per_scores) / len(per_scores)
else:
competence_score = 0.0
role_score = await self._sim.compute_role_similarity(
requirements.role_name,
team.focus_name,
)
breakdown = compute_overall(
competence_score,
role_score,
self._cfg,
)
st = ScoredTeam(
team=team,
competence_score=breakdown.competence_score,
role_score=breakdown.role_score,
overall_score=breakdown.overall_score,
category=breakdown.category,
matched_competences=matched,
missing_competences=missing,
)
scored_all.append(st)
by_category.setdefault(st.category, []).append(st)
scored_all.sort(key=lambda s: s.overall_score, reverse=True)
for cat in by_category:
by_category[cat].sort(key=lambda s: s.overall_score, reverse=True)
return TeamMatchResult(scored=scored_all, by_category=by_category)
@staticmethod
def summary_counts(
by_category: dict[str, list],
) -> dict[str, int]:
"""Return a category -> count summary.
Generic over the bucket value type: works for both
``dict[str, list[ScoredCapacity]]`` and
``dict[str, list[ScoredTeam]]`` (or any list-valued mapping).
Args:
by_category: Category mapping returned from ``match()`` or
``match_teams()``.
Returns:
Dict mapping category name to number of results.
"""
return {k: len(v) for k, v in by_category.items()}
@@ -0,0 +1,570 @@
"""Profile data classes for the LLM-based full-text matching.
This module defines the immutable data structures used as input for the
``LlmFulltextMatcher`` (see ``matching/llm_fulltext_matcher.py``):
* :class:`CapacityReferenceEntry` represents a single reference of a capacity,
combining the related partner name and the project text. ``partner_name``
may be empty when the underlying ``partner_id`` is ``NULL`` or when the
``LEFT JOIN`` on ``teamlandkarte_v_partners_latest`` does not yield a
match (see requirements 2.8 and 3.2). The reference is still kept in this
case so the LLM can evaluate the project text.
* :class:`CapacityProfile` is the aggregated full-text profile of a capacity,
built from the role, competences, description, references and
certificates retrieved via the ``DBClient``.
* :class:`TaskProfile` is the aggregated full-text profile of a task, built
from title, description and the requested skills.
All three classes are ``@dataclass(frozen=True)`` and only contain primitive
fields (``str``) and lists of primitive fields or
``CapacityReferenceEntry`` instances. The ``LlmFulltextMatcher`` uses these
profiles to produce a deterministic, human-readable serialization that is
sent to the LLM (see requirements 3.7 and 4.4).
The actual builder helpers and the deterministic serialization live in
follow-up tasks (2.2 and 2.3 of the ``llm-fulltext-matching`` spec) and
intentionally are not part of this module yet.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from teamlandkarte_mcp.database.types import CapacityReferenceRow
from teamlandkarte_mcp.models import Capacity, Requirements, Task, Team
@dataclass(frozen=True)
class CapacityReferenceEntry:
"""A single reference entry inside a :class:`CapacityProfile`.
Attributes:
partner_name: Name of the partner the reference is associated with,
taken from ``teamlandkarte_v_partners_latest.name`` via the
``LEFT JOIN`` on
``teamlandkarte_v_capacity_references_latest.partner_id =
teamlandkarte_v_partners_latest.id``. May be an empty string
when ``partner_id`` is ``NULL`` or the join does not match
(see requirements 2.8 and 3.2). An empty value MUST NOT cause
the reference to be dropped; it only suppresses the partner
token in the serialized output.
projects: Free-text project description from
``teamlandkarte_v_capacity_references_latest.projects``.
"""
partner_name: str
projects: str
@dataclass(frozen=True)
class CapacityProfile:
"""Aggregated full-text profile of a single capacity.
Used as input for the ``LlmFulltextMatcher``. Empty or ``None`` values
in the underlying database rows are converted to empty strings or empty
lists by the profile builder so that no profile is ever discarded
(see requirement 3.2).
Attributes:
id: Capacity identifier as a string (matches the persisted
``capacity_id`` form used elsewhere in the codebase).
owner_name: Display name of the capacity's owner.
role_name: Role title associated with the capacity.
competences: Ordered list of the capacity's competences.
description: Free-text description from
``teamlandkarte_v_capacities_latest.description``. Empty when
no description is available.
references: Ordered list of reference entries. Each entry combines
``partner_name`` and ``projects``; ``partner_name`` may be empty.
certificates: Ordered list of certificate descriptions from
``teamlandkarte_v_capacity_certificates_latest``.
"""
id: str
owner_name: str
role_name: str
competences: list[str]
description: str
references: list[CapacityReferenceEntry]
certificates: list[str]
@dataclass(frozen=True)
class TaskProfile:
"""Aggregated full-text profile of a single task.
Used as input for the ``LlmFulltextMatcher``. Empty or ``None`` values
are converted to empty strings or empty lists by the profile builder
so that no profile is ever discarded (see requirement 4.2).
Attributes:
id: Task identifier as a string.
title: Task title.
description: Free-text task description.
skills: Ordered list of requested competences for the task.
"""
id: str
title: str
description: str
skills: list[str]
@dataclass(frozen=True)
class TeamCompetenceEntry:
"""A single competence entry inside a :class:`TeamProfile`.
Attributes:
name: Resolved competence name from
``teamlandkarte_v_competences_latest.name`` (joined via
``competence_id``).
top_competency: ``True`` when the competence is marked as a top
competency for the team. ``NULL`` values from the database are
normalized to ``False`` by the DB layer.
"""
name: str
top_competency: bool
@dataclass(frozen=True)
class TeamReferenceEntry:
"""A single reference entry inside a :class:`TeamProfile`.
Attributes:
partner_name: Name of the partner the reference is associated with,
taken from ``teamlandkarte_v_partners_latest.name`` via the
``LEFT JOIN`` on
``teamlandkarte_v_team_references_latest.partner_id =
teamlandkarte_v_partners_latest.id``. May be an empty string
when ``partner_id`` is ``NULL`` or the join does not match.
An empty value MUST NOT cause the reference to be dropped; it
only suppresses the partner token in the serialized output.
projects: Free-text project description from
``teamlandkarte_v_team_references_latest.projects``.
Whitespace-only values are filtered out by the DB layer.
"""
partner_name: str
projects: str
@dataclass(frozen=True)
class TeamProfile:
"""Aggregated full-text profile of a single team.
Used as input for the ``LlmFulltextMatcher`` team path and for the
``get_team_details`` tool. Empty or ``None`` values in the underlying
database rows are converted to empty strings or empty lists by the
profile builder so that no profile is ever discarded (see
requirement 5.1).
The order of :attr:`competences` and :attr:`references` mirrors the
order returned by the ``DBClient`` and is therefore the single source
of truth for the deterministic serialization (see requirement 13.4).
Attributes:
id: Team identifier as a string (matches the persisted
``team_id`` form used elsewhere in the codebase).
ouid: Organizational-unit identifier of the team.
team_name: Display name of the team, resolved via the INNER JOIN
on ``teamlandkarte_v_teammeter_organizational_units_latest``.
focus_name: Free-text focus / role description of the team. Used
by the score path as a role surrogate.
about_us: Free-text description of the team.
offerings: Free-text description of the team's offerings.
interests: Free-text description of the team's interests.
competences: Ordered list of the team's competences. Order mirrors
the database ordering ``(top_competency desc, name asc)``.
references: Ordered list of reference entries. Order mirrors the
database ordering ``(partner_name asc, projects asc)``;
``partner_name`` may be empty.
"""
id: str
ouid: str
team_name: str
focus_name: str
about_us: str
offerings: str
interests: str
competences: list[TeamCompetenceEntry] = field(default_factory=list)
references: list[TeamReferenceEntry] = field(default_factory=list)
def build_capacity_profile(
capacity: Capacity,
*,
description: str | None,
certificates: list[str] | None,
references: list[CapacityReferenceRow] | None,
) -> CapacityProfile:
"""Build a :class:`CapacityProfile` from a :class:`Capacity` and DB extras.
Empty or ``None`` values are mapped to empty strings or empty lists so
that the resulting profile is never discarded (see requirements 3.2 and
3.4). The order of the fields matches the dataclass declaration and is
therefore stable across all profiles (see requirement 3.1).
Args:
capacity: The capacity loaded from the database.
description: Free-text description of the capacity, typically read
via ``DBClient.get_capacity_description``. ``None`` or
whitespace-only values become an empty string.
certificates: Certificate descriptions, typically read via
``DBClient.get_capacity_certificates``. ``None`` becomes an
empty list; empty/whitespace-only entries are dropped.
references: Reference rows, typically read via
``DBClient.get_capacity_references``. ``None`` becomes an empty
list. Each row is converted to a
:class:`CapacityReferenceEntry`. Entries with empty/whitespace
``projects`` are dropped (defensive; the DB layer already filters
these out). Entries with an empty ``partner_name`` are kept
(see requirement 3.4).
Returns:
A :class:`CapacityProfile` ready to be serialized and passed to the
LLM.
"""
description_clean = description.strip() if description else ""
competences_clean: list[str] = (
list(capacity.competences) if capacity.competences else []
)
certificates_clean: list[str] = []
for raw in certificates or []:
if raw and raw.strip():
certificates_clean.append(raw)
reference_entries: list[CapacityReferenceEntry] = []
for row in references or []:
projects_value = row.get("projects") or ""
if not projects_value.strip():
continue
partner_value = row.get("partner_name") or ""
reference_entries.append(
CapacityReferenceEntry(
partner_name=partner_value,
projects=projects_value,
)
)
return CapacityProfile(
id=str(capacity.id),
owner_name=capacity.owner_name or "",
role_name=capacity.role_name or "",
competences=competences_clean,
description=description_clean,
references=reference_entries,
certificates=certificates_clean,
)
def build_task_profile(task: Task) -> TaskProfile:
"""Build a :class:`TaskProfile` from a :class:`Task`.
Empty or ``None`` values are mapped to empty strings or empty lists so
that the resulting profile is never discarded (see requirement 4.2).
The order of the fields matches the dataclass declaration and is stable
across all profiles (see requirement 4.1).
Args:
task: The task loaded from the database.
Returns:
A :class:`TaskProfile` ready to be serialized and passed to the LLM.
"""
return TaskProfile(
id=str(task.id),
title=task.title or "",
description=task.description or "",
skills=list(task.skills) if task.skills else [],
)
def build_task_profile_from_requirements(
requirements: Requirements,
*,
task_id: str = "",
) -> TaskProfile:
"""Build a :class:`TaskProfile` from confirmed :class:`Requirements`.
Used for the ``Aufgabe → Kapazität`` search direction where no
persisted ``Task`` object exists, only the structured requirements
confirmed by the user. The role name is reused as the task title so
that the LLM still receives a non-empty title field.
Empty or ``None`` values are mapped to empty strings or empty lists so
that the resulting profile is never discarded (see requirement 4.2).
Args:
requirements: The confirmed structured requirements.
task_id: Optional task identifier; defaults to an empty string when
no task id is available.
Returns:
A :class:`TaskProfile` ready to be serialized and passed to the LLM.
"""
return TaskProfile(
id=task_id or "",
title=requirements.role_name or "",
description=requirements.description or "",
skills=(
list(requirements.competences) if requirements.competences else []
),
)
def build_team_profile(team: Team) -> TeamProfile:
"""Build a :class:`TeamProfile` from a :class:`Team`.
Performs a 1:1 mapping of the team's fields onto the profile, preserving
the order of :attr:`Team.competences` and :attr:`Team.references`. The
DB layer has already produced the deterministic order
(``(top_competency desc, name asc)`` for competences and
``(partner_name asc, projects asc)`` for references) and normalized
``NULL`` values to empty strings or ``False``, so this builder neither
re-sorts nor re-normalizes (see requirements 5.1, 5.2 and 13.4).
Args:
team: The team loaded from the database.
Returns:
A :class:`TeamProfile` ready to be serialized and passed to the LLM
or rendered by the team detail tool.
"""
competences = [
TeamCompetenceEntry(
name=competence.name,
top_competency=competence.top_competency,
)
for competence in team.competences
]
references = [
TeamReferenceEntry(
partner_name=reference.partner_name,
projects=reference.projects,
)
for reference in team.references
]
return TeamProfile(
id=str(team.team_id),
ouid=team.ouid,
team_name=team.team_name,
focus_name=team.focus_name,
about_us=team.about_us,
offerings=team.offerings,
interests=team.interests,
competences=competences,
references=references,
)
def _format_reference(entry: CapacityReferenceEntry) -> str:
"""Format a single :class:`CapacityReferenceEntry` deterministically.
The output uses one of two stable shapes:
* ``Partner: <partner_name> Projekte: <projects>`` when
``partner_name`` is non-empty, where ```` is U+2013 (en dash).
* ``Projekte: <projects>`` when ``partner_name`` is empty. No
placeholder and no ``Partner:`` token is emitted in this case
(see requirements 2.8, 3.4 and 3.6).
The ``projects`` value is stripped of surrounding whitespace before
rendering so the output is canonical.
Args:
entry: The reference entry to format.
Returns:
The deterministic, single-line string representation of the
reference.
"""
projects = entry.projects.strip()
if entry.partner_name:
return f"Partner: {entry.partner_name} \u2013 Projekte: {projects}"
return f"Projekte: {projects}"
def serialize_capacity_profile(profile: CapacityProfile) -> str:
"""Serialize a :class:`CapacityProfile` into a deterministic string.
The output has a fixed, stable field order and always contains every
field heading, even when the corresponding value is empty
(see requirements 3.3, 3.7). Empty lists are rendered as ``(keine)``
(see requirements 3.3, 3.4). The order of references mirrors the
order in :attr:`CapacityProfile.references`, which is in turn the
stable order returned by the ``DBClient`` (see requirement 3.4).
The fixed field order is:
1. ``Rolle: <role_name>``
2. ``Kompetenzen: <comma-separated competences>`` or
``Kompetenzen: (keine)`` when empty.
3. ``Beschreibung: <description>``
4. ``Referenzen:`` followed by either a bullet list of formatted
references (one per line, prefixed with ``- ``) or `` (keine)``
on the same line when there are no references.
5. ``Zertifikate:`` analogous to ``Referenzen:``.
Args:
profile: The capacity profile to serialize.
Returns:
A multi-line string suitable for inclusion in the LLM user prompt.
"""
competences_line = (
"Kompetenzen: " + ", ".join(profile.competences)
if profile.competences
else "Kompetenzen: (keine)"
)
if profile.references:
refs = [_format_reference(r) for r in profile.references]
references_line = "Referenzen:\n- " + "\n- ".join(refs)
else:
references_line = "Referenzen: (keine)"
if profile.certificates:
certificates_line = (
"Zertifikate:\n- " + "\n- ".join(profile.certificates)
)
else:
certificates_line = "Zertifikate: (keine)"
return "\n".join(
[
f"Rolle: {profile.role_name}",
competences_line,
f"Beschreibung: {profile.description}",
references_line,
certificates_line,
]
)
def serialize_task_profile(profile: TaskProfile) -> str:
"""Serialize a :class:`TaskProfile` into a deterministic string.
The output has a fixed, stable field order and always contains every
field heading, even when the corresponding value is empty
(see requirements 4.3, 4.4). Empty skill lists are rendered as
``(keine)`` (see requirement 4.3).
The fixed field order is:
1. ``Titel: <title>``
2. ``Beschreibung: <description>``
3. ``Gesuchte Kompetenzen: <comma-separated skills>`` or
``Gesuchte Kompetenzen: (keine)`` when empty.
Args:
profile: The task profile to serialize.
Returns:
A multi-line string suitable for inclusion in the LLM user prompt.
"""
skills_line = (
"Gesuchte Kompetenzen: " + ", ".join(profile.skills)
if profile.skills
else "Gesuchte Kompetenzen: (keine)"
)
return "\n".join(
[
f"Titel: {profile.title}",
f"Beschreibung: {profile.description}",
skills_line,
]
)
def _format_team_reference(entry: TeamReferenceEntry) -> str:
"""Format a single :class:`TeamReferenceEntry` deterministically.
The output uses one of two stable shapes:
* ``Partner: <partner_name> Projekte: <projects>`` when
``partner_name`` is non-empty, where ```` is U+2013 (en dash).
* ``Projekte: <projects>`` when ``partner_name`` is empty. No
placeholder and no ``Partner:`` token is emitted in this case
(see requirements 5.3 and 5.6).
The ``projects`` value is stripped of surrounding whitespace before
rendering so the output is canonical.
Args:
entry: The reference entry to format.
Returns:
The deterministic, single-line string representation of the
reference.
"""
projects = entry.projects.strip()
if entry.partner_name:
return f"Partner: {entry.partner_name} \u2013 Projekte: {projects}"
return f"Projekte: {projects}"
def serialize_team_profile(profile: TeamProfile) -> str:
"""Serialize a :class:`TeamProfile` into a deterministic string.
The output has a fixed, stable field order and always contains every
field heading, even when the corresponding value is empty (see
requirements 5.2, 5.4, 5.7, 13.1, 13.3). Empty lists are rendered as
``Kompetenzen: (keine)`` and ``Referenzen: (keine)`` on a single line
(see requirement 5.2). The order of competences and references
mirrors the order in :attr:`TeamProfile.competences` and
:attr:`TeamProfile.references`, which is in turn the stable order
returned by the ``DBClient`` (see requirement 13.4). Top competences
are marked with the ``(Top)`` suffix (see requirement 5.5).
The fixed field order is:
1. ``Teamname: <team_name>``
2. ``Schwerpunkt: <focus_name>``
3. ``Über uns: <about_us>``
4. ``Leistungen: <offerings>``
5. ``Interessen: <interests>``
6. ``Kompetenzen:`` followed by either a bullet list of competence
names (``- <name>`` or ``- <name> (Top)``) or rendered as
``Kompetenzen: (keine)`` on a single line when empty.
7. ``Referenzen:`` analogous to ``Kompetenzen:``, with each reference
formatted as ``Partner: <partner_name> Projekte: <projects>`` or
``Projekte: <projects>`` when the partner name is empty.
Args:
profile: The team profile to serialize.
Returns:
A multi-line string suitable for inclusion in the LLM user prompt
or the ``get_team_details`` rendering.
"""
if profile.competences:
competence_lines = [
f"- {c.name} (Top)" if c.top_competency else f"- {c.name}"
for c in profile.competences
]
competences_line = "Kompetenzen:\n" + "\n".join(competence_lines)
else:
competences_line = "Kompetenzen: (keine)"
if profile.references:
refs = [_format_team_reference(r) for r in profile.references]
references_line = "Referenzen:\n- " + "\n- ".join(refs)
else:
references_line = "Referenzen: (keine)"
return "\n".join(
[
f"Teamname: {profile.team_name}",
f"Schwerpunkt: {profile.focus_name}",
f"Über uns: {profile.about_us}",
f"Leistungen: {profile.offerings}",
f"Interessen: {profile.interests}",
competences_line,
references_line,
]
)
@@ -0,0 +1,76 @@
# filepath: src/teamlandkarte_mcp/matching/rrf.py
"""Reciprocal Rank Fusion (RRF) for combining ranked lists.
This module implements the RRF formula from Cormack et al. (2009) and is
intentionally kept minimal: it fuses one or more ranked lists of
``(text, score)`` pairs into a single normalized score dict.
The zero-out rule is critical: any candidate whose BM25 score is 0.0 in **all**
contributing lists receives a fused score of 0.0 as well. This ensures that
candidates with no token overlap are never promoted by the rank-based formula.
"""
from __future__ import annotations
def reciprocal_rank_fusion(
ranked_lists: list[list[tuple[str, float]]],
*,
k: int = 60,
) -> dict[str, float]:
"""Fuse multiple ranked lists using Reciprocal Rank Fusion.
For each candidate present in any contributing list the raw fused score is::
raw(c) = Σ_i 1 / (k + rank_i(c))
where ``rank_i(c)`` is the 1-based position of candidate *c* in list *i*.
**Zero-out rule:** a list whose scores are all 0.0 provides no token-overlap
signal and is skipped entirely. Candidates with BM25 score 0.0 within a
contributing list do not receive any RRF credit from that list.
**Normalization:** the highest raw score is mapped to 1.0 so the output
falls in ``[0.0, 1.0]``.
Args:
ranked_lists: One or more ranked lists of ``(text, score)`` pairs.
Each list **must** be sorted by score descending. Lists where all
scores are 0.0 are silently skipped (no token-overlap signal).
k: RRF smoothing constant (default 60, Cormack et al. 2009).
Higher *k* flattens rank differences; lower *k* amplifies them.
Returns:
Mapping of candidate text → normalized fused score in ``[0.0, 1.0]``.
Returns an empty dict if all input lists are empty or all-zero.
Examples::
>>> reciprocal_rank_fusion([[("Python", 3.5)]])
{'Python': 1.0}
>>> reciprocal_rank_fusion([[("A", 0.0), ("B", 0.0)]])
{}
"""
fused: dict[str, float] = {}
for ranked_list in ranked_lists:
if not ranked_list:
continue
# Skip lists with no BM25 signal at all (zero-out rule).
if all(score == 0.0 for _, score in ranked_list):
continue
for rank, (candidate, score) in enumerate(ranked_list, start=1):
# Candidates without token overlap do not receive RRF credit.
if score == 0.0:
continue
fused[candidate] = fused.get(candidate, 0.0) + 1.0 / (k + rank)
if not fused:
return {}
max_score = max(fused.values())
if max_score <= 0.0:
return {}
return {candidate: raw / max_score for candidate, raw in fused.items()}
@@ -0,0 +1,78 @@
from __future__ import annotations
from dataclasses import dataclass
from teamlandkarte_mcp.config import MatchingConfig
@dataclass(frozen=True)
class ScoreBreakdown:
"""Detailed scoring breakdown for a matched capacity.
Attributes:
competence_score: Score in [0.0, 1.0] derived from competence matching.
role_score: Score in [0.0, 1.0] derived from role matching.
overall_score: Weighted score in [0.0, 1.0].
category: Human-readable label based on configured thresholds.
"""
competence_score: float
role_score: float
overall_score: float
category: str
def categorize(score: float, cfg: MatchingConfig) -> str:
"""Map an overall score to a category label.
Args:
score: Overall score in [0.0, 1.0]. Values outside the range are
clamped.
cfg: Matching configuration providing threshold values.
Returns:
One of: "Top", "Good", "Partial", "Low", "Irrelevant".
"""
score = max(0.0, min(1.0, float(score)))
if score >= cfg.thresholds.top:
return "Top"
if score >= cfg.thresholds.good:
return "Good"
if score >= cfg.thresholds.partial:
return "Partial"
if score >= cfg.thresholds.low:
return "Low"
return "Irrelevant"
def compute_overall(
competence_score: float, role_score: float, cfg: MatchingConfig
) -> ScoreBreakdown:
"""Compute the weighted overall score and category.
Round 6 semantics:
overall = competence (0.8) + role (0.2)
Args:
competence_score: Competence score in [0.0, 1.0]. Values outside the
range are clamped.
role_score: Role score in [0.0, 1.0]. Values outside the range are
clamped.
cfg: Matching configuration including weights + thresholds.
Returns:
ScoreBreakdown containing the weighted overall score and category.
"""
cs = max(0.0, min(1.0, float(competence_score)))
rs = max(0.0, min(1.0, float(role_score)))
overall = (cs * cfg.competence_weight) + (rs * cfg.role_weight)
overall = max(0.0, min(1.0, overall))
return ScoreBreakdown(
competence_score=cs,
role_score=rs,
overall_score=overall,
category=categorize(overall, cfg),
)
@@ -0,0 +1,250 @@
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
from teamlandkarte_mcp.azure.openai_client import AzureOpenAIClient
from teamlandkarte_mcp.azure.cost_tracker import CostTracker
from teamlandkarte_mcp.matching.bm25 import Bm25Index, bm25_rank_competences
from teamlandkarte_mcp.matching.rrf import reciprocal_rank_fusion
if TYPE_CHECKING:
from teamlandkarte_mcp.matching.auto_tagger import AutoTagger
LOGGER = logging.getLogger(__name__)
def _normalize_text(s: str) -> str:
"""Normalize text for caching and similarity.
Normalization is trim + collapsing internal whitespace.
"""
return " ".join((s or "").strip().split())
@dataclass(frozen=True)
class SimilarityCandidate:
text: str
score: float
@dataclass(frozen=True)
class SimilarityMatch:
required: str
score: float
best_match: Optional[str]
rationale: str
_ROLE_SIMILARITY_SYSTEM_PROMPT = (
"You are a job-role similarity expert. Given two role names, "
"determine their semantic similarity on a scale from 0.0 to 1.0. "
"0.0 means completely unrelated roles, 1.0 means identical or "
"interchangeable roles. Consider synonyms, hierarchy, and domain overlap. "
'Respond with a JSON object: {"similarity": <float>}'
)
class SimilarityEngine:
"""Compute similarity scores using BM25+RRF and LLM."""
def __init__(
self,
*,
client: AzureOpenAIClient,
cost_tracker: CostTracker | None = None,
use_auto_tagging: bool = False,
auto_tagger: AutoTagger | None = None,
) -> None:
"""Create a new SimilarityEngine.
Args:
client: Azure OpenAI client wrapper.
cost_tracker: Optional CostTracker for accounting.
use_auto_tagging: When ``True``, the
:class:`~teamlandkarte_mcp.matching.auto_tagger.AutoTagger`
pre-expands each candidate's competence list before BM25
scoring. The expansion is ephemeral. Default: ``False``.
auto_tagger: Pre-constructed :class:`~teamlandkarte_mcp.matching.auto_tagger.AutoTagger`
instance. Required when ``use_auto_tagging=True``; ignored
otherwise.
"""
self._client = client
self._cost_tracker = cost_tracker
self._use_auto_tagging = use_auto_tagging
self._auto_tagger = auto_tagger
# Per-run cache for LLM role similarity: (role_a, role_b) → score
self._role_similarity_cache: dict[tuple[str, str], float] = {}
@staticmethod
def _is_bad_role(role: Optional[str]) -> bool:
"""Return True if the role is empty, None, or '(unknown)'."""
if role is None:
return True
t = (str(role) or "").strip()
if not t:
return True
return t.lower() == "(unknown)"
async def compute_competence_similarity(
self,
required: list[str],
candidate: list[str],
global_index: Bm25Index | None = None,
) -> dict[str, dict[str, object]]:
"""BM25+RRF competence similarity (einziger Pfad).
Args:
required: List of required competence strings.
candidate: Candidate's competence strings.
global_index: Pre-built :class:`~teamlandkarte_mcp.matching.bm25.Bm25Index`
over all candidates in the pool. When provided, IDF weights
reflect the full candidate pool. Falls back to a local index
when ``None``.
Returns:
Mapping of required competence → ``{"score", "best_match",
"rationale"}``.
"""
working = list(candidate)
if self._use_auto_tagging and self._auto_tagger is not None:
working = await self._auto_tagger.expand_competences(required, candidate)
return self._bm25_rrf_similarity(required, working, global_index=global_index)
def _bm25_rrf_similarity(
self,
required: list[str],
candidate: list[str],
global_index: Bm25Index | None = None,
) -> dict[str, dict[str, object]]:
"""Compute per-skill competence similarity using BM25 + RRF.
For each required competence, ranks candidates via BM25 and normalizes
via :func:`reciprocal_rank_fusion`.
When ``global_index`` is provided, IDF weights are derived from the
full candidate pool rather than from a single person's skill list.
The global ranked list is filtered to only include the current
candidate's competences before RRF fusion, so scores remain
per-candidate while benefiting from stable, pool-wide IDF.
When ``global_index`` is ``None``, a temporary local index is built
over ``candidate`` as a fallback (useful in tests and standalone
contexts, but subject to small-corpus IDF pathologies).
Candidates with no token overlap (BM25 score 0.0) receive a fused
score of 0.0, eliminating embedding-based false positives.
The output shape is identical to the legacy per-skill method so this
method is a transparent drop-in replacement.
Args:
required: List of required competence strings.
candidate: Candidate's competence strings (may be expanded by
:class:`~teamlandkarte_mcp.matching.auto_tagger.AutoTagger`).
global_index: Optional pre-built global :class:`Bm25Index`.
When supplied, this index is queried and results filtered to
``candidate``. When ``None``, a local index is built over
``candidate`` instead.
Returns:
Mapping of required competence → ``{"score", "best_match",
"rationale"}``.
"""
required = [r for r in (required or []) if str(r).strip()]
candidate = [c for c in (candidate or []) if str(c).strip()]
if not required:
return {}
if not candidate:
return {
r: {
"score": 0.0,
"best_match": None,
"rationale": "No candidate competences provided.",
}
for r in required
}
candidate_set = set(candidate)
out: dict[str, dict[str, object]] = {}
for req in required:
if global_index is not None:
# Use global IDF: rank the whole pool, then keep only this
# candidate's skills.
all_ranked = global_index.rank(req)
ranked = [(doc, score) for doc, score in all_ranked if doc in candidate_set]
else:
ranked = bm25_rank_competences(req, candidate)
fused = reciprocal_rank_fusion([ranked])
if not fused:
out[req] = {
"score": 0.0,
"best_match": None,
"rationale": "BM25: no token overlap with any candidate competence.",
}
else:
best_match = max(fused, key=fused.__getitem__)
score = float(fused[best_match])
out[req] = {
"score": score,
"best_match": best_match,
"rationale": (
f"BM25+RRF: best match {best_match!r} (score {score:.3f})."
),
}
return out
async def compute_role_similarity(
self,
required_role: Optional[str],
candidate_role: Optional[str],
) -> float:
"""LLM-basierte Rollen-Similarity.
Contract:
- If either role is empty/None or equals "(unknown)"
(case-insensitive), returns 0.0.
- For identical roles (case-insensitive), returns 1.0.
- Otherwise uses LLM chat completion to determine similarity.
- Results are cached symmetrically for the lifetime of this instance.
- On any exception, returns 0.0 without caching.
"""
if self._is_bad_role(required_role) or self._is_bad_role(candidate_role):
return 0.0
req_norm = str(required_role).strip().lower()
cand_norm = str(candidate_role).strip().lower()
if req_norm == cand_norm:
return 1.0
# Symmetrischer Cache-Key
a, b = sorted((req_norm, cand_norm))
cache_key: tuple[str, str] = (a, b)
if cache_key in self._role_similarity_cache:
return self._role_similarity_cache[cache_key]
try:
raw = await self._client.chat_completion(
system=_ROLE_SIMILARITY_SYSTEM_PROMPT,
user=f"Role A: {required_role}\nRole B: {candidate_role}",
)
payload = json.loads(raw)
score = float(payload.get("similarity", 0.0))
score = max(0.0, min(1.0, score))
except Exception:
return 0.0
self._role_similarity_cache[cache_key] = score
return score
def clear_role_cache(self) -> None:
"""Cache zwischen Matching-Runs leeren."""
self._role_similarity_cache.clear()
@@ -0,0 +1,23 @@
from __future__ import annotations
"""Deprecated chat-based task analyzer.
Azure chat-based extraction was removed as part of OpenSpec change
`add-capacity-to-task-matching-tools` Phase 7.
The server now uses LLM-based inference for role and competence matching.
"""
class AnalysisError(RuntimeError):
"""Raised when task analysis is requested but no analyzer is available."""
class TaskAnalyzer: # pragma: no cover
"""Deprecated stub.
Any legacy call sites should be migrated to LLM-based inference.
"""
def __init__(self, *args, **kwargs) -> None: # noqa: D401
raise AnalysisError("TaskAnalyzer is no longer supported (Azure chat removed).")
@@ -0,0 +1,21 @@
"""Legacy task helper (deprecated).
This module previously combined DB task fields with chat-based extraction via
`TaskAnalyzer`.
As of Phase 7 (Azure chat removal), requirement extraction is now LLM-based
and implemented directly in server tools.
"""
from __future__ import annotations
class TaskHelpersDeprecatedError(RuntimeError):
"""Raised when deprecated task helper functions are called."""
async def extract_requirements_from_task(*args, **kwargs): # pragma: no cover
raise TaskHelpersDeprecatedError(
"extract_requirements_from_task is deprecated (Azure chat removed). "
"Use validate_task_requirements(...) and LLM-based inference."
)
@@ -0,0 +1,152 @@
from __future__ import annotations
import json
import logging
from teamlandkarte_mcp.azure.openai_client import AzureOpenAIClient
from teamlandkarte_mcp.database.types import DBClient
LOGGER = logging.getLogger(__name__)
_ROLE_INFERENCE_SYSTEM_PROMPT = (
"You are a role classification expert. Given a task description and a list "
"of available roles, select the single most appropriate role for the task. "
"You MUST select exactly one role from the provided list. "
"Respond with a JSON object: "
'{"role": "<selected role name>", "confidence": <float 0.0-1.0>}'
)
_COMPETENCE_INFERENCE_SYSTEM_PROMPT = (
"You are a competence matching expert. Given a task description and a list "
"of available competences, select the most relevant competences for the task. "
"You MUST select only competences from the provided list. "
"Select at most {max_competences} competences. "
"Respond with a JSON object: "
'{{"competences": [{{"name": "<competence name>", "confidence": <float 0.0-1.0>}}]}}'
)
class VocabularyCache:
"""LLM-basierte Rollen-Inferenz aus Task-Text."""
def __init__(
self,
*,
db: DBClient,
client: AzureOpenAIClient,
) -> None:
self._db = db
self._client = client
async def infer_primary_role(
self,
*,
task_text: str,
) -> tuple[str, float] | None:
"""Infer the best matching role for a task text via LLM.
Args:
task_text: The task text (title and/or description).
Returns:
Tuple of (role_name, confidence) or None on failure/empty input.
"""
role_names = self._db.get_all_role_names()
if not role_names:
return None
text = (task_text or "").strip()
if not text:
return None
user_prompt = (
f"Task: {text}\n\n"
f"Available roles: {json.dumps(list(role_names), ensure_ascii=False)}"
)
try:
raw = await self._client.chat_completion(
system=_ROLE_INFERENCE_SYSTEM_PROMPT,
user=user_prompt,
)
payload = json.loads(raw)
role = str(payload.get("role", "")).strip()
confidence = float(payload.get("confidence", 0.0))
confidence = max(0.0, min(1.0, confidence))
# Validate: role must exist in the DB role list
if role not in set(role_names):
return None
return role, confidence
except Exception:
LOGGER.warning("VocabularyCache.infer_primary_role: LLM call failed", exc_info=True)
return None
async def infer_competences(
self,
*,
task_text: str,
max_competences: int = 10,
) -> list[tuple[str, float]]:
"""Infer matching competences for a task text via LLM.
Args:
task_text: The task text (title and/or description).
max_competences: Maximum number of competences to return.
Returns:
List of (competence_name, confidence) tuples, or [] on
failure/empty input.
"""
competence_names = self._db.get_all_competence_names()
if not competence_names:
return []
text = (task_text or "").strip()
if not text:
return []
system_prompt = _COMPETENCE_INFERENCE_SYSTEM_PROMPT.format(
max_competences=max_competences,
)
user_prompt = (
f"Task: {text}\n\n"
f"Available competences: "
f"{json.dumps(competence_names, ensure_ascii=False)}"
)
try:
raw = await self._client.chat_completion(
system=system_prompt,
user=user_prompt,
)
payload = json.loads(raw)
items = payload.get("competences", [])
if not isinstance(items, list):
return []
known = set(competence_names)
results: list[tuple[str, float]] = []
for item in items:
if not isinstance(item, dict):
continue
name = str(item.get("name", ""))
if name not in known:
continue
try:
confidence = float(item.get("confidence", 0.0))
except (TypeError, ValueError):
continue
confidence = max(0.0, min(1.0, confidence))
results.append((name, confidence))
if len(results) >= max_competences:
break
return results
except Exception:
LOGGER.warning(
"VocabularyCache.infer_competences: LLM call failed",
exc_info=True,
)
return []
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Optional
@dataclass(frozen=True)
class Task:
"""Published task loaded from the database."""
id: str
name: Optional[str]
title: str
description: str
start_date: Optional[date]
end_date: Optional[date]
created_date: datetime
skills: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class Capacity:
"""A person's available capacity entry from the database."""
id: int
owner_name: str
role_name: Optional[str]
role_level: Optional[str]
begin_date: Optional[date]
end_date: Optional[date]
competences: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class Requirements:
"""Structured matching requirements."""
role_name: Optional[str]
competences: list[str]
date_start: Optional[date] = None
date_end: Optional[date] = None
description: Optional[str] = None
@dataclass(frozen=True)
class RankedRole:
"""A role candidate extracted from a description.
Attributes:
rank: 1-based rank.
role: Role name.
rationale: Short explanation for why this role matches.
"""
rank: int
role: str
rationale: str
@dataclass(frozen=True)
class ScoredCapacity:
"""A `Capacity` plus matching scores and diagnostic lists."""
capacity: Capacity
competence_score: float
role_score: float
overall_score: float
category: str
matched_competences: list[str] = field(default_factory=list)
missing_competences: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class TeamCompetence:
"""Eine Team-Kompetenz mit aufgelöstem Namen und Top-Markierung."""
name: str
top_competency: bool
@dataclass(frozen=True)
class TeamReference:
"""Eine Team-Referenz mit aufgelöstem Partner-Namen und Projekttext.
`partner_name` ist leer, wenn `partner_id` `NULL` ist oder der LEFT JOIN
auf `teamlandkarte_v_partners_latest` keinen Treffer liefert. `projects`
ist bereits getrimmt und nie leer (Whitespace-only-Werte werden im
DBClient ausgefiltert).
"""
partner_name: str
projects: str
@dataclass(frozen=True)
class Team:
"""Aggregiertes Team-Stammdatum aus dem Data Lake."""
team_id: str
ouid: str
team_name: str
focus_name: str
about_us: str
offerings: str
interests: str
competences: list[TeamCompetence] = field(default_factory=list)
references: list[TeamReference] = field(default_factory=list)
@dataclass(frozen=True)
class ScoredTeam:
"""A `Team` plus matching scores and diagnostic lists (score mode only)."""
team: Team
competence_score: float
role_score: float
overall_score: float
category: str
matched_competences: list[str] = field(default_factory=list)
missing_competences: list[str] = field(default_factory=list)
@@ -0,0 +1,60 @@
from __future__ import annotations
from datetime import date
from typing import Optional
def parse_iso_date(value: str | None) -> Optional[date]:
"""Parse an ISO date string (YYYY-MM-DD) into a `date`.
Args:
value: ISO date string, or None/empty.
Returns:
Parsed date, or None if the input is None/empty.
Raises:
ValueError: If the input is not a valid ISO date.
"""
if value is None:
return None
v = value.strip()
if not v:
return None
return date.fromisoformat(v)
def availability_overlaps(
capacity_begin: Optional[date],
capacity_end: Optional[date],
required_start: Optional[date],
required_end: Optional[date],
) -> bool:
"""Check whether a capacity overlaps a required date range.
This implements inclusive interval overlap with open-ended end dates.
Args:
capacity_begin: Capacity start date (inclusive). None means unknown.
capacity_end: Capacity end date (inclusive). None means open-ended.
required_start: Filter start date (inclusive). None means unbounded.
required_end: Filter end date (inclusive). None means open-ended.
Returns:
True if the intervals overlap, otherwise False.
Notes:
This function is used for filtering/display only.
Availability is not part of scoring.
"""
if required_start is None and required_end is None:
return True
cap_start = capacity_begin or date.min
cap_end = capacity_end or date.max
req_start = required_start or date.min
req_end = required_end or date.max
return cap_start <= req_end and req_start <= cap_end
@@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Iterable, Sequence
def md_table(headers: Sequence[str], rows: Iterable[Sequence[str]]) -> str:
"""Render a simple GitHub-flavored Markdown table.
Args:
headers: Column header strings.
rows: Row values.
Returns:
Markdown table as a single string.
"""
header_line = "| " + " | ".join(str(h) for h in headers) + " |"
sep_line = "| " + " | ".join(["---"] * len(headers)) + " |"
body_lines = ["| " + " | ".join(str(cell) for cell in row) + " |" for row in rows]
return "\n".join([header_line, sep_line, *body_lines])