keel-context 0.1.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.
- keel/__init__.py +28 -0
- keel/adapters/__init__.py +7 -0
- keel/adapters/postgres.py +108 -0
- keel/adapters/webhook.py +38 -0
- keel/cartridge.py +151 -0
- keel/cli.py +182 -0
- keel/envelope.py +72 -0
- keel/handlers.py +56 -0
- keel/ingest.py +79 -0
- keel/mcp_server.py +67 -0
- keel/rules.py +85 -0
- keel/scopes.py +58 -0
- keel/store/__init__.py +4 -0
- keel/store/base.py +86 -0
- keel/store/memory.py +163 -0
- keel/store/sqlite.py +196 -0
- keel/tools.py +132 -0
- keel_context-0.1.0.dist-info/METADATA +178 -0
- keel_context-0.1.0.dist-info/RECORD +23 -0
- keel_context-0.1.0.dist-info/WHEEL +4 -0
- keel_context-0.1.0.dist-info/entry_points.txt +2 -0
- keel_context-0.1.0.dist-info/licenses/LICENSE +202 -0
- keel_context-0.1.0.dist-info/licenses/NOTICE +4 -0
keel/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Keel: a context layer for AI agents in vertical SaaS.
|
|
2
|
+
|
|
3
|
+
Domain events in, a bitemporal context graph per tenant out,
|
|
4
|
+
shared by every agent through five tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from keel.cartridge import Cartridge, load_cartridge
|
|
8
|
+
from keel.envelope import Event
|
|
9
|
+
from keel.handlers import HandlerRegistry
|
|
10
|
+
from keel.ingest import Ingestor
|
|
11
|
+
from keel.scopes import Scope
|
|
12
|
+
from keel.store.memory import MemoryStore
|
|
13
|
+
from keel.store.sqlite import SqliteStore
|
|
14
|
+
from keel.tools import Tools
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Cartridge",
|
|
18
|
+
"Event",
|
|
19
|
+
"HandlerRegistry",
|
|
20
|
+
"Ingestor",
|
|
21
|
+
"MemoryStore",
|
|
22
|
+
"Scope",
|
|
23
|
+
"SqliteStore",
|
|
24
|
+
"Tools",
|
|
25
|
+
"load_cartridge",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Event adapters: anything that yields `Event`s.
|
|
2
|
+
|
|
3
|
+
Shipped: JsonlAdapter (keel.ingest) for development, PostgresTableAdapter
|
|
4
|
+
(table + polling with a cursor), webhook `build_app` (FastAPI, push).
|
|
5
|
+
Planned: KafkaAdapter, SQSAdapter, Postgres CDC.
|
|
6
|
+
Each adapter only produces envelopes; it never understands payloads.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Table + polling adapter: your SaaS writes envelopes to an outbox table, Keel polls it.
|
|
2
|
+
|
|
3
|
+
Optional extra for the driver: `uv sync --extra postgres` (psycopg). Any DB-API 2.0
|
|
4
|
+
connection works; the tests run it on sqlite3.
|
|
5
|
+
|
|
6
|
+
Expected table (names are configurable):
|
|
7
|
+
|
|
8
|
+
CREATE TABLE keel_events (
|
|
9
|
+
seq bigserial PRIMARY KEY, -- the cursor: assigned at insert, read in order
|
|
10
|
+
id text NOT NULL,
|
|
11
|
+
tenant text NOT NULL,
|
|
12
|
+
type text NOT NULL,
|
|
13
|
+
subject text NOT NULL,
|
|
14
|
+
occurred_at timestamptz NOT NULL,
|
|
15
|
+
recorded_at timestamptz NOT NULL DEFAULT now(),
|
|
16
|
+
source text NOT NULL,
|
|
17
|
+
payload jsonb NOT NULL DEFAULT '{}'
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
|
|
22
|
+
import psycopg
|
|
23
|
+
from keel.adapters.postgres import PostgresTableAdapter
|
|
24
|
+
|
|
25
|
+
adapter = PostgresTableAdapter(lambda: psycopg.connect(DSN), cursor_path=".keel/cursor")
|
|
26
|
+
ingestor.ingest(adapter) # one pass over everything after the cursor
|
|
27
|
+
ingestor.ingest(adapter.follow(interval=5)) # or poll forever
|
|
28
|
+
|
|
29
|
+
The cursor is a text file holding the last `seq` the consumer took, so a restart
|
|
30
|
+
continues where it stopped. Delivery is at-least-once: the cursor advances after
|
|
31
|
+
the consumer has taken an event, so a crash mid-event re-delivers it.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import time
|
|
38
|
+
from collections.abc import Callable, Iterator
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from keel.envelope import Event
|
|
43
|
+
|
|
44
|
+
Connect = Callable[[], Any] # returns a DB-API 2.0 connection
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PostgresTableAdapter:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
connect: Connect,
|
|
51
|
+
table: str = "keel_events",
|
|
52
|
+
cursor_path: str | Path = ".keel/cursor",
|
|
53
|
+
cursor_column: str = "seq",
|
|
54
|
+
batch_size: int = 500,
|
|
55
|
+
placeholder: str = "%s", # psycopg uses %s; sqlite3 uses ?
|
|
56
|
+
) -> None:
|
|
57
|
+
self.connect = connect
|
|
58
|
+
self.cursor_path = Path(cursor_path)
|
|
59
|
+
self.cursor_column = cursor_column
|
|
60
|
+
self.batch_size = batch_size
|
|
61
|
+
# ponytail: a serial cursor can skip a row that commits late under concurrent
|
|
62
|
+
# writers; use one writer per table, or a recorded_at cursor with a lag, if that bites.
|
|
63
|
+
self.sql = (
|
|
64
|
+
f"SELECT * FROM {table} WHERE {cursor_column} > {placeholder} "
|
|
65
|
+
f"ORDER BY {cursor_column} LIMIT {placeholder}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def position(self) -> int:
|
|
70
|
+
try:
|
|
71
|
+
return int(self.cursor_path.read_text())
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
def _save(self, seq: int) -> None:
|
|
76
|
+
self.cursor_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
self.cursor_path.write_text(str(seq))
|
|
78
|
+
|
|
79
|
+
def __iter__(self) -> Iterator[Event]:
|
|
80
|
+
"""One pass: every row after the cursor, in order, then stop."""
|
|
81
|
+
conn = self.connect()
|
|
82
|
+
try:
|
|
83
|
+
while True:
|
|
84
|
+
cur = conn.cursor()
|
|
85
|
+
cur.execute(self.sql, (self.position, self.batch_size))
|
|
86
|
+
cols = [d[0] for d in cur.description]
|
|
87
|
+
rows = cur.fetchall()
|
|
88
|
+
for row in rows:
|
|
89
|
+
rec = dict(zip(cols, row))
|
|
90
|
+
seq = rec.pop(self.cursor_column)
|
|
91
|
+
if isinstance(rec.get("payload"), str):
|
|
92
|
+
rec["payload"] = json.loads(rec["payload"])
|
|
93
|
+
yield Event.model_validate(rec)
|
|
94
|
+
self._save(seq) # runs once the consumer asks for the next event
|
|
95
|
+
if len(rows) < self.batch_size:
|
|
96
|
+
return
|
|
97
|
+
finally:
|
|
98
|
+
conn.close()
|
|
99
|
+
|
|
100
|
+
def follow(self, interval: float = 5.0) -> Iterator[Event]:
|
|
101
|
+
"""Poll forever. `ingestor.ingest(adapter.follow())` never returns."""
|
|
102
|
+
while True:
|
|
103
|
+
n = 0
|
|
104
|
+
for ev in self:
|
|
105
|
+
n += 1
|
|
106
|
+
yield ev
|
|
107
|
+
if n == 0:
|
|
108
|
+
time.sleep(interval)
|
keel/adapters/webhook.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Webhook adapter: HTTP in, envelope validated, ingested. Optional extra: `uv sync --extra webhook`.
|
|
2
|
+
|
|
3
|
+
from keel.adapters.webhook import build_app
|
|
4
|
+
app = build_app(ingestor, token="shared-secret") # then: uvicorn yourmodule:app
|
|
5
|
+
|
|
6
|
+
POST /events with one envelope or a list of them and `Authorization: Bearer <token>`.
|
|
7
|
+
Unlike the pull adapters, this one pushes into the ingestor: the sender drives the loop.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hmac
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from keel.envelope import Event
|
|
16
|
+
from keel.ingest import Ingestor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_app(ingestor: Ingestor, token: str | None = None, strict: bool = False):
|
|
20
|
+
"""`token=None` accepts any caller; use it only on localhost."""
|
|
21
|
+
try:
|
|
22
|
+
from fastapi import Depends, FastAPI, Header, HTTPException
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise SystemExit("Webhook adapter needs the optional dependency: uv sync --extra webhook") from exc
|
|
25
|
+
|
|
26
|
+
app = FastAPI(title=f"keel:{ingestor.cartridge.name}")
|
|
27
|
+
|
|
28
|
+
def auth(authorization: str | None = Header(default=None)) -> None:
|
|
29
|
+
if token and not hmac.compare_digest(authorization or "", f"Bearer {token}"):
|
|
30
|
+
raise HTTPException(401, "bad or missing bearer token")
|
|
31
|
+
|
|
32
|
+
@app.post("/events", dependencies=[Depends(auth)])
|
|
33
|
+
def events(body: list[Event] | Event) -> dict[str, Any]:
|
|
34
|
+
batch = body if isinstance(body, list) else [body]
|
|
35
|
+
report = ingestor.ingest(batch, strict=strict)
|
|
36
|
+
return {"processed": report.processed, "unhandled": report.unhandled, "errors": report.errors}
|
|
37
|
+
|
|
38
|
+
return app
|
keel/cartridge.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Cartridge loader.
|
|
2
|
+
|
|
3
|
+
A cartridge is a folder:
|
|
4
|
+
|
|
5
|
+
cartridges/fleet/
|
|
6
|
+
cartridge.yaml ontology, event catalog, semantic rules, memory policies
|
|
7
|
+
handlers.py a `registry = HandlerRegistry()` with one handler per event type
|
|
8
|
+
|
|
9
|
+
The core loads the YAML, validates that every cataloged event has a
|
|
10
|
+
handler and every relation uses declared entity kinds, and hands the
|
|
11
|
+
registry to the ingestor. That is the whole contract between core and
|
|
12
|
+
domain. If writing your second cartridge makes you touch the core, some
|
|
13
|
+
domain leaked in; pull it back out.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib.util
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import timedelta
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
from keel.envelope import Event, parse_iso
|
|
27
|
+
from keel.handlers import HandlerRegistry
|
|
28
|
+
from keel.rules import problems as rule_problems
|
|
29
|
+
from keel.scopes import MemoryPolicy, Scope
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Cartridge:
|
|
34
|
+
name: str
|
|
35
|
+
path: Path
|
|
36
|
+
entities: dict[str, dict[str, Any]] # kind -> {description, ref?}
|
|
37
|
+
relations: dict[str, dict[str, str]] # rel -> {from, to}
|
|
38
|
+
events: dict[str, dict[str, Any]] # type -> {subject, owner, payload: {...}}
|
|
39
|
+
rules: dict[str, Any] = field(default_factory=dict) # semantic definitions, versioned
|
|
40
|
+
policy: MemoryPolicy = field(default_factory=MemoryPolicy.default)
|
|
41
|
+
registry: HandlerRegistry = field(default_factory=HandlerRegistry)
|
|
42
|
+
|
|
43
|
+
def validate(self) -> list[str]:
|
|
44
|
+
problems: list[str] = []
|
|
45
|
+
for rel, spec in self.relations.items():
|
|
46
|
+
for side in ("from", "to"):
|
|
47
|
+
kind = spec.get(side)
|
|
48
|
+
if kind not in self.entities:
|
|
49
|
+
problems.append(f"relation '{rel}' {side}='{kind}' is not a declared entity kind")
|
|
50
|
+
for etype, spec in self.events.items():
|
|
51
|
+
if spec.get("subject") not in self.entities:
|
|
52
|
+
problems.append(f"event '{etype}' subject kind '{spec.get('subject')}' is not declared")
|
|
53
|
+
if etype not in self.registry.types():
|
|
54
|
+
problems.append(f"event '{etype}' has no handler in handlers.py")
|
|
55
|
+
for etype in self.registry.types() - set(self.events):
|
|
56
|
+
problems.append(f"handler for '{etype}' exists but the event is not in the catalog")
|
|
57
|
+
for name, rule in self.rules.items():
|
|
58
|
+
problems += rule_problems(name, rule)
|
|
59
|
+
return problems
|
|
60
|
+
|
|
61
|
+
def check_event(self, event: Event) -> list[str]:
|
|
62
|
+
"""One event against the catalog: known type, right subject kind, declared payload fields
|
|
63
|
+
present with the declared type. Structural only: keys and types, never values."""
|
|
64
|
+
spec = self.events.get(event.type)
|
|
65
|
+
if spec is None:
|
|
66
|
+
return [f"unknown event type '{event.type}'"]
|
|
67
|
+
out = []
|
|
68
|
+
if event.subject_kind != spec.get("subject"):
|
|
69
|
+
out.append(f"'{event.type}' subject '{event.subject}' is not a '{spec.get('subject')}'")
|
|
70
|
+
for name, hint in (spec.get("payload") or {}).items():
|
|
71
|
+
kinds = {k.strip() for k in str(hint).split("|")}
|
|
72
|
+
if name not in event.payload:
|
|
73
|
+
if "null" not in kinds:
|
|
74
|
+
out.append(f"'{event.type}' payload is missing '{name}' ({hint})")
|
|
75
|
+
continue
|
|
76
|
+
value = event.payload[name]
|
|
77
|
+
if value is None and "null" in kinds:
|
|
78
|
+
continue
|
|
79
|
+
if not any(_is(value, k) for k in kinds - {"null"}):
|
|
80
|
+
out.append(f"'{event.type}' payload '{name}' should be {hint}, got {type(value).__name__}")
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is(value: Any, hint: str) -> bool:
|
|
85
|
+
"""Does a payload value match a catalog type hint? Hints the core does not know are documentation."""
|
|
86
|
+
if hint == "string":
|
|
87
|
+
return isinstance(value, str)
|
|
88
|
+
if hint == "bool":
|
|
89
|
+
return isinstance(value, bool)
|
|
90
|
+
if hint == "int":
|
|
91
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
92
|
+
if hint == "number":
|
|
93
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
94
|
+
if hint in ("date", "datetime"):
|
|
95
|
+
try:
|
|
96
|
+
parse_iso(value)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_ttl(v: Any) -> timedelta | None:
|
|
104
|
+
if v in (None, "never", "forever"):
|
|
105
|
+
return None
|
|
106
|
+
if isinstance(v, (int, float)):
|
|
107
|
+
return timedelta(days=float(v))
|
|
108
|
+
s = str(v).strip()
|
|
109
|
+
unit = s[-1]
|
|
110
|
+
n = float(s[:-1])
|
|
111
|
+
return {"d": timedelta(days=n), "h": timedelta(hours=n), "m": timedelta(minutes=n)}[unit]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _policy_from(raw: dict[str, Any] | None) -> MemoryPolicy:
|
|
115
|
+
base = MemoryPolicy.default()
|
|
116
|
+
if not raw:
|
|
117
|
+
return base
|
|
118
|
+
ttl = dict(base.ttl)
|
|
119
|
+
for k, v in (raw.get("ttl") or {}).items():
|
|
120
|
+
ttl[Scope(k)] = _parse_ttl(v)
|
|
121
|
+
req = {Scope(s) for s in raw.get("requires_evidence", [s.value for s in base.requires_evidence])}
|
|
122
|
+
return MemoryPolicy(ttl=ttl, requires_evidence=req)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _load_registry(path: Path) -> HandlerRegistry:
|
|
126
|
+
handlers_py = path / "handlers.py"
|
|
127
|
+
if not handlers_py.exists():
|
|
128
|
+
return HandlerRegistry()
|
|
129
|
+
spec = importlib.util.spec_from_file_location(f"keel_cartridge_{path.name}_handlers", handlers_py)
|
|
130
|
+
assert spec and spec.loader
|
|
131
|
+
mod = importlib.util.module_from_spec(spec)
|
|
132
|
+
spec.loader.exec_module(mod)
|
|
133
|
+
reg = getattr(mod, "registry", None)
|
|
134
|
+
if not isinstance(reg, HandlerRegistry):
|
|
135
|
+
raise TypeError(f"{handlers_py} must define `registry = HandlerRegistry()`")
|
|
136
|
+
return reg
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_cartridge(path: str | Path) -> Cartridge:
|
|
140
|
+
path = Path(path)
|
|
141
|
+
raw = yaml.safe_load((path / "cartridge.yaml").read_text(encoding="utf-8")) or {}
|
|
142
|
+
return Cartridge(
|
|
143
|
+
name=raw.get("name", path.name),
|
|
144
|
+
path=path,
|
|
145
|
+
entities=raw.get("entities") or {},
|
|
146
|
+
relations=raw.get("relations") or {},
|
|
147
|
+
events=raw.get("events") or {},
|
|
148
|
+
rules=raw.get("rules") or {},
|
|
149
|
+
policy=_policy_from(raw.get("memory")),
|
|
150
|
+
registry=_load_registry(path),
|
|
151
|
+
)
|
keel/cli.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""keel CLI: init, validate, ingest, replay, expire, export, context, serve."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from keel.cartridge import load_cartridge
|
|
11
|
+
from keel.envelope import Event
|
|
12
|
+
from keel.ingest import Ingestor, IngestReport, JsonlAdapter
|
|
13
|
+
from keel.store.memory import MemoryStore
|
|
14
|
+
from keel.store.sqlite import SqliteStore
|
|
15
|
+
from keel.tools import Tools
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="Keel: a context layer for AI agents in vertical SaaS.", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
DB = typer.Option(None, "--db", help="SQLite file for a durable graph. Without it the graph lives in memory and ends with the command.")
|
|
20
|
+
|
|
21
|
+
CARTRIDGE_TEMPLATE = """name: {name}
|
|
22
|
+
|
|
23
|
+
entities:
|
|
24
|
+
account:
|
|
25
|
+
description: The thing your product is about. Rename it.
|
|
26
|
+
|
|
27
|
+
relations: {{}}
|
|
28
|
+
|
|
29
|
+
events:
|
|
30
|
+
account.created:
|
|
31
|
+
subject: account
|
|
32
|
+
owner: core
|
|
33
|
+
payload:
|
|
34
|
+
id: string
|
|
35
|
+
name: string
|
|
36
|
+
|
|
37
|
+
rules: {{}}
|
|
38
|
+
|
|
39
|
+
memory:
|
|
40
|
+
ttl:
|
|
41
|
+
task: 14d
|
|
42
|
+
agent: 1d
|
|
43
|
+
requires_evidence: [global, tenant]
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
HANDLERS_TEMPLATE = '''"""Handlers for the {name} cartridge. One per event type, a few lines each."""
|
|
47
|
+
|
|
48
|
+
from keel.handlers import HandlerRegistry
|
|
49
|
+
|
|
50
|
+
registry = HandlerRegistry()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@registry.on("account.created")
|
|
54
|
+
def account_created(event, store):
|
|
55
|
+
p = event.payload
|
|
56
|
+
store.upsert_entity(event.tenant, "account", p["id"], ref=event.source, name=p.get("name"))
|
|
57
|
+
'''
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def init(path: Path = typer.Argument(..., help="Folder for the new cartridge, e.g. cartridges/clinics")):
|
|
62
|
+
"""Scaffold a new cartridge."""
|
|
63
|
+
path.mkdir(parents=True, exist_ok=False)
|
|
64
|
+
(path / "cartridge.yaml").write_text(CARTRIDGE_TEMPLATE.format(name=path.name), encoding="utf-8")
|
|
65
|
+
(path / "handlers.py").write_text(HANDLERS_TEMPLATE.format(name=path.name), encoding="utf-8")
|
|
66
|
+
typer.echo(f"created {path}/cartridge.yaml and {path}/handlers.py")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def validate(
|
|
71
|
+
cartridge: Path,
|
|
72
|
+
events: Path | None = typer.Option(None, help="Also check a .jsonl stream: unknown types, wrong subject kinds, payload shape."),
|
|
73
|
+
):
|
|
74
|
+
"""Check that the catalog, the ontology and the handlers agree; with --events, that a stream fits the catalog."""
|
|
75
|
+
c = load_cartridge(cartridge)
|
|
76
|
+
problems = c.validate()
|
|
77
|
+
checked = 0
|
|
78
|
+
if events:
|
|
79
|
+
for n, line in enumerate(events.read_text(encoding="utf-8").splitlines(), 1):
|
|
80
|
+
if not line.strip() or line.startswith("#"):
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
ev = Event.model_validate(json.loads(line))
|
|
84
|
+
except ValueError as exc: # json and pydantic errors both subclass ValueError
|
|
85
|
+
errs = getattr(exc, "errors", None) # pydantic: first error, with its field
|
|
86
|
+
detail = f"{'.'.join(map(str, errs()[0]['loc']))}: {errs()[0]['msg']}" if errs else str(exc)
|
|
87
|
+
problems.append(f"line {n}: bad envelope: {detail}")
|
|
88
|
+
continue
|
|
89
|
+
checked += 1
|
|
90
|
+
problems += [f"line {n}: {p}" for p in c.check_event(ev)]
|
|
91
|
+
if problems:
|
|
92
|
+
for p in problems:
|
|
93
|
+
typer.echo(f" - {p}")
|
|
94
|
+
raise typer.Exit(code=1)
|
|
95
|
+
typer.echo(f"{c.name}: {len(c.entities)} entity kinds, {len(c.relations)} relations, {len(c.events)} events, all handled")
|
|
96
|
+
if events:
|
|
97
|
+
typer.echo(f"{events}: {checked} events match the catalog")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run(cartridge: Path, events: Path | None, strict: bool = False, db: Path | None = None):
|
|
101
|
+
c = load_cartridge(cartridge)
|
|
102
|
+
store = SqliteStore(db) if db else MemoryStore()
|
|
103
|
+
ing = Ingestor(c, store)
|
|
104
|
+
report = ing.ingest(JsonlAdapter(events), strict=strict) if events else IngestReport()
|
|
105
|
+
return c, store, ing, report
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def ingest(cartridge: Path, events: Path, strict: bool = False, db: Path | None = DB):
|
|
110
|
+
"""Ingest a .jsonl event stream into the graph (in memory, or the --db file) and report."""
|
|
111
|
+
_, _, _, report = _run(cartridge, events, strict, db)
|
|
112
|
+
typer.echo(f"processed={report.processed} unhandled={len(report.unhandled)} errors={len(report.errors)}")
|
|
113
|
+
for eid, err in report.errors:
|
|
114
|
+
typer.echo(f" {eid}: {err}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def replay(cartridge: Path, events: Path, tenant: str, db: Path | None = DB):
|
|
119
|
+
"""Rebuild one tenant's subgraph from the event log (recovery path). With --db, the file is wiped for that tenant first."""
|
|
120
|
+
_, _, ing, _ = _run(cartridge, None, db=db)
|
|
121
|
+
report = ing.replay(tenant, events=JsonlAdapter(events))
|
|
122
|
+
typer.echo(f"replayed tenant={tenant} processed={report.processed}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
def expire(tenant: str, db: Path = typer.Option(..., "--db", help="The durable graph to clean.")):
|
|
127
|
+
"""Drop task/agent memory past its expiry for one tenant. Reads already ignore expired facts; run this from cron to reclaim space."""
|
|
128
|
+
n = SqliteStore(db).expire(tenant)
|
|
129
|
+
typer.echo(f"expired tenant={tenant} dropped={n}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def export(
|
|
134
|
+
tenant: str,
|
|
135
|
+
db: Path = typer.Option(..., "--db", help="The durable graph to export from."),
|
|
136
|
+
out: Path | None = typer.Option(None, "--out", help="Write here instead of stdout."),
|
|
137
|
+
):
|
|
138
|
+
"""Audit trail for one tenant as JSONL: every entity, relation and fact, closed ones included, with provenance, scope, evidence and who corrected what."""
|
|
139
|
+
from dataclasses import asdict
|
|
140
|
+
|
|
141
|
+
from keel.tools import _ser
|
|
142
|
+
|
|
143
|
+
lines = (json.dumps({"record": type(o).__name__.lower(), **_ser(asdict(o))}, ensure_ascii=False) for o in SqliteStore(db).dump(tenant))
|
|
144
|
+
if out:
|
|
145
|
+
n = 0
|
|
146
|
+
with out.open("w", encoding="utf-8") as fh:
|
|
147
|
+
for line in lines:
|
|
148
|
+
fh.write(line + "\n")
|
|
149
|
+
n += 1
|
|
150
|
+
typer.echo(f"exported tenant={tenant} records={n} to {out}")
|
|
151
|
+
else:
|
|
152
|
+
for line in lines:
|
|
153
|
+
typer.echo(line)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def context(cartridge: Path, events: Path, subject: str, tenant: str | None = None):
|
|
158
|
+
"""Ingest, then print what the `context` tool returns for a subject."""
|
|
159
|
+
c, store, ing, _ = _run(cartridge, events)
|
|
160
|
+
tenant = tenant or next(iter({e.tenant for e in ing.log}), "")
|
|
161
|
+
typer.echo(json.dumps(Tools(c, store, tenant).context(subject), indent=2, ensure_ascii=False))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def serve(
|
|
166
|
+
cartridge: Path,
|
|
167
|
+
tenant: str = typer.Option(..., help="The end-customer this server is bound to."),
|
|
168
|
+
events: Path | None = None,
|
|
169
|
+
db: Path | None = DB,
|
|
170
|
+
):
|
|
171
|
+
"""Serve the five tools over MCP (stdio). Requires `uv sync --extra mcp`. --events fills an in-memory graph; --db serves a durable one."""
|
|
172
|
+
from keel.mcp_server import build_server
|
|
173
|
+
|
|
174
|
+
c = load_cartridge(cartridge)
|
|
175
|
+
store = SqliteStore(db) if db else MemoryStore()
|
|
176
|
+
if events:
|
|
177
|
+
Ingestor(c, store).ingest(JsonlAdapter(events))
|
|
178
|
+
build_server(Tools(c, store, tenant)).run()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
app()
|
keel/envelope.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The standard event envelope.
|
|
2
|
+
|
|
3
|
+
Every event, from any source, arrives in this shape. The core never reads
|
|
4
|
+
`payload`; it only routes on `type` and keys on `tenant` and `subject`.
|
|
5
|
+
A cartridge's handlers are the only code that understands the payload.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from hashlib import sha256
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def utcnow() -> datetime:
|
|
19
|
+
return datetime.now(timezone.utc)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_iso(value: Any) -> datetime:
|
|
23
|
+
"""ISO 8601 date or datetime to an aware UTC datetime. Accepts a trailing Z on Python 3.10 too."""
|
|
24
|
+
if isinstance(value, datetime):
|
|
25
|
+
d = value
|
|
26
|
+
else:
|
|
27
|
+
d = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
28
|
+
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Event(BaseModel):
|
|
32
|
+
"""A domain fact with time, from one of the SaaS's systems."""
|
|
33
|
+
|
|
34
|
+
id: str = Field(default="", description="The source's own id when it has one; else a content hash, so replay yields the same ids.")
|
|
35
|
+
tenant: str = Field(..., description="The end-customer this fact belongs to (the tenant of the tenant).")
|
|
36
|
+
type: str = Field(..., description="Dotted event type from the cartridge catalog, e.g. 'inspection.created'.")
|
|
37
|
+
subject: str = Field(..., description="Entity reference '<kind>:<id>' the fact is about, e.g. 'unit:MXK-4821'.")
|
|
38
|
+
occurred_at: datetime = Field(..., description="When it happened in the world (valid time).")
|
|
39
|
+
recorded_at: datetime = Field(default_factory=utcnow, description="When Keel learned it (transaction time).")
|
|
40
|
+
source: str = Field(..., description="System of record, e.g. 'vec.inspections' or 'webhook:telematics'.")
|
|
41
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
@model_validator(mode="before")
|
|
44
|
+
@classmethod
|
|
45
|
+
def _content_hash_id(cls, data: Any) -> Any:
|
|
46
|
+
if isinstance(data, dict) and not data.get("id"):
|
|
47
|
+
fields = ("tenant", "type", "subject", "occurred_at", "source", "payload")
|
|
48
|
+
key = json.dumps([data.get(k) for k in fields], sort_keys=True, default=str)
|
|
49
|
+
data = {**data, "id": sha256(key.encode()).hexdigest()[:32]}
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
@field_validator("subject")
|
|
53
|
+
@classmethod
|
|
54
|
+
def _subject_shape(cls, v: str) -> str:
|
|
55
|
+
if ":" not in v:
|
|
56
|
+
raise ValueError("subject must look like '<kind>:<id>'")
|
|
57
|
+
return v
|
|
58
|
+
|
|
59
|
+
@field_validator("occurred_at", "recorded_at")
|
|
60
|
+
@classmethod
|
|
61
|
+
def _aware(cls, v: datetime) -> datetime:
|
|
62
|
+
if v.tzinfo is None:
|
|
63
|
+
return v.replace(tzinfo=timezone.utc)
|
|
64
|
+
return v
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def subject_kind(self) -> str:
|
|
68
|
+
return self.subject.split(":", 1)[0]
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def subject_id(self) -> str:
|
|
72
|
+
return self.subject.split(":", 1)[1]
|
keel/handlers.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Handler registry.
|
|
2
|
+
|
|
3
|
+
A handler is the only code that understands an event's payload. It maps
|
|
4
|
+
the payload to store primitives in a few lines. The core routes on
|
|
5
|
+
`event.type`; it never looks inside.
|
|
6
|
+
|
|
7
|
+
registry = HandlerRegistry()
|
|
8
|
+
|
|
9
|
+
@registry.on("inspection.created")
|
|
10
|
+
def inspection_created(event, store):
|
|
11
|
+
store.upsert_entity(event.tenant, "inspection", event.payload["id"], ref=event.source)
|
|
12
|
+
store.link(event.tenant, event.subject, "has_inspection", f"inspection:{event.payload['id']}", event.occurred_at)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from keel.envelope import Event
|
|
20
|
+
from keel.store.base import Store
|
|
21
|
+
|
|
22
|
+
Handler = Callable[[Event, Store], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnhandledEvent(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HandlerRegistry:
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._handlers: dict[str, list[Handler]] = {}
|
|
32
|
+
|
|
33
|
+
def on(self, event_type: str) -> Callable[[Handler], Handler]:
|
|
34
|
+
def deco(fn: Handler) -> Handler:
|
|
35
|
+
self._handlers.setdefault(event_type, []).append(fn)
|
|
36
|
+
return fn
|
|
37
|
+
|
|
38
|
+
return deco
|
|
39
|
+
|
|
40
|
+
def register(self, event_type: str, fn: Handler) -> None:
|
|
41
|
+
self._handlers.setdefault(event_type, []).append(fn)
|
|
42
|
+
|
|
43
|
+
def types(self) -> set[str]:
|
|
44
|
+
return set(self._handlers)
|
|
45
|
+
|
|
46
|
+
def dispatch(self, event: Event, store: Store) -> int:
|
|
47
|
+
handlers = self._handlers.get(event.type)
|
|
48
|
+
if not handlers:
|
|
49
|
+
raise UnhandledEvent(f"no handler for event type '{event.type}'")
|
|
50
|
+
for h in handlers:
|
|
51
|
+
h(event, store)
|
|
52
|
+
return len(handlers)
|
|
53
|
+
|
|
54
|
+
def merge(self, other: HandlerRegistry) -> None:
|
|
55
|
+
for t, hs in other._handlers.items():
|
|
56
|
+
self._handlers.setdefault(t, []).extend(hs)
|