Unified microkernel engine with pluggable handlers for AI-powered virtual contributors. Consolidates 7 formerly standalone services into a single Python 3.12 codebase using a microkernel + hexagonal (ports and adapters) architecture.
Expert evaluation resolves one frozen deeply immutable v7 composition before adapter wiring and
stores invariant plus mode-bound full composition fingerprints. It uses the
public evaluation-case-identity/v1 serializer and accepts only finite
inclusive [0,1] case and aggregate metrics. Paired Expert
comparison is fail-closed: it accepts only a failure-free flat-to-hierarchical
v7 pair with matching canonical test-set, BoK, corpus-revision, explicit
evaluation-case-identity/v1, and successful
case identities. Corpus revision is an audit token, not deployment or
re-ingestion proof; privacy approval, evaluation, enablement, and rollout stay
human gates.
Query embeddings are bounded to 32768 UTF-8 bytes (rewrites to 4096), with up
to three adapter-owned attempts under 20-second attempt and 45-second total
deadlines. Compatible Chroma requests reuse only successful exact inputs within
one request scope; provider tasks own successful cache publication and terminal
eviction. Terminal results follow unpublished → published-unacked →
settled, never raw-retrying after publication. Early-ACK tasks retrieve every
terminal state with type-only diagnostics. published-unacked forbids
application-managed raw republish or same-callback rerun, but an ambiguous ACK
may be broker-redelivered and is not an exactly-once promise. Reject settlement
faults are contained with type-only diagnostics. Expert identity is validated
before provider/scorer/file work; each successful run has exactly four required
finite unit metrics. Embedding failures never use flat
fallback or RabbitMQ redelivery and receive a generic safe response.
This remediation evidence supports only the pending review sequence: review round 05 may begin after its independent gate, while rounds 06–07 remain contingent human reviews. It does not authorize enablement, deployment, or data mutation.
- Features
- Architecture
- Repository Structure
- Prerequisites
- Getting Started
- Configuration
- Plugins
- Ingest Pipeline
- Docker Deployment
- RAG Evaluation Framework
- Testing
- Linting and Type Checking
- Adding a New Plugin
- Architecture Decision Records
- Feature Specifications
- License
- Multi-provider LLM support — Mistral, OpenAI, and Anthropic via a unified LangChain adapter with per-plugin overrides
- Configurable summarization — Optional separate LLM for ingest pipeline summarization
- RAG with score filtering — Single-collection and multi-collection retrieval with configurable score thresholds and context budgets
- PromptGraph engine — JSON-defined LangGraph workflows with dynamic Pydantic schema generation
- Content-hash deduplication — SHA-256 fingerprinting with change detection and orphan cleanup during ingest
- Ingest pipelines — Website crawling and Alkemio space tree ingestion with chunking, summarization, and embedding
- RAG evaluation — RAGAS-based quality measurement with faithfulness, relevancy, precision, and recall metrics
- Plugin architecture — Duck-typed protocol contract; add new plugins with zero core code changes
- IoC container — Automatic dependency injection via
__init__type hint introspection - Sequential processing —
prefetch=1per instance; horizontal scaling via Kubernetes replicas - Health monitoring — HTTP health server with liveness (
/healthz) and readiness (/readyz) endpoints
The system follows two complementary patterns:
Microkernel — core/ is the core system that provides infrastructure (routing, configuration, dependency injection, transport). plugins/ are independently deployable components that implement the PluginContract protocol.
Hexagonal (Ports and Adapters) — External dependencies (LLMs, vector database, message broker, embeddings) are accessed through @runtime_checkable Protocol interfaces in core/ports/. Concrete adapters in core/adapters/ can be swapped without changing plugin code.
| Port | Methods | Purpose |
|---|---|---|
LLMPort |
invoke(messages), stream(messages) |
Chat model invocation and streaming |
EmbeddingsPort |
embed(texts) |
Batch text embedding |
KnowledgeStorePort |
query(), ingest(), get(), delete(), delete_collection() |
Vector database operations |
TransportPort |
connect(), consume(), publish(), close() |
Message broker communication |
| Adapter | Implements | Technology |
|---|---|---|
LangChainLLMAdapter |
LLMPort |
Any LangChain BaseChatModel (Mistral, OpenAI, Anthropic) |
ChromaDBAdapter |
KnowledgeStorePort |
ChromaDB (HTTP client mode) |
RabbitMQAdapter |
TransportPort |
RabbitMQ via aio-pika |
OpenAICompatibleEmbeddingsAdapter |
EmbeddingsPort |
OpenAI-compatible API (Scaleway, vLLM, etc.) |
OpenAIAssistantAdapter |
N/A | OpenAI Assistants API (threads, runs, files) |
RabbitMQ message
|
v
Router.parse_event(body) # Dispatches to correct Pydantic event model
|
v
Plugin.handle(event) # Plugin-specific logic (RAG, LLM call, ingest, etc.)
|
v
Router.build_response_envelope() # Wraps response for wire format
|
v
RabbitMQ publish (result queue)
main.py
|- Load BaseConfig (env vars + .env)
|- Setup JSON structured logging
|- Resolve plugin-specific LLM overrides ({PLUGIN_NAME}_LLM_*)
|- PluginRegistry.discover(PLUGIN_TYPE)
|- Container.register() adapters:
| |- LLMPort <- create_llm_adapter(config)
| |- EmbeddingsPort <- OpenAICompatibleEmbeddingsAdapter
| |- KnowledgeStorePort <- ChromaDBAdapter
| '- OpenAIAssistantAdapter
|- Container.resolve_for_plugin(PluginClass) # Auto-inject via type hints
|- Plugin.startup()
|- RabbitMQAdapter.connect() + consume(queue, handler)
|- HealthServer.start(:8080)
'- await SIGTERM/SIGINT -> graceful shutdown
virtual-contributor/
├── core/ # Core system
│ ├── ports/ # Technology-agnostic interfaces
│ │ ├── llm.py # LLMPort — chat model invocation
│ │ ├── embeddings.py # EmbeddingsPort — text embedding
│ │ ├── knowledge_store.py # KnowledgeStorePort — vector DB
│ │ └── transport.py # TransportPort — message bus
│ ├── adapters/ # Concrete implementations
│ │ ├── langchain_llm.py # Unified LLM (any LangChain provider)
│ │ ├── openai_assistant.py # OpenAI Assistants API
│ │ ├── chromadb.py # ChromaDB vector store
│ │ ├── rabbitmq.py # RabbitMQ transport (aio-pika)
│ │ ├── openai_compatible_embeddings.py # OpenAI-compatible embeddings
│ │ └── openai_embeddings.py # OpenAI embeddings
│ ├── domain/ # Shared domain logic
│ │ ├── prompt_graph.py # LangGraph workflow engine
│ │ ├── ingest_pipeline.py # Document, Chunk, IngestResult models
│ │ ├── summarize_graph.py # Summarize-then-refine pattern
│ │ └── pipeline/ # Ingest pipeline engine
│ │ ├── README.md # Pipeline architecture, flow diagrams, step reference
│ │ ├── engine.py # Ordered step executor (sequential + batched modes)
│ │ └── steps.py # Chunk, Hash, Detect, Summarize, Embed, Store, Cleanup
│ ├── events/ # Pydantic wire-format models
│ │ ├── input.py # Input, HistoryItem, ExternalConfig
│ │ ├── response.py # Response, Source
│ │ ├── ingest_website.py # IngestWebsite
│ │ └── ingest_space.py # IngestBodyOfKnowledge
│ ├── config.py # Pydantic Settings (env var binding)
│ ├── container.py # IoC container (port -> adapter)
│ ├── registry.py # Plugin registry (import-based discovery)
│ ├── router.py # Content-based message router
│ ├── provider_factory.py # LLM provider switching (Mistral/OpenAI/Anthropic)
│ ├── health.py # HTTP health server (/healthz, /readyz)
│ └── logging.py # JSON structured logging
│
├── plugins/ # Plugin implementations (each has its own README.md)
│ ├── expert/ # PromptGraph + knowledge retrieval RAG
│ ├── generic/ # Direct LLM with history condensation
│ ├── guidance/ # Multi-collection RAG with score filtering
│ ├── openai_assistant/ # OpenAI Assistants API (threads/runs/files)
│ ├── ingest_website/ # Web crawler + HTML parser + ingest
│ └── ingest_space/ # Alkemio space tree reader + file parsers
│
├── evaluation/ # RAG evaluation framework (RAGAS)
│ ├── cli.py # Click CLI (run, compare, generate, list)
│ ├── dataset.py # Golden test set I/O (JSONL)
│ ├── metrics.py # RAGAS metric configuration
│ ├── pipeline_invoker.py # Direct plugin invocation for eval
│ ├── runner.py # Evaluation orchestration + scoring
│ ├── generator.py # Synthetic test case generation
│ └── report.py # Result reporting
│
├── tests/ # Test suite (mirrors source layout)
│ ├── conftest.py # Mock ports, event factories
│ ├── core/ # Core unit + contract tests
│ └── plugins/ # Plugin unit tests
│
├── specs/ # Feature specifications (001-025)
├── docs/adr/ # Architecture Decision Records (0001–0013)
├── main.py # Single entry point
├── Dockerfile # Distroless build: 3.13-slim-trixie builder -> distroless/python3-debian13
├── docker/
│ ├── native-imports.py # Derives native-extension modules from poetry.lock
│ └── image-verify.sh # Persisted regression: structure, interpreter match, imports, 6-plugin matrix
├── docker-compose.yaml # All services from one image
├── poetry.lock # Committed — lock-only installs, `poetry check --lock` gated
├── pyproject.toml # Poetry config + test/coverage settings
└── .github/workflows/ # CI/CD (lint, test, build, deploy) — Python 3.13
- Python 3.13 (matches the distroless runtime interpreter; CI and the image builder both pin 3.13)
- Poetry (dependency management)
- Docker + Docker Compose (for local infrastructure or full deployment)
poetry installcp .env.example .env
# Edit .env with your API keys and service endpointsPLUGIN_TYPE=generic poetry run python main.pyThe process loads config from environment variables, discovers the plugin specified by PLUGIN_TYPE, wires adapter dependencies via the IoC container, and starts consuming from the configured RabbitMQ queue.
# Build the image
docker build -t alkemio/virtual-contributor .
# Start all plugins + RabbitMQ + ChromaDB
docker-compose upEach plugin runs in its own container from the same image, differentiated only by the PLUGIN_TYPE environment variable.
curl http://localhost:8080/healthz # Liveness probe
curl http://localhost:8080/readyz # Readiness probe (RabbitMQ + plugin status)All configuration is via environment variables (or .env file). See .env.example for a complete reference.
| Variable | Default | Description |
|---|---|---|
PLUGIN_TYPE |
generic |
Plugin to run (expert, generic, guidance, openai-assistant, ingest-website, ingest-space) |
LOG_LEVEL |
INFO |
Logging level |
HEALTH_PORT |
8080 |
Health server port |
| Variable | Default | Description |
|---|---|---|
RABBITMQ_HOST |
localhost |
Broker hostname |
RABBITMQ_PORT |
5672 |
Broker port |
RABBITMQ_USER |
alkemio-admin |
Username |
RABBITMQ_PASSWORD |
alkemio! |
Password |
RABBITMQ_QUEUE |
virtual-contributor-engine-{plugin} |
Input queue name |
RABBITMQ_EVENT_BUS_EXCHANGE |
event-bus |
Exchange for publishing results |
RABBITMQ_RESULT_ROUTING_KEY |
invoke-engine-result |
Routing key for results |
| Variable | Default | Description |
|---|---|---|
VECTOR_DB_HOST |
localhost |
ChromaDB hostname |
VECTOR_DB_PORT |
8765 |
ChromaDB port |
VECTOR_DB_CREDENTIALS |
(empty) | Optional auth token |
| Variable | Default | Description |
|---|---|---|
LLM_PROVIDER |
mistral |
Provider: mistral, openai, or anthropic |
LLM_API_KEY |
(required) | API key for the chosen provider |
LLM_MODEL |
Provider default | Model name (e.g. mistral-large-latest, gpt-4o, claude-sonnet-4-6) |
LLM_BASE_URL |
(empty) | Custom endpoint for local/self-hosted models |
LLM_TEMPERATURE |
(provider default) | Sampling temperature (0.0-2.0) |
LLM_MAX_TOKENS |
(provider default) | Maximum output tokens |
LLM_TOP_P |
(provider default) | Nucleus sampling (0.0-1.0) |
LLM_TIMEOUT |
120 |
Request timeout in seconds |
Any LLM setting can be overridden per plugin by prefixing with the plugin name in uppercase:
EXPERT_LLM_PROVIDER=anthropic
EXPERT_LLM_MODEL=claude-sonnet-4-6
EXPERT_LLM_API_KEY=sk-ant-...A separate LLM can be configured for ingest pipeline summarization. All three fields are required to enable:
| Variable | Description |
|---|---|
SUMMARIZE_LLM_PROVIDER |
Provider for summarization |
SUMMARIZE_LLM_MODEL |
Model name |
SUMMARIZE_LLM_API_KEY |
API key |
SUMMARIZE_LLM_TEMPERATURE |
Sampling temperature (optional) |
SUMMARIZE_LLM_TIMEOUT |
Request timeout (optional) |
| Variable | Default | Description |
|---|---|---|
EXPERT_N_RESULTS |
5 |
Number of chunks to retrieve (expert plugin) |
EXPERT_MIN_SCORE |
0.3 |
Minimum relevance score (expert plugin) |
EXPERT_HIERARCHICAL_RETRIEVAL_ENABLED |
false |
Opt-in expert overview/summary route then branch-scoped detail retrieval |
EXPERT_HIERARCHY_MAX_BRANCHES |
3 |
Maximum relevant nearest branches selected (must be 2 or 3) |
EXPERT_HIERARCHY_DISPLAY_NAMES_ENABLED |
false |
Separately opt-in, sanitized hierarchy display names in generation context |
GUIDANCE_N_RESULTS |
5 |
Number of chunks per collection (guidance plugin) |
GUIDANCE_MIN_SCORE |
0.3 |
Minimum relevance score (guidance plugin) |
MAX_CONTEXT_CHARS |
20000 |
Context budget — lowest-scoring chunks dropped first |
This expert-only enhancement is off by default. Each eligible request runs one dense Stage-1 query over overview/summary entries and uses no permanent collection-capability cache. Specific subspace routes dominate any root route; a root-only or unusable route takes the established flat path. A safe route runs the existing hybrid/re-rank/threshold/top-K pipeline over scoped detail.
A nonempty scoped result remains scoped even when short: there is no unscoped
backfill. An empty scoped result or hierarchy-stage failure exits the hierarchy
handler before exactly one flat attempt; an error from that flat attempt remains
visible. Hierarchy labels, when disclosure is enabled, are sanitized and
rendered before the context budget, so all model-visible label bytes are
charged before eviction. Both hierarchy retrieval and display-name disclosure
are independently controlled: setting hierarchy retrieval to false restores
flat retrieval, while setting display names to false omits model-visible
hierarchy names without disabling scoped retrieval.
Enable only after deployment with both controls off, re-ingestion of selected
spaces, and the required human gates. RG-05 is the representative paired
flat/on RAGAS evaluation on one reviewed query set; its runs require equal
invariant fingerprints and present, recomputable mode-bound full fingerprints
that differ between flat and hierarchical modes. Comparison fails closed when
those conditions are not met. RG-05P is the separate
provider-processing gate required before display names may be enabled. The
repository's deterministic proxy is structural evidence only, while SC-009
is the full-suite regression gate. SUMMARIZE_ENABLED remains unchanged.
Re-orders retrieved chunks before context assembly, blending vector similarity with lexical overlap against the query's own terms. Runs in-process: no network call, no extra dependency, ~0.7 ms for a top-20 re-rank.
Off by default — it changes what every answer is grounded in, so it is opted into rather than inherited. Turning it off restores prior behaviour exactly: the same number of results is requested and no re-ranking code runs.
| Variable | Default | Description |
|---|---|---|
RERANK_ENABLED |
false |
Enable the re-ranking stage |
RERANK_CANDIDATE_N |
20 |
Candidates fetched when enabled (must be ≥ RERANK_TOP_K) |
RERANK_TOP_K |
5 |
Candidates kept after re-ranking |
RERANK_LEXICAL_WEIGHT |
0.6 |
Lexical share of the blend; 0.0 provably reproduces vector order |
Two rollbacks, coarse and fine: RERANK_ENABLED=false removes the stage
entirely, and RERANK_LEXICAL_WEIGHT=0.0 leaves it wired but reproduces vector
ordering exactly.
Keep the weight above 0.5. Scores are normalised across the candidate pool,
so the best-vector candidate sits at 1.0 and the worst at 0.0; at exactly
0.5 a chunk that matches the query's wording but has the worst vector
distance ties with the incumbent and loses — which is the case re-ranking
exists to fix.
Re-ranking is a ranking signal only. Whether a chunk is relevant enough to use is still judged on its vector score, because the blended score is relative to the pool rather than absolute.
Classifies each question and scales retrieval to it: small talk skips retrieval entirely, direct lookups retrieve narrowly, comparative questions retrieve more and get a wider context budget.
Classification is rule-based and in-process — no model, no network call, no new dependency, sub-millisecond. An LLM classifier was rejected on arithmetic: it would cost a round trip on every question including the simple ones it exists to speed up, needing (at 350ms) 39% of all traffic on the cheap path just to break even. That share has never been measured here.
Off by default. Disabled, both plugins take their existing code path with their existing constants.
| Variable | Default | Description |
|---|---|---|
ROUTING_ENABLED |
false |
Enable adaptive routing |
ROUTING_SIMPLE_N_RESULTS |
3 |
Width for direct lookups (must be ≤ the default width) |
ROUTING_COMPLEX_N_RESULTS |
10 |
Width for comparative / multi-hop questions |
ROUTING_COMPLEX_CONTEXT_CHARS |
40000 |
Context budget for those questions |
The complex budget is widened alongside the width on purpose. At the deployed 9000-character chunk size, the default 20000-character budget already admits only two chunks — so widening retrieval without widening the budget would fetch more evidence and then discard it, producing a route that looks implemented and changes nothing.
The rules are deliberately lopsided. Only one mistake is user-visible — routing a real question to skip retrieval answers it ungrounded — so that is the only route with a hard gate: the whole message must match an anchored small-talk list, carry no question mark, and be at most six words. Everything else falls through to retrieval, so every misclassification degrades to "retrieve anyway".
Logs a warning when an answer asserts something after retrieval returned nothing. That is the one case provably unsupportable without a model or citations: there was no evidence, so whatever was said came from elsewhere.
| Variable | Default | Description |
|---|---|---|
FAITHFULNESS_VALIDATION_ENABLED |
false |
Enable the check |
Observation only. The answer a member receives is never changed, delayed, or withheld — enabling this adds a log line and nothing else. Runs in-process: no model, no network call, no dependency.
What it deliberately does not do. Checking whether the answer's words appear in the context was built, measured, and rejected: no threshold separates a fabrication from a faithful paraphrase, and because the platform answers in the member's language, a Dutch answer over English context scores the same as a lie. It would have flagged every non-English answer. So this checks context sufficiency, not content matching — which is why paraphrase and translation are structurally incapable of being flagged.
Honest limit. It does not catch a fabrication built on thin-but-nonempty context. That needs either citation verification (which requires the model to be asked to cite — a separate change) or a judge.
Citation verification is a string check against the context, so it can
occupy this port directly. A judge cannot. validate() is synchronous by
design and runs on the response path, so an implementation that calls a model
would block the event loop for every message this worker is serving — not just
its own. A judge belongs out of band: the port is the seam for recording the
verdict, not for fetching it.
| Variable | Default | Description |
|---|---|---|
EMBEDDINGS_API_KEY |
(required for ingest) | API key for embedding service |
EMBEDDINGS_ENDPOINT |
https://api.scaleway.ai/v1 |
Embedding API endpoint |
EMBEDDINGS_MODEL_NAME |
qwen3-embedding-8b |
Embedding model |
EMBEDDINGS_QUERY_MAX_UTF8_BYTES |
32768 |
Logged query-input byte cap |
QUERY_REWRITE_MAX_UTF8_BYTES |
4096 |
Logged rewrite-input byte cap |
EMBEDDINGS_MAX_ATTEMPTS |
3 |
Logged outer query-attempt cap |
EMBEDDINGS_ATTEMPT_TIMEOUT_SECONDS |
20 |
Logged per-attempt query timeout |
EMBEDDINGS_TOTAL_DEADLINE_SECONDS |
45 |
Logged total query deadline |
| Variable | Default | Description |
|---|---|---|
CHUNK_SIZE |
2000 |
Characters per chunk |
CHUNK_OVERLAP |
400 |
Overlap between chunks |
BATCH_SIZE |
20 |
Embedding batch size |
SUMMARY_LENGTH |
2500 |
Max summary length |
SUMMARY_CHUNK_THRESHOLD |
4 |
Minimum chunks to trigger summarization |
PROCESS_PAGES_LIMIT |
20 |
Max pages to crawl (ingest-website) |
Legacy Mistral-specific variables (MISTRAL_API_KEY, MISTRAL_SMALL_MODEL_NAME) are still supported and used as fallbacks when the corresponding LLM_* variables are not set.
Each plugin has its own README with detailed documentation, flow diagrams, and configuration reference.
| Plugin | Type | Queue | README |
|---|---|---|---|
| Expert | expert |
virtual-contributor-engine-expert |
plugins/expert/README.md |
| Generic | generic |
virtual-contributor-engine-generic |
plugins/generic/README.md |
| Guidance | guidance |
virtual-contributor-engine-guidance |
plugins/guidance/README.md |
| OpenAI Assistant | openai-assistant |
virtual-contributor-engine-openai-assistant |
plugins/openai_assistant/README.md |
| Ingest Website | ingest-website |
virtual-contributor-ingest-website |
plugins/ingest_website/README.md |
| Ingest Space | ingest-space |
virtual-contributor-ingest-body-of-knowledge |
plugins/ingest_space/README.md |
PromptGraph-based plugin with single-collection RAG. If the incoming event includes a prompt_graph definition, it compiles a LangGraph workflow from JSON — nodes have prompt templates, input variables, and optional output schemas. A special "retrieve" node queries the knowledge store. Falls back to simple RAG if no graph is defined. Results are filtered by score threshold and capped by context budget.
Direct LLM invocation with optional chat history condensation. When conversation history is present, it first condenses prior exchanges into a standalone question via the LLM, then builds a message array from system prompts and the condensed question. Returns raw LLM output with no sources.
Multi-collection RAG that queries three Alkemio knowledge bases in parallel (alkem.io-knowledge, welcome.alkem.io-knowledge, www.alkemio.org-knowledge). Merges results, deduplicates by source URL (keeping the highest score per page), filters by threshold, and enforces context budget. Context chunks are prefixed with [source:N] citations. Parses the LLM's JSON response to extract the answer.
Wraps the OpenAI Assistants API with thread and run management. Requires per-request external_config with api_key and assistant_id. Creates or resumes threads (via thread_id in external_metadata), adds messages, polls runs to completion, and returns the answer with thread ID for conversation continuity.
Web crawler that fetches pages from a base URL (configurable page limit), extracts text and titles from HTML via BeautifulSoup, then runs the full ingest pipeline. The collection is named {domain}-knowledge.
Fetches the Alkemio space tree via GraphQL, parses attached files (PDF, DOCX, XLSX), and runs the ingest pipeline with space-specific 2,500-character chunks. The collection is named {body_of_knowledge_id}-{purpose}.
Both ingest plugins share a common pipeline engine (core/domain/pipeline/) that executes an ordered sequence of steps. The engine supports both sequential and batched execution modes.
flowchart TD
subgraph "Per-Batch Steps"
A[Documents] --> B["ChunkStep<br/><i>Split via RecursiveCharacterTextSplitter</i>"]
B --> C["ContentHashStep<br/><i>SHA-256 fingerprint per chunk</i>"]
C --> D["ChangeDetectionStep<br/><i>Query store, mark unchanged, find orphans</i>"]
D --> E["DocumentSummaryStep<br/><i>Per-document refine-pattern summary</i>"]
E --> F["EmbedStep<br/><i>Batch embed via EmbeddingsPort</i>"]
F --> G["StoreStep<br/><i>Persist to ChromaDB, skip unchanged</i>"]
G --> H{More batches?}
end
H -->|Yes| A
H -->|No| I[Finalize Steps]
subgraph "Finalize Steps"
I --> J["BodyOfKnowledgeSummaryStep<br/><i>Single overview summary</i>"]
J --> K["OrphanCleanupStep ⚠️<br/><i>Delete orphaned + removed chunks</i>"]
K --> L[IngestResult]
end
Content-hash deduplication: Each chunk gets a SHA-256 fingerprint computed from its content, title, source, type, and document ID. During change detection, existing chunks in the store are compared by hash — unchanged chunks skip re-embedding, saving compute. Orphaned chunks (from deleted or modified documents) are cleaned up after the store step succeeds.
Safety: Steps marked as destructive (e.g., OrphanCleanupStep) are automatically skipped when prior pipeline errors exist, preventing data loss from partial processing.
Each step reports metrics (duration, items in/out, error count) and failures are caught per-step so the pipeline can continue with remaining steps. See core/domain/pipeline/README.md for the full step reference and context field documentation.
The project ships a distroless runtime image (workspace#026-distroless-runtime-images), built as a matched Python 3.13 pair:
- Builder stage (
python:3.13.<patch>-slim-trixie, glibc/Debian, digest-pinned): installs Poetry, verifies the committedpoetry.lockis in sync withpyproject.toml(poetry check --lock— the build fails on a stale lock), and installs production dependencies exclusively from the lock into a relocatable/venv(POETRY_VIRTUALENVS_CREATE=falseagainst an already-activated venv). The exact interpreter patch version is recorded into/venv/PYTHON_VERSIONfor the runtime match check below. - Runtime stage (
gcr.io/distroless/python3-debian13:nonroot, digest-pinned): contains only/venv,core/,plugins/, andmain.py— no shell, no package manager, no Poetry/pip. Runs as UID 65532 (nonroot).PYTHONPATH=/venv/lib/python3.13/site-packagesexposes the relocated dependencies;CMD ["main.py"]runs against the distroless image's ownpython3entrypoint. - The builder's and runtime's Python interpreters MUST match at
major.minor (3.13 == 3.13) — the compiled-extension wheels installed by
the builder must be ABI-compatible with the runtime interpreter. Verified
mechanically by
docker/image-verify.sh. - Exposes port
8080for the health server (HEALTH_PORT,/healthz,/readyz— see Startup Sequence). - Default
PLUGIN_TYPE=generic, overridable at runtime.
docker build -t alkemio/virtual-contributor .
# Persisted regression (US3 contract `vc-runtime-image`): structure, interpreter
# match, every native-extension import from poetry.lock, and a live six-plugin
# start matrix (expert, generic, guidance, openai-assistant, ingest_website,
# ingest_space) against RabbitMQ + ChromaDB.
docker/image-verify.sh alkemio/virtual-contributorpoetry.lock is committed (not gitignored) — dependencies are installed
only from the lock, both locally (poetry install) and in the image
build. poetry check --lock is a required gate; run poetry lock and
re-commit the lock whenever pyproject.toml changes.
The docker-compose.yaml runs the full stack from a single image:
Infrastructure:
- RabbitMQ (AMQP :5672, management UI :15672)
- ChromaDB (:8765)
Engine plugins (each a separate container):
- expert, generic, guidance, openai-assistant
Ingest plugins (each a separate container):
- ingest-website, ingest-space
Each service sets its own PLUGIN_TYPE and queue configuration. All services share the same RabbitMQ and ChromaDB instances.
docker-compose up # Start everything
docker-compose up expert # Start just the expert plugin + dependenciesThe evaluation/ module provides systematic RAG quality measurement using RAGAS.
| Metric | What it Measures |
|---|---|
| Faithfulness | Is the answer grounded in the retrieved context? |
| Answer Relevancy | Does the answer address the question? |
| Context Precision | Are the retrieved chunks relevant to the question? |
| Context Recall | Are all necessary chunks retrieved? |
# Run evaluation against the guidance plugin
poetry run python -m evaluation.cli run --plugin guidance --test-set evaluation/data/test_set.jsonl
# Compare two evaluation runs
poetry run python -m evaluation.cli compare --baseline <run-id-1> --current <run-id-2>
# Generate synthetic test cases from a ChromaDB collection
poetry run python -m evaluation.cli generate --collection alkem.io-knowledge --output evaluation/data/test_set.jsonl
# List previous evaluation runs
poetry run python -m evaluation.cli list- Golden test set: JSONL file with
question,expected_answer, andrelevant_documentsfields - Pipeline invoker: Instantiates the plugin directly (bypassing RabbitMQ); for Expert, it shares production composition and captures the exact final rendered generation contexts after retrieval, ranking, filtering, and budget processing
- Scorer: Wraps RAGAS metrics, uses the pipeline's own LLM as judge via
LangchainLLMWrapper - Runner: Executes the test suite, computes per-metric aggregates, persists results to
evaluations/{id}.json
Paired Expert comparisons require equal non-secret invariant-composition fingerprints. Both full fingerprints must be present, recomputable from the invariant identity and their explicit modes, and different for flat versus hierarchical runs; otherwise comparison fails closed rather than treating a configuration change as a hierarchy-only experiment.
Tests use asyncio_mode = "auto" — async test functions run automatically without @pytest.mark.asyncio.
# Run all tests
poetry run pytest
# With coverage report
poetry run pytest --cov=core --cov=plugins --cov-report=term-missing
# Single file
poetry run pytest tests/plugins/test_expert.py
# Single test
poetry run pytest tests/plugins/test_expert.py::test_handleFor this hierarchy feature, the full suite is the SC-009 regression gate;
focused tests and the deterministic precision proxy do not replace the
representative paired RAGAS gate (RG-05).
- Mock ports (
tests/conftest.py):MockLLMPort,MockEmbeddingsPort,MockKnowledgeStorePort— in-memory implementations with call tracking - Event factories:
make_input(...),make_ingest_website(...)— build test events with sensible defaults - Coverage: Minimum 80% enforced in CI; adapters, config, health, logging, and main.py are excluded from coverage
async def test_expert_handle():
llm = MockLLMPort(responses=["The answer is 42."])
store = MockKnowledgeStorePort(results=[...])
plugin = ExpertPlugin(llm=llm, knowledge_store=store)
response = await plugin.handle(make_input(message="What is the answer?"))
assert "42" in response.result
assert len(store.query_calls) == 1# Lint with Ruff
poetry run ruff check core/ plugins/ tests/
# Type check with Pyright
poetry run pyright core/ plugins/Both checks are enforced in CI.
- Create
plugins/{name}/plugin.pywith a class implementing thePluginContract:
from core.events.input import Input
from core.events.response import Response
from core.ports.llm import LLMPort
class MyPlugin:
name = "my-plugin"
event_type = Input
def __init__(self, llm: LLMPort):
self._llm = llm
async def startup(self) -> None:
pass
async def shutdown(self) -> None:
pass
async def handle(self, event: Input) -> Response:
result = await self._llm.invoke([{"role": "user", "content": event.message}])
return Response(result=result)- Port dependencies declared as
__init__type hints are resolved automatically by the IoC container - Add tests in
tests/plugins/test_{name}.py - Run with
PLUGIN_TYPE={name} poetry run python main.py
No core code changes required. See docs/adr/0003-plugin-contract-design.md for the full contract specification.
Detailed design rationale is documented in docs/adr/:
| ADR | Decision |
|---|---|
| 0001 | Microkernel + hexagonal architecture consolidating 7 repositories |
| 0002 | TypeScript-to-Python port for ingest-space plugin |
| 0003 | Duck-typed plugin contract via Python Protocol |
| 0004 | Sequential message processing with horizontal scaling |
| 0005 | Unified LangChain adapter with provider factory |
| 0006 | Content-hash deduplication and KnowledgeStorePort extension |
| 0007 | RAGAS as RAG evaluation framework |
| 0008 | Composable step-based ingest pipeline engine |
| 0009 | Configurable summarization LLM with per-plugin retrieval parameters |
| 0010 | Pipeline destructive step safety via duck-typed gating |
| 0011 | Pipeline reliability — thread pool sizing, partial failure resilience |
| 0012 | Module-local OpenTelemetry pipeline tracing |
Pipeline tracing ships dark. It emits OTLP/HTTP traces only when an explicit
internal endpoint is configured; ambient OTEL_* exporter and sampler settings
are never used.
| Variable | Default | Purpose |
|---|---|---|
TRACING_ENABLED |
false |
Enable tracing only with an endpoint |
TRACING_OTLP_ENDPOINT |
unset | Full internal OTLP/HTTP trace URL |
TRACING_OTLP_HEADERS |
unset | Comma-separated key=value authentication headers |
TRACING_SERVICE_NAME |
plugin-derived | Service resource name |
TRACING_SAMPLE_RATIO |
1.0 |
Trace sampling ratio, from 0 through 1 |
TRACING_CAPTURE_CONTENT |
false |
Off by default. When true, exports bounded member messages, prompts, and model completions to the trace collector as span attributes — enable only as a recorded, deliberate decision. |
TRACING_CONTENT_MAX_CHARS |
1000 |
Maximum characters per content attribute |
For local self-hosted Langfuse, point the service at its OTLP endpoint:
export TRACING_ENABLED=true
export TRACING_OTLP_ENDPOINT="http://localhost:3000/api/public/otel/v1/traces"
export TRACING_OTLP_HEADERS="Authorization=Basic ${AUTH}"
PLUGIN_TYPE=guidance poetry run python main.pyThe same explicit endpoint/header configuration works with self-hosted Grafana Tempo or Elastic APM OTLP receivers. Never set an endpoint you do not control: trace content can include user messages and retrieved context. | 0013 | Grounded, citable generation prompts |
Detailed design specifications for all features are in specs/:
| Workflow | Trigger | What it Does |
|---|---|---|
ci.yml |
Push/PR | Ruff lint, Pyright type check, pytest with 80% coverage minimum |
build.yml |
Push to develop/main, tags |
Docker build and push with branch/semver/SHA tags |
deploy-dev.yml |
After successful build on develop |
Deploys all 6 plugins to alkemio-dev Kubernetes namespace |
EUPL-1.2 (European Union Public Licence)