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.
Files changed (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. 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
+ ]