Files
Orchestrator/bahn/teamlandkarte-mcp
..

Teamlandkarte MCP Server

An MCP (Model Context Protocol) server that enables AI assistants to match DB Systel employees with free work capacity to task requirements.

Key matching semantics (refined):

  • Role inference is title-first with description fallback.
  • Role and competence inference use separate cached embeddings.
  • Scores are shown as Role Score, Competence Score, and Overall Score.
  • Result categories include Top / Good / Partial / Low / Irrelevant.
    • The Low vs Irrelevant boundary is configurable via matching.thresholds.low.
  • Availability in matching outputs is shown as an overlap percentage with the reference time range.

Features

  • Dual input modes: free-text task description or structured parameters
  • Matching and scoring: competences (default 0.8) + role (default 0.2)
  • Interactive exploration: view Top results first, then explore other categories via pagination
  • Smart caching: DB cache (12h TTL) and search cache (60min TTL)
  • Read-only operations: safe, non-invasive database access
  • Embedding preload on startup (cache misses only):
    • role vocabulary + competence vocabulary from the Data Lake
    • all open tasks (embedded by task_id)
  • Stable, table-first outputs designed for MCP chat UIs:
    • Task/validation/role inference tools return Markdown tables first
    • Search tools emit deterministic headers for robust search_id parsing

Azure OpenAI usage

This server uses Azure OpenAI for:

  • Role inference (task text → closest role vocabulary entry) — embeddings
  • Competence inference (task text → closest competence vocabulary entries) — embeddings
  • Capacity↔task matching similarity (default: embeddings; optional: BM25+RRF)
  • Auto-Tagging competence expansion (optional: chat/LLM, requires use_auto_tagging = true)

The chat/LLM path is disabled by default. Enable it via use_auto_tagging = true in config.toml (requires AZURE_OPENAI_LLM_API_KEY and azure_openai.chat_deployment).

Quick Start

Prerequisites

  • Python 3.13+
  • Access to DB Systel Open Data Lake (Trino/Presto)
  • Valid database credentials
  • Azure OpenAI endpoint + embedding API key (via environment variables)

Installation

  1. Clone the repository:
git clone https://git.tech.rz.db.de/ThomasHandke/teamlandkarte-mcp.git
cd teamlandkarte-mcp
  1. Install dependencies:
uv sync
# or
pip install -e .
  1. Configure the server:
cp config.toml.example config.toml
# Edit config.toml with host/port, cache settings, and Azure OpenAI endpoint/deployment

# Create a .env file with credentials (do not commit):
# DATA_LAKE_USERNAME="..."
# DATA_LAKE_PASSWORD="..."
# AZURE_OPENAI_EMBEDDING_API_KEY="..."
# AZURE_OPENAI_LLM_API_KEY="..."   # only needed when use_auto_tagging = true

Running the Server

Run the MCP server locally via stdio:

From an activated project environment:

  • uv run teamlandkarte-mcp --config config.toml --log-level INFO

If your MCP host (e.g. Cherry Studio) cannot set a working directory, wrap it so the command runs in the project folder:

  • zsh -lc 'cd /Users/thomashandke/ws/teamlandkarte-mcp && uv run python -m teamlandkarte_mcp --config /Users/thomashandke/ws/teamlandkarte-mcp/config.toml --log-level DEBUG'

Important:

  • When using stdio MCP, stdout must be reserved for the JSON-RPC protocol. Logs go to stderr.

Running via Cherry Studio with uvx (Hot Reload)

If you develop locally and want hot reload without relying on a project-local virtualenv being activated in Cherry Studio, you can start the server through uvx and watchfiles.

This setup:

  • restarts the MCP server process on *.py changes
  • watches only src/teamlandkarte_mcp/
  • works with the SQLite embedding cache ([embedding_cache].path) because the cache is a file on disk and will be reused after restarts

Cherry Studio command (macOS / zsh):

  • Command: zsh
  • Args:
    • -lc
    • cd /Users/thomashandke/ws/teamlandkarte-mcp && uvx --from watchfiles watchmedo auto-restart --recursive --pattern '*.py' --directory src/teamlandkarte_mcp -- uv run python -m teamlandkarte_mcp --config /Users/thomashandke/ws/teamlandkarte-mcp/config.toml --log-level DEBUG

