reactifact 0.6.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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- reactifact-0.6.0.dist-info/top_level.txt +1 -0
reactifact/effects.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""reactifact.effects — the produce-scoped effect set (§24, §41, §67).
|
|
2
|
+
|
|
3
|
+
The *authoring surface* of a produce. Instead of hand-assembling a `Patch` on
|
|
4
|
+
every turn, a produce writes what should change into its effect slot, and the
|
|
5
|
+
runtime compiles the slot into one atomic patch (commit + events + trace):
|
|
6
|
+
|
|
7
|
+
class Scout(Produce[SourceRef]):
|
|
8
|
+
async def produce(self, context, inputs, event=None) -> None:
|
|
9
|
+
refs = await fan_out_sources(context, query, owner_id=...)
|
|
10
|
+
self.effects.create(SearchDone(...), id=f"scouted:{qid}")
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
`Effects` is produce-scoped and concurrency-safe: the runtime pushes a fresh
|
|
14
|
+
slot via a contextvar before invoking the produce and pops it afterwards, so
|
|
15
|
+
parallel produces never see each other's effects. Nothing is applied until the
|
|
16
|
+
runtime commits — atomicity stays structural (§41), no diff/rollback.
|
|
17
|
+
|
|
18
|
+
Users rarely touch `Patch` in an ordinary produce: `Effects` is the language,
|
|
19
|
+
`Patch` is the compiled transport. Advanced assembly (recipes, tool loops) can
|
|
20
|
+
still build a `Patch` explicitly and return it — the runtime merges both.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from contextvars import ContextVar, Token
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from .patches import (
|
|
29
|
+
Create,
|
|
30
|
+
Link,
|
|
31
|
+
Operation,
|
|
32
|
+
Patch,
|
|
33
|
+
Unlink,
|
|
34
|
+
Update,
|
|
35
|
+
_auto_id,
|
|
36
|
+
_id_of,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from .artifacts import Artifact
|
|
41
|
+
from .context import Context
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Handle:
|
|
45
|
+
"""A patch-local handle for a *planned* artifact — link/unlink without ids (§38).
|
|
46
|
+
|
|
47
|
+
Returned by `Effects.create`; the id is pinned, so the handle is a valid
|
|
48
|
+
link target (`answer.link("supported_by", evidence)`), and the reader sees
|
|
49
|
+
which artifact a provenance edge comes from.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
__slots__ = ("_effects", "id", "_data")
|
|
53
|
+
|
|
54
|
+
def __init__(self, effects: Effects, artifact_id: str, data: Any):
|
|
55
|
+
self._effects = effects
|
|
56
|
+
self.id = artifact_id
|
|
57
|
+
self._data = data
|
|
58
|
+
|
|
59
|
+
def link(self, relation: str, target: Any) -> Handle:
|
|
60
|
+
"""Appends `Link` from this planned artifact to `target` (id/Artifact/Handle)."""
|
|
61
|
+
self._effects.link(self.id, relation, _id_of(target))
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def unlink(self, relation: str | None = None, target: Any | None = None) -> Handle:
|
|
65
|
+
self._effects.unlink(
|
|
66
|
+
self.id, relation, _id_of(target) if target is not None else None
|
|
67
|
+
)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def type(self) -> str:
|
|
72
|
+
return type(self._data).__name__
|
|
73
|
+
|
|
74
|
+
def __repr__(self) -> str:
|
|
75
|
+
return f"Handle(id={self.id!r}, {self.type})"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Effects:
|
|
79
|
+
"""The current produce's effect set (creates/updates/links to commit once)."""
|
|
80
|
+
|
|
81
|
+
__slots__ = ("_context", "operations")
|
|
82
|
+
|
|
83
|
+
def __init__(self, context: Context):
|
|
84
|
+
self._context = context
|
|
85
|
+
self.operations: list[Operation] = []
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def context(self) -> Context:
|
|
89
|
+
return self._context
|
|
90
|
+
|
|
91
|
+
def add(self, op: Operation) -> Effects:
|
|
92
|
+
self.operations.append(op)
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def create(self, data: Any, *, id: str | None = None) -> Handle:
|
|
96
|
+
"""Plans a new artifact; returns a linkable/updatable handle (§38).
|
|
97
|
+
|
|
98
|
+
If `id` names an artifact that already exists when this effect is
|
|
99
|
+
applied, the runtime treats it as a refresh (a new version of the
|
|
100
|
+
same logical entity, §42/§43) rather than creating a duplicate — the
|
|
101
|
+
same rule `Runtime._apply_patch` documents. Use `upsert` at the call
|
|
102
|
+
site when that "may already exist" intent should be explicit instead
|
|
103
|
+
of implicit in a plain `create(..., id=...)`.
|
|
104
|
+
"""
|
|
105
|
+
artifact_id = id or _auto_id(type(data).__name__)
|
|
106
|
+
self.operations.append(Create(data, id=artifact_id))
|
|
107
|
+
return Handle(self, artifact_id, data)
|
|
108
|
+
|
|
109
|
+
def create_once(self, data: Any, *, id: str) -> Handle | None:
|
|
110
|
+
"""Idempotent create (§42): `None` if `id` already exists in the
|
|
111
|
+
context, otherwise the same as `create(data, id=id)`.
|
|
112
|
+
|
|
113
|
+
Folds the "already done" guard every produce needs for a re-derived
|
|
114
|
+
id (`f"answer:{qid}"`) into the call itself:
|
|
115
|
+
|
|
116
|
+
handle = self.effects.create_once(Answer(...), id=f"answer:{qid}")
|
|
117
|
+
if handle is None:
|
|
118
|
+
return None # already answered — nothing to do
|
|
119
|
+
|
|
120
|
+
instead of a separate `if context.get(f"answer:{qid}") is not None:
|
|
121
|
+
return None` above the call — one less place to get the id string
|
|
122
|
+
wrong between the guard and the create.
|
|
123
|
+
"""
|
|
124
|
+
if self._context.get(id) is not None:
|
|
125
|
+
return None
|
|
126
|
+
return self.create(data, id=id)
|
|
127
|
+
|
|
128
|
+
def upsert(self, data: Any, *, id: str) -> Handle:
|
|
129
|
+
"""Explicit create-or-refresh: same effect as `create(data, id=id)`.
|
|
130
|
+
|
|
131
|
+
Prefer this over `create(..., id=...)` when the artifact may already
|
|
132
|
+
exist (e.g. a re-derived id like `f"answer:{qid}"`) — it says at the
|
|
133
|
+
call site that an update is an expected outcome, not a surprise.
|
|
134
|
+
"""
|
|
135
|
+
return self.create(data, id=id)
|
|
136
|
+
|
|
137
|
+
def update(self, artifact: Artifact[Any], **fields: Any) -> Effects:
|
|
138
|
+
"""Bumps fields of an *existing* artifact (a new version)."""
|
|
139
|
+
new_data = artifact.data.model_copy(update=fields)
|
|
140
|
+
self.operations.append(Update(artifact.id, new_data))
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def delete(self, artifact: Any) -> Effects:
|
|
144
|
+
from .patches import Delete
|
|
145
|
+
|
|
146
|
+
self.operations.append(Delete(_id_of(artifact)))
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def link(self, source: Any, relation: str, target: Any) -> Effects:
|
|
150
|
+
self.operations.append(Link(_id_of(source), relation, _id_of(target)))
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def ask(
|
|
154
|
+
self,
|
|
155
|
+
question: str,
|
|
156
|
+
*,
|
|
157
|
+
kind: str = "general",
|
|
158
|
+
notes: dict[str, Any] | None = None,
|
|
159
|
+
id: str | None = None,
|
|
160
|
+
) -> Handle:
|
|
161
|
+
"""Poses a question to a human (HITL, §60): creates a `PendingQuestion`.
|
|
162
|
+
|
|
163
|
+
Returns a handle you can link later; the human answer is recorded via
|
|
164
|
+
`effects.resume(question_art, resolution)` (§60). Pass `id` for a
|
|
165
|
+
stable, re-derivable question (e.g. `f"steer:{qid}:{round}"`) so a
|
|
166
|
+
guard like `if context.get(id) is not None: return None` can stop the
|
|
167
|
+
produce from asking again while the question is still unanswered.
|
|
168
|
+
"""
|
|
169
|
+
from .interrupt import PendingQuestion
|
|
170
|
+
|
|
171
|
+
return self.create(
|
|
172
|
+
PendingQuestion(question=question, kind=kind, notes=dict(notes or {})),
|
|
173
|
+
id=id,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def resume(self, question: Any, resolution: str) -> Effects:
|
|
177
|
+
"""Records the human answer on a `PendingQuestion` (HITL, §60)."""
|
|
178
|
+
from datetime import UTC, datetime
|
|
179
|
+
|
|
180
|
+
self.update(
|
|
181
|
+
question,
|
|
182
|
+
answered=True,
|
|
183
|
+
resolution=resolution,
|
|
184
|
+
resolved_at=datetime.now(UTC),
|
|
185
|
+
)
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def unlink(
|
|
189
|
+
self,
|
|
190
|
+
source: Any,
|
|
191
|
+
relation: str | None = None,
|
|
192
|
+
target: Any | None = None,
|
|
193
|
+
) -> Effects:
|
|
194
|
+
self.operations.append(
|
|
195
|
+
Unlink(
|
|
196
|
+
_id_of(source), relation, _id_of(target) if target is not None else None
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def is_empty(self) -> bool:
|
|
202
|
+
return len(self.operations) == 0
|
|
203
|
+
|
|
204
|
+
def to_patch(self) -> Patch:
|
|
205
|
+
"""Compiles the effects into a `Patch` (the runtime's transport)."""
|
|
206
|
+
patch = Patch()
|
|
207
|
+
for op in self.operations:
|
|
208
|
+
patch.add(op)
|
|
209
|
+
return patch
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --------------------------------------------------------------------------- #
|
|
213
|
+
# Produce-scoped slot — the runtime pushes/pops a fresh Effects per produce run
|
|
214
|
+
# --------------------------------------------------------------------------- #
|
|
215
|
+
|
|
216
|
+
_ACTIVE: ContextVar[Effects | None] = ContextVar("reactifact_effects", default=None)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def current_effects() -> Effects | None:
|
|
220
|
+
"""The produce-scoped effect slot, or None outside a running produce."""
|
|
221
|
+
return _ACTIVE.get()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def set_effects(effects: Effects | None) -> Token[Any]:
|
|
225
|
+
return _ACTIVE.set(effects)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def reset_effects(token: Token[Any]) -> None:
|
|
229
|
+
_ACTIVE.reset(token)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = ["Effects", "Handle", "current_effects", "reset_effects", "set_effects"]
|
reactifact/eval.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""reactifact.eval — multi-level evaluation harness (§56).
|
|
2
|
+
|
|
3
|
+
Because state is structured, evaluation is not only `answer == expected`: it
|
|
4
|
+
separates *evidence quality*, *claim verification*, *provenance grounding*,
|
|
5
|
+
*calculation correctness*, *answer coverage* and *source coverage*. Each metric
|
|
6
|
+
is a pure function over the final `Context` (+ optional ground truth); the
|
|
7
|
+
harness runs a case, collects its metrics, and renders a weighted report.
|
|
8
|
+
|
|
9
|
+
cases = [EvalCase("calc-question", run=_run_knowledge_calc)]
|
|
10
|
+
report = run_suite(cases, metrics={**core_metrics, "calc": calculation_correctness()})
|
|
11
|
+
print(report.render())
|
|
12
|
+
|
|
13
|
+
All metrics are deterministic and LLM-free. They match artifact *classes by
|
|
14
|
+
name* (`Answer`, `Evidence`, …) so the harness needs no domain imports — the
|
|
15
|
+
domain stays out of the framework.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Callable, Mapping
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .context import Context
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Metric:
|
|
29
|
+
"""One measured quantity with a 0..1 score and a reporting weight (§56)."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
score: float = 0.0
|
|
33
|
+
weight: float = 1.0
|
|
34
|
+
note: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
MetricFn = Callable[[Context, Mapping[str, Any] | None], float | None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EvalCase:
|
|
42
|
+
"""A runnable evaluation case: executes the pipeline and returns its Context."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
run: Callable[[], Context]
|
|
46
|
+
expected: Mapping[str, Any] | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class EvalResult:
|
|
51
|
+
"""Scores of one case; `overall` is the weighted mean of its metrics."""
|
|
52
|
+
|
|
53
|
+
case: str
|
|
54
|
+
metrics: list[Metric] = field(default_factory=list)
|
|
55
|
+
skipped: list[str] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def overall(self) -> float:
|
|
58
|
+
metrics = self.metrics or ()
|
|
59
|
+
weights = [m.weight for m in metrics]
|
|
60
|
+
total_weight = sum(weights) or 1.0
|
|
61
|
+
return sum(m.score * m.weight for m in metrics) / total_weight
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class EvalReport:
|
|
66
|
+
"""The whole suite: one `EvalResult` per case."""
|
|
67
|
+
|
|
68
|
+
results: list[EvalResult] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
def overall(self) -> float:
|
|
71
|
+
if not self.results:
|
|
72
|
+
return 0.0
|
|
73
|
+
return sum(r.overall() for r in self.results) / len(self.results)
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"overall": round(self.overall(), 4),
|
|
78
|
+
"cases": [
|
|
79
|
+
{
|
|
80
|
+
"case": r.case,
|
|
81
|
+
"overall": round(r.overall(), 4),
|
|
82
|
+
"metrics": [
|
|
83
|
+
{
|
|
84
|
+
"name": m.name,
|
|
85
|
+
"score": round(m.score, 4),
|
|
86
|
+
"weight": m.weight,
|
|
87
|
+
"note": m.note,
|
|
88
|
+
}
|
|
89
|
+
for m in r.metrics
|
|
90
|
+
],
|
|
91
|
+
"skipped": r.skipped,
|
|
92
|
+
}
|
|
93
|
+
for r in self.results
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def render(self) -> str:
|
|
98
|
+
lines = ["eval · multi-level report (§56)", ""]
|
|
99
|
+
for r in self.results:
|
|
100
|
+
lines.append(f"[{r.case}] overall {r.overall():.3f}")
|
|
101
|
+
for m in r.metrics:
|
|
102
|
+
entry = f" {m.name:<28} {m.score:6.3f}"
|
|
103
|
+
if m.note:
|
|
104
|
+
entry += f" {m.note}"
|
|
105
|
+
lines.append(entry)
|
|
106
|
+
for name in r.skipped:
|
|
107
|
+
lines.append(f" {name:<28} — skipped (no ground truth)")
|
|
108
|
+
lines.append(f"\nsuite overall: {self.overall():.3f}")
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_case(
|
|
113
|
+
case: EvalCase,
|
|
114
|
+
metrics: Mapping[str, MetricFn],
|
|
115
|
+
) -> EvalResult:
|
|
116
|
+
"""Executes the case and scores it against every metric."""
|
|
117
|
+
context = case.run()
|
|
118
|
+
result = EvalResult(case=case.name)
|
|
119
|
+
for name, metric_fn in metrics.items():
|
|
120
|
+
score = metric_fn(context, case.expected)
|
|
121
|
+
if score is None:
|
|
122
|
+
result.skipped.append(name)
|
|
123
|
+
continue
|
|
124
|
+
result.metrics.append(
|
|
125
|
+
Metric(name=name, score=round(max(0.0, min(1.0, score)), 4))
|
|
126
|
+
)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_suite(
|
|
131
|
+
cases: list[EvalCase],
|
|
132
|
+
metrics: Mapping[str, MetricFn],
|
|
133
|
+
) -> EvalReport:
|
|
134
|
+
return EvalReport(results=[run_case(case, metrics) for case in cases])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------------- #
|
|
138
|
+
# Generic helpers — artifact classes are matched *by name* (no domain imports)
|
|
139
|
+
# --------------------------------------------------------------------------- #
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _named(context: Context, *class_names: str) -> list[Any]:
|
|
143
|
+
return [a for a in context.list_artifacts() if type(a.data).__name__ in class_names]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def answer_present(
|
|
147
|
+
context: Context, expected: Mapping[str, Any] | None = None
|
|
148
|
+
) -> float:
|
|
149
|
+
"""1.0 when at least one `Answer` artifact exists."""
|
|
150
|
+
return 1.0 if _named(context, "Answer") else 0.0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def provenance_grounded(
|
|
154
|
+
context: Context, expected: Mapping[str, Any] | None = None
|
|
155
|
+
) -> float:
|
|
156
|
+
"""Share of answers backed by at least one existing `supported_by` link.
|
|
157
|
+
|
|
158
|
+
Provenance correctness (§34): an answer without a resolvable supporting link
|
|
159
|
+
is ungrounded — the graph is the source of truth, not the text.
|
|
160
|
+
"""
|
|
161
|
+
answers = _named(context, "Answer")
|
|
162
|
+
if not answers:
|
|
163
|
+
return 0.0
|
|
164
|
+
grounded = sum(1 for a in answers if context.related(a.id, relation="supported_by"))
|
|
165
|
+
return grounded / len(answers)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def evidence_quality(
|
|
169
|
+
context: Context,
|
|
170
|
+
expected: Mapping[str, Any] | None = None,
|
|
171
|
+
*,
|
|
172
|
+
threshold: float = 0.5,
|
|
173
|
+
) -> float:
|
|
174
|
+
"""Share of `Evidence` artifacts scoring at or above `threshold` (0 if none)."""
|
|
175
|
+
evidences = _named(context, "Evidence")
|
|
176
|
+
if not evidences:
|
|
177
|
+
return 0.0
|
|
178
|
+
good = sum(
|
|
179
|
+
1 for e in evidences if (getattr(e.data, "score", 0.0) or 0.0) >= threshold
|
|
180
|
+
)
|
|
181
|
+
return good / len(evidences)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def claim_verification(
|
|
185
|
+
context: Context,
|
|
186
|
+
expected: Mapping[str, Any] | None = None,
|
|
187
|
+
*,
|
|
188
|
+
valid: tuple[str, ...] = ("verified",),
|
|
189
|
+
) -> float:
|
|
190
|
+
"""Share of `Claim` artifacts in a `valid` status (1.0 if no claims)."""
|
|
191
|
+
claims = _named(context, "Claim")
|
|
192
|
+
if not claims:
|
|
193
|
+
return 1.0
|
|
194
|
+
good = sum(1 for c in claims if getattr(c.data, "status", "") in valid)
|
|
195
|
+
return good / len(claims)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def confidence_calibration(
|
|
199
|
+
expected_key: str = "claim_correctness",
|
|
200
|
+
) -> Callable[[Context, Mapping[str, Any] | None], float | None]:
|
|
201
|
+
"""How well `Claim.confidence` predicts actual correctness (Brier score, §56).
|
|
202
|
+
|
|
203
|
+
`expected[expected_key]` maps claim artifact id -> bool (was the claim
|
|
204
|
+
actually correct). Score is `1 - mean((confidence - label) ** 2)` over
|
|
205
|
+
claims with ground truth, so 1.0 is perfectly calibrated. This is
|
|
206
|
+
distinct from `claim_verification`: a claim can have `status="verified"`
|
|
207
|
+
with a poorly-calibrated confidence and still pass that metric.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def _score(context: Context, expected: Mapping[str, Any] | None) -> float | None:
|
|
211
|
+
if expected is None or expected.get(expected_key) is None:
|
|
212
|
+
return None
|
|
213
|
+
labels: Mapping[str, bool] = expected[expected_key]
|
|
214
|
+
claims = _named(context, "Claim")
|
|
215
|
+
scored = [
|
|
216
|
+
(float(getattr(c.data, "confidence", 0.0)), 1.0 if labels[c.id] else 0.0)
|
|
217
|
+
for c in claims
|
|
218
|
+
if c.id in labels
|
|
219
|
+
]
|
|
220
|
+
if not scored:
|
|
221
|
+
return None
|
|
222
|
+
brier = sum((conf - label) ** 2 for conf, label in scored) / len(scored)
|
|
223
|
+
return 1.0 - brier
|
|
224
|
+
|
|
225
|
+
return _score
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def answer_coverage(
|
|
229
|
+
expected_key: str = "answer",
|
|
230
|
+
) -> Callable[[Context, Mapping[str, Any] | None], float | None]:
|
|
231
|
+
"""Coverage of the expected answer text by the actual answer (0..1)."""
|
|
232
|
+
|
|
233
|
+
def _score(context: Context, expected: Mapping[str, Any] | None) -> float | None:
|
|
234
|
+
from .recipes import keyword_score
|
|
235
|
+
|
|
236
|
+
if expected is None or expected.get(expected_key) is None:
|
|
237
|
+
return None
|
|
238
|
+
answers = _named(context, "Answer")
|
|
239
|
+
if not answers:
|
|
240
|
+
return 0.0
|
|
241
|
+
return keyword_score(
|
|
242
|
+
str(getattr(answers[0].data, "text", "")), str(expected[expected_key])
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return _score
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def calculation_correctness(
|
|
249
|
+
*,
|
|
250
|
+
values: tuple[float, ...] | None = None,
|
|
251
|
+
expected_key: str = "calculations",
|
|
252
|
+
) -> Callable[[Context, Mapping[str, Any] | None], float | None]:
|
|
253
|
+
"""Share of `Calculation` artifacts whose value matches the ground truth.
|
|
254
|
+
|
|
255
|
+
Expected values come from `values` and/or the case metadata `calculations`.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def _score(context: Context, expected: Mapping[str, Any] | None) -> float | None:
|
|
259
|
+
allowed = set(values or ())
|
|
260
|
+
if expected is not None and expected.get(expected_key) is not None:
|
|
261
|
+
allowed |= {float(v) for v in expected[expected_key]}
|
|
262
|
+
calcs = _named(context, "Calculation")
|
|
263
|
+
if not calcs:
|
|
264
|
+
return 0.0
|
|
265
|
+
if not allowed:
|
|
266
|
+
return None # no ground truth yet
|
|
267
|
+
good = sum(1 for c in calcs if float(getattr(c.data, "value", 0.0)) in allowed)
|
|
268
|
+
return good / len(calcs)
|
|
269
|
+
|
|
270
|
+
return _score
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def source_coverage(
|
|
274
|
+
expected_key: str = "sources",
|
|
275
|
+
) -> Callable[[Context, Mapping[str, Any] | None], float | None]:
|
|
276
|
+
"""Share of the answer's sources matched by the expected source markers."""
|
|
277
|
+
|
|
278
|
+
def _score(context: Context, expected: Mapping[str, Any] | None) -> float | None:
|
|
279
|
+
if expected is None or expected.get(expected_key) is None:
|
|
280
|
+
return None
|
|
281
|
+
markers = [str(m) for m in expected[expected_key]]
|
|
282
|
+
answers = _named(context, "Answer")
|
|
283
|
+
if not answers:
|
|
284
|
+
return 0.0
|
|
285
|
+
sources = [s for s in getattr(answers[0].data, "sources", []) if s]
|
|
286
|
+
if not sources:
|
|
287
|
+
return 0.0
|
|
288
|
+
covered = sum(1 for s in sources if any(m in s for m in markers))
|
|
289
|
+
return covered / len(sources)
|
|
290
|
+
|
|
291
|
+
return _score
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
#: The non-generative core metrics, ready to reuse (§56).
|
|
295
|
+
core_metrics: dict[str, MetricFn] = {
|
|
296
|
+
"answer_present": answer_present,
|
|
297
|
+
"provenance_grounded": provenance_grounded,
|
|
298
|
+
"evidence_quality": evidence_quality,
|
|
299
|
+
"claim_verification": claim_verification,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
__all__ = [
|
|
304
|
+
"EvalCase",
|
|
305
|
+
"EvalReport",
|
|
306
|
+
"EvalResult",
|
|
307
|
+
"Metric",
|
|
308
|
+
"answer_coverage",
|
|
309
|
+
"answer_present",
|
|
310
|
+
"calculation_correctness",
|
|
311
|
+
"claim_verification",
|
|
312
|
+
"confidence_calibration",
|
|
313
|
+
"core_metrics",
|
|
314
|
+
"evidence_quality",
|
|
315
|
+
"provenance_grounded",
|
|
316
|
+
"run_case",
|
|
317
|
+
"run_suite",
|
|
318
|
+
"source_coverage",
|
|
319
|
+
]
|
reactifact/events.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EventType(StrEnum):
|
|
8
|
+
ARTIFACT_CREATED = "artifact_created"
|
|
9
|
+
ARTIFACT_UPDATED = "artifact_updated"
|
|
10
|
+
ARTIFACT_DELETED = "artifact_deleted"
|
|
11
|
+
ARTIFACT_STALE = "artifact_stale"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Event:
|
|
15
|
+
"""A lightweight event referencing an artifact by id and type."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
type: EventType,
|
|
20
|
+
artifact_type: type | str,
|
|
21
|
+
artifact_id: str,
|
|
22
|
+
):
|
|
23
|
+
self.type = type
|
|
24
|
+
self.artifact_type = artifact_type
|
|
25
|
+
self.artifact_id = artifact_id
|
|
26
|
+
self.timestamp = datetime.now(UTC)
|
|
27
|
+
|
|
28
|
+
def __repr__(self) -> str:
|
|
29
|
+
type_name = (
|
|
30
|
+
self.artifact_type.__name__
|
|
31
|
+
if isinstance(self.artifact_type, type)
|
|
32
|
+
else self.artifact_type
|
|
33
|
+
)
|
|
34
|
+
return f"<Event {self.type.value} {type_name} id={self.artifact_id}>"
|
reactifact/interrupt.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PendingQuestion(BaseModel):
|
|
10
|
+
"""Artifact awaiting a human response (HITL, constitution §60).
|
|
11
|
+
|
|
12
|
+
Created by an agent (or directly) to block a step until user input.
|
|
13
|
+
A human answer is recorded via `self.effects.resume(question, answer)` (§60),
|
|
14
|
+
after which agents subscribed to `PendingQuestion(answered=True)` continue.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
question: str
|
|
18
|
+
kind: str = "general"
|
|
19
|
+
notes: dict[str, Any] = Field(default_factory=dict)
|
|
20
|
+
answered: bool = False
|
|
21
|
+
resolution: str | None = None
|
|
22
|
+
resolved_at: datetime | None = None
|