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
@@ -0,0 +1,202 @@
1
+ """recipes.text — deterministic text scoring without embeddings (§8, §67).
2
+
3
+ Keyword coverage is the neutral fallback where vectors are optional: the English
4
+ `knowledge` chat and the Russian `repair` demo rank documents/catalog rows by
5
+ how much of the query is present in the text. Everything here is pure and
6
+ LLM-free; `keyword_score` is stop-word- and (optionally) stem-aware.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ _WORD = re.compile(r"[\wа-яё]{2,}")
14
+
15
+ EN_STOPWORDS = frozenset(
16
+ {
17
+ "a",
18
+ "an",
19
+ "the",
20
+ "and",
21
+ "or",
22
+ "of",
23
+ "to",
24
+ "in",
25
+ "for",
26
+ "on",
27
+ "with",
28
+ "at",
29
+ "by",
30
+ "from",
31
+ "into",
32
+ "off",
33
+ "out",
34
+ "over",
35
+ "under",
36
+ "about",
37
+ "up",
38
+ "down",
39
+ "after",
40
+ "before",
41
+ "during",
42
+ "between",
43
+ "through",
44
+ "against",
45
+ "is",
46
+ "are",
47
+ "was",
48
+ "were",
49
+ "be",
50
+ "been",
51
+ "being",
52
+ "has",
53
+ "have",
54
+ "had",
55
+ "do",
56
+ "does",
57
+ "did",
58
+ "will",
59
+ "would",
60
+ "can",
61
+ "could",
62
+ "should",
63
+ "how",
64
+ "what",
65
+ "why",
66
+ "which",
67
+ "who",
68
+ "whom",
69
+ "when",
70
+ "where",
71
+ "this",
72
+ "that",
73
+ "these",
74
+ "those",
75
+ "it",
76
+ "its",
77
+ "he",
78
+ "him",
79
+ "his",
80
+ "she",
81
+ "her",
82
+ "they",
83
+ "them",
84
+ "their",
85
+ "we",
86
+ "us",
87
+ "our",
88
+ "you",
89
+ "your",
90
+ "i",
91
+ "me",
92
+ "my",
93
+ "not",
94
+ "no",
95
+ "yes",
96
+ "but",
97
+ "so",
98
+ "then",
99
+ "than",
100
+ "too",
101
+ "very",
102
+ "just",
103
+ "also",
104
+ "because",
105
+ "as",
106
+ "if",
107
+ "much",
108
+ "many",
109
+ "more",
110
+ "most",
111
+ }
112
+ )
113
+
114
+ #: Russian inflectional suffixes (used when `use_stems=True`).
115
+ _RU_SUFFIXES = (
116
+ "аться",
117
+ "иться",
118
+ "ами",
119
+ "ями",
120
+ "ой",
121
+ "ий",
122
+ "ый",
123
+ "ое",
124
+ "ее",
125
+ "ить",
126
+ "ать",
127
+ "ять",
128
+ "ом",
129
+ "ем",
130
+ "ам",
131
+ "ям",
132
+ "ия",
133
+ "ию",
134
+ "ией",
135
+ "ых",
136
+ "их",
137
+ "ая",
138
+ "яя",
139
+ "у",
140
+ "ю",
141
+ "о",
142
+ "а",
143
+ "е",
144
+ "ы",
145
+ "и",
146
+ "й",
147
+ )
148
+
149
+
150
+ def stem(word: str) -> str:
151
+ """Truncates common inflectional suffixes of a Russian word.
152
+
153
+ «аутентификацию» and «аутентификация» must match where embedders are not
154
+ needed. English words pass through unchanged (the suffix list is Cyrillic).
155
+ """
156
+ w = word.lower()
157
+ changed = True
158
+ while changed:
159
+ changed = False
160
+ for suffix in _RU_SUFFIXES:
161
+ if len(w) - len(suffix) >= 3 and w.endswith(suffix):
162
+ w = w[: -len(suffix)]
163
+ changed = True
164
+ break
165
+ return w
166
+
167
+
168
+ def _tokens(text: str) -> list[str]:
169
+ """Lowercased word tokens; digit-only tokens never match a query term."""
170
+ lower = text.lower()
171
+ return [t for t in _WORD.findall(lower) if not t.isdigit()]
172
+
173
+
174
+ def stem_words(text: str) -> frozenset[str]:
175
+ """Frozenset of word stems in the text (Latin and Cyrillic)."""
176
+ return frozenset(stem(t) for t in _tokens(text))
177
+
178
+
179
+ def keyword_score(
180
+ text: str,
181
+ query: str,
182
+ *,
183
+ stopwords: frozenset[str] = EN_STOPWORDS,
184
+ use_stems: bool = False,
185
+ ) -> float:
186
+ """Coverage of the query terms by the text (0..1), without embedders.
187
+
188
+ `stopwords` are removed from both sides (English function words by default);
189
+ with `use_stems=True` both sides are stemmed (Russian morphology), so
190
+ «аутентификация» matches «аутентификацию». Returns 0.0 for an empty query.
191
+ """
192
+ text_terms = set(_tokens(text)) - set(stopwords)
193
+ query_terms = set(_tokens(query)) - set(stopwords)
194
+ if not query_terms:
195
+ return 0.0
196
+ if use_stems:
197
+ text_terms = {stem(t) for t in text_terms}
198
+ query_terms = {stem(t) for t in query_terms}
199
+ return sum(1 for term in query_terms if term in text_terms) / len(query_terms)
200
+
201
+
202
+ __all__ = ["EN_STOPWORDS", "keyword_score", "stem", "stem_words"]
@@ -0,0 +1,104 @@
1
+ """reactifact.relations — the provenance/link graph (§15).
2
+
3
+ Extracted out of `Context`: `RelationGraph` owns only the
4
+ `(source, relation, target) → Relation` mapping and the query/mutation
5
+ operations over it. It has no knowledge of artifacts, commits, or merge —
6
+ `Context` still resolves "does this artifact exist" itself (`related()`
7
+ needs the artifact store, so that method stays on `Context`, as a thin
8
+ wrapper that filters `RelationGraph.relations()` against `self._artifacts`).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from .patches import Relation
16
+
17
+ RelationKey = tuple[str, str, str]
18
+
19
+
20
+ class RelationGraph:
21
+ """The live link graph: create/remove/query edges (§15)."""
22
+
23
+ __slots__ = ("_edges",)
24
+
25
+ def __init__(self) -> None:
26
+ self._edges: dict[RelationKey, Relation] = {}
27
+
28
+ def link(self, source_id: str, relation: str, target_id: str) -> Relation:
29
+ """Establishes a link `source_id —relation→ target_id` (idempotently, §42)."""
30
+ rel = Relation(source_id=source_id, relation=relation, target_id=target_id)
31
+ self._edges[(rel.source_id, rel.relation, rel.target_id)] = rel
32
+ return rel
33
+
34
+ def unlink(
35
+ self,
36
+ source_id: str,
37
+ relation: str | None = None,
38
+ target_id: str | None = None,
39
+ ) -> int:
40
+ """Removes edges; `relation`/`target_id` = None mean "any"."""
41
+ removed = 0
42
+ for key in [k for k in self._edges if k[0] == source_id]:
43
+ if relation is not None and key[1] != relation:
44
+ continue
45
+ if target_id is not None and key[2] != target_id:
46
+ continue
47
+ del self._edges[key]
48
+ removed += 1
49
+ return removed
50
+
51
+ def relations(
52
+ self,
53
+ source_id: str | None = None,
54
+ relation: str | None = None,
55
+ target_id: str | None = None,
56
+ ) -> list[Relation]:
57
+ """All edges, optionally filtered by any component."""
58
+ result: list[Relation] = []
59
+ for rel in self._edges.values():
60
+ if source_id is not None and rel.source_id != source_id:
61
+ continue
62
+ if relation is not None and rel.relation != relation:
63
+ continue
64
+ if target_id is not None and rel.target_id != target_id:
65
+ continue
66
+ result.append(rel)
67
+ return result
68
+
69
+ def __contains__(self, key: RelationKey) -> bool:
70
+ return key in self._edges
71
+
72
+ def __len__(self) -> int:
73
+ return len(self._edges)
74
+
75
+ def values(self) -> list[Relation]:
76
+ return list(self._edges.values())
77
+
78
+ def items(self) -> list[tuple[RelationKey, Relation]]:
79
+ return list(self._edges.items())
80
+
81
+ def copy(self) -> RelationGraph:
82
+ clone = RelationGraph()
83
+ clone._edges = dict(self._edges)
84
+ return clone
85
+
86
+ def to_dict(self) -> list[dict[str, Any]]:
87
+ return [rel.to_dict() for rel in self._edges.values()]
88
+
89
+ @classmethod
90
+ def from_dict(cls, items: list[dict[str, Any]]) -> RelationGraph:
91
+ graph = cls()
92
+ for d in items:
93
+ rel = Relation.from_dict(d)
94
+ graph._edges[(rel.source_id, rel.relation, rel.target_id)] = rel
95
+ return graph
96
+
97
+ @classmethod
98
+ def from_mapping(cls, mapping: dict[RelationKey, Relation]) -> RelationGraph:
99
+ graph = cls()
100
+ graph._edges = dict(mapping)
101
+ return graph
102
+
103
+
104
+ __all__ = ["RelationGraph", "RelationKey"]
reactifact/replay.py ADDED
@@ -0,0 +1,187 @@
1
+ """reactifact.replay — deterministic reproduction of runs (§55).
2
+
3
+ Two complementary halves:
4
+
5
+ 1. `ReplayLLM` — a provider-level recording studio. `mode="record"` wraps a real
6
+ provider and appends every `(request → response)` pair to a JSONL file;
7
+ `mode="replay"` answers *exactly* the recorded calls and raises `ReplayMiss`
8
+ when a call diverges from the recording — a divergent call must not be
9
+ answered with a wrong result (§59, §55).
10
+
11
+ Record once (real model), then re-run the runtime with the replaying LLM:
12
+ because every deterministic path is unchanged, the run reproduces the same
13
+ artifacts, and "why did the agent produce this answer?" (§55) can be answered
14
+ by walking the reproduced state.
15
+
16
+ 2. `replay_context` / `replay_summary` — reconstruct a saved session's state at
17
+ a specific commit (the commit chain is deterministic, §14), i.e. a cheap,
18
+ offline "what was the state when the agent said that".
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import logging
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING, Any, Literal
28
+
29
+ from .context import Context
30
+ from .providers import LLMProvider, LLMRequest, LLMResponse, LLMResponseChunk
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import AsyncIterator
34
+
35
+ from .session import SessionStore
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ class ReplayMiss(RuntimeError):
41
+ """A replaying call did not match the recording (§55)."""
42
+
43
+
44
+ def _request_key(model: str, request: LLMRequest) -> str:
45
+ payload = {
46
+ "model": model,
47
+ "temperature": request.temperature,
48
+ "response_format": request.response_format,
49
+ "messages": [{"role": m.role, "content": m.content} for m in request.messages],
50
+ }
51
+ canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False)
52
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
53
+
54
+
55
+ class ReplayLLM(LLMProvider):
56
+ """Records LLM calls to a JSONL file, or replays them exactly.
57
+
58
+ recorder = ReplayLLM("calls.jsonl", mode="record", inner=real_llm)
59
+ resources = RuntimeResources(llm=recorder)
60
+ runtime.run() # record pass
61
+
62
+ replay = ReplayLLM("calls.jsonl", mode="replay")
63
+ resources = RuntimeResources(llm=replay)
64
+ runtime.run() # deterministic reproduction (§55)
65
+
66
+ A recording also answers the "why" question: every call carries the exact
67
+ prompt and the exact response, so the product story is reproducible.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ recording: str | Path,
73
+ *,
74
+ mode: Literal["record", "replay"] = "replay",
75
+ inner: LLMProvider | None = None,
76
+ model: str = "",
77
+ ):
78
+ if mode == "record" and inner is None:
79
+ raise ValueError("mode='record' requires an `inner` provider")
80
+ self.recording = Path(recording)
81
+ self.mode = mode
82
+ self._inner = inner
83
+ self.model = model
84
+ self._cache: dict[str, dict[str, Any]] | None = None
85
+
86
+ # -- LLMProvider ---------------------------------------------------------
87
+
88
+ async def complete(self, request: LLMRequest) -> LLMResponse:
89
+ if self.mode == "replay":
90
+ return self._replay(request)
91
+ assert self._inner is not None
92
+ response = await self._inner.complete(request)
93
+ self._append(request, response)
94
+ return response
95
+
96
+ async def stream(self, request: LLMRequest) -> AsyncIterator[LLMResponseChunk]:
97
+ if self.mode == "record" and self._inner is not None:
98
+ async for chunk in self._inner.stream(request):
99
+ yield chunk
100
+ return
101
+ raise ReplayMiss(
102
+ "stream() is not replayed; record stream calls or use complete()"
103
+ )
104
+
105
+ # -- recording -----------------------------------------------------------
106
+
107
+ def _append(self, request: LLMRequest, response: LLMResponse) -> None:
108
+ entry = {
109
+ "key": _request_key(self.model, request),
110
+ "model": self.model,
111
+ "response": {
112
+ "text": response.text,
113
+ "finish_reason": response.finish_reason,
114
+ },
115
+ "usage": response.usage,
116
+ }
117
+ self.recording.parent.mkdir(parents=True, exist_ok=True)
118
+ with self.recording.open("a", encoding="utf-8") as handle:
119
+ handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
120
+
121
+ # -- replaying -----------------------------------------------------------
122
+
123
+ def _load(self) -> dict[str, dict[str, Any]]:
124
+ if self._cache is not None:
125
+ return self._cache
126
+ cache: dict[str, dict[str, Any]] = {}
127
+ if self.recording.exists():
128
+ for line in self.recording.read_text(encoding="utf-8").splitlines():
129
+ entry = json.loads(line)
130
+ cache[entry["key"]] = entry
131
+ self._cache = cache
132
+ return cache
133
+
134
+ def _replay(self, request: LLMRequest) -> LLMResponse:
135
+ key = _request_key(self.model, request)
136
+ entry = self._load().get(key)
137
+ if entry is None:
138
+ logger.warning("replay miss for %r", key)
139
+ raise ReplayMiss(
140
+ "the run diverged from the recording — a call was not recorded. "
141
+ "Re-record with mode='record', or check whether the prompt "
142
+ "changed since the recording was made."
143
+ )
144
+ response = entry["response"]
145
+ return LLMResponse(
146
+ text=response["text"],
147
+ finish_reason=response.get("finish_reason"),
148
+ usage=dict(entry.get("usage") or {}),
149
+ )
150
+
151
+
152
+ async def replay_context(
153
+ store: SessionStore,
154
+ session_id: str,
155
+ *,
156
+ version: int | None = None,
157
+ ) -> Context:
158
+ """Reconstructs a saved session's state, optionally at a past commit (§55).
159
+
160
+ The session checkpoint carries the full deterministic commit chain (§14), so
161
+ replaying to a version needs no agent execution — it is pure state recovery.
162
+ """
163
+ context = await store.load_session(session_id)
164
+ if context is None:
165
+ raise KeyError(f"session {session_id!r} not found")
166
+ if version is not None:
167
+ context.checkout(version)
168
+ return context
169
+
170
+
171
+ def replay_summary(context: Context) -> dict[str, Any]:
172
+ """A compact "state at this point" summary for the replay CLI."""
173
+ artifacts = context.list_artifacts()
174
+ by_type: dict[str, int] = {}
175
+ for artifact in artifacts:
176
+ tname = artifact.data.__class__.__name__
177
+ by_type[tname] = by_type.get(tname, 0) + 1
178
+ return {
179
+ "version": context.version,
180
+ "artifacts": len(artifacts),
181
+ "by_type": by_type,
182
+ "relations": len(context.relations()),
183
+ "pending_questions": len(context.pending_questions()),
184
+ }
185
+
186
+
187
+ __all__ = ["ReplayLLM", "ReplayMiss", "replay_context", "replay_summary"]
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .providers import EmbeddingProvider, LLMProvider
6
+ from .sources import Source
7
+
8
+
9
+ class RuntimeResources:
10
+ def __init__(
11
+ self,
12
+ llm: LLMProvider | None = None,
13
+ embedder: EmbeddingProvider | None = None,
14
+ sources: dict[str, Source] | None = None,
15
+ **additional: Any,
16
+ ):
17
+ self.llm = llm
18
+ self.embedder = embedder
19
+ self.sources = sources or {}
20
+ self.additional = additional
21
+
22
+ def get_source(self, source_id: str) -> Source | None:
23
+ return self.sources.get(source_id)
24
+
25
+ def set(self, name: str, value: Any) -> None:
26
+ self.additional[name] = value
27
+
28
+ def get(self, name: str) -> Any:
29
+ return self.additional.get(name)
30
+
31
+ async def aclose(self) -> None:
32
+ """Closes the llm/embedder clients if they support it.
33
+
34
+ Duck-typed: `LLMProvider`/`EmbeddingProvider` don't require `aclose`
35
+ (a fake/no-op test double doesn't need one), so it's called only when
36
+ present. Nothing in the runtime calls this automatically — resources
37
+ are typically shared across many turns/runtimes, and closing them
38
+ early would break whatever still holds a reference. Call it yourself
39
+ once, at real shutdown: a FastAPI `lifespan`, or the end of a script.
40
+ `ChatAssistant` is the one exception — see its docstring.
41
+ """
42
+ for provider in (self.llm, self.embedder):
43
+ aclose = getattr(provider, "aclose", None)
44
+ if aclose is not None:
45
+ await aclose()