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/session.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .checkpoints import KVBackend
|
|
4
|
+
from .context import Context
|
|
5
|
+
from .resources import RuntimeResources
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Session:
|
|
9
|
+
"""Named session: an isolated Context bound to a key-value store.
|
|
10
|
+
|
|
11
|
+
Saves the full state (commit chain + working tree + head),
|
|
12
|
+
so after a restart the session resumes from the last commit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
session_id: str,
|
|
18
|
+
context: Context,
|
|
19
|
+
store: SessionStore,
|
|
20
|
+
loaded: bool,
|
|
21
|
+
):
|
|
22
|
+
self.session_id = session_id
|
|
23
|
+
self.context = context
|
|
24
|
+
self._store = store
|
|
25
|
+
self.loaded = loaded
|
|
26
|
+
|
|
27
|
+
async def save(self) -> None:
|
|
28
|
+
await self._store.save_session(self.session_id, self.context)
|
|
29
|
+
|
|
30
|
+
async def delete(self) -> None:
|
|
31
|
+
await self._store.delete_session(self.session_id)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SessionStore:
|
|
35
|
+
"""Session store on top of KVBackend (session_id → Context)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, backend: KVBackend):
|
|
38
|
+
self.backend = backend
|
|
39
|
+
|
|
40
|
+
async def save_session(self, session_id: str, context: Context) -> None:
|
|
41
|
+
await context.to_kv(self.backend, session_id)
|
|
42
|
+
|
|
43
|
+
async def load_session(
|
|
44
|
+
self,
|
|
45
|
+
session_id: str,
|
|
46
|
+
resources: RuntimeResources | None = None,
|
|
47
|
+
) -> Context | None:
|
|
48
|
+
context = await Context.from_kv(self.backend, session_id)
|
|
49
|
+
if context is None:
|
|
50
|
+
return None
|
|
51
|
+
context.resources = resources or RuntimeResources()
|
|
52
|
+
return context
|
|
53
|
+
|
|
54
|
+
async def has_session(self, session_id: str) -> bool:
|
|
55
|
+
return await self.backend.get(session_id) is not None
|
|
56
|
+
|
|
57
|
+
async def list_sessions(self) -> list[str]:
|
|
58
|
+
# Branch keys are the BranchStore's namespace — keep them out of sessions.
|
|
59
|
+
keys = await self.backend.keys()
|
|
60
|
+
return [key for key in keys if not key.startswith("branch:")]
|
|
61
|
+
|
|
62
|
+
async def delete_session(self, session_id: str) -> None:
|
|
63
|
+
await self.backend.delete(session_id)
|
|
64
|
+
|
|
65
|
+
async def open(
|
|
66
|
+
self,
|
|
67
|
+
session_id: str,
|
|
68
|
+
resources: RuntimeResources | None = None,
|
|
69
|
+
) -> Session:
|
|
70
|
+
"""Opens a session: loads an existing one or creates an empty one."""
|
|
71
|
+
context = await self.load_session(session_id, resources)
|
|
72
|
+
loaded = context is not None
|
|
73
|
+
if context is None:
|
|
74
|
+
context = Context(resources=resources or RuntimeResources())
|
|
75
|
+
return Session(session_id, context, self, loaded=loaded)
|
reactifact/sources.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import csv
|
|
5
|
+
import hashlib
|
|
6
|
+
import html as _html
|
|
7
|
+
import re
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SourceRef(BaseModel):
|
|
17
|
+
"""A reference to an external object that can be saved as an artifact.
|
|
18
|
+
|
|
19
|
+
May be a search result: then score/title/excerpt are filled in
|
|
20
|
+
(retrieval as a source capability, §8). Materialization is implemented
|
|
21
|
+
through `Source.resolve`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
source_id: str
|
|
25
|
+
locator: str
|
|
26
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
27
|
+
score: float | None = None
|
|
28
|
+
title: str = ""
|
|
29
|
+
excerpt: str = ""
|
|
30
|
+
query_id: str = ""
|
|
31
|
+
|
|
32
|
+
def stable_id(self) -> str:
|
|
33
|
+
raw = f"{self.source_id}:{self.locator}"
|
|
34
|
+
return hashlib.sha1(raw.encode()).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Source(ABC):
|
|
38
|
+
def __init__(self, source_id: str):
|
|
39
|
+
self.source_id = source_id
|
|
40
|
+
# Sorting hint for aggregating search: preferred sources
|
|
41
|
+
# (e.g., vector RAG) are polled first, the rest fill in.
|
|
42
|
+
self.preferred: bool = False
|
|
43
|
+
|
|
44
|
+
def search(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
45
|
+
"""Finds references to relevant content (by default — cannot do it).
|
|
46
|
+
|
|
47
|
+
The agent must not know the search mechanics: vector/keywords/SQL/CQL
|
|
48
|
+
— that is the source's choice (§8). Sources without search simply
|
|
49
|
+
return an empty list and stay available via resolve.
|
|
50
|
+
"""
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
54
|
+
"""Asynchronous search (embeddings, API). Default — synchronous `search`.
|
|
55
|
+
|
|
56
|
+
Vector sources cannot compute the query embedding synchronously,
|
|
57
|
+
so the aggregator (`ScoutSources`) uses `asearch`.
|
|
58
|
+
"""
|
|
59
|
+
return self.search(query, limit)
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
async def resolve(self, ref: SourceRef) -> Any:
|
|
63
|
+
"""Resolves a reference into materialized data (e.g., text, structure)."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _token_overlap_score(text: str, query: str) -> float:
|
|
68
|
+
"""Core neutral keyword matcher (§67), without linguistic morphology.
|
|
69
|
+
|
|
70
|
+
Exact term overlap (unicode alphanumeric words). Language normalization
|
|
71
|
+
(stemming, synonyms) is the app's concern: its scorer is passed
|
|
72
|
+
to `FileSystemSource(scorer=...)` or `CSVSource(scorer=...)`.
|
|
73
|
+
"""
|
|
74
|
+
words = set(re.findall(r"\w{2,}", text.casefold()))
|
|
75
|
+
query_terms = [
|
|
76
|
+
t for t in re.findall(r"\w{2,}", query.casefold()) if not t.isdigit()
|
|
77
|
+
]
|
|
78
|
+
if not query_terms:
|
|
79
|
+
return 0.0
|
|
80
|
+
hits = sum(1 for term in set(query_terms) if term in words)
|
|
81
|
+
return hits / len(query_terms)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FileSystemSource(Source):
|
|
85
|
+
"""Filesystem source with keyword search (deterministic path, §67).
|
|
86
|
+
|
|
87
|
+
Search is pure lexical over .md/.txt content; no embedders.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
root: str,
|
|
93
|
+
source_id: str = "filesystem",
|
|
94
|
+
extensions: tuple[str, ...] = (".md", ".txt"),
|
|
95
|
+
scorer: Callable[[str, str], float] | None = None,
|
|
96
|
+
):
|
|
97
|
+
super().__init__(source_id=source_id)
|
|
98
|
+
self.root = Path(root)
|
|
99
|
+
self.extensions = extensions
|
|
100
|
+
self.scorer = scorer or _token_overlap_score
|
|
101
|
+
|
|
102
|
+
def search(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
103
|
+
if not self.root.exists():
|
|
104
|
+
return []
|
|
105
|
+
results: list[SourceRef] = []
|
|
106
|
+
for path in sorted(self.root.rglob("*")):
|
|
107
|
+
if not path.is_file() or path.suffix not in self.extensions:
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
content = path.read_text(encoding="utf-8")
|
|
111
|
+
except OSError:
|
|
112
|
+
continue
|
|
113
|
+
score = self.scorer(content, query)
|
|
114
|
+
if score <= 0:
|
|
115
|
+
continue
|
|
116
|
+
locator = str(path.relative_to(self.root))
|
|
117
|
+
excerpt = " ".join(content.split())[:200]
|
|
118
|
+
results.append(
|
|
119
|
+
SourceRef(
|
|
120
|
+
source_id=self.source_id,
|
|
121
|
+
locator=locator,
|
|
122
|
+
score=round(score, 3),
|
|
123
|
+
title=path.name,
|
|
124
|
+
excerpt=excerpt,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
results.sort(key=lambda r: r.score or 0.0, reverse=True)
|
|
128
|
+
return results[:limit]
|
|
129
|
+
|
|
130
|
+
async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
131
|
+
"""Offloads the directory scan to a worker thread (§ reactifact.checkpoints
|
|
132
|
+
has the same pattern): `search()` walks and reads every matching file
|
|
133
|
+
under `root` synchronously, which would otherwise stall the runtime's
|
|
134
|
+
event loop — and every other agent running concurrently with it — for
|
|
135
|
+
as long as the scan takes."""
|
|
136
|
+
return await asyncio.to_thread(self.search, query, limit)
|
|
137
|
+
|
|
138
|
+
async def resolve(self, ref: SourceRef) -> str:
|
|
139
|
+
full_path = self.root / ref.locator
|
|
140
|
+
if not full_path.exists():
|
|
141
|
+
raise FileNotFoundError(f"File not found: {full_path}")
|
|
142
|
+
return await asyncio.to_thread(full_path.read_text, encoding="utf-8")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _chunk_text(text: str, size: int) -> list[str]:
|
|
146
|
+
"""Splits text into chunks by paragraphs, gluing up to `size` characters."""
|
|
147
|
+
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
|
148
|
+
chunks: list[str] = []
|
|
149
|
+
buf = ""
|
|
150
|
+
for paragraph in paragraphs:
|
|
151
|
+
if len(buf) + len(paragraph) + 1 <= size:
|
|
152
|
+
buf = f"{buf}\n{paragraph}".strip()
|
|
153
|
+
else:
|
|
154
|
+
if buf:
|
|
155
|
+
chunks.append(buf)
|
|
156
|
+
buf = paragraph
|
|
157
|
+
if buf:
|
|
158
|
+
chunks.append(buf)
|
|
159
|
+
return chunks
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
163
|
+
if not a or not b or len(a) != len(b):
|
|
164
|
+
return 0.0
|
|
165
|
+
dot: float = sum(x * y for x, y in zip(a, b, strict=False))
|
|
166
|
+
na: float = sum(x * x for x in a) ** 0.5
|
|
167
|
+
nb: float = sum(y * y for y in b) ** 0.5
|
|
168
|
+
if na == 0 or nb == 0:
|
|
169
|
+
return 0.0
|
|
170
|
+
return dot / (na * nb)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class EmbeddingSource(Source):
|
|
174
|
+
"""Vector source (RAG): embeddings are the search strategy (§8, §9).
|
|
175
|
+
|
|
176
|
+
Indexes files (chunks + vectors) lazily on the first search; `asearch`
|
|
177
|
+
embeds the query and ranks by cosine similarity. Since embedding is
|
|
178
|
+
async, synchronous `search` is not supported — the aggregator must
|
|
179
|
+
call `asearch`. Semantic search is preferred over keyword sources,
|
|
180
|
+
hence `preferred=True` (scout polls it first).
|
|
181
|
+
|
|
182
|
+
The index is built once and kept forever — a file changed under `root`
|
|
183
|
+
after the first search stays invisible until you call `invalidate()`.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
root: str,
|
|
189
|
+
source_id: str = "rag",
|
|
190
|
+
embedder: Any | None = None,
|
|
191
|
+
extensions: tuple[str, ...] = (".md", ".txt"),
|
|
192
|
+
chunk_size: int = 1200,
|
|
193
|
+
score_threshold: float = 0.15,
|
|
194
|
+
):
|
|
195
|
+
super().__init__(source_id=source_id)
|
|
196
|
+
if embedder is None:
|
|
197
|
+
raise ValueError("EmbeddingSource requires an embedder")
|
|
198
|
+
self.preferred = True # RAG is polled first (RAG-first)
|
|
199
|
+
self.root = Path(root)
|
|
200
|
+
self.embedder = embedder
|
|
201
|
+
self.extensions = extensions
|
|
202
|
+
self.chunk_size = chunk_size
|
|
203
|
+
self.score_threshold = score_threshold
|
|
204
|
+
self._index: list[tuple[str, str, list[float]]] | None = None
|
|
205
|
+
|
|
206
|
+
def search(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
207
|
+
raise NotImplementedError(
|
|
208
|
+
"EmbeddingSource requires async asearch (embedding is async)"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _scan_chunks_sync(self) -> list[tuple[str, str]]:
|
|
212
|
+
if not self.root.exists():
|
|
213
|
+
return []
|
|
214
|
+
chunks: list[tuple[str, str]] = []
|
|
215
|
+
for path in sorted(self.root.rglob("*")):
|
|
216
|
+
if not path.is_file() or path.suffix not in self.extensions:
|
|
217
|
+
continue
|
|
218
|
+
try:
|
|
219
|
+
content = path.read_text(encoding="utf-8")
|
|
220
|
+
except OSError:
|
|
221
|
+
continue
|
|
222
|
+
for chunk in _chunk_text(content, self.chunk_size):
|
|
223
|
+
chunks.append((str(path.relative_to(self.root)), chunk))
|
|
224
|
+
return chunks
|
|
225
|
+
|
|
226
|
+
async def _ensure_index(self) -> None:
|
|
227
|
+
if self._index is not None:
|
|
228
|
+
return
|
|
229
|
+
# Directory walk + file reads are blocking — run them off the event
|
|
230
|
+
# loop (same reasoning as FileSystemSource.asearch); the embed() call
|
|
231
|
+
# below is presumably already async-native I/O, so it stays direct.
|
|
232
|
+
chunks = await asyncio.to_thread(self._scan_chunks_sync)
|
|
233
|
+
if not chunks:
|
|
234
|
+
self._index = []
|
|
235
|
+
return
|
|
236
|
+
vectors = await self.embedder.embed([text for _, text in chunks])
|
|
237
|
+
self._index = [
|
|
238
|
+
(loc, text, vec) for (loc, text), vec in zip(chunks, vectors, strict=False)
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
def invalidate(self) -> None:
|
|
242
|
+
"""Drops the cached index so the next search rebuilds it from disk.
|
|
243
|
+
|
|
244
|
+
`_ensure_index()` only ever builds the index once, lazily, on the
|
|
245
|
+
first search (see the class docstring) — nothing detects that files
|
|
246
|
+
under `root` changed since. Call this yourself (e.g. after a known
|
|
247
|
+
write, or on a periodic timer) to pick up changes.
|
|
248
|
+
"""
|
|
249
|
+
self._index = None
|
|
250
|
+
|
|
251
|
+
async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
252
|
+
await self._ensure_index()
|
|
253
|
+
if not self._index:
|
|
254
|
+
return []
|
|
255
|
+
qvec = (await self.embedder.embed([query]))[0]
|
|
256
|
+
best: dict[str, tuple[float, str]] = {}
|
|
257
|
+
for loc, text, vec in self._index:
|
|
258
|
+
score = _cosine(qvec, vec)
|
|
259
|
+
if score < self.score_threshold:
|
|
260
|
+
continue
|
|
261
|
+
if loc not in best or score > best[loc][0]:
|
|
262
|
+
best[loc] = (score, text)
|
|
263
|
+
ranked = sorted(best.items(), key=lambda kv: kv[1][0], reverse=True)
|
|
264
|
+
return [
|
|
265
|
+
SourceRef(
|
|
266
|
+
source_id=self.source_id,
|
|
267
|
+
locator=loc,
|
|
268
|
+
score=round(score, 3),
|
|
269
|
+
title=Path(loc).name,
|
|
270
|
+
excerpt=text[:200],
|
|
271
|
+
)
|
|
272
|
+
for loc, (score, text) in ranked[:limit]
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
async def resolve(self, ref: SourceRef) -> str:
|
|
276
|
+
full_path = self.root / ref.locator
|
|
277
|
+
if not full_path.exists():
|
|
278
|
+
raise FileNotFoundError(f"File not found: {full_path}")
|
|
279
|
+
return await asyncio.to_thread(full_path.read_text, encoding="utf-8")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class CSVSource(Source):
|
|
283
|
+
"""CSV source: structured data, not text (§29).
|
|
284
|
+
|
|
285
|
+
Search (`search`) is keywords over the file name, headers and first
|
|
286
|
+
rows; `resolve` returns `{"columns": [...], "rows": [[...]]}` —
|
|
287
|
+
the structure is preserved (schema + rows), the agent computes over it
|
|
288
|
+
deterministically (§67). Refs are marked `metadata.structured` so the
|
|
289
|
+
materializer knows: it is a table, not text (§64).
|
|
290
|
+
|
|
291
|
+
Matching is exact term overlap by default (no stemming/plurals — see
|
|
292
|
+
`_token_overlap_score`); pass `scorer=` (same signature as
|
|
293
|
+
`FileSystemSource`) for language-aware matching, e.g. headers like
|
|
294
|
+
`cost_usd` won't match a query for "costs" without one.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
root: str,
|
|
300
|
+
source_id: str = "csv",
|
|
301
|
+
extensions: tuple[str, ...] = (".csv",),
|
|
302
|
+
scorer: Callable[[str, str], float] | None = None,
|
|
303
|
+
):
|
|
304
|
+
super().__init__(source_id=source_id)
|
|
305
|
+
self.root = Path(root)
|
|
306
|
+
self.extensions = extensions
|
|
307
|
+
self.scorer = scorer or _token_overlap_score
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _read(path: Path) -> list[list[str]]:
|
|
311
|
+
with path.open(newline="", encoding="utf-8") as fh:
|
|
312
|
+
return [[(cell or "").strip() for cell in row] for row in csv.reader(fh)]
|
|
313
|
+
|
|
314
|
+
def search(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
315
|
+
if not self.root.exists():
|
|
316
|
+
return []
|
|
317
|
+
results: list[SourceRef] = []
|
|
318
|
+
for path in sorted(self.root.rglob("*")):
|
|
319
|
+
if not path.is_file() or path.suffix not in self.extensions:
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
rows = self._read(path)
|
|
323
|
+
except (OSError, csv.Error):
|
|
324
|
+
continue
|
|
325
|
+
if not rows:
|
|
326
|
+
continue
|
|
327
|
+
header = rows[0]
|
|
328
|
+
sample = rows[1 : 1 + _CSV_SEARCH_SAMPLE]
|
|
329
|
+
flat = (
|
|
330
|
+
" ".join(header)
|
|
331
|
+
+ " "
|
|
332
|
+
+ " ".join(" ".join(cell for cell in row) for row in sample)
|
|
333
|
+
)
|
|
334
|
+
score = self.scorer(f"{path.name} {flat}", query)
|
|
335
|
+
if score <= 0:
|
|
336
|
+
continue
|
|
337
|
+
excerpt = " | ".join(sample[0]) if sample else " | ".join(header)
|
|
338
|
+
results.append(
|
|
339
|
+
SourceRef(
|
|
340
|
+
source_id=self.source_id,
|
|
341
|
+
locator=str(path.relative_to(self.root)),
|
|
342
|
+
metadata={"structured": True},
|
|
343
|
+
score=round(score, 3),
|
|
344
|
+
title=path.name,
|
|
345
|
+
excerpt=excerpt[:200],
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
results.sort(key=lambda r: r.score or 0.0, reverse=True)
|
|
349
|
+
return results[:limit]
|
|
350
|
+
|
|
351
|
+
async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
352
|
+
"""Offloads the directory scan to a worker thread — see
|
|
353
|
+
`FileSystemSource.asearch` for why."""
|
|
354
|
+
return await asyncio.to_thread(self.search, query, limit)
|
|
355
|
+
|
|
356
|
+
async def resolve(self, ref: SourceRef) -> dict[str, Any]:
|
|
357
|
+
full_path = self.root / ref.locator
|
|
358
|
+
if not full_path.exists():
|
|
359
|
+
raise FileNotFoundError(f"File not found: {full_path}")
|
|
360
|
+
rows = await asyncio.to_thread(self._read, full_path)
|
|
361
|
+
if not rows:
|
|
362
|
+
return {"columns": [], "rows": []}
|
|
363
|
+
return {"columns": rows[0], "rows": rows[1:]}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
_CSV_SEARCH_SAMPLE = 5
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _html_to_text(html: str) -> str:
|
|
370
|
+
"""Strips scripts/styles/boilerplate/tags from an HTML document, then
|
|
371
|
+
collapses whitespace into readable paragraphs.
|
|
372
|
+
|
|
373
|
+
Best-effort and dependency-free (regex, not a DOM parser) — good enough
|
|
374
|
+
for typical page markup, where real sites (Wikipedia, docs sites) use
|
|
375
|
+
`<nav>`/`<header>`/`<footer>`/`<aside>` as flat, non-nested landmarks; a
|
|
376
|
+
same-tag region nested inside itself may not fully strip. Without this,
|
|
377
|
+
boilerplate ("Jump to content", cookie banners, site nav) can outrank
|
|
378
|
+
real content in the naive `[:200]`-style offline fallback excerpt.
|
|
379
|
+
"""
|
|
380
|
+
without_comments = re.sub(r"<!--.*?-->", " ", html, flags=re.S)
|
|
381
|
+
without_boilerplate = re.sub(
|
|
382
|
+
r"<(script|style|noscript|nav|header|footer|aside)[^>]*>.*?</\1>",
|
|
383
|
+
" ",
|
|
384
|
+
without_comments,
|
|
385
|
+
flags=re.S | re.I,
|
|
386
|
+
)
|
|
387
|
+
without_tags = re.sub(r"<[^>]+>", " ", without_boilerplate)
|
|
388
|
+
text = _html.unescape(without_tags)
|
|
389
|
+
paragraphs = [p.strip() for p in re.split(r"\s*\n\s*", text) if p.strip()]
|
|
390
|
+
return "\n".join(paragraphs).strip()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class WebSource(Source):
|
|
394
|
+
"""HTTP source (web pages) with lazy fetching (§6, §32).
|
|
395
|
+
|
|
396
|
+
Register URLs up front (`add_url`) — nothing is fetched until a search or
|
|
397
|
+
resolve actually needs the page. `asearch` lazily fetches the registered
|
|
398
|
+
pages, ranks them by keyword overlap, and returns `SourceRef`s whose
|
|
399
|
+
`locator` is the URL. `resolve` downloads the page and returns its text.
|
|
400
|
+
This keeps "go somewhere for the data" a Source capability, not core agent
|
|
401
|
+
logic. `transport` is injectable for hermetic tests (MockTransport).
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
DEFAULT_UA = "reactifact-agent (+https://github.com/bzdvdn/reactifact)"
|
|
405
|
+
|
|
406
|
+
def __init__(
|
|
407
|
+
self,
|
|
408
|
+
urls: list[str] | None = None,
|
|
409
|
+
source_id: str = "web",
|
|
410
|
+
timeout: float = 15.0,
|
|
411
|
+
transport: Any | None = None,
|
|
412
|
+
user_agent: str = DEFAULT_UA,
|
|
413
|
+
):
|
|
414
|
+
super().__init__(source_id=source_id)
|
|
415
|
+
self._urls: list[tuple[str, str]] = []
|
|
416
|
+
self._cache: dict[str, tuple[str, str]] = {} # url -> (title, text)
|
|
417
|
+
self._timeout = timeout
|
|
418
|
+
self._transport = transport
|
|
419
|
+
self._headers = {"User-Agent": user_agent, "Accept": "text/html,*/*"}
|
|
420
|
+
self._client: Any | None = None
|
|
421
|
+
if urls:
|
|
422
|
+
for url in urls:
|
|
423
|
+
self.add_url(url)
|
|
424
|
+
|
|
425
|
+
def add_url(self, url: str, title: str = "") -> None:
|
|
426
|
+
"""Registers a URL to consider; the page is fetched lazily."""
|
|
427
|
+
if not any(u == url for u, _ in self._urls):
|
|
428
|
+
self._urls.append((url, title))
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def urls(self) -> list[tuple[str, str]]:
|
|
432
|
+
"""Registered (url, title) pairs — never fetched by just listing them."""
|
|
433
|
+
return list(self._urls)
|
|
434
|
+
|
|
435
|
+
def _get_client(self) -> Any:
|
|
436
|
+
if self._client is None:
|
|
437
|
+
import httpx
|
|
438
|
+
|
|
439
|
+
self._client = httpx.AsyncClient(
|
|
440
|
+
timeout=self._timeout, transport=self._transport, headers=self._headers
|
|
441
|
+
)
|
|
442
|
+
return self._client
|
|
443
|
+
|
|
444
|
+
async def _fetch(self, url: str) -> str:
|
|
445
|
+
response = await self._get_client().get(url)
|
|
446
|
+
response.raise_for_status()
|
|
447
|
+
return str(response.text)
|
|
448
|
+
|
|
449
|
+
async def _ensure(self, url: str, title: str = "") -> tuple[str, str]:
|
|
450
|
+
"""Fetches and caches (title, text) for a URL if it is not cached yet."""
|
|
451
|
+
if url not in self._cache:
|
|
452
|
+
html_text = await self._fetch(url)
|
|
453
|
+
page_title = title
|
|
454
|
+
if not page_title:
|
|
455
|
+
match = re.search(r"<title[^>]*>(.*?)</title>", html_text, re.S | re.I)
|
|
456
|
+
page_title = match.group(1).strip() if match else url
|
|
457
|
+
self._cache[url] = (
|
|
458
|
+
page_title,
|
|
459
|
+
_html_to_text(html_text),
|
|
460
|
+
)
|
|
461
|
+
return self._cache[url]
|
|
462
|
+
|
|
463
|
+
def search(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
464
|
+
"""Keyword search over already-cached pages (§8 keyword, run on demand).
|
|
465
|
+
|
|
466
|
+
Pages that have not been fetched yet are invisible here — call
|
|
467
|
+
`asearch` for a live pass over the registered URLs.
|
|
468
|
+
"""
|
|
469
|
+
results: list[SourceRef] = []
|
|
470
|
+
for url, title in self._urls:
|
|
471
|
+
entry = self._cache.get(url)
|
|
472
|
+
if entry is None:
|
|
473
|
+
continue
|
|
474
|
+
page_title, text = entry
|
|
475
|
+
score = _token_overlap_score(text, query)
|
|
476
|
+
if score <= 0:
|
|
477
|
+
continue
|
|
478
|
+
results.append(
|
|
479
|
+
SourceRef(
|
|
480
|
+
source_id=self.source_id,
|
|
481
|
+
locator=url,
|
|
482
|
+
score=round(score, 3),
|
|
483
|
+
title=page_title or title,
|
|
484
|
+
excerpt=text[:220],
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
results.sort(key=lambda r: r.score or 0.0, reverse=True)
|
|
488
|
+
return results[:limit]
|
|
489
|
+
|
|
490
|
+
async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
|
|
491
|
+
"""Lazily fetches all registered URLs, then ranks them by relevance."""
|
|
492
|
+
if self._urls:
|
|
493
|
+
await asyncio.gather(*(self._ensure(url, t) for url, t in self._urls))
|
|
494
|
+
return self.search(query, limit)
|
|
495
|
+
|
|
496
|
+
async def resolve(self, ref: SourceRef) -> str:
|
|
497
|
+
_, text = await self._ensure(ref.locator, ref.title)
|
|
498
|
+
return text
|
reactifact/streaming.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ProgressEvent:
|
|
11
|
+
"""Progress event that agents publish into the stream (aggregate statuses).
|
|
12
|
+
|
|
13
|
+
Candidate kinds: "status" (Thinking…, Searching in …, Found N…, Composing answer…).
|
|
14
|
+
The app renders them in the chat; `data` holds the details (source, count, …).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
kind: str
|
|
18
|
+
message: str = ""
|
|
19
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, Any]:
|
|
23
|
+
return {
|
|
24
|
+
"kind": self.kind,
|
|
25
|
+
"message": self.message,
|
|
26
|
+
"data": self.data,
|
|
27
|
+
"timestamp": self.timestamp.isoformat(),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
QueueEvent = asyncio.Queue[ProgressEvent]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EventHub:
|
|
35
|
+
"""Broadcaster of agent status events into a single- or multi-stream.
|
|
36
|
+
|
|
37
|
+
Publishing without subscribers is a no-op, so ordinary (non-streaming) runtime
|
|
38
|
+
runs pay nothing for announce.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._subscribers: set[QueueEvent] = set()
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def has_subscribers(self) -> bool:
|
|
46
|
+
return bool(self._subscribers)
|
|
47
|
+
|
|
48
|
+
def subscribe(self) -> QueueEvent:
|
|
49
|
+
queue: QueueEvent = asyncio.Queue()
|
|
50
|
+
self._subscribers.add(queue)
|
|
51
|
+
return queue
|
|
52
|
+
|
|
53
|
+
def unsubscribe(self, queue: QueueEvent) -> None:
|
|
54
|
+
self._subscribers.discard(queue)
|
|
55
|
+
|
|
56
|
+
def publish(self, event: ProgressEvent) -> None:
|
|
57
|
+
for queue in self._subscribers:
|
|
58
|
+
queue.put_nowait(event)
|