Phase 7: serve the vet sample from the template tools and docs
- build.py: _TEMPLATE_COMPONENTS rewritten around the vet sample (53 kinds —
per-resource slices, jobs, worked appointment_reminder family, tests);
seed tokens dropped from the manifest ('sample: vet' instead)
- registry.py: get_app_component(kind) no longer renames item/example seeds;
prompts and tool docs updated to 'copy a slice and adapt'
- templates/README.md, 01_build_recipe, 40_ui_admin, wiki/03 updated off the
old seed scheme; repo inventory/config now exclude vet (docs move under
standards; react/vet itself is deleted in a later phase)
- regenerated MCP bundle, vendored templates, and site output
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"""# Summary
|
||||
|
||||
Appointment-reminder pipeline family — port contracts (stage interfaces + root
|
||||
interface). The demo pipeline axis of the vet sample: each stage is a small,
|
||||
testable unit; the DI container wires the tree.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LoadPort(ABC):
|
||||
"""Load the appointment the job refers to."""
|
||||
|
||||
@abstractmethod
|
||||
async def load(self, appointment_id: int) -> dict[str, Any]:
|
||||
"""Return the appointment as a JSON-ready mapping."""
|
||||
|
||||
|
||||
class ComposePort(ABC):
|
||||
"""Compose the reminder message from the appointment context."""
|
||||
|
||||
@abstractmethod
|
||||
def compose(self, appointment: dict[str, Any], pet: dict[str, Any] | None,
|
||||
parent: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return a reminder record ``{to, subject, body}``."""
|
||||
|
||||
|
||||
class DeliverPort(ABC):
|
||||
"""Deliver the composed reminder (log stub today, email/SMS adaptor later)."""
|
||||
|
||||
@abstractmethod
|
||||
async def deliver(self, reminder: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Deliver the reminder and return it with a delivery marker."""
|
||||
|
||||
|
||||
class AppointmentReminderPipelinePort(ABC):
|
||||
"""Root of the appointment-reminder family.
|
||||
|
||||
Notes:
|
||||
The slot annotations below are contracts. A concrete subclass re-annotates
|
||||
them with stage implementations; the DI container instantiates the tree.
|
||||
"""
|
||||
|
||||
loader: LoadPort
|
||||
compose: ComposePort
|
||||
deliver: DeliverPort
|
||||
|
||||
@abstractmethod
|
||||
async def run(self, appointment_id: int) -> dict[str, Any]:
|
||||
"""Run the full stage chain and return the delivered reminder."""
|
||||
@@ -0,0 +1,112 @@
|
||||
"""# Summary
|
||||
|
||||
Appointment-reminder pipeline family — concrete stages, the wired root, and its
|
||||
registration. The concrete subclass re-annotates each slot with a stage
|
||||
implementation; the worker wires the tree once per run with the CRUD services
|
||||
injected by slot name.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pipelines.appointment_reminder.abc import (
|
||||
AppointmentReminderPipelinePort,
|
||||
ComposePort,
|
||||
DeliverPort,
|
||||
LoadPort,
|
||||
)
|
||||
from pipelines.shared.loader import build_pipeline
|
||||
from pipelines.shared.registry import register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppointmentLoader(LoadPort):
|
||||
"""Load an appointment through the appointment CRUD service.
|
||||
|
||||
Notes:
|
||||
The service instance is injected by the worker (``appointment_svc``).
|
||||
"""
|
||||
|
||||
appointment_svc: Any
|
||||
|
||||
async def load(self, appointment_id: int) -> dict[str, Any]:
|
||||
doc = await self.appointment_svc.read(appointment_id)
|
||||
if doc is None:
|
||||
raise ValueError(f"appointment {appointment_id} not found")
|
||||
return doc.model_dump(mode="json", by_alias=True)
|
||||
|
||||
|
||||
class ReminderComposer(ComposePort):
|
||||
"""Compose the reminder text from the family's pattern fragments.
|
||||
|
||||
Notes:
|
||||
``registry`` is the merged pattern map injected by the shared loader.
|
||||
"""
|
||||
|
||||
registry: Any
|
||||
|
||||
def compose(self, appointment: dict[str, Any], pet: dict[str, Any] | None,
|
||||
parent: dict[str, Any] | None) -> dict[str, Any]:
|
||||
pattern = (self.registry or {}).get("reminder", {})
|
||||
pet_name = (pet or {}).get("name", "your pet")
|
||||
parent_name = (parent or {}).get("name", "there")
|
||||
to = (parent or {}).get("email", "")
|
||||
subject = pattern.get("subject", "Appointment reminder")
|
||||
body = pattern.get(
|
||||
"body",
|
||||
"Hi {parent_name}, {pet_name} has an appointment on {date}.",
|
||||
).format(
|
||||
parent_name=parent_name,
|
||||
pet_name=pet_name,
|
||||
date=appointment.get("date", ""),
|
||||
)
|
||||
return {"to": to, "subject": subject, "body": body}
|
||||
|
||||
|
||||
class LogDeliver(DeliverPort):
|
||||
"""Deliver the reminder by logging it (swap for an email/SMS adaptor)."""
|
||||
|
||||
async def deliver(self, reminder: dict[str, Any]) -> dict[str, Any]:
|
||||
logger.info("REMINDER to=%s subject=%s body=%s",
|
||||
reminder.get("to"), reminder.get("subject"), reminder.get("body"))
|
||||
reminder["delivered"] = "log"
|
||||
return reminder
|
||||
|
||||
|
||||
class AppointmentReminderPipeline(AppointmentReminderPipelinePort):
|
||||
"""Concrete appointment-reminder family — the dependency decisions live here."""
|
||||
|
||||
loader: AppointmentLoader
|
||||
compose: ReminderComposer
|
||||
deliver: LogDeliver
|
||||
|
||||
async def run(self, appointment_id: int) -> dict[str, Any]:
|
||||
appointment = await self.loader.load(appointment_id)
|
||||
pet = appointment.get("pet") if isinstance(appointment.get("pet"), dict) else None
|
||||
parent = None
|
||||
if pet and pet.get("parents"):
|
||||
first = pet["parents"][0]
|
||||
parent = first if isinstance(first, dict) else None
|
||||
reminder = self.compose.compose(appointment, pet, parent)
|
||||
return await self.deliver.deliver(reminder)
|
||||
|
||||
|
||||
def get_appointment_reminder_pipeline(appointment_svc) -> AppointmentReminderPipeline:
|
||||
"""Wire and return the appointment-reminder pipeline through the shared loader.
|
||||
|
||||
Args:
|
||||
appointment_svc: Appointment CRUD service injected into the load stage.
|
||||
|
||||
Returns:
|
||||
AppointmentReminderPipeline: The wired pipeline root.
|
||||
"""
|
||||
resources = Path(__file__).parent / "resources"
|
||||
return build_pipeline(
|
||||
AppointmentReminderPipeline,
|
||||
resources_dir=resources,
|
||||
instances={"appointment_svc": appointment_svc},
|
||||
)
|
||||
|
||||
|
||||
register("appointment_reminder", "default", AppointmentReminderPipeline)
|
||||
@@ -0,0 +1,15 @@
|
||||
# Graph spec for the appointment-reminder family: nodes, edges, and the root.
|
||||
# Mirrors the port tree in abc.py.
|
||||
schema: Reminder
|
||||
nodes:
|
||||
loader:
|
||||
port: LoadPort
|
||||
compose:
|
||||
port: ComposePort
|
||||
deliver:
|
||||
port: DeliverPort
|
||||
edges:
|
||||
- loader -> compose
|
||||
- compose -> deliver
|
||||
roots:
|
||||
default: AppointmentReminderPipeline
|
||||
@@ -0,0 +1,5 @@
|
||||
# Pattern / rule fragments for the appointment-reminder family.
|
||||
# The loader merges every patterns/*.yaml into one registry by key.
|
||||
reminder:
|
||||
subject: "Appointment reminder"
|
||||
body: "Hi {parent_name}, {pet_name} has an appointment on {date}."
|
||||
@@ -1,5 +1,5 @@
|
||||
# Runtime spec consumed by the shared loader.
|
||||
kind: example
|
||||
kind: appointment_reminder
|
||||
variant: default
|
||||
mode: default
|
||||
patterns: []
|
||||
@@ -1,7 +0,0 @@
|
||||
"""# Summary
|
||||
|
||||
Example pipeline family — a reusable skeleton, not an app-specific pipeline.
|
||||
|
||||
Replace ``Example*`` names and the three stages with your family's stages. The
|
||||
shape (abc → impl → pipeline.yaml → resources/) stays the same.
|
||||
"""
|
||||
@@ -1,53 +0,0 @@
|
||||
"""# Summary
|
||||
|
||||
Example pipeline family — port contracts (stage interfaces + root interface).
|
||||
|
||||
Ports are declared as a DI tree: the root's annotated slots *are* the dependency
|
||||
decisions. The concrete subclass in ``impl.py`` pins the stage types.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from pipelines.shared.ports import FinalizePort
|
||||
|
||||
|
||||
class IngestPort(ABC):
|
||||
"""Obtain the raw input as text/bytes."""
|
||||
|
||||
@abstractmethod
|
||||
def materialize(self, source: Any) -> str:
|
||||
"""Return the raw input materialized as text."""
|
||||
|
||||
|
||||
class ReadPort(ABC):
|
||||
"""Parse materialized input into records."""
|
||||
|
||||
@abstractmethod
|
||||
def read(self, content: str) -> list[dict[str, Any]]:
|
||||
"""Return parsed records."""
|
||||
|
||||
|
||||
class NormalizePort(ABC):
|
||||
"""Normalize raw records into the target shape."""
|
||||
|
||||
@abstractmethod
|
||||
def build(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return normalized records."""
|
||||
|
||||
|
||||
class ExamplePipelinePort(ABC):
|
||||
"""Root of the example family.
|
||||
|
||||
Notes:
|
||||
The slot annotations below are contracts. A concrete subclass re-annotates
|
||||
them with stage implementations; the DI container instantiates the tree.
|
||||
"""
|
||||
|
||||
ingest: IngestPort
|
||||
reader: ReadPort
|
||||
normalize: NormalizePort
|
||||
finalize: FinalizePort
|
||||
|
||||
@abstractmethod
|
||||
async def run(self, source: Any) -> list[dict[str, Any]]:
|
||||
"""Run the full stage chain and return the normalized records."""
|
||||
@@ -1,71 +0,0 @@
|
||||
"""# Summary
|
||||
|
||||
Example pipeline family — concrete stages, the wired root, and its registration.
|
||||
|
||||
The concrete subclass re-annotates each slot with a stage implementation; the DI
|
||||
container builds the whole tree. Register the family so the shared registry can
|
||||
resolve it by ``(kind, variant, mode)``.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pipelines.example.abc import (
|
||||
ExamplePipelinePort,
|
||||
IngestPort,
|
||||
NormalizePort,
|
||||
ReadPort,
|
||||
)
|
||||
from pipelines.shared.loader import build_pipeline
|
||||
from pipelines.shared.ports import FinalizePort
|
||||
from pipelines.shared.registry import register
|
||||
|
||||
|
||||
class TextIngest(IngestPort):
|
||||
"""Materialize any source as text."""
|
||||
|
||||
def materialize(self, source: Any) -> str:
|
||||
return source if isinstance(source, str) else str(source)
|
||||
|
||||
|
||||
class LineReader(ReadPort):
|
||||
"""Read one record per non-empty line."""
|
||||
|
||||
def read(self, content: str) -> list[dict[str, Any]]:
|
||||
return [{"line": line} for line in content.splitlines() if line.strip()]
|
||||
|
||||
|
||||
class PassthroughNormalize(NormalizePort):
|
||||
"""Return records unchanged (replace with real normalization)."""
|
||||
|
||||
def build(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return records
|
||||
|
||||
|
||||
class NoopFinalize(FinalizePort):
|
||||
"""Drop records (replace with a service-backed finalize stage)."""
|
||||
|
||||
async def finalize(self, records: list[dict[str, Any]]) -> list[Any]:
|
||||
return records
|
||||
|
||||
|
||||
class ExamplePipeline(ExamplePipelinePort):
|
||||
"""Concrete example family — the dependency decisions live here."""
|
||||
|
||||
ingest: TextIngest
|
||||
reader: LineReader
|
||||
normalize: PassthroughNormalize
|
||||
finalize: NoopFinalize
|
||||
|
||||
async def run(self, source: Any) -> list[dict[str, Any]]:
|
||||
content = self.ingest.materialize(source)
|
||||
records = self.reader.read(content)
|
||||
return self.normalize.build(records)
|
||||
|
||||
|
||||
def get_example_pipeline() -> ExamplePipeline:
|
||||
"""Wire and return the example pipeline through the shared loader."""
|
||||
resources = Path(__file__).parent / "resources"
|
||||
return build_pipeline(ExamplePipeline, resources_dir=resources)
|
||||
|
||||
|
||||
register("example", "default", ExamplePipeline)
|
||||
@@ -1,18 +0,0 @@
|
||||
# Graph spec for the example family: nodes, edges, and the root.
|
||||
# Mirrors the port tree in abc.py. Replace with your family's graph.
|
||||
schema: ExampleRecord
|
||||
nodes:
|
||||
ingest:
|
||||
port: IngestPort
|
||||
reader:
|
||||
port: ReadPort
|
||||
normalize:
|
||||
port: NormalizePort
|
||||
finalize:
|
||||
port: FinalizePort
|
||||
edges:
|
||||
- ingest -> reader
|
||||
- reader -> normalize
|
||||
- normalize -> finalize
|
||||
roots:
|
||||
default: ExamplePipeline
|
||||
@@ -1,4 +0,0 @@
|
||||
# Pattern / rule fragments for the example family.
|
||||
# The loader merges every patterns/*.yaml into one registry by key.
|
||||
example:
|
||||
enabled: true
|
||||
@@ -1,32 +0,0 @@
|
||||
"""# Summary
|
||||
|
||||
Item-sync pipeline — the demo pipeline axis of the blueprint.
|
||||
|
||||
Runs on the worker, consumes a pending job, and materializes its payload as an
|
||||
item. Steps are plain async functions here; once the chain branches or needs
|
||||
deterministic replay, wrap the steps in a dagpipe DAG (see standards
|
||||
"50_pipeline_scaffold").
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_item_sync(job, resources: dict) -> None:
|
||||
"""Materialize a pending job payload as an item.
|
||||
|
||||
Step 1: extract the payload from the job.
|
||||
Step 2: create the item through the item service.
|
||||
|
||||
Args:
|
||||
job: The pending job document.
|
||||
resources (dict): Wired services and adaptors.
|
||||
"""
|
||||
payload = job.payload or {}
|
||||
item_svc = resources["item_svc"]
|
||||
|
||||
await item_svc.create({
|
||||
"name": payload.get("name", job.name),
|
||||
"quantity": payload.get("quantity", 1),
|
||||
})
|
||||
logger.info("Created item from job %s", str(job.id))
|
||||
Reference in New Issue
Block a user