Notes:

  • The embedding cache DB file (for example .cache/embeddings.sqlite3) survives restarts; deleting it is safe but will increase Azure embedding calls until it warms up again.
  • If you see occasional database is locked errors during rapid file changes, it usually means two processes overlapped briefly. In that case, reduce the frequency of changes saved at once or adjust the watcher settings.

Configuration

Edit config.toml to customize:

  • Trino connection settings (host/port, TLS, catalog/schema)
  • Connection management: pool_size
  • Matching weights and thresholds (Round 6 semantics)
  • Cache TTLs and sizes
  • Confirmation gate (matching.require_confirmation)
  • Fuzzy filtering threshold (matching.fuzzy.min_similarity)
  • Competence inference knobs (matching.inference.*):
    • max_competences (int | null)
    • min_similarity (float | null)
  • Azure OpenAI settings ([azure_openai]) — embeddings + optional chat model for Auto-Tagging
  • Embedding cache settings ([embedding_cache])
  • BM25 + RRF lexical matching flags ([matching.similarity]):
    • use_bm25_search (boolean, default false): enables BM25+RRF lexical competence matching instead of embedding-based similarity. Skills with no token overlap score exactly 0.0, eliminating embedding-based false positives.
    • use_auto_tagging (boolean, default false): enables LLM pre-expansion of each candidate's competence list before BM25 scoring. Bridges synonym/abbreviation/cross-language gaps (e.g. "ML" → "Machine Learning"). Only active when use_bm25_search = true.
  • Chat model deployment for Auto-Tagging (azure_openai.chat_deployment, default ""): Azure deployment name for the chat model (e.g. "gpt-4.1"). Required when use_auto_tagging = true.

Database credentials are read from environment variables (recommended: a local .env file):

  • DATA_LAKE_USERNAME
  • DATA_LAKE_PASSWORD

Azure OpenAI credentials are read from environment variables:

  • AZURE_OPENAI_EMBEDDING_API_KEY — API key for embedding model (always required)
  • AZURE_OPENAI_LLM_API_KEY — API key for chat model used by Auto-Tagging (required only when use_auto_tagging = true)

See config.toml.example for a complete template.

For a step-by-step guide to configuring Azure deployments and keys, see:

  • docs/azure_openai_setup.md

Usage

The server supports two profile types for task matching, selected via the tool name:

Profile_Type Entry point What is matched
capacity (existing) find_matching_capacities(...) The task is matched against employee capacity profiles. Availability filters apply.
team (new) find_matching_teams(...) The task is matched against aggregated team profiles (Team_Name, Schwerpunkt, Über uns, Leistungen, Interessen, Top/Non-Top competences, references). Availability filters do not apply.

The persisted search session is tagged accordingly:

  • Capacity searches: search_type = "capacity_search" in SearchCache and the META JSON.
  • Team searches: search_type = "team_search" in SearchCache and the META JSON.

find_matching_teams accepts the same matching_method parameter (score | llm_fulltext) as find_matching_capacities. In score mode the team's focus_name is used as the role stand-in, and Top competences are upweighted by the configurable factor matching.team.top_competency_weight (default 1.5, must be numeric and >= 1.0).

Result columns differ between the two profile types:

Mode Columns (capacity) Columns (team)
score Owner, Role, Competences, Availability, Role Score, Competence Score, Overall Score, Category Team Name, Schwerpunkt, Top-Kompetenzen, Role Score, Competence Score, Overall Score, Category
llm_fulltext Owner, Role, Competences, Availability, Category, Begründung Team Name, Schwerpunkt, Top-Kompetenzen, Category, Begründung

Filter behavior on a team_search cache entry:

  • role_filter matches against team.focus_name (Schwerpunkt).
  • competence_filter matches against the team's competence list. Filter values with the suffix (Top) are restricted to Top competences (the suffix is stripped before comparison).
  • availability_date_start, availability_date_end, and is_fully_available are accepted but ignored. Each value is surfaced in the Applied Filters table with a team_search not-effective hint.

Matching Methods

The server supports two matching methods that callers select per call via the matching_method parameter on find_matching_capacities, find_matching_tasks, and find_matching_teams:

