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.
28 KiB
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.
- The Low vs Irrelevant boundary is configurable via
- 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_idparsing
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
- Clone the repository:
git clone https://git.tech.rz.db.de/ThomasHandke/teamlandkarte-mcp.git
cd teamlandkarte-mcp
- Install dependencies:
uv sync
# or
pip install -e .
- 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
*.pychanges - 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:
-lccd /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 lockederrors 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, defaultfalse): 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, defaultfalse): 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 whenuse_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 whenuse_auto_tagging = true.
Database credentials are read from environment variables (recommended: a local
.env file):
DATA_LAKE_USERNAMEDATA_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 whenuse_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
Profile types: capacity vs team search
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"inSearchCacheand the META JSON. - Team searches:
search_type = "team_search"inSearchCacheand 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_filtermatches againstteam.focus_name(Schwerpunkt).competence_filtermatches 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, andis_fully_availableare accepted but ignored. Each value is surfaced in theApplied Filterstable with ateam_searchnot-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, 1–2 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_fulltextmode the result tables use aBegründungcolumn instead of the score columns. The persisted META JSON contains"matching_method": "llm_fulltext"soget_results_by_categoryandfilter_search_resultsrender the correct schema automatically. - In
llm_fulltextmodefilter_search_resultsignoresmin_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.descriptionteamlandkarte_v_capacity_certificates_latest(joined oncapacity_id)teamlandkarte_v_capacity_references_latest(joined oncapacity_id)teamlandkarte_v_partners_latest— joined to references via a LEFT JOINteamlandkarte_v_capacity_references_latest.partner_id = teamlandkarte_v_partners_latest.id. The partnernamecolumn is exposed aspartner_nameand surfaces inside the LLM-visible profile. Whenpartner_idisNULLor no partner matches,partner_nameis the empty string and the reference is still kept (only thePartner: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:
-
Capture and confirm requirements as usual (
collect_structured_requirement_data,show_pending_requirements,confirm_requirements(confirm=true)). -
Ask the user once: "Welches Verfahren soll ich verwenden –
scoreoderllm_fulltext?" -
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", ) -
The tool returns the
Summarycounts + the first non-empty category table (with theBegründungcolumn) and emits a META line containing"matching_method":"llm_fulltext". -
Page through other categories with
get_results_by_category(search_id=..., category="Good", ...). Because the persisted payload carriesmatching_method, the table is rendered automatically withBegründunginstead of score columns. -
Optional refinement via
filter_search_results(search_id=..., role_filter=..., competence_filter=...).min_similarityis accepted but ignored in this mode and surfaces in Applied Filters asmin_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 columnsTeam 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. ReturnsTeam 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:
- DB task workflow (browse → inspect → validate → match)
- Ad-hoc requirement gathering (extract/update/structured/guided)
- 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 withdescription=...)- 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 = falseinconfig.toml
Guided capture (step-by-step)
If the user did not provide enough project details, use the guided tools:
start_guided_capture()guided_set_description(description)guided_set_role(role_name)guided_set_time_range(date_start?, date_end?)(open-ended allowed)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.
- Review + ask user:
show_pending_requirements()(preferred)- ask the user to confirm (Yes/No)
- Confirm (only after user approval):
confirm_requirements(confirm=true)
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_capacitiesincludes a## Summarycounts 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
Levelcolumn in matching result tables (Level is only shown in capacity listing tools).
-
filter_search_resultssupports 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_resultsdoes not repeat the summary counts. It returns:FILTER_ID=<uuid>- an Applied Filters table
- a flat results table (top rows) with separate columns for
ScoreandCategory - the results table is always a Markdown table (even for a single matching row, and also when there are zero matches)
min_similarityis only applied when the user providesrole_filterand/orcompetence_filter(availability-only filtering does not apply similarity).
-
get_results_by_categorydoes 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)
- Call
list_open_tasks(limit=...) - Call
get_task_details(task_id) - (Optional, only on explicit user request) Call
validate_task_requirements(task_id)- Validation is independent from matching and is not required to search.
- Capture/update requirements (e.g. via
collect_structured_requirement_data(...)) - (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)
- Call
find_capacities_for_task(task_id)orfind_matching_capacities(...)- Returns headers including
Using SEARCH_ID=<uuid>/SEARCH_ID=<uuid>
- Returns headers including
- Optionally call
filter_search_results(search_id, ...)- Returns
FILTER_ID=<uuid>under the sameSEARCH_ID
- Returns
- 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
- Open-ended capacity end dates (
Global rules
- 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 latestsearch_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.
- 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_capacitiesagain. - Only rerun matching if:
- no prior
search_idexists, or - the user explicitly requests a new search, or
get_results_by_category/filter_search_resultsreturns “unknown_or_expired”. In that case: do not try another ID; rerunfind_matching_capacities.
- no prior
- If the user’s
search_idis 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>orMETA={...}).
- Confirmation gate
- If
find_matching_capacitiesreturns 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()beforeconfirm_requirements(...).)
- Call
- If confirmation is disabled by config, proceed directly.
- 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_latestteamlandkarte_v_capacity_competences_latestteamlandkarte_v_competences_latest
Capacity LLM-fulltext profiles additionally read:
teamlandkarte_v_capacities_latest.descriptionteamlandkarte_v_capacity_certificates_latest(joined oncapacity_id)teamlandkarte_v_capacity_references_latest(joined oncapacity_id)teamlandkarte_v_partners_latest(LEFT JOIN oncapacity_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 JOINteams_latest.team_id = organizational_units_latest.id. The columnnameis 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 viaouid. Each entry carries atop_competencyflag (NULL is normalized tofalse). The competence name is resolved viateamlandkarte_v_competences_latestanalogous to capacity competences. Entries without a resolvable competence name are filtered out.teamlandkarte_v_team_references_latest— joined to teams viaouid, with a LEFT JOINteam_references.partner_id = partners_latest.idto resolve the partner name. References withNULLor whitespace-onlyprojectsare filtered out; references without a matching partner keeppartner_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_latestbeschaffungstool_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.
-
Create/sync the environment:
uv venv .venvuv pip sync pyproject.toml
-
Install the project as editable (creates the
teamlandkarte-mcpconsole script):uv pip install -e .
-
Configure credentials:
- Copy
config.toml.exampletoconfig.tomland fill in your credentials. config.tomlis ignored via.gitignoreand must stay local-only.
- Copy
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.tomlcontains 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
- Review the OpenSpec workflow
- Create change proposals for significant features
- Follow the implementation tasks
- Submit merge requests on GitLab
License
[Add license information]
Authors
Thomas Handke - Deutsche Bahn Systel GmbH