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.
Files changed (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. 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}>"
@@ -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