Value Description Output columns
score (default) BM25+RRF competence similarity + LLM role similarity. Existing behavior. Role Score, Competence Score, Overall Score, Category
llm_fulltext LLM-based full-text comparison of complete profiles via Azure OpenAI Chat Completion. One call per candidate, returns {category, rationale}. Category, Begründung (rationale, 12 sentences). No numeric scores.

When matching_method is omitted, the server uses the configured default:

[matching]
# Default method for new searches when callers omit matching_method.
# Allowed: "score" | "llm_fulltext"
default_method = "score"

Both methods share the same prefilters (e.g. availability), the same search_id/filter_id flow, and the same Markdown summary table. Differences:

  • In llm_fulltext mode the result tables use a Begründung column instead of the score columns. The persisted META JSON contains "matching_method": "llm_fulltext" so get_results_by_category and filter_search_results render the correct schema automatically.
  • In llm_fulltext mode filter_search_results ignores min_similarity (it is not applicable without numeric scores). It is surfaced in the Applied Filters table as a hint.

Additional database views in llm_fulltext mode

In addition to the views used by the score method, llm_fulltext reads:

  • teamlandkarte_v_capacities_latest.description
  • teamlandkarte_v_capacity_certificates_latest (joined on capacity_id)
  • teamlandkarte_v_capacity_references_latest (joined on capacity_id)
  • teamlandkarte_v_partners_latest — joined to references via a LEFT JOIN teamlandkarte_v_capacity_references_latest.partner_id = teamlandkarte_v_partners_latest.id. The partner name column is exposed as partner_name and surfaces inside the LLM-visible profile. When partner_id is NULL or no partner matches, partner_name is the empty string and the reference is still kept (only the Partner: token is omitted in the serialized profile).

The partner LEFT JOIN is part of the same SQL query that loads references; no extra round-trip is needed.

Example output (LLM mode)

A capacity-search result table in llm_fulltext mode looks roughly like this:

## Summary
| Category | Count |
| --- | --- |
| Top | 1 |
| Good | 2 |
| Partial | 0 |
| Low | 0 |
| Irrelevant | 1 |

## Top
| ID | Owner | Role | Competences | Availability | Category | Begründung |
| --- | --- | --- | --- | --- | --- | --- |
| 12345 | Erika Musterfrau | Senior Backend Developer | Python, FastAPI, PostgreSQL | 100% | Top | Profil deckt Python/FastAPI vollständig ab und enthält Referenzprojekte mit identischem Tech-Stack. |

A typical META line emitted by the matching tools (and echoed by get_results_by_category / filter_search_results):

META={"search_id":"7c0e6f50-...","filter_id":null,"default_category":"Top","matching_method":"llm_fulltext"}

In score mode the same META line carries "matching_method":"score" and the result table keeps the existing Role Score | Competence Score | Overall Score | Category columns.

Mini walkthrough (LLM mode)

A typical agent-driven flow when the user asks for an LLM-based capacity search:

  1. Capture and confirm requirements as usual (collect_structured_requirement_data, show_pending_requirements, confirm_requirements(confirm=true)).

  2. Ask the user once: "Welches Verfahren soll ich verwenden score oder llm_fulltext?"

  3. Call the tool with the chosen method:

    find_matching_capacities(
        role_name="Senior Backend Developer",
        competences=["Python", "FastAPI", "PostgreSQL"],
        date_start="2025-03-01",
        date_end="2025-12-31",
        matching_method="llm_fulltext",
    )
    
  4. The tool returns the Summary counts + the first non-empty category table (with the Begründung column) and emits a META line containing "matching_method":"llm_fulltext".

  5. Page through other categories with get_results_by_category(search_id=..., category="Good", ...). Because the persisted payload carries matching_method, the table is rendered automatically with Begründung instead of score columns.

  6. Optional refinement via filter_search_results(search_id=..., role_filter=..., competence_filter=...). min_similarity is accepted but ignored in this mode and surfaces in Applied Filters as min_similarity (ignored: not applicable in llm_fulltext mode).

Capacity browsing + capacity→task matching

New tools for capacity-driven workflows:

  • list_free_capacities(limit=20)
  • get_capacity_details(capacity_id)
  • find_matching_tasks(capacity_id)

Team browsing + team matching

