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,86 @@
1
+ """activegraph.packs.diligence — the reference pack for v0.9.
2
+
3
+ This is the production-quality pack for investment diligence,
4
+ evolved from `examples/diligence_with_tools.py` (which remains in
5
+ place per CONTRACT v0.9 #22).
6
+
7
+ What it provides:
8
+ - 8 object types (company, document, question, claim, evidence,
9
+ contradiction, risk, memo) with Pydantic schemas.
10
+ - 6 relation types (addresses, supports, contradicts, references,
11
+ derived_from, mitigates) with source/target type rules.
12
+ - 7 behaviors: company_planner, question_generator,
13
+ document_researcher (LLM + tools), evidence_linker (deterministic
14
+ safety net), contradiction_detector (pattern subscription),
15
+ risk_identifier (LLM, activate_after=8), memo_synthesizer (LLM).
16
+ - 3 pack-scoped tools: fetch_company_docs, search_filings,
17
+ summarize_document. v0.9 backs these with recorded fixtures; a
18
+ production user would swap real implementations.
19
+ - 2 policies: memo_approval, risk_approval (gated by
20
+ DiligenceSettings.auto_approve_memos / auto_approve_risks).
21
+ - 4 prompts with TOML frontmatter, content-hashed for replay.
22
+ - Settings: DiligenceSettings (Pydantic, all fields have defaults).
23
+ - Recorded fixtures for three companies, suitable for running the
24
+ killer demo in CI without API keys or network access.
25
+
26
+ How to use it:
27
+
28
+ from activegraph import Runtime, Graph
29
+ from activegraph.packs.diligence import pack, DiligenceSettings
30
+
31
+ rt = Runtime(Graph(), llm_provider=my_provider)
32
+ rt.load_pack(pack, settings=DiligenceSettings(...))
33
+ rt.run_goal("Diligence: Northwind Robotics")
34
+
35
+ The Pack is exported as the single `pack` symbol per
36
+ `[project.entry-points."activegraph.packs"]` in the framework's
37
+ `pyproject.toml`. Once installed, `load_by_name("diligence")` works
38
+ from any user code.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from pathlib import Path
44
+
45
+ from activegraph.packs import (
46
+ Pack,
47
+ PackPolicy,
48
+ load_prompts_from_dir,
49
+ )
50
+ from activegraph.packs.diligence.behaviors import BEHAVIORS
51
+ from activegraph.packs.diligence.object_types import OBJECT_TYPES, RELATION_TYPES
52
+ from activegraph.packs.diligence.settings import DiligenceSettings
53
+ from activegraph.packs.diligence.tools import TOOLS
54
+
55
+
56
+ _PROMPTS_DIR = Path(__file__).parent / "prompts"
57
+
58
+
59
+ pack = Pack(
60
+ name="diligence",
61
+ version="0.1.0",
62
+ description=(
63
+ "Investment diligence: claims, evidence, contradictions, risks, "
64
+ "memos. The v0.9 reference pack. Three behaviors are LLM-backed; "
65
+ "fixtures ship with the pack for reproducible demos."
66
+ ),
67
+ object_types=OBJECT_TYPES,
68
+ relation_types=RELATION_TYPES,
69
+ behaviors=BEHAVIORS,
70
+ tools=TOOLS,
71
+ policies=[
72
+ PackPolicy(
73
+ name="memo_approval",
74
+ requires_approval=("memo",),
75
+ ),
76
+ PackPolicy(
77
+ name="risk_approval",
78
+ requires_approval=("risk",),
79
+ ),
80
+ ],
81
+ prompts=load_prompts_from_dir(_PROMPTS_DIR),
82
+ settings_schema=DiligenceSettings,
83
+ )
84
+
85
+
86
+ __all__ = ["pack", "DiligenceSettings"]
@@ -0,0 +1,447 @@
1
+ """Diligence pack behaviors. CONTRACT v0.9 #15 / #16 / #17.
2
+
3
+ Seven behaviors. Two are pure-Python deterministic (evidence_linker,
4
+ contradiction_detector — the latter via pattern subscription). The
5
+ others are LLM-backed.
6
+
7
+ This file is the reference implementation of pack-aware behaviors:
8
+ - decorators imported from `activegraph.packs` (no global side effects)
9
+ - settings via typed parameter injection (`*, settings: DiligenceSettings`)
10
+ - pack-scoped tool refs (`tools=[fetch_company_docs]`)
11
+ - prompts loaded from `prompts/<name>.md` by matching name (the pack
12
+ loader wires them up — see `loader._resolve_prompt_template`)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+ from pydantic import BaseModel, Field
20
+
21
+ from activegraph.packs import behavior, llm_behavior, relation_behavior
22
+ from activegraph.packs.diligence.object_types import (
23
+ Claim,
24
+ Company,
25
+ Contradiction,
26
+ Document,
27
+ Evidence,
28
+ Memo,
29
+ Question,
30
+ Risk,
31
+ )
32
+ from activegraph.packs.diligence.settings import DiligenceSettings
33
+ from activegraph.packs.diligence.tools import (
34
+ fetch_company_docs,
35
+ search_filings,
36
+ summarize_document,
37
+ )
38
+
39
+
40
+ # ---------------------------------------------------- LLM output schemas
41
+
42
+
43
+ class QuestionList(BaseModel):
44
+ questions: list[str] = Field(min_length=1)
45
+
46
+
47
+ class ResearcherClaim(BaseModel):
48
+ text: str
49
+ confidence: float = Field(ge=0.0, le=1.0)
50
+ source_document_url: Optional[str] = None
51
+ evidence_quote: str = ""
52
+ # The verbatim text of an existing claim this new claim
53
+ # contradicts. The handler resolves text -> claim_id and emits a
54
+ # `:contradicts` edge, which the contradiction_detector pattern
55
+ # subscription picks up. Optional; most claims do not contradict.
56
+ contradicts_claim_text: Optional[str] = None
57
+
58
+
59
+ class ResearchFindings(BaseModel):
60
+ document_url: str
61
+ summary: str
62
+ claims: list[ResearcherClaim]
63
+
64
+
65
+ class RiskList(BaseModel):
66
+ class _Risk(BaseModel):
67
+ title: str
68
+ description: str
69
+ severity: str = Field(default="medium", pattern=r"^(low|medium|high)$")
70
+ related_claim_texts: list[str] = []
71
+
72
+ risks: list[_Risk] = []
73
+
74
+
75
+ class MemoBody(BaseModel):
76
+ summary: str
77
+ thesis_questions_addressed: list[dict]
78
+ key_claims: list[dict]
79
+ open_contradictions: list[dict]
80
+ contradictions_note: str = ""
81
+ risks: list[dict]
82
+
83
+
84
+ # ---------------------------------------------------- behaviors
85
+
86
+
87
+ @behavior(
88
+ name="company_planner",
89
+ on=["goal.created"],
90
+ )
91
+ def company_planner(event, graph, ctx):
92
+ """Bootstrap: turn the goal into a `company` object so downstream
93
+ LLM behaviors have something to react to.
94
+
95
+ The goal payload carries the company name (the demo's
96
+ `company_goal()` helper formats it as "Diligence: <Company Name>").
97
+ """
98
+ goal_text = event.payload.get("goal", "")
99
+ if not goal_text.startswith("Diligence:"):
100
+ return
101
+ company_name = goal_text.split("Diligence:", 1)[1].strip()
102
+ graph.add_object(
103
+ "company",
104
+ Company(
105
+ name=company_name,
106
+ description=f"Target company for diligence run: {company_name}",
107
+ ).model_dump(),
108
+ )
109
+
110
+
111
+ @llm_behavior(
112
+ name="question_generator",
113
+ on=["object.created"],
114
+ where={"object.type": "company"},
115
+ description="Generate the initial set of research questions from "
116
+ "the diligence thesis. One-shot in v0.9 — produces "
117
+ "between min_questions and max_questions questions; "
118
+ "the researcher works through them in order.",
119
+ output_schema=QuestionList,
120
+ creates=["question"],
121
+ deterministic=True,
122
+ )
123
+ def question_generator(event, graph, ctx, out, *, settings: DiligenceSettings):
124
+ """Typed settings injection (CONTRACT v0.9 #7 Form 1)."""
125
+ company_id = event.payload["object"]["id"]
126
+ company_name = event.payload["object"]["data"].get("name", "")
127
+ questions = list(out.questions)
128
+ # Honour the settings bounds. Trim or warn-via-trace if the model
129
+ # over/undershoots.
130
+ if len(questions) > settings.max_questions:
131
+ questions = questions[: settings.max_questions]
132
+ # min_questions is a soft floor — we record what we got.
133
+ for q_text in questions:
134
+ graph.add_object(
135
+ "question",
136
+ Question(
137
+ text=q_text,
138
+ company_id=company_id,
139
+ company_name=company_name,
140
+ status="open",
141
+ ).model_dump(),
142
+ )
143
+
144
+
145
+ @llm_behavior(
146
+ name="document_researcher",
147
+ on=["object.created"],
148
+ where={"object.type": "question"},
149
+ description=(
150
+ "Research one question by fetching documents about the company, "
151
+ "summarizing them, and extracting claims with confidence and "
152
+ "evidence quotes. Use fetch_company_docs first, then "
153
+ "summarize_document for promising documents."
154
+ ),
155
+ output_schema=ResearchFindings,
156
+ tools=[fetch_company_docs, summarize_document],
157
+ creates=["document", "claim", "evidence"],
158
+ deterministic=True,
159
+ budget={"max_tool_calls": 6},
160
+ )
161
+ def document_researcher(
162
+ event, graph, ctx, out, *, settings: DiligenceSettings,
163
+ ):
164
+ question_id = event.payload["object"]["id"]
165
+ q_obj = graph.get_object(question_id)
166
+ if q_obj is None:
167
+ return # question vanished — defensive
168
+
169
+ company_id = q_obj.data.get("company_id")
170
+ # Materialize one document object (the one the LLM cited) plus claims.
171
+ doc_url = out.document_url
172
+ existing_doc = next(
173
+ (o for o in ctx.view.objects(type="document") if o.data.get("url") == doc_url),
174
+ None,
175
+ )
176
+ if existing_doc is None:
177
+ doc = graph.add_object(
178
+ "document",
179
+ Document(
180
+ title=_title_from_url(doc_url),
181
+ url=doc_url,
182
+ company_id=company_id or "",
183
+ summary=out.summary,
184
+ ).model_dump(),
185
+ )
186
+ doc_id = doc.id
187
+ else:
188
+ doc_id = existing_doc.id
189
+
190
+ capped = list(out.claims)[: settings.max_claims_per_document]
191
+ for rc in capped:
192
+ claim = graph.add_object(
193
+ "claim",
194
+ Claim(
195
+ text=rc.text,
196
+ confidence=rc.confidence,
197
+ company_id=company_id or "",
198
+ source_document_id=doc_id,
199
+ status="open",
200
+ ).model_dump(),
201
+ )
202
+ graph.add_relation(claim.id, question_id, "addresses")
203
+ graph.add_relation(claim.id, doc_id, "derived_from")
204
+ # Attach an evidence quote.
205
+ if rc.evidence_quote:
206
+ ev = graph.add_object(
207
+ "evidence",
208
+ Evidence(
209
+ text=rc.evidence_quote,
210
+ document_id=doc_id,
211
+ claim_id=claim.id,
212
+ location="",
213
+ ).model_dump(),
214
+ )
215
+ graph.add_relation(ev.id, claim.id, "supports")
216
+ # If the researcher flagged a contradiction with an existing
217
+ # claim, materialize the `:contradicts` edge. The pattern
218
+ # subscription (contradiction_detector) creates the
219
+ # `contradiction` object asynchronously.
220
+ if rc.contradicts_claim_text:
221
+ target = _find_claim_by_text(ctx, rc.contradicts_claim_text, company_id)
222
+ if target is not None:
223
+ graph.add_relation(claim.id, target, "contradicts")
224
+
225
+ # Mark the question answered.
226
+ graph.patch_object(question_id, {"status": "answered"})
227
+
228
+
229
+ def _find_claim_by_text(ctx, text: str, company_id: Optional[str]) -> Optional[str]:
230
+ for o in ctx.view.objects(type="claim"):
231
+ if company_id and o.data.get("company_id") != company_id:
232
+ continue
233
+ if o.data.get("text") == text:
234
+ return o.id
235
+ return None
236
+
237
+
238
+ @behavior(
239
+ name="evidence_linker",
240
+ on=["object.created"],
241
+ where={"object.type": "evidence"},
242
+ )
243
+ def evidence_linker(event, graph, ctx):
244
+ """Deterministic: when an evidence is created, ensure it's linked
245
+ to its claim via a `supports` edge (the researcher already does this
246
+ above, but this behavior is a safety net for evidence objects added
247
+ by other paths or by future packs).
248
+ """
249
+ e_data = event.payload["object"]["data"]
250
+ claim_id = e_data.get("claim_id")
251
+ e_id = event.payload["object"]["id"]
252
+ if not claim_id:
253
+ return
254
+ if graph.get_object(claim_id) is None:
255
+ return
256
+ # Idempotent: don't add a duplicate edge.
257
+ for r in ctx.view.relations(type="supports"):
258
+ if r.source == e_id and r.target == claim_id:
259
+ return
260
+ graph.add_relation(e_id, claim_id, "supports")
261
+
262
+
263
+ @behavior(
264
+ name="contradiction_detector",
265
+ on=["relation.created"],
266
+ where={"relation.type": "contradicts"},
267
+ pattern=(
268
+ "(c1:claim)-[r:contradicts]->(c2:claim) "
269
+ "WHERE c1.confidence > 0.7 AND c2.confidence > 0.7"
270
+ ),
271
+ )
272
+ def contradiction_detector(event, graph, ctx, *, settings: DiligenceSettings):
273
+ """Pattern subscription: fires on every new `:contradicts` edge
274
+ between two claims whose confidence exceeds the threshold. Creates
275
+ a `contradiction` object. NO automatic resolution in v0.9
276
+ (CONTRACT v0.9 #17).
277
+ """
278
+ for m in ctx.matches:
279
+ c1_id = m["c1"]
280
+ c2_id = m["c2"]
281
+ c1 = graph.get_object(c1_id)
282
+ c2 = graph.get_object(c2_id)
283
+ if c1 is None or c2 is None:
284
+ continue
285
+ # Apply the configured threshold (the pattern hard-codes 0.7;
286
+ # if settings.confidence_threshold_for_review is HIGHER, gate
287
+ # additionally here).
288
+ if min(c1.data.get("confidence", 0), c2.data.get("confidence", 0)) < settings.confidence_threshold_for_review:
289
+ continue
290
+ graph.add_object(
291
+ "contradiction",
292
+ Contradiction(
293
+ claim_a_id=c1_id,
294
+ claim_b_id=c2_id,
295
+ rationale=(
296
+ f"Both claims exceed the confidence threshold "
297
+ f"({settings.confidence_threshold_for_review}) and assert "
298
+ f"conflicting facts. v0.9 surfaces this for human review."
299
+ ),
300
+ status="open",
301
+ ).model_dump(),
302
+ )
303
+
304
+
305
+ @llm_behavior(
306
+ name="risk_identifier",
307
+ # Triggered on every claim creation. The handler is idempotent —
308
+ # it only produces risks once per company (checks the graph for
309
+ # an existing risk). Simpler than activate_after for v0.9; the
310
+ # killer demo doesn't need delayed scheduling here.
311
+ on=["object.created"],
312
+ where={"object.type": "claim"},
313
+ description=(
314
+ "Identify material risks for the company based on accumulated "
315
+ "claims. Outputs a list of risks with severity and "
316
+ "related_claim_texts (short verbatim quotes that the post-LLM "
317
+ "handler will resolve to claim ids)."
318
+ ),
319
+ output_schema=RiskList,
320
+ creates=["risk"],
321
+ deterministic=True,
322
+ )
323
+ def risk_identifier(event, graph, ctx, out, *, settings: DiligenceSettings):
324
+ """Idempotent: produces one risk batch per company. Maps the LLM's
325
+ `related_claim_texts` (verbatim quotes) back to claim ids via
326
+ exact text match.
327
+
328
+ Uses ctx.propose_object when auto_approve_risks=False
329
+ (CONTRACT v0.9 #15 — risk_approval policy).
330
+ """
331
+ claim_data = event.payload["object"]["data"]
332
+ company_id = claim_data.get("company_id")
333
+ if not company_id:
334
+ return
335
+ # Idempotency: one risk batch per company. Check both materialized
336
+ # risks AND pending risk approvals (the pack's risk_approval policy
337
+ # gates writes; pending approvals are not yet visible in the view).
338
+ for o in ctx.view.objects(type="risk"):
339
+ if o.data.get("company_id") == company_id:
340
+ return
341
+ if ctx._runtime is not None:
342
+ for pa in ctx._runtime.pending_approvals():
343
+ if pa.object_type == "risk" and pa.data.get("company_id") == company_id:
344
+ return
345
+
346
+ # Build a {text -> claim_id} lookup for the company's claims.
347
+ text_to_claim: dict[str, str] = {}
348
+ for o in ctx.view.objects(type="claim"):
349
+ if o.data.get("company_id") != company_id:
350
+ continue
351
+ text_to_claim[o.data.get("text", "")] = o.id
352
+
353
+ for r in out.risks:
354
+ related = [text_to_claim[t] for t in r.related_claim_texts if t in text_to_claim]
355
+ risk_payload = Risk(
356
+ title=r.title,
357
+ description=r.description,
358
+ severity=r.severity,
359
+ company_id=company_id,
360
+ related_claim_ids=related,
361
+ ).model_dump()
362
+ if settings.auto_approve_risks:
363
+ graph.add_object("risk", risk_payload)
364
+ else:
365
+ ctx.propose_object(
366
+ "risk", risk_payload,
367
+ reason=f"risk_approval policy: {r.title}",
368
+ )
369
+
370
+
371
+ @llm_behavior(
372
+ name="memo_synthesizer",
373
+ on=["object.created"],
374
+ # Fires once per company once its first risk lands. (The risk
375
+ # identifier runs after claims have accumulated; the first risk
376
+ # is the signal that diligence is "ready to summarize.")
377
+ where={"object.type": "risk"},
378
+ description=(
379
+ "Synthesize the final diligence memo for the company. The "
380
+ "memo MUST have the contracted structure: summary, thesis "
381
+ "questions addressed, key claims (with evidence citations), "
382
+ "open contradictions, risks. Cite evidence for every claim. "
383
+ "If no contradictions were found, say so explicitly."
384
+ ),
385
+ output_schema=MemoBody,
386
+ creates=["memo"],
387
+ deterministic=True,
388
+ )
389
+ def memo_synthesizer(event, graph, ctx, out, *, settings: DiligenceSettings):
390
+ risk_id = event.payload["object"]["id"]
391
+ risk_obj = graph.get_object(risk_id)
392
+ if risk_obj is None:
393
+ return
394
+ company_id = risk_obj.data.get("company_id")
395
+
396
+ # Idempotent: only one memo per company. The risk_identifier may
397
+ # produce more than one risk object — we only synthesize on the
398
+ # first one.
399
+ existing = [
400
+ o for o in ctx.view.objects(type="memo")
401
+ if o.data.get("company_id") == company_id
402
+ ]
403
+ if existing:
404
+ return
405
+
406
+ payload = Memo(
407
+ company_id=company_id or "",
408
+ summary=out.summary,
409
+ thesis_questions_addressed=out.thesis_questions_addressed,
410
+ key_claims=out.key_claims,
411
+ open_contradictions=out.open_contradictions,
412
+ contradictions_note=out.contradictions_note,
413
+ risks=out.risks,
414
+ ).model_dump()
415
+
416
+ if settings.auto_approve_memos:
417
+ graph.add_object("memo", payload)
418
+ else:
419
+ ctx.propose_object(
420
+ "memo", payload,
421
+ reason=f"memo_approval policy: company {company_id}",
422
+ )
423
+
424
+
425
+ # ---------------------------------------------------- behavior list
426
+
427
+
428
+ BEHAVIORS = [
429
+ company_planner,
430
+ question_generator,
431
+ document_researcher,
432
+ evidence_linker,
433
+ contradiction_detector,
434
+ risk_identifier,
435
+ memo_synthesizer,
436
+ ]
437
+
438
+
439
+ # ---------------------------------------------------- helpers
440
+
441
+
442
+ def _title_from_url(url: str) -> str:
443
+ if "://" in url:
444
+ url = url.split("://", 1)[1]
445
+ parts = url.rstrip("/").split("/")
446
+ slug = parts[-1] if parts else url
447
+ return slug.replace("-", " ").replace("_", " ").title() or url