Building a Module¶
This guide walks through the complete process of building a new module (plugin) for the Knowledge Platform. By the end you will have a fully functional, self-contained module that can be enabled through modules.yaml without changing host code.
The worked example creates a Kanban module — a task board with Card nodes and Column nodes linked by InColumn edges. The same steps apply to any module.
Overview¶
A module consists of four components:
graph LR
GT[GraphType Domain Schema]
SVC[Module Service Domain Operations]
PROJ[Projection Graph to View Model]
MOD[ModuleDescriptor Entry Point]
UI[Widget and ViewModel]
GT -->|defines schema for| SVC
SVC -->|uses| PROJ
MOD -->|provides| GT
MOD -->|creates| UI
UI -->|calls| SVC
| Component | Location | Purpose |
|---|---|---|
graph_type.py |
modules/<name>/graph_type.py |
Defines node/edge schemas |
service.py |
modules/<name>/service.py |
High-level domain operations |
projection.py |
modules/<name>/projection.py |
Converts Graph → view-friendly structure |
module.py |
modules/<name>/module.py |
ModuleDescriptor entry-point, widget factory, document hooks |
view.py |
ui/<name>/view.py |
Qt widget (view layer) |
view_model.py |
ui/<name>/view_model.py |
Qt ViewModel (state + commands) |
Step 1: Create the Package Structure¶
# Under src/knowledge_platform/
mkdir -p src/knowledge_platform/modules/kanban
touch src/knowledge_platform/modules/kanban/__init__.py
touch src/knowledge_platform/modules/kanban/graph_type.py
touch src/knowledge_platform/modules/kanban/service.py
touch src/knowledge_platform/modules/kanban/projection.py
touch src/knowledge_platform/modules/kanban/module.py
# UI package
mkdir -p src/knowledge_platform/ui/kanban
touch src/knowledge_platform/ui/kanban/__init__.py
touch src/knowledge_platform/ui/kanban/view.py
touch src/knowledge_platform/ui/kanban/view_model.py
Step 2: Define the Graph Type¶
graph_type.py declares what node and edge types exist and what attributes they carry.
# src/knowledge_platform/modules/kanban/graph_type.py
from __future__ import annotations
from knowledge_platform.domain.graph_type import (
AttributeField, EdgeSchema, GraphType, NodeSchema,
)
# ── Node schemas ──────────────────────────────────────────────────────────────
COLUMN_SCHEMA = NodeSchema(
type_name="Column",
fields=(
AttributeField("title", str, required=True, description="Column heading, e.g. 'To Do'"),
AttributeField("position", int, required=False, default=0,
description="Left-to-right column order"),
),
description="A lane/column on the Kanban board.",
)
CARD_SCHEMA = NodeSchema(
type_name="Card",
fields=(
AttributeField("title", str, required=True, description="Card headline"),
AttributeField("description", str, required=False, default=""),
AttributeField("priority", int, required=False, default=0,
description="0=low, 1=medium, 2=high"),
AttributeField("position", int, required=False, default=0,
description="Vertical order within the column"),
),
description="A single task card.",
)
# ── Edge schemas ──────────────────────────────────────────────────────────────
IN_COLUMN_SCHEMA = EdgeSchema(
type_name="InColumn",
allowed_source_types=frozenset({"Card"}),
allowed_target_types=frozenset({"Column"}),
description="Card belongs to a Column.",
)
BLOCKS_SCHEMA = EdgeSchema(
type_name="Blocks",
allowed_source_types=frozenset({"Card"}),
allowed_target_types=frozenset({"Card"}),
description="Card A is blocked by Card B (B must be done before A).",
)
# ── Graph type ────────────────────────────────────────────────────────────────
class KanbanGraphType(GraphType):
"""Graph type for Kanban boards."""
type_name = "kanban"
node_schemas = {
"Column": COLUMN_SCHEMA,
"Card": CARD_SCHEMA,
}
edge_schemas = {
"InColumn": IN_COLUMN_SCHEMA,
"Blocks": BLOCKS_SCHEMA,
}
Rules for defining schemas:
type_nameonGraphTypemust be unique across all registered types.required=Truefields must be present in attributes when the node/edge is created.allowed_source_types/allowed_target_types: leave empty (frozenset()) to allow any type.
Step 3: Define a Projection¶
A projection converts a flat Graph into a view-friendly data structure. This is a pure function — no I/O, no Qt.
# src/knowledge_platform/modules/kanban/projection.py
from __future__ import annotations
from dataclasses import dataclass, field
from knowledge_platform.core.graph import Graph
from knowledge_platform.core.identifiers import NodeId
from knowledge_platform.core.node import Node
@dataclass
class KanbanCard:
"""View object for a single card."""
node: Node
children: list["KanbanCard"] = field(default_factory=list)
@property
def node_id(self) -> NodeId:
return self.node.id
@property
def title(self) -> str:
return str(self.node.attributes.get("title", ""))
@property
def priority(self) -> int:
return int(self.node.attributes.get("priority", 0))
@property
def position(self) -> int:
return int(self.node.attributes.get("position", 0))
@dataclass
class KanbanColumn:
"""View object for a column and its cards."""
node: Node
cards: list[KanbanCard] = field(default_factory=list)
@property
def node_id(self) -> NodeId:
return self.node.id
@property
def title(self) -> str:
return str(self.node.attributes.get("title", ""))
@property
def position(self) -> int:
return int(self.node.attributes.get("position", 0))
class KanbanProjection:
"""Projects a kanban Graph into an ordered list of KanbanColumn objects."""
def project(self, graph: Graph) -> list[KanbanColumn]:
columns = {
n.id: KanbanColumn(node=n)
for n in graph.nodes(type_name="Column")
}
cards = {
n.id: KanbanCard(node=n)
for n in graph.nodes(type_name="Card")
}
for edge in graph.edges(type_name="InColumn"):
col = columns.get(edge.target_id)
card = cards.get(edge.source_id)
if col and card:
col.cards.append(card)
for col in columns.values():
col.cards.sort(key=lambda c: c.position)
return sorted(columns.values(), key=lambda c: c.position)
Step 4: Define the Module Service¶
The service layer wraps IGraphService with kanban-specific helpers.
# src/knowledge_platform/modules/kanban/service.py
from __future__ import annotations
from knowledge_platform.core.graph import Graph
from knowledge_platform.core.identifiers import GraphId, NodeId, WorkspaceId
from knowledge_platform.core.node import Node
from knowledge_platform.modules.kanban.projection import KanbanColumn, KanbanProjection
from knowledge_platform.services.interfaces import IGraphService
class KanbanService:
"""High-level operations for Kanban boards."""
GRAPH_TYPE = "kanban"
COLUMN_TYPE = "Column"
CARD_TYPE = "Card"
IN_COLUMN_EDGE = "InColumn"
def __init__(self, graph_service: IGraphService) -> None:
self._gs = graph_service
self._projection = KanbanProjection()
def create_board(self, workspace_id: WorkspaceId, name: str) -> Graph:
"""Create a new Kanban board with default columns."""
graph = self._gs.create_graph(workspace_id, self.GRAPH_TYPE, name)
for pos, title in enumerate(["To Do", "In Progress", "Done"]):
self._gs.add_node(graph.id, self.COLUMN_TYPE, {"title": title, "position": pos})
return self._gs.get_graph(graph.id)
def add_card(
self,
graph_id: GraphId,
column_id: NodeId,
title: str,
description: str = "",
priority: int = 0,
position: int = 0,
) -> Node:
"""Add a card to a column."""
card = self._gs.add_node(
graph_id, self.CARD_TYPE,
{"title": title, "description": description,
"priority": priority, "position": position},
)
self._gs.add_edge(graph_id, card.id, column_id, self.IN_COLUMN_EDGE, {})
return card
def move_card(self, graph_id: GraphId, card_id: NodeId, new_column_id: NodeId) -> None:
"""Move a card to a different column."""
graph = self._gs.get_graph(graph_id)
for edge in list(graph.outgoing_edges(card_id, type_name=self.IN_COLUMN_EDGE)):
self._gs.remove_edge(graph_id, edge.id)
self._gs.add_edge(graph_id, card_id, new_column_id, self.IN_COLUMN_EDGE, {})
def get_board(self, graph_id: GraphId) -> list[KanbanColumn]:
"""Return the board as a list of KanbanColumn view objects."""
graph = self._gs.get_graph(graph_id)
return self._projection.project(graph)
Step 5: Create the Module Entry-Point¶
# src/knowledge_platform/modules/kanban/module.py
from __future__ import annotations
from typing import TYPE_CHECKING
from knowledge_platform.modules.kanban.graph_type import KanbanGraphType
from knowledge_platform.modules.kanban.service import KanbanService
from knowledge_platform.services.interfaces import IGraphService
if TYPE_CHECKING:
from PySide6.QtWidgets import QWidget
class KanbanModule:
"""ModuleDescriptor entry-point for the Kanban plugin."""
module_id = "kanban"
display_name = "Kanban"
document_label_singular = "Board"
document_label_plural = "Boards"
graph_types = (KanbanGraphType(),)
primary_graph_type = "kanban"
def configure(self, settings) -> None:
self._settings = dict(settings)
def supports_graph_type(self, type_name: str) -> bool:
return type_name == "kanban"
def create_widget(
self,
graph_service: IGraphService,
parent: "QWidget | None" = None,
) -> "QWidget":
# Lazy import keeps PySide6 out of headless test runs
from knowledge_platform.ui.kanban.view import KanbanView # type: ignore[import]
return KanbanView(graph_service=graph_service, parent=parent)
def create_document(self, graph_service: IGraphService, workspace_id, name):
return KanbanService(graph_service).create_board(workspace_id, name)
def list_documents(self, graph_service: IGraphService, workspace_id):
return [
graph
for graph in graph_service.list_graphs(workspace_id)
if graph.type_name == "kanban"
]
Lazy import pattern
Always put the from PySide6... import inside create_widget(). This prevents ImportError in headless test environments where Qt is not installed.
Step 6: Create the ViewModel¶
The ViewModel exposes Qt signals for the view to connect to and calls the service for mutations.
# src/knowledge_platform/ui/kanban/view_model.py
from __future__ import annotations
from PySide6.QtCore import QObject, Signal
from knowledge_platform.core.identifiers import GraphId, NodeId
from knowledge_platform.modules.kanban.projection import KanbanColumn
from knowledge_platform.modules.kanban.service import KanbanService
from knowledge_platform.services.interfaces import IGraphService
class KanbanViewModel(QObject):
"""ViewModel for the Kanban board view."""
board_refreshed = Signal(list) # list[KanbanColumn]
error_raised = Signal(str)
def __init__(self, graph_service: IGraphService, parent: QObject | None = None) -> None:
super().__init__(parent)
self._service = KanbanService(graph_service)
self._current_graph_id: GraphId | None = None
def load_board(self, graph_id: GraphId) -> None:
self._current_graph_id = graph_id
self.refresh()
def refresh(self) -> None:
if self._current_graph_id is None:
return
try:
columns = self._service.get_board(self._current_graph_id)
self.board_refreshed.emit(columns)
except Exception as e:
self.error_raised.emit(str(e))
def move_card(self, card_id: NodeId, new_column_id: NodeId) -> None:
if self._current_graph_id is None:
return
try:
self._service.move_card(self._current_graph_id, card_id, new_column_id)
self.refresh()
except Exception as e:
self.error_raised.emit(str(e))
Step 7: Create the View¶
The view contains only presentation logic — no domain imports. All operations go through the ViewModel.
# src/knowledge_platform/ui/kanban/view.py
from __future__ import annotations
from PySide6.QtWidgets import QHBoxLayout, QLabel, QWidget
from knowledge_platform.modules.kanban.projection import KanbanColumn
from knowledge_platform.services.interfaces import IGraphService
from knowledge_platform.ui.kanban.view_model import KanbanViewModel
class KanbanView(QWidget):
"""Main Kanban board widget."""
def __init__(self, graph_service: IGraphService, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._vm = KanbanViewModel(graph_service, parent=self)
self._vm.board_refreshed.connect(self._on_board_refreshed)
self._vm.error_raised.connect(self._on_error)
self._layout = QHBoxLayout(self)
self.setLayout(self._layout)
def _on_board_refreshed(self, columns: list[KanbanColumn]) -> None:
# Clear and re-render columns
while self._layout.count():
item = self._layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
for col in columns:
label = QLabel(f"{col.title}\n({len(col.cards)} cards)", self)
self._layout.addWidget(label)
def _on_error(self, message: str) -> None:
print(f"Kanban error: {message}")
Step 8: Register the Module for Runtime Loading¶
Add the module to your package metadata so the host can discover it as an entry point:
[project.entry-points."knowledge_platform.modules"]
kanban = "knowledge_platform.modules.kanban.module:KanbanModule"
Then enable it in modules.yaml:
version: 1
strict: true
modules:
- id: outline
enabled: true
source: entry_point
- id: kanban
enabled: true
source: entry_point
For local development before reinstalling package metadata, an explicit import path is also valid:
modules:
- id: kanban
enabled: true
source: import
factory: knowledge_platform.modules.kanban.module:KanbanModule
At startup, ModuleLoader reads this config, loads the descriptor, registers its graph types into TypeRegistry, and the MainWindow creates the tab automatically.
Step 9: Write Tests¶
Create a test file at tests/unit/modules/test_kanban.py:
# tests/unit/modules/test_kanban.py
from __future__ import annotations
import pytest
from knowledge_platform.core.engine import GraphEngine
from knowledge_platform.core.identifiers import new_workspace_id
from knowledge_platform.domain.registry import TypeRegistry
from knowledge_platform.modules.kanban.graph_type import KanbanGraphType
from knowledge_platform.modules.kanban.module import KanbanModule
from knowledge_platform.modules.kanban.service import KanbanService
from knowledge_platform.persistence.store import SqliteGraphRepository
from knowledge_platform.services.graph_service import GraphService
@pytest.fixture()
def kanban_service() -> KanbanService:
repo = SqliteGraphRepository(":memory:")
engine = GraphEngine(repo)
type_registry = TypeRegistry()
type_registry.register(KanbanGraphType())
graph_service = GraphService(engine, type_registry)
return KanbanService(graph_service)
def test_create_board_has_three_columns(kanban_service: KanbanService) -> None:
ws_id = new_workspace_id()
board = kanban_service.create_board(ws_id, "Sprint 1")
columns = kanban_service.get_board(board.id)
assert len(columns) == 3
assert [c.title for c in columns] == ["To Do", "In Progress", "Done"]
def test_add_card_appears_in_column(kanban_service: KanbanService) -> None:
ws_id = new_workspace_id()
board = kanban_service.create_board(ws_id, "Board")
col_id = kanban_service.get_board(board.id)[0].node_id
kanban_service.add_card(board.id, col_id, "Write docs")
columns = kanban_service.get_board(board.id)
assert len(columns[0].cards) == 1
assert columns[0].cards[0].title == "Write docs"
def test_kanban_module_implements_descriptor() -> None:
from knowledge_platform.modules.base import ModuleDescriptor
module = KanbanModule()
assert isinstance(module, ModuleDescriptor)
assert module.module_id == "kanban"
Checklist¶
Before submitting a new module, verify:
- [ ]
module_idis unique (snake_case, no spaces) - [ ]
GraphType.type_nameis unique in theTypeRegistry - [ ] All required
AttributeFields have clear descriptions - [ ]
create_widget()uses a lazy import for PySide6 - [ ] The module service has no imports from
ui.* - [ ] Views have no imports from
core.*,domain.*, orpersistence.* - [ ] Tests use
SqliteGraphRepository(":memory:")— never a real file - [ ] Tests cover: create, add, update, remove, and projection
- [ ] Test coverage for the module is ≥ 85%
Module Registration Sequence¶
sequenceDiagram
participant App as create_application()
participant ML as ModuleLoader
participant TR as TypeRegistry
participant MR as ModuleRegistry
participant MW as MainWindow
App->>ML: load modules.yaml
ML->>TR: register(Kanban graph types)
ML->>MR: register(KanbanModule())
App->>MW: load_modules()
MW->>MR: all_modules()
MR-->>MW: [OutlineModule, KanbanModule]
MW->>MW: create tab "Outline"
MW->>MW: create tab "Kanban"
MW->>KanbanModule: create_widget(graph_service)
KanbanModule-->>MW: KanbanView