Tools for team-driven workflows:

  • list_teams(limit=20) — Markdown table with columns Team Id, Team Name, Schwerpunkt, Anzahl Kompetenzen, Anzahl Referenzen.
  • get_team_details(team_id) — Markdown table for one team plus sections ## Über uns, ## Leistungen, ## Interessen, ## Kompetenzen (with (Top) marker), ## Referenzen (Partner_Name + Projekte) and ## Next steps. Returns Team not found: <team_id> for unknown ids.
  • find_matching_teams(role_name, competences, matching_method?) — Match a task against team profiles. No date parameters: availability filters are not applicable to team searches.

Task-driven matching (task→capacity)

Core tools:

  • list_open_tasks(limit=...)
  • get_task_details(task_id)
  • validate_task_requirements(task_id) (embeddings-only inference tables)
  • find_matching_capacities(role_name, competences, date_start?, date_end?)

Note on dates

With chat removed, dates are not extracted from free text. Provide dates via collect_structured_requirement_data(...) / guided capture.

Tool workflow overview

This server exposes multiple MCP tools for three main activities:

  1. DB task workflow (browse → inspect → validate → match)
  2. Ad-hoc requirement gathering (extract/update/structured/guided)
  3. Search execution + refinement (match → filter → page by category)

Important UX notes:

  • Scoring is categorical-first (Top/Good/Partial/Low). Numeric scores remain visible for transparency.
  • Matching can be hard-gated by confirmation (default). Requirement capture tools write pending requirements which must be confirmed before matching.

Confirmation gating

By default, matching is blocked until the user confirms the pending requirements:

Note: The server tracks pending/confirmed requirements in in-memory session state. This is a convenience guard for typical single-client chats and is not a multi-tenant source of truth. Clients/agents must enforce the confirmation rule themselves.

  • Capture requirements using one of:
    • extract_requirements(...)
    • collect_structured_requirement_data(...) (optionally with description=...)
    • guided capture tools (see below)
    • update_requirements(...)

If matching.require_confirmation = true:

  • Review + ask user for confirmation (must be after the last requirements update):
    • show_pending_requirements()
    • ask the user to confirm (Yes/No)
  • Confirm them (only after explicit user approval):
    • confirm_requirements(confirm=true)
  • Then run:
    • find_matching_capacities(...)

If matching.require_confirmation = false:

  • Do not ask for confirmation.
  • Do not call show_pending_requirements() / confirm_requirements().
  • Proceed directly to matching once requirements are complete.

You can disable the gate for convenience/testing via:

  • matching.require_confirmation = false in config.toml

Guided capture (step-by-step)

If the user did not provide enough project details, use the guided tools:

  1. start_guided_capture()
  2. guided_set_description(description)
  3. guided_set_role(role_name)
  4. guided_set_time_range(date_start?, date_end?) (open-ended allowed)
  5. guided_set_competences(competences) (writes pending requirements)

The assistant MUST ensure a concrete topic/goal description exists. A single skill request like "JavaScript" is not sufficient; ask for scope/goal.

  1. Review + ask user:
    • show_pending_requirements() (preferred)
    • ask the user to confirm (Yes/No)
  2. Confirm (only after user approval):
    • confirm_requirements(confirm=true)
  3. find_matching_capacities(...)

Search sessions: search_id / filter_id (robust parsing)

Search-related tools (find_matching_capacities, filter_search_results, get_results_by_category) emit deterministic, machine-parseable headers.

Always treat the most recent tool output as authoritative.

  • First line marker (required by some clients):
    • Using SEARCH_ID=<uuid>
  • Machine-readable headers:
    • SEARCH_ID=<uuid>
    • FILTER_ID=<uuid> (only when a filter was created)
    • META=<json> (includes status and echo of ids)

Output shape notes:

  • find_matching_capacities includes a ## Summary counts table (Top/Good/Partial/Low/Irrelevant) and a results table for the first non-empty category.

  • Matching and pagination tables show:

    • Availability as overlap %
    • Separate Role Score / Competence Score / Overall Score columns
    • No Level column in matching result tables (Level is only shown in capacity listing tools).
  • filter_search_results supports both search directions:

    • capacity search filtering (role/competences/availability)
    • task search filtering (task text and inferred required competences)
    • optional full-coverage availability (is_fully_available=true)
  • filter_search_results does not repeat the summary counts. It returns:

    • FILTER_ID=<uuid>
    • an Applied Filters table
    • a flat results table (top rows) with separate columns for Score and Category
    • the results table is always a Markdown table (even for a single matching row, and also when there are zero matches)
    • min_similarity is only applied when the user provides role_filter and/or competence_filter (availability-only filtering does not apply similarity).
  • get_results_by_category does not repeat the summary counts. It returns a single category page table.

