activegraph 1.0.0rc2__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.
- activegraph/__init__.py +222 -0
- activegraph/__main__.py +5 -0
- activegraph/behaviors/__init__.py +1 -0
- activegraph/behaviors/base.py +153 -0
- activegraph/behaviors/decorators.py +218 -0
- activegraph/cli/__init__.py +17 -0
- activegraph/cli/main.py +793 -0
- activegraph/cli/quickstart.py +556 -0
- activegraph/core/__init__.py +1 -0
- activegraph/core/clock.py +46 -0
- activegraph/core/event.py +33 -0
- activegraph/core/graph.py +846 -0
- activegraph/core/ids.py +135 -0
- activegraph/core/patch.py +47 -0
- activegraph/core/view.py +59 -0
- activegraph/errors.py +342 -0
- activegraph/frame.py +15 -0
- activegraph/llm/__init__.py +51 -0
- activegraph/llm/anthropic.py +339 -0
- activegraph/llm/cache.py +180 -0
- activegraph/llm/errors.py +257 -0
- activegraph/llm/prompt.py +420 -0
- activegraph/llm/provider.py +66 -0
- activegraph/llm/recorded.py +270 -0
- activegraph/llm/types.py +130 -0
- activegraph/observability/__init__.py +61 -0
- activegraph/observability/logging.py +205 -0
- activegraph/observability/metrics.py +219 -0
- activegraph/observability/migration.py +404 -0
- activegraph/observability/prometheus.py +101 -0
- activegraph/observability/status.py +83 -0
- activegraph/packs/__init__.py +999 -0
- activegraph/packs/diligence/__init__.py +86 -0
- activegraph/packs/diligence/behaviors.py +447 -0
- activegraph/packs/diligence/fixtures/__init__.py +364 -0
- activegraph/packs/diligence/fixtures/companies.py +511 -0
- activegraph/packs/diligence/object_types.py +163 -0
- activegraph/packs/diligence/settings.py +60 -0
- activegraph/packs/diligence/tools.py +124 -0
- activegraph/packs/loader.py +709 -0
- activegraph/packs/scaffold.py +317 -0
- activegraph/policy.py +20 -0
- activegraph/runtime/__init__.py +1 -0
- activegraph/runtime/behavior_graph.py +151 -0
- activegraph/runtime/budget.py +120 -0
- activegraph/runtime/config_errors.py +124 -0
- activegraph/runtime/diff.py +145 -0
- activegraph/runtime/errors.py +216 -0
- activegraph/runtime/exec_errors.py +232 -0
- activegraph/runtime/patterns.py +946 -0
- activegraph/runtime/queue.py +27 -0
- activegraph/runtime/registration_errors.py +291 -0
- activegraph/runtime/registry.py +111 -0
- activegraph/runtime/runtime.py +2441 -0
- activegraph/runtime/scheduler.py +206 -0
- activegraph/runtime/view_builder.py +65 -0
- activegraph/store/__init__.py +41 -0
- activegraph/store/base.py +66 -0
- activegraph/store/conformance.py +161 -0
- activegraph/store/errors.py +84 -0
- activegraph/store/memory.py +118 -0
- activegraph/store/postgres.py +609 -0
- activegraph/store/serde.py +202 -0
- activegraph/store/sqlite.py +446 -0
- activegraph/store/url.py +230 -0
- activegraph/tools/__init__.py +64 -0
- activegraph/tools/base.py +57 -0
- activegraph/tools/cache.py +157 -0
- activegraph/tools/context.py +48 -0
- activegraph/tools/decorators.py +70 -0
- activegraph/tools/errors.py +320 -0
- activegraph/tools/graph_query.py +94 -0
- activegraph/tools/recorded.py +205 -0
- activegraph/tools/web_fetch.py +80 -0
- activegraph/trace/__init__.py +1 -0
- activegraph/trace/causal.py +123 -0
- activegraph/trace/printer.py +495 -0
- activegraph-1.0.0rc2.dist-info/METADATA +228 -0
- activegraph-1.0.0rc2.dist-info/RECORD +82 -0
- activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
- activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
- activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Recorded fixtures for the Diligence pack.
|
|
2
|
+
|
|
3
|
+
CONTRACT v0.9 #18: fixtures ship inside the pack package, not in the
|
|
4
|
+
framework and not in the user's tests directory. The killer demo
|
|
5
|
+
references them by relative path so the demo runs without API keys
|
|
6
|
+
and without network access.
|
|
7
|
+
|
|
8
|
+
Three companies, each with a small set of documents and canned
|
|
9
|
+
research findings. The `RecordedDiligenceProvider` is the
|
|
10
|
+
`LLMProvider`-protocol-conforming scripted provider used by the demo
|
|
11
|
+
and by the integration test.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from activegraph.llm.types import LLMMessage, LLMResponse
|
|
20
|
+
|
|
21
|
+
from activegraph.packs.diligence.fixtures.companies import (
|
|
22
|
+
THREE_COMPANIES,
|
|
23
|
+
company_goal,
|
|
24
|
+
DOCS_BY_COMPANY,
|
|
25
|
+
FILINGS_BY_COMPANY,
|
|
26
|
+
SUMMARIES_BY_URL,
|
|
27
|
+
QUESTIONS_BY_COMPANY,
|
|
28
|
+
RESEARCH_FINDINGS_BY_QUESTION,
|
|
29
|
+
RISKS_BY_COMPANY,
|
|
30
|
+
MEMO_BODIES_BY_COMPANY,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------- tool fixtures
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def lookup_company_docs(company_name: str, *, max_results: int = 5) -> list[dict]:
|
|
38
|
+
docs = DOCS_BY_COMPANY.get(company_name.lower(), [])
|
|
39
|
+
return docs[:max_results]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lookup_filings(company_name: str, keyword: str = "") -> list[dict]:
|
|
43
|
+
out = FILINGS_BY_COMPANY.get(company_name.lower(), [])
|
|
44
|
+
if keyword:
|
|
45
|
+
out = [f for f in out if keyword.lower() in (f["title"] + f.get("summary", "")).lower()]
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lookup_summary(url: str) -> dict:
|
|
50
|
+
return SUMMARIES_BY_URL.get(url, {"summary": "", "facts": []})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------- scripted LLM provider
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RecordedDiligenceProvider:
|
|
57
|
+
"""Scripted LLM provider keyed off behavior name and the triggering
|
|
58
|
+
object. Implements `LLMProvider` per CONTRACT v0.6 #3.
|
|
59
|
+
|
|
60
|
+
The provider inspects:
|
|
61
|
+
- the system prompt (contains the behavior_name on the
|
|
62
|
+
'## Behavior:' line)
|
|
63
|
+
- the user message (contains the triggering event payload)
|
|
64
|
+
- the output_schema (selects which response shape to return)
|
|
65
|
+
|
|
66
|
+
For the document_researcher, the provider returns:
|
|
67
|
+
- turn 1: tool_call to `diligence.fetch_company_docs`
|
|
68
|
+
- turn 2: tool_call to `diligence.summarize_document`
|
|
69
|
+
- turn 3: the final ResearchFindings
|
|
70
|
+
|
|
71
|
+
For all other LLM behaviors, returns the canned structured output
|
|
72
|
+
in one turn.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, companies: list[dict]) -> None:
|
|
76
|
+
# Used only for sanity; the dispatch logic keys off behavior name.
|
|
77
|
+
self.companies = list(companies)
|
|
78
|
+
|
|
79
|
+
def complete(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
system,
|
|
83
|
+
messages,
|
|
84
|
+
model,
|
|
85
|
+
max_tokens,
|
|
86
|
+
temperature,
|
|
87
|
+
top_p,
|
|
88
|
+
output_schema,
|
|
89
|
+
timeout_seconds,
|
|
90
|
+
tools=None,
|
|
91
|
+
) -> LLMResponse:
|
|
92
|
+
behavior = _extract_behavior_name(system or "")
|
|
93
|
+
last_user = _last_user_content(messages)
|
|
94
|
+
company_name = _extract_company_name(last_user)
|
|
95
|
+
|
|
96
|
+
if behavior == "diligence.question_generator":
|
|
97
|
+
return _resp(model, output_schema, {
|
|
98
|
+
"questions": QUESTIONS_BY_COMPANY.get(company_name.lower(), [
|
|
99
|
+
"What is the company's primary revenue source?",
|
|
100
|
+
"Who are the main competitors?",
|
|
101
|
+
"What is the growth trajectory?",
|
|
102
|
+
"What regulatory exposures exist?",
|
|
103
|
+
"How concentrated is the customer base?",
|
|
104
|
+
"What is the unit economics picture?",
|
|
105
|
+
"Are there any pending lawsuits?",
|
|
106
|
+
"What is the leadership team's track record?",
|
|
107
|
+
]),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
if behavior == "diligence.document_researcher":
|
|
111
|
+
return _researcher_turn(
|
|
112
|
+
model=model,
|
|
113
|
+
output_schema=output_schema,
|
|
114
|
+
messages=messages,
|
|
115
|
+
last_user=last_user,
|
|
116
|
+
company_name=company_name,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if behavior == "diligence.risk_identifier":
|
|
120
|
+
return _resp(model, output_schema, {
|
|
121
|
+
"risks": RISKS_BY_COMPANY.get(company_name.lower(), [
|
|
122
|
+
{
|
|
123
|
+
"title": "limited fixture coverage",
|
|
124
|
+
"description": "Recorded fixtures do not include a risk profile for this company.",
|
|
125
|
+
"severity": "low",
|
|
126
|
+
"related_claim_texts": [],
|
|
127
|
+
},
|
|
128
|
+
]),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if behavior == "diligence.memo_synthesizer":
|
|
132
|
+
return _resp(model, output_schema, MEMO_BODIES_BY_COMPANY.get(
|
|
133
|
+
company_name.lower(),
|
|
134
|
+
_empty_memo(company_name),
|
|
135
|
+
))
|
|
136
|
+
|
|
137
|
+
# Fallback: empty schema-valid object.
|
|
138
|
+
return _resp(model, output_schema, {})
|
|
139
|
+
|
|
140
|
+
def estimate_cost(self, *, input_tokens, output_tokens, model) -> Decimal:
|
|
141
|
+
return Decimal("0.001")
|
|
142
|
+
|
|
143
|
+
def count_tokens(self, *, system, messages, model) -> int:
|
|
144
|
+
total = len(system or "") + sum(len(m.content) for m in messages)
|
|
145
|
+
return max(1, total // 4)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------- helpers
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _extract_behavior_name(system: str) -> str:
|
|
152
|
+
"""The runtime's system prompt opens with
|
|
153
|
+
'You are an active-graph behavior named "<name>".' (per
|
|
154
|
+
`activegraph.llm.prompt.build_system_prompt`). We sniff that back
|
|
155
|
+
out, lowercased.
|
|
156
|
+
"""
|
|
157
|
+
import re
|
|
158
|
+
m = re.search(r'behavior named "([^"]+)"', system or "")
|
|
159
|
+
if m:
|
|
160
|
+
return m.group(1).lower()
|
|
161
|
+
return ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _last_user_content(messages: list[LLMMessage]) -> str:
|
|
165
|
+
for m in reversed(messages):
|
|
166
|
+
if m.role == "user":
|
|
167
|
+
return m.content
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _extract_company_name(text: str) -> str:
|
|
172
|
+
"""Find which company this turn is about. Scope to the triggering
|
|
173
|
+
event section so we don't pick up a different company that
|
|
174
|
+
appears in the graph view block.
|
|
175
|
+
"""
|
|
176
|
+
marker = "## Triggering event"
|
|
177
|
+
if marker in text:
|
|
178
|
+
section = text.split(marker, 1)[1]
|
|
179
|
+
section = section.split("\n## ", 1)[0]
|
|
180
|
+
else:
|
|
181
|
+
section = text
|
|
182
|
+
lower = section.lower()
|
|
183
|
+
for c in THREE_COMPANIES:
|
|
184
|
+
if c["name"].lower() in lower:
|
|
185
|
+
return c["name"]
|
|
186
|
+
# Fallback: scan the whole text. This is needed for the
|
|
187
|
+
# question_generator turn, whose triggering event is `company.created`
|
|
188
|
+
# — the company name is in the event payload's data.
|
|
189
|
+
full_lower = text.lower()
|
|
190
|
+
for c in THREE_COMPANIES:
|
|
191
|
+
if c["name"].lower() in full_lower:
|
|
192
|
+
return c["name"]
|
|
193
|
+
return ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _researcher_turn(
|
|
197
|
+
*, model, output_schema, messages, last_user, company_name: str,
|
|
198
|
+
) -> LLMResponse:
|
|
199
|
+
"""Three-turn loop:
|
|
200
|
+
turn 1: tool_call fetch_company_docs
|
|
201
|
+
turn 2: tool_call summarize_document
|
|
202
|
+
turn 3: final ResearchFindings
|
|
203
|
+
"""
|
|
204
|
+
from activegraph.llm.types import ToolCall
|
|
205
|
+
|
|
206
|
+
fetched = any(
|
|
207
|
+
m.role == "tool" and m.tool_name and "fetch_company_docs" in m.tool_name
|
|
208
|
+
for m in messages
|
|
209
|
+
)
|
|
210
|
+
summarized = any(
|
|
211
|
+
m.role == "tool" and m.tool_name and "summarize_document" in m.tool_name
|
|
212
|
+
for m in messages
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if not fetched:
|
|
216
|
+
call = ToolCall(
|
|
217
|
+
id="call_fetch",
|
|
218
|
+
name="diligence.fetch_company_docs",
|
|
219
|
+
args={"company_name": company_name, "max_results": 3},
|
|
220
|
+
)
|
|
221
|
+
return LLMResponse(
|
|
222
|
+
raw_text="",
|
|
223
|
+
parsed=None,
|
|
224
|
+
input_tokens=100,
|
|
225
|
+
output_tokens=15,
|
|
226
|
+
cost_usd=Decimal("0.0008"),
|
|
227
|
+
latency_seconds=0.3,
|
|
228
|
+
model=model,
|
|
229
|
+
finish_reason="tool_use",
|
|
230
|
+
tool_calls=[call],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if not summarized:
|
|
234
|
+
# Pull the first doc URL from the tool result.
|
|
235
|
+
first_doc_url = _first_doc_url_from_history(messages, company_name)
|
|
236
|
+
if first_doc_url is None:
|
|
237
|
+
# Skip directly to final answer if fixtures didn't yield a doc.
|
|
238
|
+
return _final_research(model, output_schema, last_user, company_name, doc_url="")
|
|
239
|
+
call = ToolCall(
|
|
240
|
+
id="call_summarize",
|
|
241
|
+
name="diligence.summarize_document",
|
|
242
|
+
args={"url": first_doc_url, "max_words": 80},
|
|
243
|
+
)
|
|
244
|
+
return LLMResponse(
|
|
245
|
+
raw_text="",
|
|
246
|
+
parsed=None,
|
|
247
|
+
input_tokens=120,
|
|
248
|
+
output_tokens=20,
|
|
249
|
+
cost_usd=Decimal("0.0009"),
|
|
250
|
+
latency_seconds=0.3,
|
|
251
|
+
model=model,
|
|
252
|
+
finish_reason="tool_use",
|
|
253
|
+
tool_calls=[call],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Final turn: build the ResearchFindings from the question + company.
|
|
257
|
+
return _final_research(model, output_schema, last_user, company_name,
|
|
258
|
+
doc_url=_first_doc_url_from_history(messages, company_name) or "")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _first_doc_url_from_history(messages: list[LLMMessage], company_name: str) -> Optional[str]:
|
|
262
|
+
import json as _json
|
|
263
|
+
for m in messages:
|
|
264
|
+
if m.role != "tool":
|
|
265
|
+
continue
|
|
266
|
+
try:
|
|
267
|
+
payload = _json.loads(m.content)
|
|
268
|
+
except Exception:
|
|
269
|
+
continue
|
|
270
|
+
docs = payload.get("documents") or payload.get("filings") or []
|
|
271
|
+
if docs:
|
|
272
|
+
return docs[0].get("url")
|
|
273
|
+
# Fallback to the first fixture doc for the company.
|
|
274
|
+
docs = DOCS_BY_COMPANY.get(company_name.lower(), [])
|
|
275
|
+
return docs[0]["url"] if docs else None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _final_research(model, output_schema, last_user: str, company_name: str, doc_url: str) -> LLMResponse:
|
|
279
|
+
question_text = _extract_question_text(last_user)
|
|
280
|
+
findings = RESEARCH_FINDINGS_BY_QUESTION.get(
|
|
281
|
+
(company_name.lower(), question_text),
|
|
282
|
+
{
|
|
283
|
+
"document_url": doc_url,
|
|
284
|
+
"summary": (
|
|
285
|
+
f"No fixture-specific findings for question {question_text!r}; "
|
|
286
|
+
f"returning a generic placeholder."
|
|
287
|
+
),
|
|
288
|
+
"claims": [
|
|
289
|
+
{
|
|
290
|
+
"text": f"{company_name}: no specific finding for this question in the fixtures.",
|
|
291
|
+
"confidence": 0.3,
|
|
292
|
+
"source_document_url": doc_url,
|
|
293
|
+
"evidence_quote": "(no fixture quote)",
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
# Make sure document_url is set even when fixture omits it.
|
|
299
|
+
if not findings.get("document_url"):
|
|
300
|
+
findings = {**findings, "document_url": doc_url}
|
|
301
|
+
return _resp(model, output_schema, findings)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _extract_question_text(user_text: str) -> str:
|
|
305
|
+
"""The runtime's user message contains a '## Triggering event'
|
|
306
|
+
section with the question's payload (per `build_user_message`).
|
|
307
|
+
Scope extraction to that section so we don't pick up unrelated
|
|
308
|
+
question objects from the view block.
|
|
309
|
+
"""
|
|
310
|
+
import re
|
|
311
|
+
marker = "## Triggering event"
|
|
312
|
+
if marker in user_text:
|
|
313
|
+
section = user_text.split(marker, 1)[1]
|
|
314
|
+
else:
|
|
315
|
+
section = user_text
|
|
316
|
+
# Stop at the next ## section header so we only see triggering-event content.
|
|
317
|
+
section = section.split("\n## ", 1)[0]
|
|
318
|
+
m = re.search(r'"text"\s*:\s*"([^"]+)"', section)
|
|
319
|
+
if m:
|
|
320
|
+
return m.group(1)
|
|
321
|
+
m = re.search(r"'text'\s*:\s*'([^']+)'", section)
|
|
322
|
+
if m:
|
|
323
|
+
return m.group(1)
|
|
324
|
+
return ""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _resp(model: str, output_schema, payload: dict) -> LLMResponse:
|
|
328
|
+
import json as _json
|
|
329
|
+
raw = _json.dumps(payload, sort_keys=True)
|
|
330
|
+
parsed = output_schema.model_validate(payload) if output_schema else None
|
|
331
|
+
return LLMResponse(
|
|
332
|
+
raw_text=raw,
|
|
333
|
+
parsed=parsed,
|
|
334
|
+
input_tokens=110,
|
|
335
|
+
output_tokens=22,
|
|
336
|
+
cost_usd=Decimal("0.0010"),
|
|
337
|
+
latency_seconds=0.4,
|
|
338
|
+
model=model,
|
|
339
|
+
finish_reason="end_turn",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _empty_memo(company_name: str) -> dict:
|
|
344
|
+
return {
|
|
345
|
+
"summary": f"Insufficient fixture data for {company_name}.",
|
|
346
|
+
"thesis_questions_addressed": [],
|
|
347
|
+
"key_claims": [],
|
|
348
|
+
"open_contradictions": [],
|
|
349
|
+
"contradictions_note": "no contradictions found",
|
|
350
|
+
"risks": [
|
|
351
|
+
{"risk_id": "", "title": "fixture coverage", "severity": "low",
|
|
352
|
+
"description": "Limited fixture data."}
|
|
353
|
+
],
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
__all__ = [
|
|
358
|
+
"RecordedDiligenceProvider",
|
|
359
|
+
"THREE_COMPANIES",
|
|
360
|
+
"company_goal",
|
|
361
|
+
"lookup_company_docs",
|
|
362
|
+
"lookup_filings",
|
|
363
|
+
"lookup_summary",
|
|
364
|
+
]
|