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,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
|