Notes:

  • There is intentionally no tool that returns a "last search id" (multi-user/session unsafe). Always extract the id from tool output.
  • Pagination/filter tools reject non-UUID inputs early.
  • If a cache entry is missing/expired, the tools return a table-first error payload with META.status=unknown_or_expired.

Typical workflow (DB task)

  1. Call list_open_tasks(limit=...)
  2. Call get_task_details(task_id)
  3. (Optional, only on explicit user request) Call validate_task_requirements(task_id)
    • Validation is independent from matching and is not required to search.
  4. Capture/update requirements (e.g. via collect_structured_requirement_data(...))
  5. (If confirmation is enabled) review + confirm requirements (after the last update):
    • show_pending_requirements()
    • ask the user to confirm (Yes/No)
    • confirm_requirements(confirm=true)
  6. Call find_capacities_for_task(task_id) or find_matching_capacities(...)
    • Returns headers including Using SEARCH_ID=<uuid> / SEARCH_ID=<uuid>
  7. Optionally call filter_search_results(search_id, ...)
    • Returns FILTER_ID=<uuid> under the same SEARCH_ID
  8. Page through results using get_results_by_category(search_id, filter_id?, category, page, page_size)

If you see unknown_or_expired or invalid_search_id_format, re-run the matching tool to create a new search.

Notes:

  • Round 6 semantics: scoring = competence (0.8) + role (0.2) only
  • Availability is displayed and can be used as an optional date-range filter only
    • Open-ended capacity end dates (end_date = null) are treated as "available without limit" for filtering

Global rules

  1. Always prefer the latest search
  • When a tool returns a JSON block containing search_id, treat it as the current/latest search.
  • Replace any previously stored search context with this latest search_id.
  • If the user says “show Good/Partial/Low” without a search_id, use the latest search_id.
  • If the tool output contains a line Using SEARCH_ID=<uuid>, treat that exact value as authoritative.
  • Never guess or substitute a UUID from other context. Only use search IDs that were returned by tools.
  • Never “make up” a UUID and never copy a UUID from user prose/logs—only from tool output headers.
  1. Do not rerun a search unless necessary
  • If the user asks to show another category/page for an existing search, do not call find_matching_capacities again.
  • Only rerun matching if:
    • no prior search_id exists, or
    • the user explicitly requests a new search, or
    • get_results_by_category / filter_search_results returns “unknown_or_expired”. In that case: do not try another ID; rerun find_matching_capacities.
  1. If the users search_id is invalid
  • If the server returns “Invalid search_id format (expected UUID)”, instruct the user to copy the UUID from the tool output header (SEARCH_ID=<uuid> or META={...}).
  1. Confirmation gate
  • If find_matching_capacities returns a confirmation error (requirements must be confirmed), do not auto-confirm.
    • Call show_pending_requirements() (preferred) to display the review table.
    • Ask the user explicitly to confirm (Yes/No).
    • Only after user approval call confirm_requirements(confirm=true).
    • Then rerun the matching call.
    • If the user says No: call confirm_requirements(confirm=false) and continue requirement capture.
    • (Alternative marker: if requirements were already shown to the user in chat, call request_requirements_confirmation() before confirm_requirements(...).)
  • If confirmation is disabled by config, proceed directly.
  1. Be table-first
  • Prefer tool outputs that are Markdown tables.

Tools and when to use them

Database Schema

The server queries these views from the Open Data Lake (Trino):

Capacity profiles:

  • teamlandkarte_v_capacities_latest
  • teamlandkarte_v_capacity_competences_latest
  • teamlandkarte_v_competences_latest

Capacity LLM-fulltext profiles additionally read:

  • teamlandkarte_v_capacities_latest.description
  • teamlandkarte_v_capacity_certificates_latest (joined on capacity_id)
  • teamlandkarte_v_capacity_references_latest (joined on capacity_id)
  • teamlandkarte_v_partners_latest (LEFT JOIN on capacity_references.partner_id = partners.id)

