crewcontext 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.
- crewcontext/__init__.py +40 -0
- crewcontext/cli.py +39 -0
- crewcontext/context.py +281 -0
- crewcontext/demos/__init__.py +1 -0
- crewcontext/demos/vendor_discrepancy.py +205 -0
- crewcontext/models.py +134 -0
- crewcontext/projection/__init__.py +5 -0
- crewcontext/projection/neo4j.py +228 -0
- crewcontext/projection/projector.py +130 -0
- crewcontext/router.py +237 -0
- crewcontext/store/__init__.py +5 -0
- crewcontext/store/base.py +86 -0
- crewcontext/store/postgres.py +356 -0
- crewcontext/utils.py +8 -0
- crewcontext-0.1.0.dist-info/METADATA +135 -0
- crewcontext-0.1.0.dist-info/RECORD +26 -0
- crewcontext-0.1.0.dist-info/WHEEL +5 -0
- crewcontext-0.1.0.dist-info/entry_points.txt +2 -0
- crewcontext-0.1.0.dist-info/licenses/LICENSE +21 -0
- crewcontext-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/conftest.py +29 -0
- tests/test_models.py +102 -0
- tests/test_postgres_store.py +176 -0
- tests/test_router.py +165 -0
- tests/test_temporal_asof.py +6 -0
crewcontext/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""CrewContext — Context coordination layer for multi-agent workflows.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from crewcontext import ProcessContext, Entity, Event, Relation
|
|
5
|
+
|
|
6
|
+
with ProcessContext(process_id="p1", agent_id="agent-1") as ctx:
|
|
7
|
+
event = ctx.emit("invoice.received", {"amount": 5000}, entity_id="inv-1")
|
|
8
|
+
"""
|
|
9
|
+
from .context import ProcessContext
|
|
10
|
+
from .models import Entity, Event, Relation, RoutingDecision, generate_id
|
|
11
|
+
from .router import (
|
|
12
|
+
PolicyRouter,
|
|
13
|
+
all_of,
|
|
14
|
+
any_of,
|
|
15
|
+
none_of,
|
|
16
|
+
data_field_eq,
|
|
17
|
+
data_field_gt,
|
|
18
|
+
data_field_ne,
|
|
19
|
+
data_fields_differ,
|
|
20
|
+
event_type_is,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ProcessContext",
|
|
26
|
+
"Entity",
|
|
27
|
+
"Event",
|
|
28
|
+
"Relation",
|
|
29
|
+
"RoutingDecision",
|
|
30
|
+
"generate_id",
|
|
31
|
+
"PolicyRouter",
|
|
32
|
+
"all_of",
|
|
33
|
+
"any_of",
|
|
34
|
+
"none_of",
|
|
35
|
+
"data_field_eq",
|
|
36
|
+
"data_field_gt",
|
|
37
|
+
"data_field_ne",
|
|
38
|
+
"data_fields_differ",
|
|
39
|
+
"event_type_is",
|
|
40
|
+
]
|
crewcontext/cli.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""CLI for CrewContext."""
|
|
2
|
+
import click
|
|
3
|
+
from .utils import load_env
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.group()
|
|
7
|
+
@click.version_option(version="0.1.0")
|
|
8
|
+
def main():
|
|
9
|
+
"""CrewContext - Context coordination for multi-agent workflows."""
|
|
10
|
+
load_env()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@main.group()
|
|
14
|
+
def demo():
|
|
15
|
+
"""Run demo scenarios."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@demo.command("vendor-discrepancy")
|
|
20
|
+
def vendor_discrepancy():
|
|
21
|
+
"""Run the vendor discrepancy demo."""
|
|
22
|
+
from .demos.vendor_discrepancy import run_demo
|
|
23
|
+
run_demo()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@main.command("init-db")
|
|
27
|
+
@click.option("--db-url", envvar="CREWCONTEXT_DB_URL", default=None)
|
|
28
|
+
def init_db(db_url):
|
|
29
|
+
"""Initialise the database schema."""
|
|
30
|
+
from .store.postgres import PostgresStore
|
|
31
|
+
store = PostgresStore(db_url)
|
|
32
|
+
store.connect()
|
|
33
|
+
store.init_schema()
|
|
34
|
+
store.close()
|
|
35
|
+
click.echo("Database schema initialised.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
crewcontext/context.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""ProcessContext — the main API agents interact with.
|
|
2
|
+
|
|
3
|
+
This is the public interface. Agents emit events, query history,
|
|
4
|
+
build entity snapshots, and trace causal chains through this class.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
with ProcessContext(process_id="proc-1", agent_id="agent-a") as ctx:
|
|
8
|
+
e1 = ctx.emit("invoice.received", {"amount": 5000}, entity_id="inv-1")
|
|
9
|
+
e2 = ctx.emit("invoice.validated", {"ok": True}, entity_id="inv-1", caused_by=[e1])
|
|
10
|
+
history = ctx.timeline("inv-1")
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
from .models import Entity, Event, Relation, RoutingDecision, generate_id
|
|
19
|
+
from .projection.projector import Neo4jProjector
|
|
20
|
+
from .router import PolicyRouter
|
|
21
|
+
from .store.postgres import PostgresStore
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProcessContext:
|
|
27
|
+
"""Scoped, temporal, causal context for a business process.
|
|
28
|
+
|
|
29
|
+
Features:
|
|
30
|
+
- **emit()**: Record events with optional causal parents.
|
|
31
|
+
- **query()**: Retrieve events with temporal/scope/type filtering.
|
|
32
|
+
- **timeline()**: Ordered event history for an entity.
|
|
33
|
+
- **snapshot()**: Save/retrieve versioned entity state.
|
|
34
|
+
- **causal_chain()**: Walk the causal DAG.
|
|
35
|
+
- **subscribe()**: React to event types in real-time.
|
|
36
|
+
- **batch_emit()**: Atomic multi-event writes.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
process_id: str,
|
|
42
|
+
agent_id: str,
|
|
43
|
+
scope: str = "default",
|
|
44
|
+
db_url: Optional[str] = None,
|
|
45
|
+
enable_neo4j: bool = True,
|
|
46
|
+
):
|
|
47
|
+
self.process_id = process_id
|
|
48
|
+
self.agent_id = agent_id
|
|
49
|
+
self.scope = scope
|
|
50
|
+
|
|
51
|
+
self._store = PostgresStore(db_url)
|
|
52
|
+
self._projector = Neo4jProjector() if enable_neo4j else None
|
|
53
|
+
self._router = PolicyRouter()
|
|
54
|
+
self._connected = False
|
|
55
|
+
|
|
56
|
+
# -- context manager ----------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def __enter__(self) -> ProcessContext:
|
|
59
|
+
self.connect()
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
63
|
+
self.close()
|
|
64
|
+
|
|
65
|
+
def connect(self) -> None:
|
|
66
|
+
self._store.connect()
|
|
67
|
+
self._store.init_schema()
|
|
68
|
+
self._connected = True
|
|
69
|
+
if self._projector:
|
|
70
|
+
self._projector.connect() # best-effort, logs on failure
|
|
71
|
+
log.info(
|
|
72
|
+
"ProcessContext ready: process=%s agent=%s scope=%s",
|
|
73
|
+
self.process_id, self.agent_id, self.scope,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
self._store.close()
|
|
78
|
+
if self._projector:
|
|
79
|
+
self._projector.close()
|
|
80
|
+
self._connected = False
|
|
81
|
+
|
|
82
|
+
# -- router access ------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def router(self) -> PolicyRouter:
|
|
86
|
+
return self._router
|
|
87
|
+
|
|
88
|
+
# -- emit events --------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def emit(
|
|
91
|
+
self,
|
|
92
|
+
event_type: str,
|
|
93
|
+
data: Dict[str, Any],
|
|
94
|
+
entity_id: Optional[str] = None,
|
|
95
|
+
relation_id: Optional[str] = None,
|
|
96
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
97
|
+
caused_by: Optional[Sequence[Event]] = None,
|
|
98
|
+
) -> Event:
|
|
99
|
+
"""Emit an event into the process context.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
event_type: Dotted event name (e.g. "invoice.received").
|
|
103
|
+
data: Event payload.
|
|
104
|
+
entity_id: Optional entity this event affects.
|
|
105
|
+
relation_id: Optional relation this event affects.
|
|
106
|
+
metadata: Arbitrary metadata.
|
|
107
|
+
caused_by: Parent events that caused this one (builds the DAG).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The persisted Event object.
|
|
111
|
+
"""
|
|
112
|
+
parent_ids = tuple(e.id for e in caused_by) if caused_by else ()
|
|
113
|
+
|
|
114
|
+
event = Event(
|
|
115
|
+
id=generate_id(),
|
|
116
|
+
type=event_type,
|
|
117
|
+
process_id=self.process_id,
|
|
118
|
+
data=data,
|
|
119
|
+
agent_id=self.agent_id,
|
|
120
|
+
entity_id=entity_id,
|
|
121
|
+
relation_id=relation_id,
|
|
122
|
+
scope=self.scope,
|
|
123
|
+
metadata=metadata or {},
|
|
124
|
+
parent_ids=parent_ids,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# 1. Persist to source of truth
|
|
128
|
+
self._store.save_event(event)
|
|
129
|
+
|
|
130
|
+
# 2. Project to graph (best-effort)
|
|
131
|
+
if self._projector:
|
|
132
|
+
self._projector.project_event(event)
|
|
133
|
+
|
|
134
|
+
# 3. Notify subscribers
|
|
135
|
+
self._router.notify_subscribers(event)
|
|
136
|
+
|
|
137
|
+
# 4. Evaluate routing rules
|
|
138
|
+
decision = self._router.evaluate(event)
|
|
139
|
+
if decision:
|
|
140
|
+
self._persist_routing_decision(decision, parent_event=event)
|
|
141
|
+
|
|
142
|
+
log.debug("Emitted: %s (type=%s, entity=%s)", event.id[:8], event_type, entity_id)
|
|
143
|
+
return event
|
|
144
|
+
|
|
145
|
+
def batch_emit(
|
|
146
|
+
self, events_spec: List[Dict[str, Any]]
|
|
147
|
+
) -> List[Event]:
|
|
148
|
+
"""Atomically emit multiple events in a single transaction.
|
|
149
|
+
|
|
150
|
+
Each spec is a dict with keys: event_type, data, and optionally
|
|
151
|
+
entity_id, relation_id, metadata.
|
|
152
|
+
"""
|
|
153
|
+
events = []
|
|
154
|
+
for spec in events_spec:
|
|
155
|
+
event = Event(
|
|
156
|
+
id=generate_id(),
|
|
157
|
+
type=spec["event_type"],
|
|
158
|
+
process_id=self.process_id,
|
|
159
|
+
data=spec["data"],
|
|
160
|
+
agent_id=self.agent_id,
|
|
161
|
+
entity_id=spec.get("entity_id"),
|
|
162
|
+
relation_id=spec.get("relation_id"),
|
|
163
|
+
scope=self.scope,
|
|
164
|
+
metadata=spec.get("metadata", {}),
|
|
165
|
+
)
|
|
166
|
+
events.append(event)
|
|
167
|
+
|
|
168
|
+
self._store.save_events(events)
|
|
169
|
+
|
|
170
|
+
# Best-effort graph projection
|
|
171
|
+
if self._projector:
|
|
172
|
+
for ev in events:
|
|
173
|
+
self._projector.project_event(ev)
|
|
174
|
+
|
|
175
|
+
return events
|
|
176
|
+
|
|
177
|
+
def _persist_routing_decision(
|
|
178
|
+
self, decision: RoutingDecision, parent_event: Event
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Save a routing decision as a child event (no re-evaluation)."""
|
|
181
|
+
decision_event = Event(
|
|
182
|
+
id=generate_id(),
|
|
183
|
+
type="routing.decision",
|
|
184
|
+
process_id=self.process_id,
|
|
185
|
+
data=decision.to_dict(),
|
|
186
|
+
agent_id="system.router",
|
|
187
|
+
entity_id=parent_event.entity_id,
|
|
188
|
+
scope=self.scope,
|
|
189
|
+
parent_ids=(parent_event.id,),
|
|
190
|
+
)
|
|
191
|
+
self._store.save_event(decision_event)
|
|
192
|
+
if self._projector:
|
|
193
|
+
self._projector.project_event(decision_event)
|
|
194
|
+
|
|
195
|
+
# -- query events -------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def query(
|
|
198
|
+
self,
|
|
199
|
+
entity_id: Optional[str] = None,
|
|
200
|
+
event_type: Optional[str] = None,
|
|
201
|
+
scope: Optional[str] = None,
|
|
202
|
+
as_of: Optional[datetime] = None,
|
|
203
|
+
limit: int = 1000,
|
|
204
|
+
offset: int = 0,
|
|
205
|
+
) -> List[Dict[str, Any]]:
|
|
206
|
+
"""Query events in this process with optional filters."""
|
|
207
|
+
return self._store.query_events(
|
|
208
|
+
self.process_id,
|
|
209
|
+
entity_id=entity_id,
|
|
210
|
+
event_type=event_type,
|
|
211
|
+
scope=scope or self.scope,
|
|
212
|
+
as_of=as_of,
|
|
213
|
+
limit=limit,
|
|
214
|
+
offset=offset,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def timeline(self, entity_id: str, as_of: Optional[datetime] = None) -> List[Dict[str, Any]]:
|
|
218
|
+
"""Get the full ordered event history for an entity."""
|
|
219
|
+
return self._store.query_events(
|
|
220
|
+
self.process_id,
|
|
221
|
+
entity_id=entity_id,
|
|
222
|
+
as_of=as_of,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# -- entity snapshots ---------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def save_entity(self, entity: Entity) -> None:
|
|
228
|
+
"""Save a versioned entity snapshot."""
|
|
229
|
+
self._store.save_entity(entity)
|
|
230
|
+
if self._projector:
|
|
231
|
+
self._projector.project_entity(entity)
|
|
232
|
+
|
|
233
|
+
def get_entity(
|
|
234
|
+
self, entity_id: str, as_of: Optional[datetime] = None
|
|
235
|
+
) -> Optional[Dict[str, Any]]:
|
|
236
|
+
"""Retrieve the latest entity state (or state at a point in time)."""
|
|
237
|
+
return self._store.get_entity(entity_id, as_of=as_of)
|
|
238
|
+
|
|
239
|
+
# -- relations ----------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def save_relation(self, relation: Relation) -> None:
|
|
242
|
+
"""Persist a typed relation between entities."""
|
|
243
|
+
self._store.save_relation(relation)
|
|
244
|
+
if self._projector:
|
|
245
|
+
self._projector.project_relation(relation)
|
|
246
|
+
|
|
247
|
+
# -- causal DAG ---------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def causal_parents(self, event_id: str) -> List[str]:
|
|
250
|
+
"""Get the events that caused this event."""
|
|
251
|
+
return self._store.get_causal_parents(event_id)
|
|
252
|
+
|
|
253
|
+
def causal_children(self, event_id: str) -> List[str]:
|
|
254
|
+
"""Get the events caused by this event."""
|
|
255
|
+
return self._store.get_causal_children(event_id)
|
|
256
|
+
|
|
257
|
+
def causal_chain(self, event_id: str, max_depth: int = 10) -> List[Dict[str, Any]]:
|
|
258
|
+
"""Walk the full causal DAG via Neo4j (if available)."""
|
|
259
|
+
if self._projector:
|
|
260
|
+
return self._projector.get_causal_chain(event_id, max_depth=max_depth)
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
# -- graph queries ------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def lineage(self, entity_id: str, max_depth: int = 20) -> List[Dict[str, Any]]:
|
|
266
|
+
"""Get Neo4j lineage for an entity (if available)."""
|
|
267
|
+
if self._projector:
|
|
268
|
+
return self._projector.get_lineage(entity_id, max_depth=max_depth)
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
def cypher(self, query: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
|
272
|
+
"""Execute a raw Cypher query against Neo4j."""
|
|
273
|
+
if self._projector:
|
|
274
|
+
return self._projector.run_cypher(query, params)
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
# -- pub/sub convenience ------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def subscribe(self, event_type: str, callback: Callable[[Event], None]) -> None:
|
|
280
|
+
"""Subscribe to events of a given type."""
|
|
281
|
+
self._router.subscribe(event_type, callback)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Demo scenarios."""
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Vendor discrepancy demo — showcases the full CrewContext pipeline.
|
|
2
|
+
|
|
3
|
+
Scenario: An invoice arrives with a vendor mismatch. Three agents handle
|
|
4
|
+
it in sequence — receiver, validator, reconciler. CrewContext preserves
|
|
5
|
+
full context, causal chains, and routing decisions across handoffs.
|
|
6
|
+
"""
|
|
7
|
+
from crewcontext.context import ProcessContext
|
|
8
|
+
from crewcontext.models import Entity, Relation, generate_id
|
|
9
|
+
from crewcontext.router import all_of, data_field_gt, data_fields_differ
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_demo():
|
|
13
|
+
process_id = f"demo-{generate_id()[:8]}"
|
|
14
|
+
invoice_id = f"inv-{generate_id()[:8]}"
|
|
15
|
+
|
|
16
|
+
print(f"{'=' * 60}")
|
|
17
|
+
print(f" CrewContext Demo: Vendor Discrepancy Resolution")
|
|
18
|
+
print(f" Process: {process_id}")
|
|
19
|
+
print(f" Invoice: {invoice_id}")
|
|
20
|
+
print(f"{'=' * 60}\n")
|
|
21
|
+
|
|
22
|
+
# ── Agent 1: Invoice Receiver ─────────────────────────────
|
|
23
|
+
|
|
24
|
+
print("[Agent: invoice-receiver]")
|
|
25
|
+
with ProcessContext(
|
|
26
|
+
process_id=process_id,
|
|
27
|
+
agent_id="agent-invoice-receiver",
|
|
28
|
+
enable_neo4j=True,
|
|
29
|
+
) as ctx:
|
|
30
|
+
|
|
31
|
+
# Set up routing rules
|
|
32
|
+
ctx.router.add_rule(
|
|
33
|
+
name="high-value-review",
|
|
34
|
+
condition=data_field_gt("amount", 1000),
|
|
35
|
+
action="route-to-senior-auditor",
|
|
36
|
+
priority=10,
|
|
37
|
+
metadata={"sla_hours": 4},
|
|
38
|
+
)
|
|
39
|
+
ctx.router.add_rule(
|
|
40
|
+
name="vendor-mismatch",
|
|
41
|
+
condition=data_fields_differ("vendor_id", "expected_vendor_id"),
|
|
42
|
+
action="flag-for-reconciliation",
|
|
43
|
+
priority=5,
|
|
44
|
+
metadata={"requires_manual_review": True},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Emit: invoice received
|
|
48
|
+
e1 = ctx.emit(
|
|
49
|
+
"invoice.received",
|
|
50
|
+
{
|
|
51
|
+
"invoice_id": invoice_id,
|
|
52
|
+
"vendor_id": "V-1001",
|
|
53
|
+
"expected_vendor_id": "V-1002",
|
|
54
|
+
"amount": 15000,
|
|
55
|
+
"currency": "USD",
|
|
56
|
+
},
|
|
57
|
+
entity_id=invoice_id,
|
|
58
|
+
)
|
|
59
|
+
print(f" Emitted: invoice.received (amount=15000 USD)")
|
|
60
|
+
print(f" Event ID: {e1.id[:12]}...")
|
|
61
|
+
|
|
62
|
+
# Save entity snapshot
|
|
63
|
+
ctx.save_entity(Entity(
|
|
64
|
+
id=invoice_id, type="Invoice",
|
|
65
|
+
attributes={
|
|
66
|
+
"amount": 15000, "currency": "USD",
|
|
67
|
+
"vendor_id": "V-1001", "status": "received",
|
|
68
|
+
},
|
|
69
|
+
provenance={"agent": "agent-invoice-receiver"},
|
|
70
|
+
))
|
|
71
|
+
print(f" Entity snapshot saved: {invoice_id} v1")
|
|
72
|
+
|
|
73
|
+
# Check routing decisions
|
|
74
|
+
decisions = ctx.query(event_type="routing.decision")
|
|
75
|
+
for d in decisions:
|
|
76
|
+
data = d.get("data", {})
|
|
77
|
+
print(f" Routing: {data.get('rule_name')} -> {data.get('action')}")
|
|
78
|
+
|
|
79
|
+
# ── Agent 2: Validator ────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
print(f"\n[Agent: invoice-validator]")
|
|
82
|
+
with ProcessContext(
|
|
83
|
+
process_id=process_id,
|
|
84
|
+
agent_id="agent-invoice-validator",
|
|
85
|
+
enable_neo4j=True,
|
|
86
|
+
) as ctx:
|
|
87
|
+
|
|
88
|
+
# Query what happened before (context handoff!)
|
|
89
|
+
history = ctx.timeline(invoice_id)
|
|
90
|
+
print(f" Context received: {len(history)} prior events")
|
|
91
|
+
|
|
92
|
+
# Emit: validation result (caused by e1)
|
|
93
|
+
e2 = ctx.emit(
|
|
94
|
+
"invoice.validated",
|
|
95
|
+
{
|
|
96
|
+
"invoice_id": invoice_id,
|
|
97
|
+
"validation_status": "discrepancy_found",
|
|
98
|
+
"discrepancy_type": "vendor_mismatch",
|
|
99
|
+
"vendor_on_invoice": "V-1001",
|
|
100
|
+
"vendor_expected": "V-1002",
|
|
101
|
+
},
|
|
102
|
+
entity_id=invoice_id,
|
|
103
|
+
caused_by=[e1],
|
|
104
|
+
)
|
|
105
|
+
print(f" Emitted: invoice.validated (discrepancy_found)")
|
|
106
|
+
print(f" Causal parent: {e1.id[:12]}...")
|
|
107
|
+
|
|
108
|
+
# Update entity snapshot
|
|
109
|
+
ctx.save_entity(Entity(
|
|
110
|
+
id=invoice_id, type="Invoice", version=2,
|
|
111
|
+
attributes={
|
|
112
|
+
"amount": 15000, "currency": "USD",
|
|
113
|
+
"vendor_id": "V-1001", "status": "discrepancy_found",
|
|
114
|
+
},
|
|
115
|
+
provenance={"agent": "agent-invoice-validator"},
|
|
116
|
+
))
|
|
117
|
+
print(f" Entity snapshot saved: {invoice_id} v2")
|
|
118
|
+
|
|
119
|
+
# ── Agent 3: Reconciler ───────────────────────────────────
|
|
120
|
+
|
|
121
|
+
print(f"\n[Agent: reconciler]")
|
|
122
|
+
with ProcessContext(
|
|
123
|
+
process_id=process_id,
|
|
124
|
+
agent_id="agent-reconciler",
|
|
125
|
+
enable_neo4j=True,
|
|
126
|
+
) as ctx:
|
|
127
|
+
|
|
128
|
+
# Full context available
|
|
129
|
+
history = ctx.timeline(invoice_id)
|
|
130
|
+
print(f" Context received: {len(history)} prior events")
|
|
131
|
+
|
|
132
|
+
# Emit: reconciliation
|
|
133
|
+
e3 = ctx.emit(
|
|
134
|
+
"reconciliation.completed",
|
|
135
|
+
{
|
|
136
|
+
"invoice_id": invoice_id,
|
|
137
|
+
"resolution": "vendor_corrected",
|
|
138
|
+
"original_vendor": "V-1001",
|
|
139
|
+
"corrected_vendor": "V-1002",
|
|
140
|
+
},
|
|
141
|
+
entity_id=invoice_id,
|
|
142
|
+
caused_by=[e2],
|
|
143
|
+
)
|
|
144
|
+
print(f" Emitted: reconciliation.completed")
|
|
145
|
+
|
|
146
|
+
# Save relation
|
|
147
|
+
ctx.save_relation(Relation(
|
|
148
|
+
id=generate_id(), type="RECONCILED_BY",
|
|
149
|
+
from_entity_id=invoice_id, to_entity_id="agent-reconciler",
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
# Final entity snapshot
|
|
153
|
+
ctx.save_entity(Entity(
|
|
154
|
+
id=invoice_id, type="Invoice", version=3,
|
|
155
|
+
attributes={
|
|
156
|
+
"amount": 15000, "currency": "USD",
|
|
157
|
+
"vendor_id": "V-1002", "status": "reconciled",
|
|
158
|
+
},
|
|
159
|
+
provenance={"agent": "agent-reconciler"},
|
|
160
|
+
))
|
|
161
|
+
print(f" Entity snapshot saved: {invoice_id} v3 (reconciled)")
|
|
162
|
+
|
|
163
|
+
# ── Summary ──────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
print(f"\n{'=' * 60}")
|
|
166
|
+
print(f" SUMMARY")
|
|
167
|
+
print(f"{'=' * 60}")
|
|
168
|
+
|
|
169
|
+
full_timeline = ctx.timeline(invoice_id)
|
|
170
|
+
print(f"\n Timeline ({len(full_timeline)} events):")
|
|
171
|
+
for evt in full_timeline:
|
|
172
|
+
agent = evt.get("agent_id", "?")
|
|
173
|
+
etype = evt.get("type", "?")
|
|
174
|
+
ts = evt.get("timestamp", "?")
|
|
175
|
+
print(f" {ts} [{agent}] {etype}")
|
|
176
|
+
|
|
177
|
+
# Causal chain
|
|
178
|
+
parents = ctx.causal_parents(e3.id)
|
|
179
|
+
print(f"\n Causal parents of reconciliation: {len(parents)}")
|
|
180
|
+
children = ctx.causal_children(e1.id)
|
|
181
|
+
print(f" Causal children of initial receipt: {len(children)}")
|
|
182
|
+
|
|
183
|
+
# Entity state
|
|
184
|
+
entity = ctx.get_entity(invoice_id)
|
|
185
|
+
if entity:
|
|
186
|
+
print(f"\n Final entity state:")
|
|
187
|
+
print(f" Version: {entity.get('version')}")
|
|
188
|
+
print(f" Status: {entity.get('attributes', {}).get('status', 'unknown') if isinstance(entity.get('attributes'), dict) else 'see attributes'}")
|
|
189
|
+
|
|
190
|
+
# Neo4j lineage
|
|
191
|
+
lineage = ctx.lineage(invoice_id)
|
|
192
|
+
if lineage:
|
|
193
|
+
print(f"\n Neo4j Lineage ({len(lineage)} events):")
|
|
194
|
+
for rec in lineage:
|
|
195
|
+
print(f" {rec.get('type')} by {rec.get('agent_id')}")
|
|
196
|
+
else:
|
|
197
|
+
print(f"\n (Neo4j lineage not available)")
|
|
198
|
+
|
|
199
|
+
print(f"\n{'=' * 60}")
|
|
200
|
+
print(f" Demo completed successfully!")
|
|
201
|
+
print(f"{'=' * 60}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
run_demo()
|