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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""FastAPI router for viewing traces (§54).
|
|
2
|
+
|
|
3
|
+
Mounted in the app: `app.include_router(create_trace_router(store))`.
|
|
4
|
+
Serves a JSON API (`/api/traces`, `/api/traces/{id}`), a `/traces` list, and a
|
|
5
|
+
trace page `/traces/{id}` (templates in `templates/`). Polling provides "real
|
|
6
|
+
time".
|
|
7
|
+
|
|
8
|
+
The router works against any `TraceReader` — SQLite (`TraceStore`) or Postgres
|
|
9
|
+
(`PostgresStore`) — both have async `query`/`get`.
|
|
10
|
+
|
|
11
|
+
FastAPI is imported lazily inside `create_trace_router`: the module itself and the whole
|
|
12
|
+
`tracing` package do not require fastapi installed — it is needed only where
|
|
13
|
+
the router is created (a web app).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from .store import TraceReader
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from fastapi import APIRouter
|
|
26
|
+
|
|
27
|
+
_TEMPLATES = Path(__file__).parent / "templates"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_trace_router(
|
|
31
|
+
store: TraceReader,
|
|
32
|
+
*,
|
|
33
|
+
username: str | None = None,
|
|
34
|
+
password: str | None = None,
|
|
35
|
+
) -> APIRouter:
|
|
36
|
+
"""Router over a trace store (SQLite or Postgres).
|
|
37
|
+
|
|
38
|
+
Returns `fastapi.APIRouter`; fastapi is imported here (lazily)
|
|
39
|
+
so that `reactifact.tracing.web` works without it.
|
|
40
|
+
|
|
41
|
+
If `username`/`password` are set — all handlers (including UI pages)
|
|
42
|
+
are protected with HTTP Basic auth. Traces contain full prompts and
|
|
43
|
+
artifact contents — do not expose them without auth.
|
|
44
|
+
"""
|
|
45
|
+
import secrets
|
|
46
|
+
|
|
47
|
+
from .._extras import require_extra
|
|
48
|
+
|
|
49
|
+
require_extra("tracing.web.create_trace_router", "fastapi", "web")
|
|
50
|
+
|
|
51
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
52
|
+
from fastapi.responses import HTMLResponse
|
|
53
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
54
|
+
|
|
55
|
+
dependencies = []
|
|
56
|
+
if username is not None and password is not None:
|
|
57
|
+
security = HTTPBasic(auto_error=False)
|
|
58
|
+
expected_user = username
|
|
59
|
+
expected_pass = password
|
|
60
|
+
|
|
61
|
+
def _check(
|
|
62
|
+
credentials: HTTPBasicCredentials | None = Depends( # noqa: B008
|
|
63
|
+
security
|
|
64
|
+
),
|
|
65
|
+
) -> None:
|
|
66
|
+
if credentials is None or not (
|
|
67
|
+
secrets.compare_digest(credentials.username, expected_user)
|
|
68
|
+
and secrets.compare_digest(credentials.password, expected_pass)
|
|
69
|
+
):
|
|
70
|
+
raise HTTPException(
|
|
71
|
+
status_code=401,
|
|
72
|
+
detail="Unauthorized",
|
|
73
|
+
headers={"WWW-Authenticate": "Basic"},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
dependencies = [Depends(_check)]
|
|
77
|
+
|
|
78
|
+
router = APIRouter(dependencies=dependencies)
|
|
79
|
+
|
|
80
|
+
@router.get("/api/traces")
|
|
81
|
+
async def list_traces(
|
|
82
|
+
session_id: str | None = Query(default=None),
|
|
83
|
+
outcome: str | None = Query(default=None),
|
|
84
|
+
limit: int = Query(default=25, ge=1, le=200),
|
|
85
|
+
offset: int = Query(default=0, ge=0),
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
return await store.query(
|
|
88
|
+
session_id=session_id, outcome=outcome, limit=limit, offset=offset
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@router.get("/api/traces/{trace_id}")
|
|
92
|
+
async def get_trace(trace_id: str) -> dict[str, Any]:
|
|
93
|
+
trace = await store.get(trace_id)
|
|
94
|
+
if trace is None:
|
|
95
|
+
raise HTTPException(status_code=404, detail="trace not found")
|
|
96
|
+
return trace.to_dict()
|
|
97
|
+
|
|
98
|
+
@router.get("/traces", response_class=HTMLResponse)
|
|
99
|
+
async def traces_list_page() -> str:
|
|
100
|
+
return (_TEMPLATES / "ui.html").read_text(encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
@router.get("/traces/{trace_id}", response_class=HTMLResponse)
|
|
103
|
+
async def traces_run_page(trace_id: str) -> str:
|
|
104
|
+
from ..viz import trace_provenance_to_mermaid, trace_to_mermaid
|
|
105
|
+
|
|
106
|
+
trace = await store.get(trace_id)
|
|
107
|
+
mermaid = trace_to_mermaid(trace) if trace is not None else ""
|
|
108
|
+
provenance = trace_provenance_to_mermaid(trace) if trace is not None else ""
|
|
109
|
+
return (
|
|
110
|
+
(_TEMPLATES / "ui_run.html")
|
|
111
|
+
.read_text(encoding="utf-8")
|
|
112
|
+
.replace("__RUN_ID__", json.dumps(trace_id))
|
|
113
|
+
.replace("__MERMAID__", json.dumps(mermaid))
|
|
114
|
+
.replace("__MERMAID_GRAPH__", json.dumps(provenance))
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return router
|
reactifact/triggers.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .artifacts import Artifact
|
|
7
|
+
from .context import Context
|
|
8
|
+
from .events import Event, EventType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Trigger:
|
|
12
|
+
"""Condition for launching an agent."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
event_type: EventType,
|
|
17
|
+
artifact_type: type | None = None,
|
|
18
|
+
condition: Callable[[Artifact[Any]], bool] | None = None,
|
|
19
|
+
):
|
|
20
|
+
self.event_type = event_type
|
|
21
|
+
self.artifact_type = artifact_type
|
|
22
|
+
self.condition = condition
|
|
23
|
+
|
|
24
|
+
def matches(self, event: Event, workspace: Context | None = None) -> bool:
|
|
25
|
+
"""Checks whether the event matches this trigger.
|
|
26
|
+
If a condition is set, requires a workspace to fetch the artifact."""
|
|
27
|
+
if event.type != self.event_type:
|
|
28
|
+
return False
|
|
29
|
+
if self.artifact_type is not None and event.artifact_type != self.artifact_type:
|
|
30
|
+
return False
|
|
31
|
+
if self.condition is not None:
|
|
32
|
+
if workspace is None:
|
|
33
|
+
raise ValueError("Workspace is required to evaluate condition")
|
|
34
|
+
artifact = workspace.get(event.artifact_id)
|
|
35
|
+
if artifact is None:
|
|
36
|
+
return False # the artifact may have been deleted
|
|
37
|
+
return self.condition(artifact)
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
return f"<Trigger {self.event_type.value} {self.artifact_type.__name__ if self.artifact_type else '*'}>"
|
reactifact/viz.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""reactifact.viz — Mermaid diagram renderers (§72).
|
|
2
|
+
|
|
3
|
+
Pure functions returning Mermaid source strings (no rendering dependency).
|
|
4
|
+
Render them anywhere Mermaid works (GitHub, Notion, mermaid.live), or view the
|
|
5
|
+
trace diagram directly in the dashboard and the CLI:
|
|
6
|
+
|
|
7
|
+
python -m reactifact graph examples.knowledge.agents
|
|
8
|
+
python -m reactifact context <sessions-db>
|
|
9
|
+
python -m reactifact trace traces.db [run_id]
|
|
10
|
+
|
|
11
|
+
The two "graphs" here are honest to the architecture: `blueprint` is the
|
|
12
|
+
*static map* (what agents can consume/produce), `context_to_mermaid` is the
|
|
13
|
+
*dynamic state* — artifacts and their provenance relations (§72). There is no
|
|
14
|
+
execution graph to draw; the runtime derives execution from state changes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from .agents import Agent
|
|
23
|
+
from .context import Context
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .tracing.models import RunTrace
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _esc(text: Any) -> str:
|
|
30
|
+
"""Mermaid-safe inline label text (quotes/newlines are the danger zone)."""
|
|
31
|
+
return str(text).replace("\r", " ").replace("\n", " ").replace('"', "'").strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _artifact_node(lines: list[str], node_ids: dict[str, str], type_name: str) -> str:
|
|
35
|
+
"""Returns (and registers) the mermaid node id for an artifact type."""
|
|
36
|
+
node = node_ids.get(type_name)
|
|
37
|
+
if node is None:
|
|
38
|
+
node = f"ART{len(node_ids)}"
|
|
39
|
+
node_ids[type_name] = node
|
|
40
|
+
lines.append(f' {node}["{_esc(type_name)}"]')
|
|
41
|
+
return node
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def blueprint(
|
|
45
|
+
agents: Sequence[Agent],
|
|
46
|
+
*,
|
|
47
|
+
with_stages: bool = True,
|
|
48
|
+
title: str = "reactifact blueprint",
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Static map of the system: artifact types = nodes, agents = edges.
|
|
51
|
+
|
|
52
|
+
For every `Agent` renders its `consumes` (input edge) and `produces`
|
|
53
|
+
(create edge; a `lifecycle` edge for `StatusMachine`-style produces).
|
|
54
|
+
"""
|
|
55
|
+
lines: list[str] = ["flowchart LR"]
|
|
56
|
+
lines.append(f' subgraph SG["{_esc(title)}"]')
|
|
57
|
+
lines.append(" direction LR")
|
|
58
|
+
node_ids: dict[str, str] = {}
|
|
59
|
+
seen: set[tuple[str, str, str]] = set()
|
|
60
|
+
for i, agent in enumerate(agents):
|
|
61
|
+
aid = f"A{i}"
|
|
62
|
+
produces = [
|
|
63
|
+
p
|
|
64
|
+
for p in agent.produces or ()
|
|
65
|
+
if getattr(p, "artifact_type", None) is not None
|
|
66
|
+
]
|
|
67
|
+
agent_label = agent.name or f"agent{i}"
|
|
68
|
+
if with_stages and produces:
|
|
69
|
+
agent_label += "<br/>" + " · ".join(type(p).__name__ for p in produces)
|
|
70
|
+
lines.append(f' {aid}["{_esc(agent_label)}"]')
|
|
71
|
+
|
|
72
|
+
for consume in agent.consumes or ():
|
|
73
|
+
tname = getattr(consume, "artifact_type", None)
|
|
74
|
+
if tname is None:
|
|
75
|
+
continue
|
|
76
|
+
node = _artifact_node(lines, node_ids, tname.__name__)
|
|
77
|
+
key = ("consume", node, aid)
|
|
78
|
+
if key in seen:
|
|
79
|
+
continue
|
|
80
|
+
seen.add(key)
|
|
81
|
+
lines.append(f" {node} -.->|Consume| {aid}")
|
|
82
|
+
|
|
83
|
+
for produce in produces:
|
|
84
|
+
ptype = produce.artifact_type
|
|
85
|
+
if ptype is None:
|
|
86
|
+
continue
|
|
87
|
+
node = _artifact_node(
|
|
88
|
+
lines, node_ids, getattr(ptype, "__name__", type(ptype).__name__)
|
|
89
|
+
)
|
|
90
|
+
machine = hasattr(produce, "next_status") and hasattr(
|
|
91
|
+
produce, "status_field"
|
|
92
|
+
)
|
|
93
|
+
action = "lifecycle" if machine else "creates"
|
|
94
|
+
key = ("produce", aid, node)
|
|
95
|
+
if key in seen:
|
|
96
|
+
continue
|
|
97
|
+
seen.add(key)
|
|
98
|
+
lines.append(f" {aid} ==>|{action}| {node}")
|
|
99
|
+
|
|
100
|
+
lines.append(" end")
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def context_to_mermaid(
|
|
105
|
+
context: Context,
|
|
106
|
+
*,
|
|
107
|
+
relate: bool = True,
|
|
108
|
+
limit: int | None = None,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Live provenance graph of a context: artifacts grouped by type + relations.
|
|
111
|
+
|
|
112
|
+
The "graph" here is the *state* — artifacts and `patch.link` relations, not
|
|
113
|
+
orchestration (§72). With `limit` only the first N artifacts (in store order)
|
|
114
|
+
are shown and relations are restricted to them.
|
|
115
|
+
"""
|
|
116
|
+
artifacts = context.list_artifacts()
|
|
117
|
+
if limit is not None and limit > 0:
|
|
118
|
+
artifacts = artifacts[:limit]
|
|
119
|
+
|
|
120
|
+
type_ids: dict[str, str] = {}
|
|
121
|
+
node_ids: dict[str, str] = {}
|
|
122
|
+
for i, artifact in enumerate(artifacts):
|
|
123
|
+
tname = artifact.data.__class__.__name__
|
|
124
|
+
type_ids.setdefault(tname, f"T{len(type_ids)}")
|
|
125
|
+
node_ids[artifact.id] = f"N{i}"
|
|
126
|
+
|
|
127
|
+
lines: list[str] = ["flowchart TD"]
|
|
128
|
+
by_type: dict[str, list[str]] = {}
|
|
129
|
+
for artifact in artifacts:
|
|
130
|
+
by_type.setdefault(artifact.data.__class__.__name__, []).append(artifact.id)
|
|
131
|
+
for tname, tid in type_ids.items():
|
|
132
|
+
lines.append(f' subgraph {tid}["{_esc(tname)}"]')
|
|
133
|
+
for mid in by_type[tname]:
|
|
134
|
+
lines.append(f' {node_ids[mid]}["{_esc(tname)}:{_esc(mid)}"]')
|
|
135
|
+
lines.append(" end")
|
|
136
|
+
|
|
137
|
+
if relate:
|
|
138
|
+
for rel in context.relations():
|
|
139
|
+
if rel.source_id not in node_ids or rel.target_id not in node_ids:
|
|
140
|
+
continue
|
|
141
|
+
lines.append(
|
|
142
|
+
f' {node_ids[rel.source_id]} -->|"{_esc(rel.relation)}"| '
|
|
143
|
+
f"{node_ids[rel.target_id]}"
|
|
144
|
+
)
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def trace_to_mermaid(trace: RunTrace) -> str:
|
|
149
|
+
"""sequenceDiagram of one run: agent spans over time, writes, LLM calls."""
|
|
150
|
+
lines: list[str] = ["sequenceDiagram"]
|
|
151
|
+
lines.append(" participant RT as Runtime")
|
|
152
|
+
pid_of: dict[str, str] = {"__runtime__": "RT"}
|
|
153
|
+
has_llm = any(span.llm_calls for span in trace.spans)
|
|
154
|
+
for span in trace.spans:
|
|
155
|
+
if span.agent not in pid_of:
|
|
156
|
+
pid = f"A{len(pid_of) - 1}"
|
|
157
|
+
pid_of[span.agent] = pid
|
|
158
|
+
lines.append(f' participant {pid} as "{_esc(span.agent)}"')
|
|
159
|
+
if has_llm:
|
|
160
|
+
lines.append(' participant LL as "LLM (recording)"')
|
|
161
|
+
|
|
162
|
+
for span in trace.spans:
|
|
163
|
+
pid = pid_of[span.agent]
|
|
164
|
+
pieces = [_esc(span.event_type) if span.event_type else "react"]
|
|
165
|
+
pieces.append(_plural(len(span.writes), "write"))
|
|
166
|
+
pieces.append(_plural(len(span.reads), "read"))
|
|
167
|
+
msg = " · ".join(pieces) + f" · {span.latency_ms:.0f} ms"
|
|
168
|
+
if span.error:
|
|
169
|
+
msg += f" · ⚠ {_esc(span.error)}"
|
|
170
|
+
lines.append(f" {pid}->>{pid}: {msg}")
|
|
171
|
+
for call in span.llm_calls:
|
|
172
|
+
model = call.model or call.provider or "llm"
|
|
173
|
+
stat = (
|
|
174
|
+
f"{call.prompt_tokens} in → {call.completion_tokens} out · "
|
|
175
|
+
f"{call.latency_ms:.0f} ms"
|
|
176
|
+
)
|
|
177
|
+
span_msg = f"{_esc(model)} · {stat}"
|
|
178
|
+
lines.append(f" {pid}->>LL: {span_msg}")
|
|
179
|
+
|
|
180
|
+
outcome = trace.outcome or "?"
|
|
181
|
+
lines.append(
|
|
182
|
+
f" Note over RT: outcome={_esc(outcome)} · "
|
|
183
|
+
f"duration={trace.duration_ms:.0f} ms"
|
|
184
|
+
)
|
|
185
|
+
return "\n".join(lines)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def trace_provenance_to_mermaid(trace: RunTrace) -> str:
|
|
189
|
+
"""Mermaid provenance graph of one run: written artifacts + `patch.link` edges (§34).
|
|
190
|
+
|
|
191
|
+
Artifacts written by any span become nodes; the `relations` recorded by the
|
|
192
|
+
spans become the edges — a per-run "evidence graph" (`Answer →supported_by→
|
|
193
|
+
Claim →derived_from→ Evidence →extracted_from→ Doc`).
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def _short(data_type: str) -> str:
|
|
197
|
+
return data_type.rsplit(".", 1)[-1] if data_type else "artifact"
|
|
198
|
+
|
|
199
|
+
nodes: dict[str, tuple[str, str]] = {}
|
|
200
|
+
for span in trace.spans:
|
|
201
|
+
for write in span.writes:
|
|
202
|
+
nodes.setdefault(
|
|
203
|
+
write.artifact_id, (_short(write.data_type), write.artifact_id)
|
|
204
|
+
)
|
|
205
|
+
for rel in span.relations:
|
|
206
|
+
nodes.setdefault(
|
|
207
|
+
rel.source_id, (rel.source_type or "artifact", rel.source_id)
|
|
208
|
+
)
|
|
209
|
+
nodes.setdefault(
|
|
210
|
+
rel.target_id, (rel.target_type or "artifact", rel.target_id)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
for span in trace.spans:
|
|
214
|
+
for rel in span.relations:
|
|
215
|
+
if rel.source_id not in nodes:
|
|
216
|
+
nodes[rel.source_id] = (rel.source_type or "artifact", rel.source_id)
|
|
217
|
+
if rel.target_id not in nodes:
|
|
218
|
+
nodes[rel.target_id] = (rel.target_type or "artifact", rel.target_id)
|
|
219
|
+
|
|
220
|
+
if not nodes:
|
|
221
|
+
return 'flowchart TD\n EMPTY["no provenance recorded"]'
|
|
222
|
+
|
|
223
|
+
lines: list[str] = ["flowchart TD"]
|
|
224
|
+
node_ids: dict[str, str] = {}
|
|
225
|
+
for index, (artifact_id, (tname, _)) in enumerate(nodes.items()):
|
|
226
|
+
node = f"N{index}"
|
|
227
|
+
node_ids[artifact_id] = node
|
|
228
|
+
lines.append(f' {node}["{_esc(tname)}:{_esc(artifact_id)}"]')
|
|
229
|
+
for span in trace.spans:
|
|
230
|
+
for rel in span.relations:
|
|
231
|
+
if rel.source_id in node_ids and rel.target_id in node_ids:
|
|
232
|
+
lines.append(
|
|
233
|
+
f' {node_ids[rel.source_id]} -->|"{_esc(rel.relation)}"| '
|
|
234
|
+
f"{node_ids[rel.target_id]}"
|
|
235
|
+
)
|
|
236
|
+
return "\n".join(lines)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _plural(n: int, word: str) -> str:
|
|
240
|
+
return f"{n} {word}" + ("s" if n != 1 else "")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = [
|
|
244
|
+
"blueprint",
|
|
245
|
+
"context_to_mermaid",
|
|
246
|
+
"trace_provenance_to_mermaid",
|
|
247
|
+
"trace_to_mermaid",
|
|
248
|
+
]
|
reactifact/web.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""reactifact.web — web adapter over the chat layer (FastAPI, SSE).
|
|
2
|
+
|
|
3
|
+
Because every app already owns its FastAPI instance, this module ships an
|
|
4
|
+
`APIRouter`, not an app. Mount it wherever:
|
|
5
|
+
|
|
6
|
+
from reactifact import ChatAssistant, SessionStore
|
|
7
|
+
from reactifact.web import create_chat_router
|
|
8
|
+
|
|
9
|
+
app.include_router(create_chat_router(assistant))
|
|
10
|
+
|
|
11
|
+
The wire contract is the canonical chat (owned by `reactifact.chat`):
|
|
12
|
+
``session`` → ``status``… → ``message`` over Server-Sent Events, plus the
|
|
13
|
+
standard runs (list) / deletion endpoints.
|
|
14
|
+
|
|
15
|
+
If your `ChatAssistant` was built with a *shared* `resources=` instance (not
|
|
16
|
+
a callable — see its docstring), that instance's provider owns an HTTP
|
|
17
|
+
client for the app's lifetime; close it in your own FastAPI shutdown, since
|
|
18
|
+
this module owns only the router, not the app:
|
|
19
|
+
|
|
20
|
+
@asynccontextmanager
|
|
21
|
+
async def lifespan(app: FastAPI):
|
|
22
|
+
yield
|
|
23
|
+
await resources.aclose()
|
|
24
|
+
|
|
25
|
+
app = FastAPI(lifespan=lifespan)
|
|
26
|
+
|
|
27
|
+
FastAPI is imported lazily (via `reactifact._extras`): the module imports without
|
|
28
|
+
fastapi installed, and only `create_chat_router` requires the `web` extra —
|
|
29
|
+
`pip install "reactifact[web]"`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from collections.abc import AsyncIterator
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel
|
|
39
|
+
|
|
40
|
+
from ._extras import require_extra
|
|
41
|
+
from .chat import ChatAssistant
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from fastapi import APIRouter
|
|
45
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ChatMessage(BaseModel):
|
|
49
|
+
"""Wire shape of an incoming user turn."""
|
|
50
|
+
|
|
51
|
+
message: str
|
|
52
|
+
session_id: str = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sse(event_type: str, data: dict[str, Any]) -> str:
|
|
56
|
+
"""Formats one Server-Sent-Events frame."""
|
|
57
|
+
return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def create_chat_router(
|
|
61
|
+
assistant: ChatAssistant,
|
|
62
|
+
*,
|
|
63
|
+
prefix: str = "/api",
|
|
64
|
+
with_health: bool = True,
|
|
65
|
+
) -> APIRouter:
|
|
66
|
+
"""Builds the chat router on top of a `ChatAssistant`.
|
|
67
|
+
|
|
68
|
+
Routes (default prefix `/api`):
|
|
69
|
+
POST /api/chat/stream — SSE turn (session → status… → message)
|
|
70
|
+
GET /api/runs/{id} — reconstructed chat thread
|
|
71
|
+
DELETE /api/runs/{id} — delete a session's history
|
|
72
|
+
GET /api/health — liveness (opt-out via `with_health=False`)
|
|
73
|
+
"""
|
|
74
|
+
# Readable error when the `web` extra is missing — then a regular
|
|
75
|
+
# (mypy-visible) import for the real types.
|
|
76
|
+
require_extra("web.create_chat_router", "fastapi", "web")
|
|
77
|
+
from fastapi import APIRouter
|
|
78
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
79
|
+
|
|
80
|
+
router = APIRouter(prefix=prefix)
|
|
81
|
+
|
|
82
|
+
if with_health:
|
|
83
|
+
|
|
84
|
+
@router.get("/health")
|
|
85
|
+
async def health() -> dict[str, bool]:
|
|
86
|
+
return {"ok": True}
|
|
87
|
+
|
|
88
|
+
@router.post("/chat/stream")
|
|
89
|
+
async def chat_stream(req: ChatMessage) -> StreamingResponse:
|
|
90
|
+
async def stream() -> AsyncIterator[str]:
|
|
91
|
+
async for event in assistant.stream(req.message, req.session_id):
|
|
92
|
+
if event.kind == "session":
|
|
93
|
+
yield sse("session", {"session_id": event.session_id})
|
|
94
|
+
elif event.kind == "status":
|
|
95
|
+
yield sse("status", {"message": event.message})
|
|
96
|
+
elif event.kind == "message":
|
|
97
|
+
yield sse("message", event.payload or {"reply": ""})
|
|
98
|
+
|
|
99
|
+
return StreamingResponse(
|
|
100
|
+
stream(),
|
|
101
|
+
media_type="text/event-stream",
|
|
102
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@router.get("/runs/{session_id}")
|
|
106
|
+
async def runs(session_id: str) -> JSONResponse:
|
|
107
|
+
return JSONResponse(await assistant.history(session_id))
|
|
108
|
+
|
|
109
|
+
@router.delete("/runs/{session_id}")
|
|
110
|
+
async def run_delete(session_id: str) -> dict[str, bool]:
|
|
111
|
+
await assistant.store.delete_session(session_id)
|
|
112
|
+
return {"ok": True}
|
|
113
|
+
|
|
114
|
+
return router
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["ChatMessage", "create_chat_router", "sse"]
|