cairn-pack-hello 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cairn_pack_hello/__init__.py +11 -0
- cairn_pack_hello/data/sample.jsonl +20 -0
- cairn_pack_hello/data/train.jsonl +48 -0
- cairn_pack_hello/flows/assistant.md +97 -0
- cairn_pack_hello/flows/engine-check.yaml +23 -0
- cairn_pack_hello/flows/lora.yaml +25 -0
- cairn_pack_hello/flows/nightly-job.md +59 -0
- cairn_pack_hello/flows/summarise.yaml +18 -0
- cairn_pack_hello/hooks.py +51 -0
- cairn_pack_hello/pack.toml +50 -0
- cairn_pack_hello/pipelines/ask-the-docs.yaml +85 -0
- cairn_pack_hello/pipelines/nightly-ops.yaml +108 -0
- cairn_pack_hello/pipelines/profile-and-publish.yaml +193 -0
- cairn_pack_hello/pipelines/tiny-finetune.yaml +150 -0
- cairn_pack_hello/policies/require-review-on-publish.yaml +31 -0
- cairn_pack_hello/profiles/hello-infisical.toml +19 -0
- cairn_pack_hello/profiles/hello.toml +46 -0
- cairn_pack_hello/rag/docs/budgets.txt +7 -0
- cairn_pack_hello/rag/docs/gates.txt +12 -0
- cairn_pack_hello/rag/docs/triggers.txt +8 -0
- cairn_pack_hello/tools/calc_server.py +36 -0
- cairn_pack_hello-0.2.0.dist-info/METADATA +238 -0
- cairn_pack_hello-0.2.0.dist-info/RECORD +25 -0
- cairn_pack_hello-0.2.0.dist-info/WHEEL +4 -0
- cairn_pack_hello-0.2.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""hello-cairn — the example domain pack (#2423, ADR-0127 D2). Data plus glue; this
|
|
2
|
+
shim builds the Pack from the ``pack.toml`` beside it. The glue (``hooks.py``,
|
|
3
|
+
``tools/calc_server.py``) is reached through an entry point and a profile, never
|
|
4
|
+
imported here."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from cairn.core.pack import Pack
|
|
8
|
+
|
|
9
|
+
pack = Pack.from_manifest(__file__)
|
|
10
|
+
|
|
11
|
+
__all__ = ["pack"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{"id": 1, "text": "The soup arrived hot and the bread was fresh.", "label": "positive"}
|
|
2
|
+
{"id": 2, "text": "Delivery took two hours longer than promised.", "label": "negative"}
|
|
3
|
+
{"id": 3, "text": "The packaging was fine, nothing special.", "label": "neutral"}
|
|
4
|
+
{"id": 4, "text": "Best noodles I have had in months.", "label": "positive"}
|
|
5
|
+
{"id": 5, "text": "The order was missing the side salad.", "label": "negative"}
|
|
6
|
+
{"id": 6, "text": "Portion size was about what I expected.", "label": "neutral"}
|
|
7
|
+
{"id": 7, "text": "The courier was polite and quick.", "label": "positive"}
|
|
8
|
+
{"id": 8, "text": "My drink had spilled inside the bag.", "label": "negative"}
|
|
9
|
+
{"id": 9, "text": "I ordered the same dish as last week.", "label": "neutral"}
|
|
10
|
+
{"id": 10, "text": "The dessert was a lovely surprise.", "label": "positive"}
|
|
11
|
+
{"id": 11, "text": "The curry was far too salty to finish.", "label": "negative"}
|
|
12
|
+
{"id": 12, "text": "Paid with the app, it worked as usual.", "label": "neutral"}
|
|
13
|
+
{"id": 13, "text": "Everything was still warm when it came.", "label": "positive"}
|
|
14
|
+
{"id": 14, "text": "The tracking map never updated.", "label": "negative"}
|
|
15
|
+
{"id": 15, "text": "The menu has not changed since spring.", "label": "neutral"}
|
|
16
|
+
{"id": 16, "text": "Great value for a weeknight dinner.", "label": "positive"}
|
|
17
|
+
{"id": 17, "text": "The rice was undercooked again.", "label": "negative"}
|
|
18
|
+
{"id": 18, "text": "Arrived at the time the app estimated.", "label": "neutral"}
|
|
19
|
+
{"id": 19, "text": "The new vegetarian option is excellent.", "label": "positive"}
|
|
20
|
+
{"id": 20, "text": "Support never answered my refund request.", "label": "negative"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{"text": "The tea works well on cold mornings."}
|
|
2
|
+
{"text": "A paper map needed a second try."}
|
|
3
|
+
{"text": "The bicycle bell fits in a coat pocket."}
|
|
4
|
+
{"text": "This notebook looks better in person."}
|
|
5
|
+
{"text": "The garden hose is lighter than it looks."}
|
|
6
|
+
{"text": "A wool scarf smells faintly of cedar."}
|
|
7
|
+
{"text": "The desk lamp made less noise than expected."}
|
|
8
|
+
{"text": "That umbrella arrived a day early."}
|
|
9
|
+
{"text": "The bread knife lasted the whole season."}
|
|
10
|
+
{"text": "A clay mug was easy to set up."}
|
|
11
|
+
{"text": "The tea works well on cold mornings."}
|
|
12
|
+
{"text": "A paper map needed a second try."}
|
|
13
|
+
{"text": "The bicycle bell fits in a coat pocket."}
|
|
14
|
+
{"text": "This notebook looks better in person."}
|
|
15
|
+
{"text": "The garden hose is lighter than it looks."}
|
|
16
|
+
{"text": "A wool scarf smells faintly of cedar."}
|
|
17
|
+
{"text": "The desk lamp made less noise than expected."}
|
|
18
|
+
{"text": "That umbrella arrived a day early."}
|
|
19
|
+
{"text": "The bread knife lasted the whole season."}
|
|
20
|
+
{"text": "A clay mug was easy to set up."}
|
|
21
|
+
{"text": "The tea works well on cold mornings."}
|
|
22
|
+
{"text": "A paper map needed a second try."}
|
|
23
|
+
{"text": "The bicycle bell fits in a coat pocket."}
|
|
24
|
+
{"text": "This notebook looks better in person."}
|
|
25
|
+
{"text": "The garden hose is lighter than it looks."}
|
|
26
|
+
{"text": "A wool scarf smells faintly of cedar."}
|
|
27
|
+
{"text": "The desk lamp made less noise than expected."}
|
|
28
|
+
{"text": "That umbrella arrived a day early."}
|
|
29
|
+
{"text": "The bread knife lasted the whole season."}
|
|
30
|
+
{"text": "A clay mug was easy to set up."}
|
|
31
|
+
{"text": "The tea works well on cold mornings."}
|
|
32
|
+
{"text": "A paper map needed a second try."}
|
|
33
|
+
{"text": "The bicycle bell fits in a coat pocket."}
|
|
34
|
+
{"text": "This notebook looks better in person."}
|
|
35
|
+
{"text": "The garden hose is lighter than it looks."}
|
|
36
|
+
{"text": "A wool scarf smells faintly of cedar."}
|
|
37
|
+
{"text": "The desk lamp made less noise than expected."}
|
|
38
|
+
{"text": "That umbrella arrived a day early."}
|
|
39
|
+
{"text": "The bread knife lasted the whole season."}
|
|
40
|
+
{"text": "A clay mug was easy to set up."}
|
|
41
|
+
{"text": "The tea works well on cold mornings."}
|
|
42
|
+
{"text": "A paper map needed a second try."}
|
|
43
|
+
{"text": "The bicycle bell fits in a coat pocket."}
|
|
44
|
+
{"text": "This notebook looks better in person."}
|
|
45
|
+
{"text": "The garden hose is lighter than it looks."}
|
|
46
|
+
{"text": "A wool scarf smells faintly of cedar."}
|
|
47
|
+
{"text": "The desk lamp made less noise than expected."}
|
|
48
|
+
{"text": "That umbrella arrived a day early."}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assistant
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
module: ask
|
|
5
|
+
tags:
|
|
6
|
+
- flow:state_machine
|
|
7
|
+
- domain:example
|
|
8
|
+
description: |
|
|
9
|
+
Answer a question from the pack's own docs: retrieve from the `docs` corpus,
|
|
10
|
+
re-rank, ask a model, measure the answer with a tool on the example's MCP server,
|
|
11
|
+
and remember the exchange. The agentic leg of `ask-the-docs`.
|
|
12
|
+
|
|
13
|
+
flow:
|
|
14
|
+
type: state_machine
|
|
15
|
+
source: behavior_tree
|
|
16
|
+
root:
|
|
17
|
+
type: sequence
|
|
18
|
+
children:
|
|
19
|
+
# Retrieval: the builtin rag/search over the corpus pack.toml declares as
|
|
20
|
+
# [requires.rag.docs] (sqlite-vec by default; pgvector when RAG_VECTOR_STORE
|
|
21
|
+
# says so). Read-only.
|
|
22
|
+
- type: operation
|
|
23
|
+
operator: rag/search
|
|
24
|
+
id: search
|
|
25
|
+
input:
|
|
26
|
+
corpus: docs
|
|
27
|
+
query: $input.question
|
|
28
|
+
top_k: 5
|
|
29
|
+
output:
|
|
30
|
+
hits: $state.findings.hits
|
|
31
|
+
ok: $state.findings.search_ok
|
|
32
|
+
error: $state.findings.search_error
|
|
33
|
+
# Re-rank the hits (identity unless a cross-encoder or Cohere is installed).
|
|
34
|
+
- type: operation
|
|
35
|
+
operator: rag/rerank
|
|
36
|
+
id: rerank
|
|
37
|
+
input:
|
|
38
|
+
query: $input.question
|
|
39
|
+
hits: $default($state.findings.hits, [])
|
|
40
|
+
top_k: 3
|
|
41
|
+
output:
|
|
42
|
+
hits: $state.findings.context
|
|
43
|
+
reranker_used: $state.findings.reranker
|
|
44
|
+
# The model call. The compiler builds it through core's build_chat_model, so
|
|
45
|
+
# the call is redacted (when a redact.text binding is active) and costed.
|
|
46
|
+
- type: action
|
|
47
|
+
ref: answer
|
|
48
|
+
# A tool call on the example's own stdio MCP server (tools/calc_server.py),
|
|
49
|
+
# through the builtin mcp/call operator: governed and audited like any tool call.
|
|
50
|
+
- type: operation
|
|
51
|
+
operator: mcp/call
|
|
52
|
+
id: measure
|
|
53
|
+
input:
|
|
54
|
+
server: hello-calc
|
|
55
|
+
tool: word_count
|
|
56
|
+
args:
|
|
57
|
+
text: $state.findings.answer
|
|
58
|
+
output:
|
|
59
|
+
result: $state.findings.answer_stats
|
|
60
|
+
ok: $state.findings.measure_ok
|
|
61
|
+
error: $state.findings.measure_error
|
|
62
|
+
# Remember the exchange in a local memory store (memory/write, sqlite-vec). A
|
|
63
|
+
# store name is a collection name: letters, digits and underscores only.
|
|
64
|
+
- type: operation
|
|
65
|
+
operator: memory/write
|
|
66
|
+
id: remember
|
|
67
|
+
input:
|
|
68
|
+
store: hello_answers
|
|
69
|
+
record:
|
|
70
|
+
question: $input.question
|
|
71
|
+
answer: $state.findings.answer
|
|
72
|
+
embed_text: $input.question
|
|
73
|
+
output:
|
|
74
|
+
ok: $state.findings.remembered
|
|
75
|
+
id: $state.findings.memory_id
|
|
76
|
+
error: $state.findings.memory_error
|
|
77
|
+
|
|
78
|
+
nodes:
|
|
79
|
+
answer:
|
|
80
|
+
type: prompt
|
|
81
|
+
model: gpt-4o-mini
|
|
82
|
+
capabilities: []
|
|
83
|
+
prompt_section: answer
|
|
84
|
+
|
|
85
|
+
tools:
|
|
86
|
+
required: []
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
# Ask the docs
|
|
90
|
+
|
|
91
|
+
## answer
|
|
92
|
+
|
|
93
|
+
You answer a question about the hello-cairn example pack. The retrieved passages
|
|
94
|
+
are in `state.findings.context` (each hit's `payload.content` is the text, and
|
|
95
|
+
`payload.source` names the file). Answer in at most three sentences, using only
|
|
96
|
+
what the passages say, and name the file you used. When the passages do not cover
|
|
97
|
+
the question, say that the docs do not cover it. Do not invent features.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# flows/engine-check.yaml — a data flow on the builtin engine (#2423).
|
|
2
|
+
#
|
|
3
|
+
# A flow is a list of stages the dataflow engine runs as ONE governed leg. The
|
|
4
|
+
# public core installs exactly one stage provider, cairn-operator-dataflow, and it
|
|
5
|
+
# ships only the conformance stages `noop` and `noop.slow` (the real kernels:
|
|
6
|
+
# dedup, PII redaction, export, live in official operator packs). So this flow
|
|
7
|
+
# is honest about what it is: it proves the flow-leg path end to end (engine
|
|
8
|
+
# selection, the flow gate on aggregate stage risk, a leg Run in the record)
|
|
9
|
+
# and transforms nothing. Swap the stage for a real kernel once one is installed.
|
|
10
|
+
name: engine-check
|
|
11
|
+
kind: data
|
|
12
|
+
description: >
|
|
13
|
+
One `noop` stage on the builtin dataflow engine: shows a flow leg in the
|
|
14
|
+
record without touching data, and registers the checked file's content as a
|
|
15
|
+
version of the dataset the leg names (#2632).
|
|
16
|
+
stages:
|
|
17
|
+
- stage: noop
|
|
18
|
+
config:
|
|
19
|
+
# Bound by the pipeline leg's `with:`; unbound keys are dropped. With both set,
|
|
20
|
+
# the control plane registers `register` at `source`'s content hash: a changed
|
|
21
|
+
# file is a new version, which is what a `dataset` watch sees.
|
|
22
|
+
register: $config.register
|
|
23
|
+
source: $config.source
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# flows/lora.yaml — a LoRA fine-tune as an ml flow (hello-cairn WP6, ADR-0127 D4).
|
|
2
|
+
#
|
|
3
|
+
# One stage, `train.lora`, from the builtin train-local operator pack
|
|
4
|
+
# (cairn-operator-train-local, contract train.finetune/1.0.0). The stage takes the
|
|
5
|
+
# contract's input keys as its config; every `$config.<key>` the leg does not bind is
|
|
6
|
+
# dropped, so the stage's own defaults apply (Qwen/Qwen2.5-0.5B, 8 steps, 64 rows).
|
|
7
|
+
# `mock: true` runs the torch-free stand-in: no training stack, deterministic numbers.
|
|
8
|
+
#
|
|
9
|
+
# The real fine-tune needs the `[train]` extra of cairn-operator-train-local (torch,
|
|
10
|
+
# transformers, peft, datasets) on the box the ml engine runs on; CPU is enough. The
|
|
11
|
+
# stage writes the adapter under the leg's workdir and reports `kind: model`, so the
|
|
12
|
+
# leg registers a model artifact the pipeline can promote.
|
|
13
|
+
name: lora
|
|
14
|
+
kind: ml
|
|
15
|
+
description: >
|
|
16
|
+
LoRA fine-tune a small causal LM on a JSONL of {"text": ...} rows (CPU).
|
|
17
|
+
stages:
|
|
18
|
+
- stage: train.lora
|
|
19
|
+
config:
|
|
20
|
+
dataset: $config.dataset
|
|
21
|
+
base_model: $config.base_model
|
|
22
|
+
lora_rank: $config.lora_rank
|
|
23
|
+
steps: $config.steps
|
|
24
|
+
max_samples: $config.max_samples
|
|
25
|
+
mock: $config.mock
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nightly-job
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
module: ops
|
|
5
|
+
tags:
|
|
6
|
+
- flow:state_machine
|
|
7
|
+
- flow:operation-driven
|
|
8
|
+
- domain:example
|
|
9
|
+
description: |
|
|
10
|
+
The nightly job: check that the job's secret is present, then run a report
|
|
11
|
+
command on an SSH box and fetch what it wrote. The first leg of `nightly-ops`.
|
|
12
|
+
|
|
13
|
+
flow:
|
|
14
|
+
type: state_machine
|
|
15
|
+
source: behavior_tree
|
|
16
|
+
root:
|
|
17
|
+
type: sequence
|
|
18
|
+
children:
|
|
19
|
+
# A capability node, not an operator: the active profile's
|
|
20
|
+
# [profile.bindings] "secrets.store" picks the implementation
|
|
21
|
+
# (secrets/env in profiles/hello.toml, secrets/infisical in
|
|
22
|
+
# profiles/hello-infisical.toml). A presence check: the value never
|
|
23
|
+
# enters the run's state.
|
|
24
|
+
- type: operation
|
|
25
|
+
capability: contract://secrets.store/1.0.0
|
|
26
|
+
id: secret
|
|
27
|
+
input:
|
|
28
|
+
name: $input.secret_name
|
|
29
|
+
output:
|
|
30
|
+
present: $state.findings.secret_present
|
|
31
|
+
# The SSH job (builtin remote/exec). The box comes from a `compute.ssh`
|
|
32
|
+
# connection named by the run input, never from the template.
|
|
33
|
+
- type: operation
|
|
34
|
+
operator: remote/exec
|
|
35
|
+
id: report
|
|
36
|
+
execution:
|
|
37
|
+
compute_target: $input.compute_target
|
|
38
|
+
input:
|
|
39
|
+
command: $input.command
|
|
40
|
+
env:
|
|
41
|
+
HELLO_SECRET_PRESENT: $state.findings.secret_present
|
|
42
|
+
output:
|
|
43
|
+
status: $state.findings.job_status
|
|
44
|
+
exit_code: $state.findings.job_exit_code
|
|
45
|
+
host: $state.findings.job_host
|
|
46
|
+
metrics: $state.findings.report
|
|
47
|
+
error: $state.findings.job_error
|
|
48
|
+
nodes: {}
|
|
49
|
+
|
|
50
|
+
tools:
|
|
51
|
+
required: []
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
# Nightly job
|
|
55
|
+
|
|
56
|
+
Two operation nodes and no model: the secret check runs through whichever
|
|
57
|
+
`secrets.store` implementation the profile binds, and the report runs over SSH on
|
|
58
|
+
the box the `compute_target` input names. The command writes `metrics.json` in its
|
|
59
|
+
working directory; `remote/exec` reads it back as `report`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# flows/summarise.yaml — the nightly report's data flow (hello-cairn WP6).
|
|
2
|
+
#
|
|
3
|
+
# Two builtin stages on the builtin dataflow engine. The public core ships only the
|
|
4
|
+
# conformance stages (`noop`, `noop.slow`), so this flow carries the report through
|
|
5
|
+
# the engine without transforming it: what it proves is the flow leg itself (engine
|
|
6
|
+
# selection, the flow gate on aggregate stage risk, a leg Run and a registered
|
|
7
|
+
# artifact in the record). `noop.slow` with a short `seconds` shows a leg that takes
|
|
8
|
+
# time and reports progress. Swap in a real kernel once an operator pack provides one.
|
|
9
|
+
name: summarise
|
|
10
|
+
kind: data
|
|
11
|
+
description: >
|
|
12
|
+
Carry the nightly report through the builtin dataflow engine: two conformance
|
|
13
|
+
stages, nothing transformed.
|
|
14
|
+
stages:
|
|
15
|
+
- stage: noop
|
|
16
|
+
- stage: noop.slow
|
|
17
|
+
config:
|
|
18
|
+
seconds: 1
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""hello-cairn's run-hook listener: a receipt line per settled leg and finished run.
|
|
2
|
+
|
|
3
|
+
Glue, not an operator (ADR-0127 D2). The engine fires run hooks through the
|
|
4
|
+
`cairn.run_hooks` entry-point group (cairn/core/pack/run_hooks.py); this pack's
|
|
5
|
+
pyproject declares
|
|
6
|
+
|
|
7
|
+
[project.entry-points."cairn.run_hooks"]
|
|
8
|
+
hello-receipts = "cairn_pack_hello.hooks:receipts"
|
|
9
|
+
|
|
10
|
+
so installing the wheel is what turns it on. A dev pack loaded through
|
|
11
|
+
`CAIRN_PACK_PATH` declares no entry points, so nothing fires there.
|
|
12
|
+
|
|
13
|
+
Every `leg.finished`, `run.finished` and `run.failed` appends one JSON line to
|
|
14
|
+
`$CAIRN_HOME/hello-cairn/receipts.jsonl` (`CAIRN_HOME` defaults to `~/.cairn`). The
|
|
15
|
+
hook sees every run on the engine, not only this pack's, which is what a receipt log
|
|
16
|
+
is for. Only JSON scalars from the payload are written: `run.finished` also carries
|
|
17
|
+
the dispatcher's run store, which is an object, not data.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
#: The events a receipt is written for; the others pass through untouched.
|
|
29
|
+
EVENTS = ("leg.finished", "run.finished", "run.failed")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def receipts_path() -> Path:
|
|
33
|
+
home = os.environ.get("CAIRN_HOME") or str(Path.home() / ".cairn")
|
|
34
|
+
return Path(home).expanduser() / "hello-cairn" / "receipts.jsonl"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def receipts(event: str, payload: dict[str, Any]) -> None:
|
|
38
|
+
"""``(event, payload) -> None``, the run-hook signature."""
|
|
39
|
+
if event not in EVENTS:
|
|
40
|
+
return
|
|
41
|
+
line = {k: v for k, v in payload.items() if isinstance(v, (str, int, float, bool)) or v is None}
|
|
42
|
+
line["at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
43
|
+
# Hooks run on the engine's event loop; the file write goes to a worker thread so a
|
|
44
|
+
# slow disk never stalls the dispatcher.
|
|
45
|
+
await asyncio.to_thread(_append, receipts_path(), json.dumps(line, sort_keys=True))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _append(path: Path, text: str) -> None:
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
51
|
+
fh.write(text + "\n")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# hello-cairn — the example domain pack (#2423, ADR-0120 D1; the feature proof,
|
|
2
|
+
# ADR-0127 D1/D2).
|
|
3
|
+
#
|
|
4
|
+
# The one pack the public core ships so a fresh install has something governed
|
|
5
|
+
# to run. It needs the engine and the builtin operator packs, and nothing from the
|
|
6
|
+
# platform: four pipelines, one per kind of work (data, agentic, ml, operations).
|
|
7
|
+
#
|
|
8
|
+
# A domain pack is DATA plus glue, never operators (ADR-0127 D2): this manifest,
|
|
9
|
+
# `pipelines/*.yaml` (the runnable surface), `flows/` (the components the
|
|
10
|
+
# pipelines' flow and template legs name), `rag/docs` (a corpus), `data/`,
|
|
11
|
+
# `policies/`, `profiles/`, the one-line `__init__.py`, a run-hook listener
|
|
12
|
+
# (`hooks.py`, declared through the `cairn.run_hooks` entry point in pyproject.toml)
|
|
13
|
+
# and a stdio MCP tool server (`tools/calc_server.py`). Neither piece of glue
|
|
14
|
+
# registers an operation. Assets are found by convention (`pipelines/`, `flows/`,
|
|
15
|
+
# `rag/`, `data/`, `policies/`), so there is no `[assets]` block.
|
|
16
|
+
|
|
17
|
+
[pack]
|
|
18
|
+
name = "hello-cairn"
|
|
19
|
+
version = "0.2.0"
|
|
20
|
+
description = "Example pack: check a dataset, ask its docs, fine-tune a small model and run a nightly job, each behind gates a person or the data answers."
|
|
21
|
+
tags = [
|
|
22
|
+
"domain:example",
|
|
23
|
+
"data:jsonl",
|
|
24
|
+
]
|
|
25
|
+
kind = "domain"
|
|
26
|
+
publisher = "cairn"
|
|
27
|
+
trust_level = "builtin"
|
|
28
|
+
|
|
29
|
+
[compat]
|
|
30
|
+
cairn_engine = ">=0.1.0"
|
|
31
|
+
|
|
32
|
+
# No `[requires] capabilities`: that table is satisfied by other DOMAIN packs'
|
|
33
|
+
# `[provides]`, not by operator packs, so naming `data.profile` / `storage.put`
|
|
34
|
+
# here would be a composition conflict on every deployment. The pipeline's
|
|
35
|
+
# task legs are checked against the operator registry at plan time instead
|
|
36
|
+
# (`cairn pipeline explain` names the operator class each leg resolves to).
|
|
37
|
+
|
|
38
|
+
# The corpus ask-the-docs retrieves from. Indexed by the engine, not by a leg:
|
|
39
|
+
# with CAIRN_RAG_AUTOINDEX=1 the server indexes every declared corpus at boot into
|
|
40
|
+
# the collection rag/search reads (cairn/services/rag/lifecycle.py).
|
|
41
|
+
[requires.rag.docs]
|
|
42
|
+
corpus = "rag/docs"
|
|
43
|
+
access = "vector_search"
|
|
44
|
+
|
|
45
|
+
# The MCP server ask-the-docs' assistant template calls (tools/calc_server.py).
|
|
46
|
+
# The profile names it under [profile.mcp_servers]; `cairn doctor` reports a
|
|
47
|
+
# profile that does not.
|
|
48
|
+
[requires.mcp]
|
|
49
|
+
servers = ["hello-calc"]
|
|
50
|
+
tools = ["hello-calc:word_count", "hello-calc:add"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# pipelines/ask-the-docs.yaml — the agentic pipeline (hello-cairn WP6, ADR-0127 D1).
|
|
2
|
+
#
|
|
3
|
+
# assistant (template: search → rerank → model → MCP tool → memory)
|
|
4
|
+
# → length (monitor/threshold) → review ⏸
|
|
5
|
+
#
|
|
6
|
+
# The work happens in ONE template leg, flows/assistant.md: retrieval over the
|
|
7
|
+
# `docs` corpus this pack ships (pack.toml [requires.rag.docs] → rag/docs/*.txt), a
|
|
8
|
+
# re-rank, one model call built by core's build_chat_model, a tool call on the
|
|
9
|
+
# example's own stdio MCP server (tools/calc_server.py, through the builtin mcp/call)
|
|
10
|
+
# and a memory write. Then a threshold check on how long the answer is, and a person
|
|
11
|
+
# reads it before anyone relies on it.
|
|
12
|
+
#
|
|
13
|
+
# What this needs from the engine (README "ask-the-docs"):
|
|
14
|
+
# * a model: OPENAI_API_KEY (or LLM_GATEWAY=proxy + LLM_GATEWAY_URL for any
|
|
15
|
+
# OpenAI-compatible gateway). The engine has no built-in mock model.
|
|
16
|
+
# * an embedder for retrieval and memory: RAG_EMBED_PROVIDER=openai (or gemini).
|
|
17
|
+
# * the corpus indexed: CAIRN_RAG_AUTOINDEX=1 on the server indexes every declared
|
|
18
|
+
# corpus at boot (needs the `rag` extra). There is no indexing operator; an
|
|
19
|
+
# un-indexed corpus answers with zero hits, and the model says the docs do not
|
|
20
|
+
# cover the question.
|
|
21
|
+
# * the MCP server named in the engine's environment, CAIRN_MCP_SERVERS (README).
|
|
22
|
+
#
|
|
23
|
+
# Starts on an event (ADR-0131 D4): when `profile-and-publish` publishes, its
|
|
24
|
+
# `publish` leg emits `dataset.ready`; the `kind: event` trigger below starts this
|
|
25
|
+
# pipeline once per message, paused until someone turns it on (README "Events").
|
|
26
|
+
name: ask-the-docs
|
|
27
|
+
kind: pipeline
|
|
28
|
+
visibility: public
|
|
29
|
+
description: >
|
|
30
|
+
Answer a question from the example's own docs with retrieval, a model call and
|
|
31
|
+
an MCP tool, check the answer's length, and have a person read it.
|
|
32
|
+
outcome: Ask a question of a small document set
|
|
33
|
+
suite: operate
|
|
34
|
+
|
|
35
|
+
inputs:
|
|
36
|
+
question: {type: str, default: "What is the difference between a data gate and a person gate?", description: "The question to answer from rag/docs."}
|
|
37
|
+
max_words: {type: int, default: 120, description: "Answers longer than this many words are flagged by the length check."}
|
|
38
|
+
|
|
39
|
+
triggers:
|
|
40
|
+
- kind: event
|
|
41
|
+
topic: dataset.ready
|
|
42
|
+
enabled: false
|
|
43
|
+
description: Ask the docs again whenever the sample dataset is published.
|
|
44
|
+
|
|
45
|
+
governance:
|
|
46
|
+
# One small model call; the reservation is the ceiling, not the expected spend.
|
|
47
|
+
budget: {usd: 0.5}
|
|
48
|
+
series: hello
|
|
49
|
+
approvers: data-owners
|
|
50
|
+
|
|
51
|
+
steps:
|
|
52
|
+
# ── the agentic leg ───────────────────────────────────────────────────────
|
|
53
|
+
- id: assistant
|
|
54
|
+
label: Answer from the docs
|
|
55
|
+
template: assistant
|
|
56
|
+
with:
|
|
57
|
+
question: $input.question
|
|
58
|
+
|
|
59
|
+
# ── a threshold on the answer (read-only) ─────────────────────────────────
|
|
60
|
+
# `crossed` is true when the answer runs longer than max_words. The word count
|
|
61
|
+
# comes from the MCP tool the template called.
|
|
62
|
+
- id: length
|
|
63
|
+
label: Check the answer's length
|
|
64
|
+
task: monitor/threshold
|
|
65
|
+
depends_on: [assistant]
|
|
66
|
+
with:
|
|
67
|
+
metric: answer words
|
|
68
|
+
value: $steps.assistant.answer_stats.words
|
|
69
|
+
threshold: $input.max_words
|
|
70
|
+
comparator: gt
|
|
71
|
+
|
|
72
|
+
# ── the PERSON gate ───────────────────────────────────────────────────────
|
|
73
|
+
- gate: review
|
|
74
|
+
depends_on: [length]
|
|
75
|
+
approvers: data-owners
|
|
76
|
+
show:
|
|
77
|
+
- assistant.answer
|
|
78
|
+
- assistant.reranker
|
|
79
|
+
- length.value
|
|
80
|
+
- length.crossed
|
|
81
|
+
|
|
82
|
+
outputs:
|
|
83
|
+
answer: assistant.answer
|
|
84
|
+
words: length.value
|
|
85
|
+
too_long: length.crossed
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# pipelines/nightly-ops.yaml — the operations pipeline (hello-cairn WP6, ADR-0127 D1).
|
|
2
|
+
#
|
|
3
|
+
# job (template: secret check → SSH report) → summarise (flow)
|
|
4
|
+
# → propose ⏸ → audit
|
|
5
|
+
#
|
|
6
|
+
# A job that runs every night without a person starting it, and still asks one
|
|
7
|
+
# before anything follows from it:
|
|
8
|
+
#
|
|
9
|
+
# * `job` (flows/nightly-job.md) checks the job's secret through whatever the
|
|
10
|
+
# profile binds to `secrets.store` (secrets/env by default, Infisical with
|
|
11
|
+
# profiles/hello-infisical.toml), then runs `command` on an SSH box through the
|
|
12
|
+
# builtin remote/exec. The box is a `compute.ssh` connection (`cairn connect add
|
|
13
|
+
# ssh`) the `compute_target` input names; a template node is where remote/exec
|
|
14
|
+
# gets one (a `task:` leg has no `execution.compute_target`).
|
|
15
|
+
# * `summarise` carries the report through the builtin dataflow engine.
|
|
16
|
+
# * `propose` files the follow-up for a person: monitor/propose declares
|
|
17
|
+
# `requires_approval`, so this leg waits on the approval queue.
|
|
18
|
+
# * `audit` writes a hash-chained audit row.
|
|
19
|
+
#
|
|
20
|
+
# Every leg that settles fires the `leg.finished` run hook; this pack's
|
|
21
|
+
# `hello-receipts` listener (hooks.py) appends a receipt line for it.
|
|
22
|
+
name: nightly-ops
|
|
23
|
+
kind: pipeline
|
|
24
|
+
visibility: public
|
|
25
|
+
description: >
|
|
26
|
+
Check a secret, run a report on an SSH box, carry it through a flow, propose
|
|
27
|
+
the follow-up to a person, and audit it. Runs nightly once enabled.
|
|
28
|
+
outcome: Run a nightly job on a box you own
|
|
29
|
+
suite: operate
|
|
30
|
+
|
|
31
|
+
inputs:
|
|
32
|
+
compute_target: {type: str, default: "hello-ssh", description: "The compute.ssh connection to run the report on (cairn connect add ssh --name hello-ssh …)."}
|
|
33
|
+
secret_name: {type: str, default: "HELLO_REPORT_TOKEN", description: "The secret the job needs; checked for presence, never read into the run."}
|
|
34
|
+
command:
|
|
35
|
+
type: str
|
|
36
|
+
default: "printf '{\"lines\": %s, \"host\": \"%s\"}' \"$(ls / | wc -l | tr -d ' ')\" \"$(hostname)\" > metrics.json && cat metrics.json"
|
|
37
|
+
description: "The report command. It writes metrics.json in its working directory; remote/exec reads it back."
|
|
38
|
+
max_lines: {type: int, default: 100, description: "The proposal's threshold, shown on the approval card."}
|
|
39
|
+
|
|
40
|
+
governance:
|
|
41
|
+
budget: {usd: 0.1}
|
|
42
|
+
series: hello
|
|
43
|
+
approvers: ops
|
|
44
|
+
|
|
45
|
+
triggers:
|
|
46
|
+
# The nightly run. `enabled: true` arms it when the pipeline is enabled in a
|
|
47
|
+
# workspace; until then it is a declaration.
|
|
48
|
+
- kind: schedule
|
|
49
|
+
cron: "30 2 * * *"
|
|
50
|
+
misfire: skip
|
|
51
|
+
enabled: true
|
|
52
|
+
description: "Every night at 02:30 (engine time)."
|
|
53
|
+
# Rows past the watermark in a Postgres table start a proposal. A `table` source
|
|
54
|
+
# needs a database the engine can reach: here the DSN is read from the env var
|
|
55
|
+
# HELLO_EVENTS_DSN at fire time, never written in this file. Declared, NOT armed on a
|
|
56
|
+
# pipeline: a `table` watch needs the platform's governed import, so core names it
|
|
57
|
+
# unarmed (ADR-0131 D3) — it shows in the console as declared, not armed here.
|
|
58
|
+
- kind: watch
|
|
59
|
+
source: {kind: table, dsn_env: HELLO_EVENTS_DSN, table: hello_events, cursor: id}
|
|
60
|
+
every: "*/10 * * * *"
|
|
61
|
+
register_as: hello-events
|
|
62
|
+
on_change: propose
|
|
63
|
+
enabled: false
|
|
64
|
+
description: "Propose a run when hello_events gains rows (needs Postgres)."
|
|
65
|
+
|
|
66
|
+
steps:
|
|
67
|
+
# ── the job: secret check + SSH report (template leg) ─────────────────────
|
|
68
|
+
- id: job
|
|
69
|
+
label: Run the nightly report
|
|
70
|
+
template: nightly-job
|
|
71
|
+
with:
|
|
72
|
+
compute_target: $input.compute_target
|
|
73
|
+
secret_name: $input.secret_name
|
|
74
|
+
command: $input.command
|
|
75
|
+
|
|
76
|
+
# ── carry the report through a flow ───────────────────────────────────────
|
|
77
|
+
- id: summarise
|
|
78
|
+
label: Summarise the report
|
|
79
|
+
flow: summarise
|
|
80
|
+
depends_on: [job]
|
|
81
|
+
|
|
82
|
+
# ── propose the follow-up (waits for a person) ────────────────────────────
|
|
83
|
+
- id: propose
|
|
84
|
+
label: Propose the follow-up
|
|
85
|
+
task: monitor/propose
|
|
86
|
+
depends_on: [summarise]
|
|
87
|
+
with:
|
|
88
|
+
metric: report lines
|
|
89
|
+
value: $default($steps.job.report.lines, 0)
|
|
90
|
+
threshold: $input.max_lines
|
|
91
|
+
proposed_workflow: "pipeline:hello-cairn/profile-and-publish"
|
|
92
|
+
|
|
93
|
+
# ── the audit record ──────────────────────────────────────────────────────
|
|
94
|
+
- id: audit
|
|
95
|
+
label: Record the nightly run
|
|
96
|
+
task: audit/store
|
|
97
|
+
depends_on: [propose]
|
|
98
|
+
with:
|
|
99
|
+
action: hello.nightly.reported
|
|
100
|
+
outcome: ok
|
|
101
|
+
actor: ops
|
|
102
|
+
target: $steps.job.job_host
|
|
103
|
+
|
|
104
|
+
outputs:
|
|
105
|
+
host: job.job_host
|
|
106
|
+
report: job.report
|
|
107
|
+
approved: propose.approved
|
|
108
|
+
audit_hash: audit.hash
|