Files
Orchestrator/bahn/teamlandkarte-mcp/openspec/changes/add-capacity-matching-mcp-server/design.md
T
ankn a5f8fb49ab Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
2026-06-30 20:39:52 +02:00

608 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design: Capacity Matching MCP Server
> **Note**: For comprehensive architecture documentation including ADRs, quality requirements, and deployment view, see [architecture.md](./architecture.md).
## Architecture Overview
> **Update (2026-02)**: The server uses **Azure OpenAI** for role/requirements
> extraction and similarity scoring via embeddings.
> There are **no heuristic fallbacks** for these features.
>
> The embedding model is `text-embedding-3-large` with **3072 dimensions** and
> results are cached in a local SQLite embedding cache.
## Embedding similarity acceleration (global prefetch + batch embeddings)
> **Update (2026-02)**: Embedding similarity is optimized via **global bulk prefetch**
> plus **true Azure embeddings batch requests**. This is primarily implemented in
> `SimilarityEngine` and `AzureOpenAIClient`.
### Motivation
The matching pipeline can be slow if embeddings are requested on-demand from inner
loops. While the system already uses a persistent SQLite embedding cache
(`EmbeddingCache`), additional optimization is necessary to avoid repeated:
- normalization + cache-key computation
- SQLite reads
- sequential Azure embedding calls for cache misses
### Goals
- Bulk prefetch embeddings for a matching run (roles + competences), across **all free
capacities at once** (not person-by-person / incremental embedding).
- Maintain deterministic behavior and strict error semantics.
- Preserve existing similarity contracts (`per_skill` / `aggregate`) and tool outputs.
### Data flow
1. **Collect** all texts that will be embedded in the current run:
- required competences
- candidate competences (from **all free capacities**)
- required roles
- candidate roles (from **all free capacities**)
2. **Normalize + deduplicate** using existing `_normalize_text()` logic:
- skip empty/whitespace-only texts
- emit a **Python logger warning** if all candidate competences normalize to empty
3. **Resolve embeddings** using a layered cache approach:
1) in-memory cache (instance-level, attached to `SimilarityEngine`; not created/cleared per invocation)
2) persistent SQLite embedding cache (`EmbeddingCache`)
3) Azure embeddings API (batch only the cache-missing texts; chunked)
4. **Compute** cosine similarities locally using the prefetched mapping.
### Normalization and cache keys
Cache key behavior remains unchanged to avoid invalidating the on-disk cache:
- normalization: trim + collapse whitespace
- key: SHA256 of `model|dims|normalized(text).lower()`
### Azure embeddings batch API + chunking
The Azure embeddings API supports embedding multiple inputs per request via
`input=[...]`. The implementation requirements are:
- preserve stable mapping from input texts → returned vectors
- rely on the `index` field in the API response if available
- otherwise assume stable ordering
- chunk large requests using `azure_openai.embedding_batch_size` (default: 128)
- strict failure semantics:
- the matching operation fails immediately on error
- log the failing **chunk input list** via Python logging
- cost tracking: log once per chunk with a count of embeddings requested
### Trade-offs
- Batch calls reduce network requests dramatically but increase blast radius: one
failing request affects multiple inputs.
- mitigation: keep retry logic at the chunk level
- Prefetch holds a larger in-memory mapping for the current run.
- mitigation: only store vectors required for that run (after dedup)
### Tests
- prefetch/dedup: client is called once per unique normalized text (cache misses only)
- cache layering: in-memory first, then SQLite, then Azure; no duplicate calls
- chunking: multiple Azure requests when `len(missing_texts) > batch_size`
- empty input handling: warning when all candidate competences normalize to empty
- strict error propagation: batch failures identify/log which chunk failed
```text
(diagram below is historical; “sampling” arrows represent the clients LLM runtime.
The MCP server itself does not rely on MCP “sampling” for Azure OpenAI calls.)
```
```
┌──────────────┐
│ MCP Client │
│ (with LLM) │
└──────┬───────┘
│ MCP protocol (tool calls)
┌───────────────────────────────────────────────────────────────────────┐
│ MCP Server │
│ │
│ Task Management Tools: │
│ - list_open_tasks │
│ - get_task_details (table-first) │
│ - validate_task_requirements (table-first) │
│ - find_capacities_for_task (requires confirm) │
│ - infer_roles │
│ │
│ Requirement Gathering Tools: │
│ - extract_requirements (writes pending) │
│ - update_requirements (writes pending) │
│ - collect_structured_requirement_data │
│ (writes pending) │
│ - start_guided_capture │
│ - guided_set_description │
│ - guided_set_role │
│ - guided_set_time_range (open-ended) │
│ - guided_set_competences (writes pending) │
│ - confirm_requirements │
│ │
│ Search Execution & Refinement: │
│ - find_matching_capacities (hard-gated) │
│ - filter_search_results │
│ - get_results_by_category │
│ │
│ Business Logic: Matcher / Scorer / Caches │
│ │
│ Integrations: │
│ - Trino / Open Data Lake (read-only SQL) │
│ - Azure OpenAI (embeddings only) │
└───────────────────────────────────────────────────────────────────────┘
┌────────────────────────────┐
│ Open Data Lake (Trino) │
└────────────────────────────┘
```
## Component Design
### 1. Configuration Layer (`src/teamlandkarte_mcp/config.py`)
**Purpose**: Load and validate configuration from `config.toml` and environment.
**Important**:
- `config.toml` contains **non-secret** settings only (endpoints, model/deployment names, cache settings).
- credentials come from environment (recommended: `.env`) and must not be committed:
- `DATA_LAKE_USERNAME`
- `DATA_LAKE_PASSWORD`
- `AZURE_OPENAI_EMBEDDING_API_KEY`
**config.toml structure (excerpt)**:
```toml
[database]
host = "..."
port = 8446
backend = "trino"
http_scheme = "https"
verify_ssl = true
catalog = "hive"
schema = "tier1_open_lake"
[azure_openai]
endpoint = "https://<resource>.openai.azure.com"
api_version = "2024-02-15-preview"
embedding_deployment = "text-embedding-3-large"
[embedding_cache]
path = ".cache/embeddings.sqlite3"
ttl_days = 30
[matching.similarity]
# `per_skill` = best-match per required competence
# `aggregate` = mean(required) vs mean(candidate)
strategy = "per_skill"
```
### Roundtrip examples (tool-level sequences)
These examples describe the intended end-to-end client/server interaction.
They explicitly include the strict **Review → Ask → Confirm** step before any
matching execution when `matching.require_confirmation = true`.
#### Roundtrip A: Ad-hoc search (underspecified) using guided capture
1. `start_guided_capture()`
2. `guided_set_description("...")` (must capture concrete scope/goal; skill-only is insufficient)
3. `guided_set_role("Backend Developer")`
4. `guided_set_time_range(date_start="2026-04-01", date_end="2026-06-30")`
5. `guided_set_competences(["Python", "FastAPI", "Docker"])`
6. **Review**: `show_pending_requirements()` (server returns a single review table)
7. **Ask** (assistant → user): "Soll ich diese Anforderungen so übernehmen und die Suche starten?" (Ja/Nein)
8. **Confirm** (only on "Ja"): `confirm_requirements(confirm=true)`
9. Execute: `find_matching_capacities(role_name="Backend Developer", competences=[...], date_start="2026-04-01", date_end="2026-06-30")`
- Server returns deterministic headers (`Using SEARCH_ID=...`, `SEARCH_ID=...`, `META=...`) plus `## Summary` and Top results.
If the user answers "Nein" in step 7, call `confirm_requirements(confirm=false)`
and continue capturing/updating requirements.
#### Roundtrip B: DB task workflow (task_id-based)
1. `list_open_tasks(limit=...)`
2. `get_task_details(task_id)`
3. Optional helpers:
- `infer_primary_role(task_id=...)` (if role unclear)
- `validate_task_requirements(task_id)` (**only if the user explicitly requests validation**; validation is independent from matching)
4. Capture/update requirements as needed (e.g. from task details)
5. **Review**: `show_pending_requirements()`
6. **Ask** (assistant → user): confirm Yes/No
7. **Confirm** (only on "Yes"): `confirm_requirements(confirm=true)`
8. Execute matching (depending on the client UX):
- `find_capacities_for_task(task_id)` OR
- `find_matching_capacities(...)`
> Alternative marker: If the assistant already displayed requirements to the user
> outside of the server review table, it MAY call `request_requirements_confirmation()`
> instead of `show_pending_requirements()`. The actual confirmation is still
> performed exclusively via `confirm_requirements(confirm=true)` after user approval.
### 2. Database Layer (`src/teamlandkarte_mcp/database/trino_client.py`)
**Purpose**: Manage Trino/Presto connectivity to the Open Data Lake and execute read-only queries.
**Database Schema**:
**Capacity Tables** (existing):
- `teamlandkarte_v_capacities_latest`
- Fields: `id`, `owner_name`, `role_name`, `role_level`, `begin_date`, `end_date`, `deletion_reason`
- Filter: `deletion_reason IS NULL` (active capacities only)
- `teamlandkarte_v_capacity_competences_latest`
- Fields: `capacity_id` (FK), `competence_id` (FK)
- JOIN: Links capacities to competences
- `teamlandkarte_v_competences_latest`
- Fields: `id`, `name`
- Stores competence/skill names
**Task Tables** (new for Round 5):
- `beschaffungstool_kmp_task_latest`
- Fields: `id`, `title__c`, `description__c`, `startdate__c`, `enddate__c`, `createddate`, `status__c`
- Filter: `status__c = "Veröffentlicht"` (published tasks only)
- Note: Role is NOT stored in DB, extracted from description
- `beschaffungstool_kmp_skill_latest`
- Fields: `task__c` (FK to task.id), `skillname__c`
- JOIN: Links tasks to their required skills
- Multiple rows per task (one per skill)
> Note: Trino catalog/schema are configured via `config.toml` (`catalog` / `schema`).
> The queries in code use unqualified view names.
**Key Classes**:
```python
class TrinoClient:
def __init__(self, config: DatabaseConfig):
# Initialize Trino DB-API connection
def get_all_capacities_with_competences(self) -> List[Capacity]:
"""Fetch all active capacities with their competences in a single query"""
query = """
SELECT
cap.id,
cap.owner_name,
cap.role_name,
cap.role_level,
cap.begin_date,
cap.end_date,
comp.name as competence_name
FROM teamlandkarte_v_capacities_latest cap
LEFT JOIN teamlandkarte_v_capacity_competences_latest cap_comp
ON cap.id = cap_comp.capacity_id
LEFT JOIN teamlandkarte_v_competences_latest comp
ON cap_comp.competence_id = comp.id
WHERE cap.deletion_reason IS NULL
ORDER BY cap.id, comp.name
"""
# Execute query with proper parameterization
# Group results by capacity_id in Python
# Return List[Capacity] with competences populated
def get_open_tasks(self, limit: int = 20) -> List[Task]:
"""Fetch latest open tasks with their skills."""
# Apply LIMIT to tasks before joining skills, otherwise one task with
# many skill rows can consume the full LIMIT.
query = """
WITH latest_tasks AS (
SELECT
t.id,
t.title__c,
t.description__c,
t.startdate__c,
t.enddate__c,
t.createddate
FROM beschaffungstool_kmp_task_latest t
WHERE t.status__c = ?
ORDER BY t.createddate DESC
LIMIT ?
)
SELECT
t.id,
t.title__c,
t.description__c,
t.startdate__c,
t.enddate__c,
t.createddate,
s.skillname__c
FROM latest_tasks t
LEFT JOIN beschaffungstool_kmp_skill_latest s
ON t.id = s.task__c
ORDER BY t.createddate DESC
"""
# Execute with limit parameter
# Group results by task_id (multiple rows per task for skills)
# Return List[Task] with skills populated
def get_task_by_id(self, task_id: str) -> Optional[Task]:
"""Fetch a single task with its skills by ID"""
query = """
SELECT
t.id,
t.title__c,
t.description__c,
t.startdate__c,
t.enddate__c,
t.createddate,
s.skillname__c
FROM beschaffungstool_kmp_task_latest t
LEFT JOIN beschaffungstool_kmp_skill_latest s
ON t.id = s.task__c
WHERE t.id = ? AND t.status__c = 'Veröffentlicht'
"""
# Execute with task_id parameter
# Group skill rows into single Task object
# Return Task or None if not found
@dataclass
class Capacity:
id: int
owner_name: str
role_name: str
role_level: str
begin_date: date
end_date: date
competences: List[str] = field(default_factory=list)
@dataclass
class Task:
id: str
title: str
description: str
start_date: Optional[date]
end_date: Optional[date]
created_date: datetime
skills: List[str] = field(default_factory.list)
```
**Safety**:
- Connection uses read-only user (enforced by DB permissions)
- Query validation to prevent any INSERT/UPDATE/DELETE
- Parameterized queries to prevent SQL injection
- Single join query reduces round-trips and complexity
**Implementation Note**:
The query returns multiple rows per capacity (one per competence). Python code groups these rows by capacity_id to create the final List[Capacity] structure.
### 3. Cache Layer (`src/cache/`)
**Purpose**: Provide two-tier caching for database queries and search results.
**Search session behavior**:
- Matching creates a new `search_id` (UUID) and stores full results in-memory.
- Filtering creates a new `filter_id` under the same `search_id`.
- Search-related tools emit a JSON block so clients can reliably capture IDs.
- Tools reject non-UUID `search_id` early to avoid confusing cache-miss flows.
**Implementation**:
```python
from cachetools import TTLCache
from datetime import timedelta
import json
import uuid
class QueryCache:
"""Database query cache with 12-hour TTL"""
def __init__(self, ttl_hours: int = 12, max_size: int = 100):
self.cache = TTLCache(
maxsize=max_size,
ttl=ttl_hours * 3600
)
self.stats = {"hits": 0, "misses": 0}
def get_or_fetch(self, key: str, fetch_fn: callable) -> Any:
if key in self.cache:
self.stats["hits"] += 1
return self.cache[key]
self.stats["misses"] += 1
result = fetch_fn()
self.cache[key] = result
return result
class SearchCache:
"""Search results cache with 60-minute TTL, keyed by search_id"""
def __init__(self, ttl_minutes: int = 60, max_size: int = 100):
self.cache = TTLCache(
maxsize=max_size,
ttl=ttl_minutes * 60
)
self.stats = {"hits": 0, "misses": 0}
def store_search(self, search_data: dict) -> str:
"""Store search results and return search_id"""
search_id = str(uuid.uuid4())
self.cache[search_id] = json.dumps(search_data)
return search_id
def get_search(self, search_id: str) -> Optional[dict]:
"""Retrieve search results by search_id"""
if search_id in self.cache:
self.stats["hits"] += 1
return json.loads(self.cache[search_id])
self.stats["misses"] += 1
return None
def update_search(self, search_id: str, search_data: dict) -> bool:
"""Update existing search results (e.g., add filters)"""
if search_id in self.cache:
self.cache[search_id] = json.dumps(search_data)
return True
return False
```
**Cache Keys**:
**QueryCache**:
- `"all_capacities_with_competences"` - Complete dataset from database
**SearchCache**:
- `<search_id>` (UUID) - Stores complete search results and associated filters as JSON:
```json
{
"task_id": "T-12345", // NEW: Reference to database task (null for ad-hoc searches)
"requirements": {
"required_competences": ["Python", "FastAPI"],
"preferred_role": "Backend Developer",
"candidate_roles": ["Backend Developer", "Software Engineer"],
"date_start": "2026-04-01",
"date_end": "2026-06-30"
},
"results_by_category": {
"Top": [...],
"Good": [...],
"Partial": [...],
"Low": [...]
},
"filters": {
"filter-1": {
"criteria": {
"role": "Developer",
"competences": ["Python", "Docker"],
"min_similarity": 0.7
},
"results_by_category": {
"Top": [...],
"Good": [...],
"Partial": [...],
"Low": [...]
},
"timestamp": "2026-02-11T14:35:00Z"
},
"filter-2": {
"criteria": {
"competences": ["Cloud"],
"min_similarity": 0.7
},
"results_by_category": {...},
"timestamp": "2026-02-11T14:40:00Z"
}
},
"timestamp": "2026-02-11T14:30:00Z"
}
```
**Notes**:
- `task_id` field is `null` for ad-hoc searches (via `find_matching_capacities`)
- `task_id` field contains database task ID for task-based searches (via `find_capacities_for_task`)
- This allows tracking the source of each search for audit and debugging purposes
**Session State** (in-memory, per client session):
- `"last_requirements"` - Stores TaskRequirements object for use with `update_requirements`
- Cleared when new search is executed via `find_matching_capacities`
### 4. Matching Engine (`src/matching/matcher.py`)
**Purpose**: Analyze tasks and match capacities based on requirements.
**Key Components**:
#### Matching Logic
```python
# NOTE (2026-02): This section is historical pseudocode. The current
# implementation uses Azure OpenAI via `AzureOpenAIClient` (chat + embeddings)
# and does not rely on MCP sampling.
class CapacityMatcher:
def __init__(
self,
config: MatchingConfig,
similarity_engine,
):
self.config = config
self.similarity_engine = similarity_engine
async def _match_competences(
self,
capacity_comps: List[str],
required_comps: List[str],
) -> float:
"""Compute similarity via embeddings + cosine similarity."""
details = await self.similarity_engine.compute_competence_similarity(
required=required_comps,
candidate=capacity_comps,
)
if not details:
return 0.0
return sum(d["score"] for d in details.values()) / len(details)
```
### 5. Scoring System (`src/matching/scorer.py`)
**Purpose**: Categorize match results into Top/Good/Partial/Low.
```python
class ScoreCategory(Enum):
TOP = "Top"
GOOD = "Good"
PARTIAL = "Partial"
LOW = "Low"
class Scorer:
def __init__(self, config: MatchingConfig):
self.thresholds = {
ScoreCategory.TOP: config.top_threshold,
ScoreCategory.GOOD: config.good_threshold,
ScoreCategory.PARTIAL: config.partial_threshold
}
def categorize(self, score: float) -> ScoreCategory:
if score >= self.thresholds[ScoreCategory.TOP]:
return ScoreCategory.TOP
elif score >= self.thresholds[ScoreCategory.GOOD]:
return ScoreCategory.GOOD
elif score >= self.thresholds[ScoreCategory.PARTIAL]:
return ScoreCategory.PARTIAL
else:
return ScoreCategory.LOW
def group_by_category(self, results: List[MatchResult]) -> Dict[ScoreCategory, List[MatchResult]]:
"""Group results by score category"""
grouped = {cat: [] for cat in ScoreCategory}
for result in results:
category = self.categorize(result.overall_score)
result.category = category
grouped[category].append(result)
return grouped
```
### 6. MCP Server (`src/mcp_server.py`)
**Purpose**: Expose capacity matching as MCP tools.
**Workflow Design**:
The server provides a **multi-step workflow** with separate tools for each phase:
0. **Task Management Phase** (NEW in Round 5):
- `list_open_tasks()` - Browse published tasks from database
- `get_task_details()` - View task details with extracted role
- `validate_task_requirements()` - Compare DB skills vs description-extracted skills
- `find_capacities_for_task()` - Automated capacity search for database task
1. **Requirement Gathering Phase** (Ad-hoc):
- `extract_requirements()` - Extract from natural language task description
- `collect_structured_requirement_data()` - Interactive step-by-step collection
- `update_requirements()` - Modify previously extracted requirements
2. **Search Execution Phase**:
- `find_matching_capacities()` - Execute search with confirmed requirements
3. **Result Refinement Phase**:
- `filter_search_results()` - Apply fuzzy filters to results
- `get_results_by_category()` - Browse results by score category
**Key Design Principles**:
- **Separation of Concerns**: Each tool has a single, clear responsibility
- **LLM State Management**: The client-side LLM tracks requirement state across tool calls
- **Explicit Confirmation**: Requirements are returned for user confirmation before search execution
- **Filter Chain Tracking**: Filters use `filter_id` to maintain navigation hierarchy
- **Database Task Integration**: Seamless workflow for published tasks with automatic validation
- **Common Extraction Logic**: DRY principle with shared `_extract_requirements_from_description()` function
- **Availability as filter**: Availability is displayed and can be used as a date-range filter (not part of scoring)
**MCP Tools**: