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:
@@ -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])
|
||||
Reference in New Issue
Block a user