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,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"]