Team profiles read four additional views (used by find_matching_teams, list_teams, and get_team_details):

  • teamlandkarte_v_teams_latest — team master data (team_id, ouid, about_us, offerings, interests, focus_name).
  • teamlandkarte_v_teammeter_organizational_units_latest — joined to teams via INNER JOIN teams_latest.team_id = organizational_units_latest.id. The column name is exposed as the team name. Teams without a matching organizational unit row are excluded by the inner join.
  • teamlandkarte_v_teammeter_team_competences_latest — joined to teams via ouid. Each entry carries a top_competency flag (NULL is normalized to false). The competence name is resolved via teamlandkarte_v_competences_latest analogous to capacity competences. Entries without a resolvable competence name are filtered out.
  • teamlandkarte_v_team_references_latest — joined to teams via ouid, with a LEFT JOIN team_references.partner_id = partners_latest.id to resolve the partner name. References with NULL or whitespace-only projects are filtered out; references without a matching partner keep partner_name = "" and are still returned.

The partner LEFT JOIN is part of the same SQL query that loads the references, so no extra round-trip is needed.

Tasks/skills are read from:

  • beschaffungstool_kmp_task_latest
  • beschaffungstool_kmp_skill_latest

Notes:

  • The task "required role name" field is currently not available in the DB and is therefore not shown.

Embeddings performance: global prefetch + batching

Matching uses Azure OpenAI embeddings for competence/role similarity.

To reduce latency and cost, the server:

  • deduplicates and prefetches embeddings globally per matching run (required + all candidate competences/roles)
  • resolves embeddings via in-memory cache → SQLite cache → Azure API
  • uses batched Azure embeddings calls (input=[...]) with sequential chunking

Tuning batch size

You can adjust the number of inputs per Azure embeddings request via:

  • azure_openai.embedding_batch_size (default: 128)

See config.toml.example for details.

Development

Project Structure

teamlandkarte-mcp/
├── src/
│   └── teamlandkarte_mcp/
│       ├── mcp_server.py          # MCP server implementation
│       ├── config.py              # Configuration management
│       ├── database/
│       │   └── trino_client.py    # Trino database client
│       ├── matching/
│       │   ├── matcher.py         # Matching algorithm
│       │   └── scorer.py          # Scoring logic
│       └── cache/
│           └── query_cache.py     # Result caching
├── openspec/                      # Specifications & changes
├── config.toml                    # Configuration (not in VCS)
└── main.py                        # Entry point

OpenSpec Workflow

This project uses OpenSpec for spec-driven development:

# View active changes
openspec list

# View current change
openspec show add-capacity-matching-mcp-server

# Validate specifications
openspec validate --strict

Local Setup (uv)

This project uses uv to manage dependencies and a virtual environment under .venv.

  1. Create/sync the environment:

    • uv venv .venv
    • uv pip sync pyproject.toml
  2. Install the project as editable (creates the teamlandkarte-mcp console script):

    • uv pip install -e .
  3. Configure credentials:

    • Copy config.toml.example to config.toml and fill in your credentials.
    • config.toml is ignored via .gitignore and must stay local-only.

Running (stdio MCP)

Run the MCP server locally via stdio:

  • .venv/bin/teamlandkarte-mcp

If your shell does not automatically find the console script, always use the explicit path above.

Security

  • config.toml contains credentials and must not be committed.
  • If credentials were pasted into chat logs or ever committed to git history, rotate them.

See docs/security.md.

Documentation

  • Configuration reference: config.toml.example
  • Architecture and ADRs: openspec/changes/add-capacity-matching-mcp-server/architecture.md
  • Design notes: openspec/changes/add-capacity-matching-mcp-server/design.md
  • Capacity matching specification (Round 6): openspec/changes/add-capacity-matching-mcp-server/specs/capacity-matching/spec.md
  • Security: docs/security.md

Roadmap

Stage 1 (Current): Basic capacity matching

  • Free-text and structured input modes
  • LLM-based competence matching
  • Categorical scoring
  • Result caching

Future Stages:

  • Advanced filtering options
  • Capacity booking workflow
  • Historical trend analysis
  • Integration with other HR systems

Contributing

  1. Review the OpenSpec workflow
  2. Create change proposals for significant features
  3. Follow the implementation tasks
  4. Submit merge requests on GitLab

License

[Add license information]

Authors

Thomas Handke - Deutsche Bahn Systel GmbH