diff --git a/Dockerfile b/Dockerfile index 4c6468e..99e7756 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ COPY nisse-core nisse-core COPY nisse-dev nisse-dev COPY nisse-ops nisse-ops COPY nisse-review nisse-review +COPY nisse-ui nisse-ui # nisse-dev and what it needs, installed (not editable): the source tree # isn't part of the image. diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 41355e9..84cf92b 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -40,7 +40,7 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders. | **D3–D4** Workspace, git | partial | Idempotent publishing; no fresh workspace (#62), `git add -A` (#80) | | **D5** Review | partial | PR conversation comments only (#69), watch stall (#70) | | **D7–D8** Deployment, projects | partial | Bring-up runs `nisse-dev`'s and `nisse-review`'s images under systemd on this box; project entries in config (M1.4); egress not enforced | -| **O**, **X** | partial | `nisse-dev` has its image (M1.5), `nisse-review` its own (M7.6, acceptance after merge); nothing runs on the agent host | +| **O**, **X** | partial | `nisse-dev` has its image (M1.5), `nisse-review` its own (M7.6, acceptance after merge); nothing runs on the agent host; the status page's walkthrough runs on this box (M6.6b) | | `specs/` | done for now | `loop.qnt` (the architecture, #91); `orchestrator.qnt` went with `control.py` (M2.8) | --- @@ -585,9 +585,11 @@ bring-up, end-to-end and benchmark runs; only d waits for M6.1. - **a. The call record** (L7) in `nisse-core`: written by the shared model client and `Driver.run`, with each message's provenance from the per-role view and fitting; wired in each agent's build; deleted at start. Each system prompt - says the calls are recorded, while they are. + says the calls are recorded, while they are. **Done** (#179). - **b. The walkthrough:** an item's steps, each run's turns, and each request read - against the log it was made from; runs going in circles flagged. + against the log it was made from; runs going in circles flagged. **Done** (this + PR): the `nisse-ui` package, on 127.0.0.1 behind an SSH tunnel until d. The + loop rules are `nisse_core.loop.circles`, for M7.8 to reuse. - **c. The overview:** each agent's active, queued, parked and blocked items. - **d. Deployment:** its image, the read-only mounts and a Caddy route behind Authelia (with #166). diff --git a/nisse-core/src/nisse_core/loop/circles.py b/nisse-core/src/nisse_core/loop/circles.py new file mode 100644 index 0000000..e9a897b --- /dev/null +++ b/nisse-core/src/nisse_core/loop/circles.py @@ -0,0 +1,111 @@ +"""Runs going in circles (L2, X6): turns that repeat near-identically, one +correction given again and again, or apologies over and over. + +A pure function over a run's turns, so the status page can flag a loop now +and ``Driver.run`` can end one later (M7.8) from the same rules. +""" + +import re +from collections.abc import Sequence +from dataclasses import dataclass +from difflib import SequenceMatcher + +SIMILAR = 0.9 # how alike two replies are to count as the same +REPEATS = 3 # how many times before it's a circle +_COMPARED = 2000 # characters of each reply compared + +_APOLOGY = re.compile( + r"\b(sorry|apologi[sz]e|apologies|my mistake|my apologies|please disregard|" + r"disregard (this|that|the previous|my))\b", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class Turn: + """What a circle is judged on: the model's reply (its text and tool + calls) and what the harness told it before this turn.""" + + text: str = "" + calls: tuple[tuple[str, str], ...] = () # (tool, arguments as sent) + told: tuple[str, ...] = () # corrections given since the turn before + + +@dataclass(frozen=True) +class Circle: + kind: str # "repeated reply" | "repeated correction" | "repeated apology" + turns: tuple[int, ...] # indexes into the turns given + detail: str + + +def find_circles( + turns: Sequence[Turn], *, similar: float = SIMILAR, repeats: int = REPEATS +) -> list[Circle]: + """Every circle in a run, in the order they started.""" + found = _repeated_replies(turns, similar, repeats) + found += _repeated_corrections(turns, repeats) + apologies = tuple(i for i, t in enumerate(turns) if _APOLOGY.search(t.text)) + if len(apologies) >= repeats: + found.append( + Circle( + "repeated apology", + apologies, + f"the model apologised in {len(apologies)} turns", + ) + ) + return sorted(found, key=lambda c: c.turns[0]) + + +def _signature(turn: Turn) -> str: + calls = "\n".join(f"{tool}({args})" for tool, args in turn.calls) + return f"{turn.text.strip()}\n{calls}"[:_COMPARED] + + +def _alike(a: str, b: str, similar: float) -> bool: + if a == b: + return True + matcher = SequenceMatcher(None, a, b, autojunk=False) + return matcher.quick_ratio() >= similar and matcher.ratio() >= similar + + +def _repeated_replies( + turns: Sequence[Turn], similar: float, repeats: int +) -> list[Circle]: + """Stretches of consecutive turns each alike the one before.""" + found: list[Circle] = [] + signatures = [_signature(t) for t in turns] + start = 0 + for i in range(1, len(turns) + 1): + if ( + i < len(turns) + and signatures[i].strip() + and _alike(signatures[i - 1], signatures[i], similar) + ): + continue + if i - start >= repeats: + found.append( + Circle( + "repeated reply", + tuple(range(start, i)), + f"{i - start} near-identical replies in a row", + ) + ) + start = i + return found + + +def _repeated_corrections(turns: Sequence[Turn], repeats: int) -> list[Circle]: + """One correction given in ``repeats`` or more turns, in a row or not.""" + given: dict[str, list[int]] = {} + for i, turn in enumerate(turns): + for text in dict.fromkeys(turn.told): + given.setdefault(text, []).append(i) + return [ + Circle( + "repeated correction", + tuple(at), + f"told {len(at)} times: {text[:120]}", + ) + for text, at in given.items() + if len(at) >= repeats + ] diff --git a/nisse-core/src/nisse_core/models/calls.py b/nisse-core/src/nisse_core/models/calls.py index 5086355..8372718 100644 --- a/nisse-core/src/nisse_core/models/calls.py +++ b/nisse-core/src/nisse_core/models/calls.py @@ -29,7 +29,7 @@ import sqlite3 import threading import uuid import zlib -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Iterable, Iterator, Mapping, Sequence from contextvars import ContextVar from dataclasses import dataclass, replace from datetime import UTC, datetime @@ -38,6 +38,7 @@ from typing import Any from nisse_core.log.canonical import StoredEvent, event_type, to_payload from nisse_core.oplog import OpLog +from nisse_core.state import readonly LAYOUT_VERSION = 1 @@ -241,8 +242,9 @@ class CallRecorder: for statement in _SCHEMA: conn.execute(statement) conn.execute( - "INSERT INTO meta (key, value) VALUES ('layout_version', ?)", - (str(LAYOUT_VERSION),), + "INSERT INTO meta (key, value) VALUES ('layout_version', ?), " + "('started_at', ?)", + (str(LAYOUT_VERSION), utc_now()), ) conn.execute("COMMIT") self._conn = conn @@ -427,11 +429,10 @@ class UnknownLayout(RuntimeError): class CallReader: - """Opens a call record read-only (``mode=ro``) and never writes to it.""" + """Opens a call record read-only and never writes to it (``readonly``).""" def __init__(self, path: str | Path) -> None: - self._conn = sqlite3.connect(f"file:{Path(path)}?mode=ro", uri=True) - self._conn.row_factory = sqlite3.Row + self._conn = readonly.connect(path) row = self._conn.execute( "SELECT value FROM meta WHERE key = 'layout_version'" ).fetchone() @@ -442,6 +443,11 @@ class CallReader: f"call record layout {self.layout_version}; this reader knows " f"{LAYOUT_VERSION}" ) + row = self._conn.execute( + "SELECT value FROM meta WHERE key = 'started_at'" + ).fetchone() + # When recording began: work before it has its log but no requests. + self.started_at: str | None = row["value"] if row else None def blob(self, digest: str) -> Any: row = self._conn.execute( @@ -464,6 +470,26 @@ class CallReader: rows = self._conn.execute( "SELECT * FROM calls WHERE run = ? ORDER BY id", (run,) ) + return self._decoded(rows) + + def calls_for(self, work_item: str | None) -> list[dict[str, Any]]: + """One work item's calls, or the agent-level ones (None).""" + if work_item is None: + rows = self._conn.execute( + "SELECT * FROM calls WHERE work_item IS NULL ORDER BY id" + ) + else: + rows = self._conn.execute( + "SELECT * FROM calls WHERE work_item = ? ORDER BY id", (work_item,) + ) + return self._decoded(rows) + + def call(self, call_id: int) -> dict[str, Any] | None: + rows = self._conn.execute("SELECT * FROM calls WHERE id = ?", (call_id,)) + found = self._decoded(rows) + return found[0] if found else None + + def _decoded(self, rows: Iterable[sqlite3.Row]) -> list[dict[str, Any]]: out = [] for row in rows: call = dict(row) diff --git a/nisse-core/src/nisse_core/models/render.py b/nisse-core/src/nisse_core/models/render.py index 2a87238..7022aeb 100644 --- a/nisse-core/src/nisse_core/models/render.py +++ b/nisse-core/src/nisse_core/models/render.py @@ -88,13 +88,34 @@ class OpenAIChatRenderer: model: str, system: str | None = None, ) -> dict[str, Any]: + messages, _ = self._messages(events, system) + body: dict[str, Any] = {"model": model, "messages": messages} + if tools: + body["tools"] = tools + return body + + def message_indexes( + self, events: list[StoredEvent], system: str | None = None + ) -> list[int | None]: + """For each event, the index of the request message ``render`` puts it + in, or None where a compaction stands in for it: so a request can be + read against the log it was made from (X6). Several events share an + index where one turn's text and tool calls fold into one message.""" + _, where = self._messages(events, system) + return [where.get(id(stored)) for stored in events] + + def _messages( + self, events: list[StoredEvent], system: str | None + ) -> tuple[list[dict[str, Any]], dict[int, int]]: messages: list[dict[str, Any]] = [] + where: dict[int, int] = {} # id() of an event given -> its message if system is not None: messages.append({"role": "system", "content": system}) kept = effective_events(events) answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)} for stored in kept: _append_merged(messages, self._message_for(stored.event)) + where[id(stored)] = len(messages) - 1 # Providers reject a tool call with no result following it. One # can legitimately be missing: a crash between the call and its # result (L8), or a turn-ending gate tool. Render a placeholder so @@ -109,10 +130,7 @@ class OpenAIChatRenderer: "content": "[no result recorded]", } ) - body: dict[str, Any] = {"model": model, "messages": messages} - if tools: - body["tools"] = tools - return body + return messages, where def _message_for(self, event: object) -> dict[str, Any]: match event: diff --git a/nisse-core/src/nisse_core/state/readonly.py b/nisse-core/src/nisse_core/state/readonly.py new file mode 100644 index 0000000..1bf3a97 --- /dev/null +++ b/nisse-core/src/nisse_core/state/readonly.py @@ -0,0 +1,26 @@ +"""Opening an agent's SQLite file without writing to it (X6), for readers +beside the agent: the status page, the call record's reader. + +``mode=ro`` alone still creates the ``-wal`` and ``-shm`` files of a +database whose writer has closed, which writes to the agent's volume and +fails on a read-only mount. With no ``-wal`` file there is no writer, so the +file is opened immutable instead: nothing is created, nothing is locked. +With one, a writer is (or was) there, and SQLite's own read-only WAL +reading applies. +""" + +import sqlite3 +from pathlib import Path +from urllib.parse import quote + + +def connect(path: str | Path, *, timeout: float = 5.0) -> sqlite3.Connection: + path = Path(path) + if not path.is_file(): + raise sqlite3.OperationalError(f"unable to open database file: {path}") + mode = "ro" if Path(f"{path}-wal").exists() else "ro&immutable=1" + conn = sqlite3.connect( + f"file:{quote(str(path.resolve()))}?mode={mode}", uri=True, timeout=timeout + ) + conn.row_factory = sqlite3.Row + return conn diff --git a/nisse-core/tests/test_calls.py b/nisse-core/tests/test_calls.py index fa1256a..90a470b 100644 --- a/nisse-core/tests/test_calls.py +++ b/nisse-core/tests/test_calls.py @@ -374,3 +374,21 @@ def test_the_reader_never_writes(recorder: calls.CallRecorder) -> None: reader = read(recorder) with pytest.raises(sqlite3.OperationalError, match="readonly"): reader._conn.execute("DELETE FROM calls") + + +def test_the_page_reads_one_items_calls_and_when_recording_began( + recorder: calls.CallRecorder, +) -> None: + client = budgeted(Server(), recorder) + with calls.run(recorder, work_item="w", role="driver"): + client.complete(stored([UserMessage(text="go", source="t")]), None, "local") + with calls.run(recorder, work_item=None, role="classifier"): + client.complete(stored([UserMessage(text="hi", source="t")]), None, "local") + reader = read(recorder) + [mine] = reader.calls_for("w") + [agent_level] = reader.calls_for(None) + assert (mine["work_item"], agent_level["work_item"]) == ("w", None) + assert reader.call(agent_level["id"])["role"] == "classifier" + assert reader.call(999) is None + assert reader.started_at is not None + assert reader.started_at <= mine["at"] diff --git a/nisse-core/tests/test_circles.py b/nisse-core/tests/test_circles.py new file mode 100644 index 0000000..c94c8c8 --- /dev/null +++ b/nisse-core/tests/test_circles.py @@ -0,0 +1,73 @@ +"""Runs going in circles (L2, X6), judged from the turns alone.""" + +from nisse_core.loop.circles import Turn, find_circles + +NUDGE = "Your last reply was prose. Every reply must be a tool call." + + +def comment(n: int) -> Turn: + return Turn( + text="", + calls=(("post_comment", f'{{"body": "Duplicate, please disregard. ({n})"}}'),), + ) + + +def test_near_identical_replies_in_a_row_are_a_circle() -> None: + # The Flash-Next review loop of 2026-10-24: the same comment posted over + # and over, differing only in a counter. + turns = [Turn(calls=(("read_file", '{"path": "a.py"}'),))] + turns += [comment(n) for n in range(6)] + turns += [Turn(calls=(("finish", "{}"),))] + [circle] = find_circles(turns) + assert circle.kind == "repeated reply" + assert circle.turns == (1, 2, 3, 4, 5, 6) + + +def test_work_that_moves_on_is_no_circle() -> None: + turns = [ + Turn(calls=(("read_file", '{"path": "a.py"}'),)), + Turn(calls=(("run_command", '{"command": "pytest -q"}'),)), + Turn(calls=(("edit_file", '{"path": "a.py", "old": "x", "new": "y"}'),)), + Turn(calls=(("run_command", '{"command": "pytest -q"}'),)), + Turn(text="Done.", calls=(("finish", "{}"),)), + ] + assert find_circles(turns) == [] + + +def test_two_alike_is_not_yet_a_circle() -> None: + assert find_circles([comment(1), comment(2), Turn(text="done")]) == [] + + +def test_one_correction_given_again_and_again_is_a_circle() -> None: + # Invented tools met with the prose reminder turn after turn, the replies + # themselves all different. + replies = [ + "I'll run the linter first: lint_fix", + "read_file src/review.py", + "Installing the dependencies with install(requirements.txt) now.", + "Let me use the skill tool to summarise the diff for the review.", + ] + turns = [Turn(text=r, told=(NUDGE,) if i else ()) for i, r in enumerate(replies)] + [circle] = find_circles(turns) + assert circle.kind == "repeated correction" + assert circle.turns == (1, 2, 3) + assert NUDGE[:40] in circle.detail + + +def test_apologies_over_and_over_are_a_circle() -> None: + turns = [ + Turn(text="Sorry, I made an error reading the file."), + Turn(calls=(("read_file", '{"path": "b.py"}'),)), + Turn(text="My apologies, that was the wrong path."), + Turn(calls=(("list_files", "{}"),)), + Turn(text="Please disregard my previous comment."), + ] + [circle] = find_circles(turns) + assert circle.kind == "repeated apology" + assert circle.turns == (0, 2, 4) + + +def test_the_limits_are_tuning() -> None: + turns = [comment(1), comment(2)] + assert [c.kind for c in find_circles(turns, repeats=2)] == ["repeated reply"] + assert find_circles([comment(1), comment(22), comment(333)], similar=1.0) == [] diff --git a/nisse-core/tests/test_readonly.py b/nisse-core/tests/test_readonly.py new file mode 100644 index 0000000..52c0a78 --- /dev/null +++ b/nisse-core/tests/test_readonly.py @@ -0,0 +1,43 @@ +"""Readers beside an agent open its files without writing to them (X6).""" + +import sqlite3 +from pathlib import Path + +import pytest + +from nisse_core.state import readonly + + +def wal_db(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(path, isolation_level=None) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("CREATE TABLE t (v TEXT)") + conn.execute("INSERT INTO t VALUES ('one')") + return conn + + +def test_a_closed_database_is_read_without_creating_any_file(tmp_path: Path) -> None: + wal_db(tmp_path / "a.db").close() + assert sorted(p.name for p in tmp_path.iterdir()) == ["a.db"] + conn = readonly.connect(tmp_path / "a.db") + assert conn.execute("SELECT v FROM t").fetchall()[0]["v"] == "one" + conn.close() + assert sorted(p.name for p in tmp_path.iterdir()) == ["a.db"] + + +def test_a_live_database_is_read_as_its_writer_commits(tmp_path: Path) -> None: + writer = wal_db(tmp_path / "a.db") + conn = readonly.connect(tmp_path / "a.db") + writer.execute("INSERT INTO t VALUES ('two')") + assert [r["v"] for r in conn.execute("SELECT v FROM t")] == ["one", "two"] + with pytest.raises(sqlite3.OperationalError): + conn.execute("INSERT INTO t VALUES ('three')") + conn.close() + writer.execute("INSERT INTO t VALUES ('four')") # no lock left behind + writer.close() + + +def test_a_missing_file_is_an_error_not_a_new_database(tmp_path: Path) -> None: + with pytest.raises(sqlite3.OperationalError): + readonly.connect(tmp_path / "none.db") + assert not (tmp_path / "none.db").exists() diff --git a/nisse-core/tests/test_render.py b/nisse-core/tests/test_render.py index 0985117..18374c6 100644 --- a/nisse-core/tests/test_render.py +++ b/nisse-core/tests/test_render.py @@ -163,3 +163,47 @@ def test_roles_never_repeat_for_strict_templates() -> None: roles = _roles(OpenAIChatRenderer().render(events, tools=None, model="q")) for first, second in pairwise(roles): assert not (first == second and first in ("user", "assistant")), roles + + +def test_each_event_maps_to_the_message_render_puts_it_in() -> None: + # A turn's text and tool call fold into one message; a call with no + # result gets a placeholder that came from no event; the system prompt + # is message 0. + events = stored( + [ + UserMessage(text="go", source="m:1"), + AssistantText(text="Looking.", model="m"), + ToolCall(call_id="c1", tool="read_file", arguments={"path": "a"}), + ToolResult(call_id="c1", result="text", success=True), + ToolCall(call_id="c2", tool="finish", arguments={}), + UserMessage(text="and then?", source="m:2"), + ] + ) + renderer = OpenAIChatRenderer() + body = renderer.render(events, tools=None, model="m", system="sys") + indexes = renderer.message_indexes(events, system="sys") + assert indexes == [1, 2, 2, 3, 4, 6] + assert body["messages"][5]["content"] == "[no result recorded]" + for event, index in zip(events, indexes, strict=True): + message = body["messages"][index] + if isinstance(event.event, ToolCall): + ids = [c["id"] for c in message["tool_calls"]] + assert event.event.call_id in ids + elif isinstance(event.event, (UserMessage, AssistantText)): + assert event.event.text in message["content"] + assert renderer.message_indexes(events) == [i - 1 for i in indexes] + + +def test_events_a_compaction_replaced_map_to_no_message() -> None: + events = stored( + [ + UserMessage(text="one", source="m:1"), + AssistantText(text="two", model="m"), + UserMessage(text="three", source="m:3"), + Compaction(summary="one and two", model="m", replaces=[1, 2]), + ] + ) + # The summary takes the place of what it replaced, and the user message + # after it folds into the same user message. + indexes = OpenAIChatRenderer().message_indexes(events) + assert indexes == [None, None, 0, 0] diff --git a/nisse-ui/README.md b/nisse-ui/README.md new file mode 100644 index 0000000..2746693 --- /dev/null +++ b/nisse-ui/README.md @@ -0,0 +1,40 @@ +# nisse-ui + +The status page (architecture X6): a read-only view of what the agents did +and how, for studying context, compaction and memory in practice. + +It reads each agent's state store and the call record beside it (L7), and +never writes to either. It runs no model and holds no credentials. + + uv run nisse-ui --source dev=/var/lib/nisse-dev/state.db \ + --source review=/var/lib/nisse-review/state.db \ + --runs /path/to/kept-runs + +- `--source NAME=STATE_DB`: an agent's state store. Its call record is + `.calls.db` beside it. +- `--runs DIR`: runs the benchmark kept (`scripts/benchmark.py run + --keep-runs DIR`). Each folder holding a `state.db` is a source. + +It listens on 127.0.0.1:8765, because it shows everything the models saw, +including what other people wrote. Until it sits behind Caddy and Authelia +(M6.6d), reach it through an SSH tunnel: + + ssh -L 8765:localhost:8765 + +then open http://localhost:8765/. + +**What it shows:** for each work item, its steps (what people and the +orchestrator said, and each run), each run's turns, and for each turn: + +- the request exactly as sent, message by message, beside the log events + each came from and what rendering did to them; +- what changed since the turn before, and where prompt reuse had to stop, + next to the server's own count; +- the reply, with its reasoning; +- the raw JSON. + +Runs going in circles are flagged (`nisse_core.loop.circles`). + +**Safety:** everything is escaped, and the page sends a Content Security +Policy that runs no script at all. Files are opened through +`nisse_core.state.readonly`, which never creates or locks anything. diff --git a/nisse-ui/pyproject.toml b/nisse-ui/pyproject.toml new file mode 100644 index 0000000..0424db4 --- /dev/null +++ b/nisse-ui/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "nisse-ui" +version = "0.1.0" +description = "Status page: a read-only view of what the agents did and how (X6)" +requires-python = ">=3.12" +dependencies = ["nisse-core", "jinja2>=3.1"] + +[project.scripts] +nisse-ui = "nisse_ui.main:main" + +[tool.uv.sources] +nisse-core = { workspace = true } + +[build-system] +requires = ["uv_build>=0.12,<0.13"] +build-backend = "uv_build" diff --git a/nisse-ui/src/nisse_ui/__init__.py b/nisse-ui/src/nisse_ui/__init__.py new file mode 100644 index 0000000..8c9456a --- /dev/null +++ b/nisse-ui/src/nisse_ui/__init__.py @@ -0,0 +1 @@ +"""nisse-ui: the operator's read-only view of the agents (architecture X6).""" diff --git a/nisse-ui/src/nisse_ui/app.py b/nisse-ui/src/nisse_ui/app.py new file mode 100644 index 0000000..7795cd4 --- /dev/null +++ b/nisse-ui/src/nisse_ui/app.py @@ -0,0 +1,230 @@ +"""The page's routes, as a plain function from a path to a response, so the +tests drive it without a socket (X6). + + / the sources: agents and kept runs + /s// a source's work items + /s//item/ an item's walkthrough ("-": the agent-level log) + /s//call/ one turn, read against its log + /s//event/ one log event in full +""" + +import json +import sqlite3 +from dataclasses import dataclass, field +from importlib import resources +from typing import Any +from urllib.parse import quote, unquote, urlsplit + +from jinja2 import Environment, PackageLoader, StrictUndefined + +from nisse_core.models import calls +from nisse_ui import walk +from nisse_ui.sources import Source, StateReader, UnknownSchema, call_record + +AGENT_LEVEL = "-" # the item id that stands for the agent-level log + +# Everything shown is untrusted text (L11): escaped by the templates, and +# the browser runs no script at all, loads nothing from elsewhere, and +# sends no referrer. +HEADERS = { + "Content-Security-Policy": ( + "default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; " + "form-action 'none'; frame-ancestors 'none'" + ), + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Cache-Control": "no-store", +} + + +@dataclass(frozen=True) +class Response: + status: int + body: bytes + content_type: str = "text/html; charset=utf-8" + headers: dict[str, str] = field(default_factory=lambda: dict(HEADERS)) + + +def _when(ts: Any) -> str: + return str(ts)[:19].replace("T", " ") if ts else "—" + + +def _num(n: Any) -> str: + return f"{n:,}" if isinstance(n, int) and not isinstance(n, bool) else "—" + + +def _pretty(text: Any) -> str: + """JSON text indented, anything else as it is.""" + if not isinstance(text, str): + return json.dumps(text, indent=2, ensure_ascii=False) + try: + return json.dumps(json.loads(text), indent=2, ensure_ascii=False) + except ValueError: + return text + + +def _clip(text: Any, limit: int = 300) -> str: + text = str(text or "") + return ( + text + if len(text) <= limit + else text[:limit] + f" … [{len(text) - limit:,} more]" + ) + + +def summary(kind: str, payload: dict[str, Any]) -> str: + """One event as a line of text: what it said or did.""" + match kind: + case "user_message" | "assistant_text" | "fact": + return str(payload.get("text", "")) + case "tool_call": + args = json.dumps(payload.get("arguments", {}), ensure_ascii=False) + return f"{payload.get('tool', '?')}({args})" + case "tool_result": + mark = "ok" if payload.get("success") else "FAILED" + return f"{mark}: {payload.get('result', '')}" + case "plan_submitted": + return str(payload.get("plan", "")) + case "gate_opened": + return f"{payload.get('gate', '')}: {payload.get('reason', '')}" + case "delegation_started": + return f"({payload.get('model', '')}) {payload.get('brief', '')}" + case "delegation_completed": + return f"[{payload.get('exit_status', '')}] {payload.get('handback', '')}" + case "compaction": + first, last = (payload.get("replaces") or ["?", "?"])[:2] + return f"replaces {first}–{last}: {payload.get('summary', '')}" + case "action_verdict": + return ( + f"{payload.get('tool', '')}: {payload.get('effect', '')} → " + f"{payload.get('decision', '')} ({payload.get('reason', '')})" + ) + case _: + return json.dumps(payload, ensure_ascii=False) + + +def _reply_line(reply: walk.Reply) -> str: + calls_made = "; ".join(f"{tool}({args})" for tool, args in reply.calls) + text = " ".join(reply.text.split()) + return " · ".join(p for p in (text, calls_made) if p) or "(no reply)" + + +class App: + def __init__(self, sources: list[Source]) -> None: + self.sources = list(sources) + self.by_name = {s.name: s for s in sources} + env = Environment( + loader=PackageLoader("nisse_ui", "templates"), + autoescape=True, # always: every page shows untrusted text + undefined=StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + ) + env.filters.update( + u=lambda s: quote(str(s), safe=""), + when=_when, + num=_num, + pretty=_pretty, + clip=_clip, + reply_line=_reply_line, + ) + env.globals.update(summary=summary, AGENT_LEVEL=AGENT_LEVEL) + self.env = env + + def page(self, template: str, status: int = 200, **values: Any) -> Response: + html = self.env.get_template(template).render(**values) + return Response(status, html.encode()) + + def problem(self, status: int, title: str, detail: str) -> Response: + return self.page("problem.html", status, title=title, detail=detail) + + def handle(self, raw_path: str) -> Response: + path = urlsplit(raw_path).path + parts = [unquote(p) for p in path.split("/")[1:]] + if parts in ([], [""]): + return self.home() + if parts == ["static", "style.css"]: + css = resources.files("nisse_ui").joinpath("static/style.css").read_bytes() + return Response(200, css, "text/css; charset=utf-8") + if len(parts) >= 2 and parts[0] == "s": + source = self.by_name.get(parts[1]) + if source is None: + return self.problem(404, "No such source", parts[1]) + rest = parts[2:] + try: + if rest in ([], [""]): + return self.items(source) + if len(rest) == 2 and rest[0] == "item": + item = None if rest[1] == AGENT_LEVEL else rest[1] + return self.item(source, item) + if ( + len(rest) == 2 + and rest[0] in ("call", "event") + and rest[1].isdigit() + ): + if rest[0] == "call": + return self.call(source, int(rest[1])) + return self.event(source, int(rest[1])) + except UnknownSchema as exc: + return self.problem( + 409, "A state store this page doesn't know", str(exc) + ) + except calls.UnknownLayout as exc: + return self.problem( + 409, "A call record this page doesn't know", str(exc) + ) + except sqlite3.Error as exc: + return self.problem(503, "Couldn't read the store", str(exc)) + return self.problem(404, "Not found", path) + + # --- pages --------------------------------------------------------------------- + + def home(self) -> Response: + rows = [] + for source in self.sources: + row: dict[str, Any] = {"source": source, "count": None, "note": ""} + try: + with StateReader(source.state_db) as state: + row["count"] = state.count_items() + row["schema"] = state.schema_version + except (UnknownSchema, sqlite3.Error) as exc: + row["note"] = str(exc) + row["record"] = source.calls_db.is_file() + rows.append(row) + return self.page("sources.html", rows=rows) + + def items(self, source: Source) -> Response: + with StateReader(source.state_db) as state: + return self.page( + "items.html", + source=source, + items=state.items(), + active=state.active_item(), + ) + + def item(self, source: Source, item_id: str | None) -> Response: + with StateReader(source.state_db) as state: + if item_id is not None and state.item(item_id) is None: + return self.problem(404, "No such work item", item_id) + try: + with call_record(source) as reader: + view = walk.walkthrough(state, reader, item_id) + except calls.UnknownLayout as exc: + view = walk.walkthrough(state, None, item_id, record_note=str(exc)) + return self.page("item.html", source=source, view=view) + + def call(self, source: Source, call_id: int) -> Response: + with StateReader(source.state_db) as state, call_record(source) as reader: + if reader is None: + return self.problem(404, "No call record", str(source.calls_db)) + view = walk.turn(state, reader, call_id) + if view is None: + return self.problem(404, "No such call", str(call_id)) + return self.page("call.html", source=source, view=view) + + def event(self, source: Source, event_id: int) -> Response: + with StateReader(source.state_db) as state: + found = state.event(event_id) + if found is None: + return self.problem(404, "No such event", str(event_id)) + return self.page("event.html", source=source, event=found) diff --git a/nisse-ui/src/nisse_ui/main.py b/nisse-ui/src/nisse_ui/main.py new file mode 100644 index 0000000..8f68ef5 --- /dev/null +++ b/nisse-ui/src/nisse_ui/main.py @@ -0,0 +1,70 @@ +"""Serve the status page (X6), read-only, on this machine only. + + nisse-ui --source dev=/var/lib/nisse-dev/state.db --runs bench-kept/ + +Each source is an agent's state store; its call record is found beside it. +``--runs`` names folders of runs the benchmark kept (``--keep-runs``). It +listens on 127.0.0.1: until it sits behind Authelia (M6.6d), reach it +through an SSH tunnel, since it shows everything the models saw. +""" + +import argparse +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from nisse_ui.app import App +from nisse_ui.sources import discover + + +def _source(text: str) -> tuple[str, Path]: + name, sep, path = text.partition("=") + if not sep or not name or not path: + raise argparse.ArgumentTypeError("expected NAME=STATE_DB") + return name, Path(path) + + +def handler_for(app: App) -> type[BaseHTTPRequestHandler]: + class Handler(BaseHTTPRequestHandler): + server_version = "nisse-ui" + sys_version = "" + + def do_GET(self) -> None: + response = app.handle(self.path) + self.send_response(response.status) + self.send_header("Content-Type", response.content_type) + self.send_header("Content-Length", str(len(response.body))) + for key, value in response.headers.items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(response.body) + + def log_message(self, format: str, *args: object) -> None: + sys.stderr.write(f"nisse-ui {self.address_string()} {format % args}\n") + + return Handler + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--source", action="append", type=_source, default=[], metavar="NAME=STATE_DB" + ) + parser.add_argument("--runs", action="append", type=Path, default=[], metavar="DIR") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args(argv) + app = App(discover(args.source, args.runs)) + server = ThreadingHTTPServer((args.host, args.port), handler_for(app)) + print(f"nisse-ui on http://{args.host}:{args.port}/", file=sys.stderr) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nisse-ui/src/nisse_ui/sources.py b/nisse-ui/src/nisse_ui/sources.py new file mode 100644 index 0000000..c5f7eae --- /dev/null +++ b/nisse-ui/src/nisse_ui/sources.py @@ -0,0 +1,229 @@ +"""Where the page reads from (X6): each agent's state store and the call +record beside it, and the runs the benchmark kept. Everything is opened +read-only (SQLite ``mode=ro``), one short connection per page, so the agent +stays the only writer of its state (L8) and nothing here can lock it.""" + +import contextlib +import json +import re +import sqlite3 +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Self + +from nisse_core.models import calls +from nisse_core.state import readonly + +# The state store schema versions this page knows: the shared store's, and +# each agent's that adds tables of its own. The page reads only the shared +# tables, but says so for any version it doesn't know rather than guessing. +KNOWN_SCHEMAS = {4: "nisse-core (nisse-review)", 5: "nisse-dev"} + + +class UnknownSchema(RuntimeError): + pass + + +@dataclass(frozen=True) +class Source: + name: str # what the page calls it, safe in a URL + state_db: Path + kind: str # "agent" | "run" (a kept benchmark run) + + @property + def calls_db(self) -> Path: + return calls.path_for(self.state_db) + + +def safe_name(text: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-") or "source" + + +def discover(agents: list[tuple[str, Path]], runs: list[Path]) -> list[Source]: + """The agents named on the command line, then each run kept under the + runs folders (a folder holding a ``state.db``), newest first.""" + found = [Source(safe_name(name), path, "agent") for name, path in agents] + for folder in runs: + kept = sorted( + (p for p in folder.glob("*/state.db") if p.is_file()), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + found += [ + Source(safe_name(f"{folder.name}.{p.parent.name}"), p, "run") for p in kept + ] + names: set[str] = set() + unique = [] + for source in found: + name, n = source.name, 2 + while name in names: + name, n = f"{source.name}-{n}", n + 1 + names.add(name) + unique.append(Source(name, source.state_db, source.kind)) + return unique + + +@dataclass(frozen=True) +class Item: + id: str + title: str + status: str + outcome: str | None + position: str + scope: str | None + opened_at: str + updated_at: str + closed_at: str | None + data: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Event: + id: int + ts: str + type: str + payload: dict[str, Any] + work_item: str | None + actor: str + + +def _loads(text: str | None) -> Any: + """Item data and payloads, read tolerantly: whatever doesn't parse is kept + as text, so a field from a newer agent never breaks the page.""" + if text is None: + return None + try: + return json.loads(text) + except (TypeError, ValueError): + return {"unreadable": text} + + +def connect(path: Path) -> sqlite3.Connection: + return readonly.connect(path) + + +class StateReader: + """A state store, read-only. Use as a context manager: it closes at the + end of the page that opened it.""" + + def __init__(self, path: Path) -> None: + self.path = path + self._conn = connect(path) + try: + row = self._conn.execute( + "SELECT value FROM meta WHERE key = 'schema_version'" + ).fetchone() + except sqlite3.Error as exc: + self._conn.close() + raise UnknownSchema(f"not a state store: {exc}") from exc + self.schema_version = int(row["value"]) if row else 0 + if self.schema_version not in KNOWN_SCHEMAS: + self._conn.close() + known = ", ".join(str(v) for v in sorted(KNOWN_SCHEMAS)) + raise UnknownSchema( + f"state store schema {self.schema_version}, not known to this " + f"page (it knows {known})" + ) + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def close(self) -> None: + self._conn.close() + + def active_item(self) -> str | None: + row = self._conn.execute( + "SELECT active_item FROM agent WHERE id = 1" + ).fetchone() + return row["active_item"] if row else None + + def items(self) -> list[Item]: + rows = self._conn.execute( + "SELECT * FROM work_items ORDER BY opened_at DESC, id" + ).fetchall() + return [self._item(r) for r in rows] + + def item(self, item_id: str) -> Item | None: + row = self._conn.execute( + "SELECT * FROM work_items WHERE id = ?", (item_id,) + ).fetchone() + return self._item(row) if row else None + + def _item(self, row: sqlite3.Row) -> Item: + data = _loads(row["data"]) + return Item( + id=row["id"], + title=row["title"], + status=row["status"], + outcome=row["outcome"], + position=row["position"], + scope=row["scope"], + opened_at=row["opened_at"], + updated_at=row["updated_at"], + closed_at=row["closed_at"], + data=data if isinstance(data, dict) else {"unreadable": row["data"]}, + ) + + def events(self, work_item: str | None) -> list[Event]: + """One item's log, or the agent-level log (None).""" + if work_item is None: + rows = self._conn.execute( + "SELECT * FROM canonical_events WHERE work_item IS NULL ORDER BY id" + ) + else: + rows = self._conn.execute( + "SELECT * FROM canonical_events WHERE work_item = ? ORDER BY id", + (work_item,), + ) + return [self._event(r) for r in rows] + + def event(self, event_id: int) -> Event | None: + row = self._conn.execute( + "SELECT * FROM canonical_events WHERE id = ?", (event_id,) + ).fetchone() + return self._event(row) if row else None + + def events_by_id(self, ids: list[int]) -> dict[int, Event]: + found: dict[int, Event] = {} + wanted = sorted({i for i in ids if i > 0}) + for start in range(0, len(wanted), 500): + chunk = wanted[start : start + 500] + marks = ",".join("?" * len(chunk)) + for row in self._conn.execute( + f"SELECT * FROM canonical_events WHERE id IN ({marks})", chunk + ): + found[row["id"]] = self._event(row) + return found + + def count_items(self) -> int: + return self._conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0] + + def _event(self, row: sqlite3.Row) -> Event: + payload = _loads(row["payload"]) + return Event( + id=row["id"], + ts=row["ts"], + type=row["type"], + payload=payload if isinstance(payload, dict) else {"value": payload}, + work_item=row["work_item"], + actor=row["actor"], + ) + + +@contextlib.contextmanager +def call_record(source: Source) -> Iterator[calls.CallReader | None]: + """The source's call record, or None when there is none (an agent + started before M6.6a, or recording turned off). An unknown layout raises + ``calls.UnknownLayout`` for the page to report.""" + if not source.calls_db.is_file(): + yield None + return + reader = calls.CallReader(source.calls_db) + try: + yield reader + finally: + reader.close() diff --git a/nisse-ui/src/nisse_ui/static/style.css b/nisse-ui/src/nisse_ui/static/style.css new file mode 100644 index 0000000..e31fde6 --- /dev/null +++ b/nisse-ui/src/nisse_ui/static/style.css @@ -0,0 +1,77 @@ +/* nisse-ui: plain, readable, no external assets (X6). */ +:root { + --bg: #fbfbfa; --fg: #1d1d1b; --muted: #6b6b66; --line: #deded8; + --panel: #f2f2ee; --accent: #2f5d8a; --bad: #a3322a; --warn: #8a5a00; + --new: #e3f0e0; --change: #fbe6c8; --flag: #f6d9d6; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #161615; --fg: #e6e6e1; --muted: #9a9a93; --line: #34342f; + --panel: #20201e; --accent: #8fb8e0; --bad: #ec8a80; --warn: #e2b462; + --new: #233523; --change: #3d2f17; --flag: #432522; + } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; } +main { max-width: 1100px; margin: 0 auto; padding: 0 16px 64px; } +a { color: var(--accent); } +h1 { font-size: 1.5rem; margin: 1.2rem 0 .4rem; } +h2 { font-size: 1.15rem; margin: 1.8rem 0 .6rem; border-bottom: 1px solid var(--line); } +h3 { font-size: 1rem; margin: 1.2rem 0 .4rem; } +.top { border-bottom: 1px solid var(--line); background: var(--panel); } +.crumbs { max-width: 1100px; margin: 0 auto; padding: 8px 16px; } +.muted, .meta { color: var(--muted); } +.meta { font-size: .9rem; } +.bad { color: var(--bad); } +.path { font-family: var(--mono); font-size: .85rem; word-break: break-all; } +pre, code { font-family: var(--mono); font-size: .85rem; } +pre { background: var(--panel); border: 1px solid var(--line); border-radius: 4px; + padding: 8px 10px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; margin: .4rem 0; } +table { border-collapse: collapse; width: 100%; margin: .6rem 0; font-size: .92rem; } +th, td { text-align: left; vertical-align: top; padding: 4px 8px; border-bottom: 1px solid var(--line); } +th { color: var(--muted); font-weight: 600; } +tr.active td { background: var(--new); } +table.facts th { width: 14%; white-space: nowrap; } +table.log td.text { font-family: var(--mono); font-size: .8rem; word-break: break-word; } +details { margin: .4rem 0; } +summary { cursor: pointer; } +.tag { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px; + background: var(--panel); border: 1px solid var(--line); margin-right: 2px; } +.tag.warn { color: var(--warn); } +.flag { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px; + background: var(--flag); color: var(--bad); margin-left: 4px; } +.badge { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px; } +.badge.new { background: var(--new); } +.badge.change { background: var(--change); } +.note { background: var(--panel); border-left: 3px solid var(--accent); padding: 6px 10px; } +.circle { background: var(--flag); color: var(--bad); padding: 4px 10px; border-radius: 4px; } +.timeline > * { margin: .5rem 0; } +.step { border-left: 3px solid var(--line); padding: 2px 10px; } +.step .text { white-space: pre-wrap; word-break: break-word; } +.step-user_message { border-left-color: var(--accent); } +.step-gate_opened, .step-fact { border-left-color: var(--warn); } +.run { border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; background: var(--bg); } +.run .run { margin: .4rem 0 .4rem 12px; } +.run > summary { padding: 4px 0; } +.turn { padding: 4px 0 4px 8px; border-top: 1px dashed var(--line); } +.turn.flagged { background: var(--flag); } +.turn-no { font-weight: 600; margin-right: 6px; } +.turn .reply { font-family: var(--mono); font-size: .82rem; color: var(--muted); word-break: break-word; } +.told { font-size: .82rem; color: var(--warn); } +.pager { display: flex; justify-content: space-between; margin-top: 1rem; } +.msg { border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; margin: .6rem 0; } +.msg.first-change { border-color: var(--warn); border-width: 2px; } +.msg-head { font-size: .88rem; } +.msg-head .role { font-weight: 700; text-transform: uppercase; letter-spacing: .03em; margin-right: 4px; } +.role-user { border-left: 4px solid var(--accent); } +.role-assistant { border-left: 4px solid #6a8f4e; } +.role-tool { border-left: 4px solid var(--muted); } +.role-system { border-left: 4px solid var(--warn); } +ul.from { margin: .3rem 0; padding-left: 1.2rem; font-size: .85rem; } +ul.from .text { font-family: var(--mono); font-size: .8rem; color: var(--muted); } +.toolcall { margin: .3rem 0; } +@media (max-width: 640px) { + table.facts th, table.facts td { display: block; width: auto; border: 0; } +} diff --git a/nisse-ui/src/nisse_ui/templates/base.html b/nisse-ui/src/nisse_ui/templates/base.html new file mode 100644 index 0000000..bed8d18 --- /dev/null +++ b/nisse-ui/src/nisse_ui/templates/base.html @@ -0,0 +1,17 @@ + + + + + +{% block title %}Agents{% endblock %} · nisse-ui + + + +
+ +
+
+{% block main %}{% endblock %} +
+ + diff --git a/nisse-ui/src/nisse_ui/templates/call.html b/nisse-ui/src/nisse_ui/templates/call.html new file mode 100644 index 0000000..a95f994 --- /dev/null +++ b/nisse-ui/src/nisse_ui/templates/call.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} +{% from "macros.html" import src_link, item_href, event_link, flags %} +{% set c = view.call %} +{% block title %}Turn {{ view.number }}{% endblock %} +{% block crumbs %} › {{ src_link(source) }} › {{ c.work_item or "agent" }} › {% if view.run %}{{ view.run.kind }} {{ view.run.role }} › {% endif %}turn {{ view.number }}{% endblock %} +{% block main %} + +

Turn {{ view.number }}{% if c.purpose %}: {{ c.purpose }}{% endif %} {{ flags(view.flags) }}

+ + + + + + + {{ c.outcome }} +
Role{{ c.role or "—" }}Time{{ c.at|when }}, {{ "%.2f"|format(c.seconds) }}s
Model asked{{ c.model }}Answered by{{ c.answered_by or "—" }}
Tokens{{ c.tokens_in|num }} in · {{ c.tokens_out|num }} outPrompt reuse (server){{ view.reused or "not reported" }}
Estimate{{ c.estimate|num }} tokens (factor {{ c.factor }})Route{{ c.route or "—" }}, HTTP {{ c.status or "—" }}
OutcomeFitting{% if c.trim %}{{ c.trim|pretty }}{% else %}nothing cut{% endif %}
+{% if c.error %}
{{ c.error }}
{% endif %} + +

The reply

+{% if view.reply.reasoning %} +
Reasoning ({{ view.reply.reasoning|length|num }} characters)
{{ view.reply.reasoning }}
+{% endif %} +{% if view.reply.text %}
{{ view.reply.text }}
{% endif %} +{% for tool, args in view.reply.calls %} +
tool call {{ tool }}
{{ args|pretty }}
+{% endfor %} +{% if not view.reply.text and not view.reply.calls and not view.reply.reasoning %}

No reply recorded.

{% endif %} +{% if view.reply.finish %}

Finish reason: {{ view.reply.finish }}

{% endif %} + +

The request

+{% if view.settings %} +{% for k, v in view.settings.items() %}{% endfor %}
{{ k }}{{ v|pretty }}
+{% endif %} +{% if view.tools %} +
{{ view.tools|length }} tools offered{% if view.tools_changed %} changed since the turn before{% endif %} +

{{ view.tools|join(", ") }}

+{% endif %} +{% if view.compared %} +

Messages marked new weren't in the turn before. first change marks where this request first differs from the last one: prompt reuse can't go past it. The server reused {{ view.reused or "an unreported number of" }} tokens.

+{% endif %} +{% if view.match_note %}

{{ view.match_note }}

{% endif %} + +{% for m in view.messages %} +
+
+ {{ m.role }}{% if m.index >= 0 %} #{{ m.index }}{% endif %} + {% if m.new %}new{% endif %} + {% if m.first_change %}first change{% endif %} + {% if m.tool_call_id %}result of {{ m.tool_call_id }}{% endif %} + {{ m.text|length|num }} characters +
+ {% if m.role == "system" %} +
System prompt
{{ m.text }}
+ {% elif m.text %}
{{ m.text }}
{% endif %} + {% for id, tool, args in m.tool_calls %} +
tool call {{ tool }} {{ id }}
{{ args|pretty }}
+ {% endfor %} + {% if m.events %} +
    + {% for s in m.events %}
  • from {{ event_link(source, s.id) }} {{ s.type|replace("_", " ") }} by {{ s.actor }}: {% for t in s.tags %}{{ t }}{% endfor %}
  • {% endfor %} +
+ {% endif %} +
+{% endfor %} + +{% if view.unmatched %} +

Events sent{% if view.match_note %}, not matched to messages{% else %} but replaced by a compaction{% endif %}

+
    +{% for s in view.unmatched %}
  • {{ event_link(source, s.id) }} {{ s.type|replace("_", " ") }} by {{ s.actor }}: {% for t in s.tags %}{{ t }}{% endfor %} +
    {{ summary(s.type, s.payload if s.payload is mapping else {})|clip(300) }}
  • {% endfor %} +
+{% endif %} + +{% if view.compacted %} +

Replaced by compaction

+

{{ view.compacted|length }} events of the log stood behind a compaction in this request: +{% for e, comp in view.compacted %}{{ event_link(source, e) }}{% if comp %} → {{ event_link(source, comp) }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}

+{% endif %} + +{% if view.dropped %} +

Dropped by fitting

+
    +{% for id, e in view.dropped %}
  • {{ event_link(source, id) }}{% if e %} {{ e.type|replace("_", " ") }} by {{ e.actor }}: {{ summary(e.type, e.payload)|clip(200) }}{% else %} (not in this store){% endif %}
  • {% endfor %} +
+{% endif %} + +

Raw

+
Request JSON, exactly as sent ({{ view.raw_request|length|num }} characters)
{{ view.raw_request or "(not recorded)" }}
+
Response JSON, as received ({{ view.raw_response|length|num }} characters)
{{ view.raw_response or "(not recorded)" }}
+{% endblock %} diff --git a/nisse-ui/src/nisse_ui/templates/event.html b/nisse-ui/src/nisse_ui/templates/event.html new file mode 100644 index 0000000..026ef83 --- /dev/null +++ b/nisse-ui/src/nisse_ui/templates/event.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% from "macros.html" import src_link, item_href %} +{% block title %}Event {{ event.id }}{% endblock %} +{% block crumbs %} › {{ src_link(source) }} › {{ event.work_item or "agent" }} › event {{ event.id }}{% endblock %} +{% block main %} +

Event #{{ event.id }}: {{ event.type|replace("_", " ") }}

+

{{ event.ts|when }} · by {{ event.actor }} · work item {{ event.work_item or "(agent-level)" }}

+
{{ summary(event.type, event.payload) }}
+
Payload
{{ event.payload|pretty }}
+{% endblock %} diff --git a/nisse-ui/src/nisse_ui/templates/item.html b/nisse-ui/src/nisse_ui/templates/item.html new file mode 100644 index 0000000..25c100e --- /dev/null +++ b/nisse-ui/src/nisse_ui/templates/item.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% from "macros.html" import src_link, run_node, turn_row, step, event_link %} +{% set title = view.item.title if view.item else "Agent-level log" %} +{% block title %}{{ title }}{% endblock %} +{% block crumbs %} › {{ src_link(source) }} › {{ view.item.id if view.item else "agent" }}{% endblock %} +{% block main %} +

{{ title }}

+{% if view.item %} +

{{ view.item.id }} · {{ view.item.status }}{% if view.item.outcome %} ({{ view.item.outcome }}){% endif %} + · position {{ view.item.position }} · opened {{ view.item.opened_at|when }}{% if view.item.closed_at %} · closed {{ view.item.closed_at|when }}{% endif %}

+
Item data
{{ view.item.data|pretty }}
+{% endif %} + +{% if view.record_note %}

{{ view.record_note }}

+{% elif view.before_record %}

Recording began {{ view.started_at|when }}: steps before then have no requests.

{% endif %} + +

Steps

+
+{% for kind, obj in view.timeline %} + {% if kind == "event" %}{{ step(source, obj) }} + {% elif kind == "run" %}{{ run_node(source, obj) }} + {% else %}{{ turn_row(source, obj) }}{% endif %} +{% else %} +

Nothing recorded yet.

+{% endfor %} +
+ +

The log

+ + All {{ view.log|length }} events, as stored + + {% for e in view.log %} + + {% endfor %} +
{{ event_link(source, e.id) }}{{ e.ts|when }}{{ e.actor }}{{ e.type }}{{ summary(e.type, e.payload)|clip(200) }}
+ +{% endblock %} diff --git a/nisse-ui/src/nisse_ui/templates/items.html b/nisse-ui/src/nisse_ui/templates/items.html new file mode 100644 index 0000000..7d80a3b --- /dev/null +++ b/nisse-ui/src/nisse_ui/templates/items.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% from "macros.html" import item_href %} +{% block title %}{{ source.name }}{% endblock %} +{% block crumbs %} › {{ source.name }}{% endblock %} +{% block main %} +

{{ source.name }}

+

{{ source.state_db }}

+

Agent-level log: what happened outside any work item.

+ + + + {% for item in items %} + + + + + + + + + {% else %} + + {% endfor %} + +
Work itemTitleStatusPositionOpenedUpdated
{{ item.id }}{% if item.id == active %} active{% endif %}{{ item.title }}{{ item.status }}{% if item.outcome %} ({{ item.outcome }}){% endif %}{{ item.position }}{{ item.opened_at|when }}{{ item.updated_at|when }}
No work items.
+{% endblock %} diff --git a/nisse-ui/src/nisse_ui/templates/macros.html b/nisse-ui/src/nisse_ui/templates/macros.html new file mode 100644 index 0000000..e1ba2c8 --- /dev/null +++ b/nisse-ui/src/nisse_ui/templates/macros.html @@ -0,0 +1,48 @@ +{% macro src_link(source) %}{{ source.name }}{% endmacro %} + +{% macro item_href(source, item_id) %}/s/{{ source.name|u }}/item/{{ (item_id if item_id is not none else AGENT_LEVEL)|u }}{% endmacro %} + +{% macro event_link(source, id) %}{% if id > 0 %}#{{ id }}{% else %}(no id){% endif %}{% endmacro %} + +{% macro flags(names) %}{% for f in names %}{{ f }}{% endfor %}{% endmacro %} + +{% macro turn_row(source, t) %} +
+ turn {{ t.number }} + + {% if t.call.purpose %}{{ t.call.purpose }}{% endif %} + {{ t.call.role or "" }} · {{ t.call.model }}{% if t.call.answered_by and t.call.answered_by != t.call.model %} → {{ t.call.answered_by }}{% endif %} + · {{ "%.1f"|format(t.call.seconds) }}s · {{ t.call.tokens_in|num }} in / {{ t.call.tokens_out|num }} out + {% if t.reused %} · reused {{ t.reused }}{% endif %} + {% if t.call.outcome != "answered" %} · {{ t.call.outcome }}{% endif %} + + {{ flags(t.flags) }} + {% for told in t.told %}
orchestrator: {{ told|clip(160) }}
{% endfor %} +
{{ t.reply|reply_line|clip(240) }}
+
+{% endmacro %} + +{% macro run_node(source, node) %} +
+ + {{ node.run.kind }} {{ node.run.role }} + · {{ node.run.outcome or "running" }} + · {{ node.run.started_at|when }} → {{ node.run.ended_at|when }} + · {{ node.turns|length }} turns + {% if node.run.note %} · {{ node.run.note|clip(160) }}{% endif %} + + {% for c in node.circles %} +

Going in circles: {{ c.kind }} at turns {% for i in c.turns %}{{ i + 1 }}{% if not loop.last %}, {% endif %}{% endfor %}: {{ c.detail }}

+ {% endfor %} + {% for kind, obj in node.entries %} + {% if kind == "turn" %}{{ turn_row(source, obj) }}{% elif kind == "event" %}{{ step(source, obj) }}{% else %}{{ run_node(source, obj) }}{% endif %} + {% endfor %} +
+{% endmacro %} + +{% macro step(source, e) %} +
+ {{ event_link(source, e.id) }} {{ e.ts|when }} · {{ e.actor }} · {{ e.type|replace("_", " ") }} +
{{ summary(e.type, e.payload)|clip(600) }}
+
+{% endmacro %} diff --git a/nisse-ui/src/nisse_ui/templates/problem.html b/nisse-ui/src/nisse_ui/templates/problem.html new file mode 100644 index 0000000..72616be --- /dev/null +++ b/nisse-ui/src/nisse_ui/templates/problem.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block main %} +

{{ title }}

+

{{ detail }}

+{% endblock %} diff --git a/nisse-ui/src/nisse_ui/templates/sources.html b/nisse-ui/src/nisse_ui/templates/sources.html new file mode 100644 index 0000000..6107547 --- /dev/null +++ b/nisse-ui/src/nisse_ui/templates/sources.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% from "macros.html" import src_link %} +{% block title %}Sources{% endblock %} +{% block main %} +

Agents and recorded runs

+{% if not rows %}

No sources. Start with --source NAME=STATE_DB or --runs DIR.

{% endif %} + + + + {% for r in rows %} + + + + + + + + {% endfor %} + +
SourceKindWork itemsCall recordState store
{{ src_link(r.source) }}{{ r.source.kind }}{% if r.count is not none %}{{ r.count }}{% else %}{{ r.note }}{% endif %}{{ "yes" if r.record else "none" }}{{ r.source.state_db }}
+{% endblock %} diff --git a/nisse-ui/src/nisse_ui/walk.py b/nisse-ui/src/nisse_ui/walk.py new file mode 100644 index 0000000..06f2fd1 --- /dev/null +++ b/nisse-ui/src/nisse_ui/walk.py @@ -0,0 +1,543 @@ +"""What the pages show, built from the two stores (X6): an item's +walkthrough (its steps, runs and turns) and one turn read against the log it +was made from. Pure reading: nothing here writes.""" + +import json +from dataclasses import dataclass, field +from typing import Any + +from nisse_core.log.canonical import StoredEvent, from_payload +from nisse_core.loop.circles import Circle, Turn, find_circles +from nisse_core.models import calls +from nisse_core.models.render import OpenAIChatRenderer +from nisse_ui.sources import Event, Item, StateReader + +# The log events shown as steps between runs: what people and the +# orchestrator said and decided. A model's own turns are shown as turns. +STEP_TYPES = { + "user_message", + "fact", + "gate_opened", + "plan_submitted", + "delegation_started", + "delegation_completed", + "compaction", +} +_TURN_TYPES = {"assistant_text", "tool_call", "tool_result", "action_verdict"} + + +# --- a model's reply ------------------------------------------------------------ + + +@dataclass(frozen=True) +class Reply: + text: str = "" + reasoning: str = "" + calls: tuple[tuple[str, str], ...] = () # (tool, arguments as sent) + finish: str | None = None + + +def _text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and isinstance(part.get("text"), str): + parts.append(part["text"]) + else: + parts.append(json.dumps(part, ensure_ascii=False)) + return "\n".join(parts) + return json.dumps(content, ensure_ascii=False) + + +def _arguments(value: Any) -> str: + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) + + +def reply_of(response: Any) -> Reply: + """The reply in a raw response, from either route: the OpenAI-shaped chat + route or Anthropic's messages route. Anything else is no reply.""" + if not isinstance(response, dict): + return Reply() + choices = response.get("choices") + if isinstance(choices, list) and choices and isinstance(choices[0], dict): + message = choices[0].get("message") or {} + reasoning = message.get("reasoning_content") or message.get("reasoning") or "" + tool_calls = [] + for call in message.get("tool_calls") or []: + function = (call.get("function") or {}) if isinstance(call, dict) else {} + tool_calls.append( + (str(function.get("name", "?")), _arguments(function.get("arguments"))) + ) + return Reply( + text=_text(message.get("content")), + reasoning=_text(reasoning), + calls=tuple(tool_calls), + finish=choices[0].get("finish_reason"), + ) + blocks = response.get("content") + if isinstance(blocks, list): + texts, thinking, tool_calls = [], [], [] + for block in blocks: + if not isinstance(block, dict): + continue + match block.get("type"): + case "text": + texts.append(str(block.get("text", ""))) + case "thinking": + thinking.append(str(block.get("thinking", ""))) + case "tool_use": + tool_calls.append( + (str(block.get("name", "?")), _arguments(block.get("input"))) + ) + return Reply( + text="\n".join(texts), + reasoning="\n".join(thinking), + calls=tuple(tool_calls), + finish=response.get("stop_reason"), + ) + return Reply() + + +# --- an item's walkthrough -------------------------------------------------------- + + +@dataclass +class TurnRow: + """One call, as a line of the walkthrough.""" + + call: dict[str, Any] + number: int # within its run, from 1 + reply: Reply + told: tuple[str, ...] # corrections given since the turn before + flags: list[str] = field(default_factory=list) + + @property + def reused(self) -> str | None: + return reuse(self.call) + + +@dataclass +class RunNode: + run: dict[str, Any] + entries: list[tuple[str, Any]] = field(default_factory=list) # turns, runs + turns: list[TurnRow] = field(default_factory=list) + circles: list[Circle] = field(default_factory=list) + + +@dataclass +class Walkthrough: + item: Item | None # None: the agent-level log + timeline: list[ + tuple[str, Any] + ] # ("event", Event) | ("run", RunNode) | ("turn", TurnRow) + log: list[Event] + record_note: str | None # why there are no requests, when there aren't + started_at: str | None + before_record: bool # some of this work happened before recording began + + +def reuse(call: dict[str, Any]) -> str | None: + """Prompt reuse as the server counted it: cached tokens of the prompt.""" + cached, fresh = call.get("cache_n"), call.get("prompt_n") + if isinstance(cached, int) and isinstance(fresh, int): + return f"{cached:,} of {cached + fresh:,}" + return None + + +def _corrections( + reader: calls.CallReader, call: dict[str, Any], before: set[int] +) -> tuple[str, ...]: + """The orchestrator's statements first sent in this call: a correction + is appended to the log each time it's given, so it is new each time.""" + told = [] + for event_id, actor, kind, digest in call["sent"]: + if kind == "fact" and actor == "orchestrator" and event_id not in before: + try: + told.append(str(reader.blob(digest).get("text", ""))) + except (KeyError, AttributeError): + continue + return tuple(told) + + +def _response(reader: calls.CallReader, call: dict[str, Any]) -> Any: + try: + return reader.response(call) + except KeyError: + return None + + +def walkthrough( + state: StateReader, + reader: calls.CallReader | None, + item_id: str | None, + *, + record_note: str | None = None, +) -> Walkthrough: + item = state.item(item_id) if item_id is not None else None + log = state.events(item_id) + steps = [e for e in log if e.type in STEP_TYPES or e.type not in _TURN_TYPES] + timeline: list[tuple[str, str, Any]] = [] + started_at = None + if reader is None: + record_note = record_note or "No call record: requests weren't recorded." + else: + started_at = reader.started_at + nodes = { + r["id"]: RunNode(r) for r in reader.runs() if r["work_item"] == item_id + } + seen: dict[str | None, set[int]] = {} + numbers: dict[str | None, int] = {} + loose: list[TurnRow] = [] + for call in reader.calls_for(item_id): + run = call["run"] if call["run"] in nodes else None + before = seen.setdefault(run, set()) + numbers[run] = numbers.get(run, 0) + 1 + row = TurnRow( + call=call, + number=numbers[run], + reply=reply_of(_response(reader, call)), + told=_corrections(reader, call, before), + ) + before.update(entry[0] for entry in call["sent"]) + if run is None: + loose.append(row) + else: + nodes[run].turns.append(row) + # A step taken while a run was going on (a correction, a fact) is + # shown inside the innermost run it happened during. + inside: dict[str, list[tuple[str, str, Any]]] = {} + for e in steps: + during = [n for n in nodes.values() if _during(n.run, e.ts)] + if during: + innermost = max(during, key=lambda n: n.run["started_at"]) + inside.setdefault(innermost.run["id"], []).append(("event", e.ts, e)) + else: + timeline.append(("event", e.ts, e)) + steps = [] + for node in nodes.values(): + node.circles = find_circles( + [Turn(t.reply.text, t.reply.calls, t.told) for t in node.turns] + ) + for circle in node.circles: + for i in circle.turns: + node.turns[i].flags.append(circle.kind) + children = [n for n in nodes.values() if n.run["parent"] == node.run["id"]] + entries = [("turn", t.call["at"], t) for t in node.turns] + entries += [("run", c.run["started_at"], c) for c in children] + entries += inside.get(node.run["id"], []) + node.entries = [(kind, obj) for kind, _, obj in sorted(entries, key=_when)] + for node in nodes.values(): + if node.run["parent"] not in nodes: + timeline.append(("run", node.run["started_at"], node)) + timeline += [("turn", t.call["at"], t) for t in loose] + timeline += [("event", e.ts, e) for e in steps] + ordered = [(kind, obj) for kind, _, obj in sorted(timeline, key=_when)] + before_record = reader is None or ( + started_at is not None and bool(log) and log[0].ts < started_at + ) + return Walkthrough(item, ordered, log, record_note, started_at, before_record) + + +def _during(run: dict[str, Any], ts: str) -> bool: + ended = run.get("ended_at") + return run["started_at"] <= ts and (ended is None or ts <= ended) + + +def _when(entry: tuple[str, str, Any]) -> str: + return entry[1] or "" + + +# --- one turn, read against its log ----------------------------------------------- + + +@dataclass +class SentEvent: + id: int + actor: str + type: str + payload: Any + tags: list[str] + log: Event | None + + +@dataclass +class Message: + index: int + role: str + text: str + tool_calls: list[tuple[str, str, str]] # (id, tool, arguments) + tool_call_id: str | None + events: list[SentEvent] = field(default_factory=list) + new: bool = False # not in the turn before + first_change: bool = False # where this request first differs from the last + + +@dataclass +class TurnView: + call: dict[str, Any] + number: int + run: dict[str, Any] | None + previous: int | None + following: int | None + request: dict[str, Any] | None + response: Any + reply: Reply + messages: list[Message] + unmatched: list[SentEvent] # sent events not matched to a message + match_note: str | None + dropped: list[tuple[int, Event | None]] + compacted: list[tuple[int, int | None]] + tools: list[str] + tools_changed: bool + settings: dict[str, Any] # the request's other fields: tool_choice, limits + compared: bool # whether there was a turn before to compare with + flags: list[str] + raw_request: str + raw_response: str + + @property + def reused(self) -> str | None: + return reuse(self.call) + + +def _fields(call: dict[str, Any] | None) -> dict[str, Any]: + if not call or not call.get("request"): + return {} + try: + return dict(json.loads(call["request"])["fields"]) + except (ValueError, KeyError, TypeError): + return {} + + +def _tags( + entry: list[Any], payload: Any, log: Event | None, call: dict[str, Any] +) -> list[str]: + event_id, _, kind, _ = entry + tags: list[str] = [] + cut = call["shortened"].get(str(event_id)) + if event_id == 0: + tags.append("made for this call, not in the log") + elif kind == "compaction" and isinstance(payload, dict): + first, last = (payload.get("replaces") or [None, None])[:2] + tags.append(f"compaction of events {first}–{last}") + elif log is None: + tags.append("not in this state store") + elif log.payload == payload and log.type == kind: + tags.append("unchanged") + else: + # A role's view says another's work as a labelled statement (L6): + # a tool step narrated, a fact or someone's message labelled. + source = payload.get("source", "") if isinstance(payload, dict) else "" + viewed = kind == "user_message" and str(source).startswith("view:") + was = f"{log.type.replace('_', ' ')} by {log.actor}" + if viewed and log.type in ("tool_call", "tool_result"): + tags.append(f"narrated from {was}") + elif viewed: + tags.append( + "labelled for this role" + + ("" if log.type == "user_message" else f" (a {was})") + ) + elif log.type != kind: + tags.append(f"rewritten from {was}") + elif cut is None: + tags.append("changed for this call") + if cut is not None: + tags.append(f"shortened by {int(cut):,} characters") + return tags + + +def turn(state: StateReader, reader: calls.CallReader, call_id: int) -> TurnView | None: + call = reader.call(call_id) + if call is None: + return None + run = next((r for r in reader.runs() if r["id"] == call["run"]), None) + siblings = ( + reader.calls(run=call["run"]) + if call["run"] + else [c for c in reader.calls_for(call["work_item"]) if not c["run"]] + ) + at = next(i for i, c in enumerate(siblings) if c["id"] == call_id) + before = siblings[at - 1] if at > 0 else None + after = siblings[at + 1] if at + 1 < len(siblings) else None + + try: + request = reader.request(call) + except KeyError: + request = None + response = _response(reader, call) + reply = reply_of(response) + + logged = state.events_by_id([entry[0] for entry in call["sent"]]) + dropped = state.events_by_id(call["dropped"]) + sent: list[SentEvent] = [] + for entry in call["sent"]: + try: + payload = reader.blob(entry[3]) + except KeyError: + payload = None + log = logged.get(entry[0]) + sent.append( + SentEvent( + entry[0], + entry[1], + entry[2], + payload, + _tags(entry, payload, log, call), + log, + ) + ) + + raw_messages = (request or {}).get("messages") + messages: list[Message] = [] + if isinstance(raw_messages, list): + for i, m in enumerate(raw_messages): + m = m if isinstance(m, dict) else {"role": "?", "content": m} + calls_made = [] + for c in m.get("tool_calls") or []: + function = (c.get("function") or {}) if isinstance(c, dict) else {} + calls_made.append( + ( + str(c.get("id", "")) if isinstance(c, dict) else "", + str(function.get("name", "?")), + _arguments(function.get("arguments")), + ) + ) + if isinstance(m.get("content"), list): # Anthropic content blocks + for block in m["content"]: + if isinstance(block, dict) and block.get("type") == "tool_use": + calls_made.append( + ( + str(block.get("id", "")), + str(block.get("name", "?")), + _arguments(block.get("input")), + ) + ) + messages.append( + Message( + index=i, + role=str(m.get("role", "?")), + text=_text(m.get("content")), + tool_calls=calls_made, + tool_call_id=m.get("tool_call_id"), + ) + ) + + unmatched, note = _match(call, request, sent, messages) + + mine, theirs = _fields(call), _fields(before) + compared = bool(theirs) + if compared: + old = theirs.get("messages") or [] + new = mine.get("messages") or [] + old_set = set(old) + for message, digest in zip(messages, new, strict=False): + message.new = digest not in old_set + change = next( + (i for i, (a, b) in enumerate(zip(old, new, strict=False)) if a != b), + min(len(old), len(new)), + ) + if change < len(messages): + messages[change].first_change = True + + tools_raw = (request or {}).get("tools") or [] + tools = [] + for t in tools_raw if isinstance(tools_raw, list) else []: + if isinstance(t, dict): + tools.append( + str((t.get("function") or {}).get("name") or t.get("name", "?")) + ) + settings = { + k: v + for k, v in (request or {}).items() + if k not in ("messages", "tools", "system") + } + if isinstance(request, dict) and "system" in request: + messages.insert( + 0, + Message( + index=-1, + role="system", + text=_text(request["system"]), + tool_calls=[], + tool_call_id=None, + ), + ) + + flags = [] + if run is not None: + node_turns = [ + Turn(r.text, r.calls, ()) + for r in (reply_of(_response(reader, c)) for c in siblings) + ] + flags = sorted({c.kind for c in find_circles(node_turns) if at in c.turns}) + + return TurnView( + call=call, + number=at + 1, + run=run, + previous=before["id"] if before else None, + following=after["id"] if after else None, + request=request, + response=response, + reply=reply, + messages=messages, + unmatched=unmatched, + match_note=note, + dropped=[(i, dropped.get(i)) for i in call["dropped"]], + compacted=[(int(k), v) for k, v in call["compacted"].items()], + tools=tools, + tools_changed=compared and mine.get("tools") != theirs.get("tools"), + settings=settings, + compared=compared, + flags=flags, + raw_request=json.dumps(request, indent=2, ensure_ascii=False) + if request is not None + else "", + raw_response=json.dumps(response, indent=2, ensure_ascii=False) + if response is not None + else "", + ) + + +def _match( + call: dict[str, Any], + request: dict[str, Any] | None, + sent: list[SentEvent], + messages: list[Message], +) -> tuple[list[SentEvent], str | None]: + """Each sent event beside the message it went into, found with the same + renderer that built the request; when that can't be done exactly, the + events are listed apart, with why.""" + if not sent: + return [], None + if request is None: + return sent, "The request wasn't recorded (the call failed before it)." + if call.get("route") != "chat": + return sent, "Messages-route requests are shown beside their events, unmatched." + try: + events = [ + StoredEvent(id=s.id, ts="", event=from_payload(s.type, s.payload)) + for s in sent + ] + except (ValueError, TypeError): + return sent, "Some events didn't read as this version's event types." + first = messages[0] if messages else None + system = first.text if first is not None and first.role == "system" else None + renderer = OpenAIChatRenderer() + expected = renderer.render(events, None, "m", system)["messages"] + if len(expected) != len(messages): + return sent, ( + f"Rendering the sent events gives {len(expected)} messages, the request " + f"has {len(messages)}; shown apart." + ) + unmatched = [] + for s, index in zip(sent, renderer.message_indexes(events, system), strict=True): + if index is None: + unmatched.append(s) + else: + messages[index].events.append(s) + return unmatched, None diff --git a/nisse-ui/tests/test_ui_contract.py b/nisse-ui/tests/test_ui_contract.py new file mode 100644 index 0000000..6b14018 --- /dev/null +++ b/nisse-ui/tests/test_ui_contract.py @@ -0,0 +1,102 @@ +"""The page and the agents are released together (X6): these fail in the PR +that would break the page, not in the operator's browser.""" + +import threading +from pathlib import Path + +import httpx +import pytest +from ui_fixtures import recorded, store_at + +from nisse_core.models import calls +from nisse_core.state.store import StateStore as CoreStore +from nisse_dev.state import StateStore as DevStore +from nisse_review.state import StateStore as ReviewStore +from nisse_ui import main as ui_main +from nisse_ui.app import App +from nisse_ui.sources import KNOWN_SCHEMAS, discover + + +@pytest.mark.parametrize("store", [CoreStore, DevStore, ReviewStore]) +def test_every_agents_schema_is_one_the_page_knows(store: type[CoreStore]) -> None: + # An agent that moves its schema on must teach the page too. + assert store.SCHEMA_VERSION in KNOWN_SCHEMAS + + +@pytest.mark.parametrize("store_class", [DevStore, ReviewStore]) +def test_each_agents_own_store_opens( + tmp_path: Path, store_class: type[CoreStore] +) -> None: + store = store_at(tmp_path, store_class) + store.open_work_item("i1", title="Agent item", position="NEW", cause="t") + store.close() + app = App(discover([("a", tmp_path / "state.db")], [])) + assert app.handle("/s/a/").status == 200 + assert "Agent item" in app.handle("/s/a/item/i1").body.decode() + + +def test_runs_the_benchmark_kept_are_sources(tmp_path: Path) -> None: + # As bench --keep-runs leaves them: /-/state.db + record. + for name in ("fix-bug-1a2b3c4d", "trace-bug-5e6f7a8b"): + _, store = recorded(tmp_path / "kept" / name) + store.close() + (tmp_path / "kept" / "not-a-run").mkdir() + sources = discover([], [tmp_path / "kept"]) + assert sorted(s.name for s in sources) == [ + "kept.fix-bug-1a2b3c4d", + "kept.trace-bug-5e6f7a8b", + ] + assert all(s.kind == "run" and s.calls_db.is_file() for s in sources) + assert sources[0].calls_db == calls.path_for(sources[0].state_db) + + +def test_source_names_are_safe_and_unique(tmp_path: Path) -> None: + sources = discover( + [("dev agent/1", tmp_path / "a"), ("dev agent/1", tmp_path / "b")], [] + ) + assert [s.name for s in sources] == ["dev-agent-1", "dev-agent-1-2"] + + +def test_the_server_listens_on_this_machine_and_sends_the_headers( + tmp_path: Path, +) -> None: + source, store = recorded(tmp_path / "a") + store.close() + server = ui_main.ThreadingHTTPServer( + ("127.0.0.1", 0), ui_main.handler_for(App([source])) + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + port = server.server_address[1] + response = httpx.get(f"http://127.0.0.1:{port}/s/agent/item/w1") + assert response.status_code == 200 + assert "Fix the parser" in response.text + assert "default-src 'none'" in response.headers["content-security-policy"] + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["server"].startswith("nisse-ui") + finally: + server.shutdown() + server.server_close() + + +def test_the_command_line_defaults_to_this_machine_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert ui_main._source("dev=/var/lib/nisse-dev/state.db") == ( + "dev", + Path("/var/lib/nisse-dev/state.db"), + ) + with pytest.raises(Exception, match="NAME=STATE_DB"): + ui_main._source("no-equals") + + class Bound(Exception): + pass + + def server(address: tuple[str, int], handler: object) -> None: + raise Bound(address) + + monkeypatch.setattr(ui_main, "ThreadingHTTPServer", server) + with pytest.raises(Bound) as bound: + ui_main.main([]) + assert bound.value.args[0] == ("127.0.0.1", 8765) diff --git a/nisse-ui/tests/test_ui_pages.py b/nisse-ui/tests/test_ui_pages.py new file mode 100644 index 0000000..d5bbf1d --- /dev/null +++ b/nisse-ui/tests/test_ui_pages.py @@ -0,0 +1,316 @@ +"""The walkthrough (X6, M6.6b): an item's steps, its runs' turns, and each +turn's request read against the log it was made from.""" + +import hashlib +import sqlite3 +from pathlib import Path + +import pytest +from ui_fixtures import TOKEN, circling, recorded + +from nisse_core.log.canonical import UserMessage +from nisse_core.models import calls +from nisse_ui import walk +from nisse_ui.app import App +from nisse_ui.sources import Source, StateReader, call_record + + +@pytest.fixture +def agent(tmp_path: Path) -> Source: + source, store = recorded(tmp_path / "agent") + store.close() + return source + + +def page(app: App, path: str, status: int = 200) -> str: + response = app.handle(path) + assert response.status == status, response.body.decode()[:500] + return response.body.decode() + + +def view(source: Source, call_id: int) -> walk.TurnView: + with StateReader(source.state_db) as state, call_record(source) as reader: + assert reader is not None + found = walk.turn(state, reader, call_id) + assert found is not None + return found + + +def steps(source: Source, item: str | None) -> walk.Walkthrough: + with StateReader(source.state_db) as state, call_record(source) as reader: + return walk.walkthrough(state, reader, item) + + +# --- the way in ------------------------------------------------------------------- + + +def test_the_sources_and_an_agents_items_are_listed(agent: Source) -> None: + app = App([agent]) + home = page(app, "/") + assert "/s/agent/" in home and ">1<" in home # one work item + items = page(app, "/s/agent/") + assert "Fix the parser" in items and "/s/agent/item/w1" in items + assert "/s/agent/item/-" in items # the agent-level log + + +# --- the walkthrough -------------------------------------------------------------- + + +def test_an_items_steps_hold_its_runs_turns_and_what_the_orchestrator_said( + agent: Source, +) -> None: + found = steps(agent, "w1") + kinds = [kind for kind, _ in found.timeline] + assert kinds == ["event", "event", "run"] # the restatement, "go", the run + [run] = [obj for kind, obj in found.timeline if kind == "run"] + assert run.run["outcome"] == "wait" + assert [t.number for t in run.turns] == [1, 2, 3] + # The correction for the prose reply came during the run: it's shown + # inside it, between the turns it fell between. + inner = [kind for kind, _ in run.entries] + assert inner == ["turn", "turn", "event", "turn"] + assert any("prose" in told for told in run.turns[2].told) + assert run.turns[0].reply.calls == (("read_file", '{"path": "parser.py"}'),) + assert run.turns[1].reused == "280 of 340" + html = page(App([agent]), "/s/agent/item/w1") + assert "turn 3" in html and "reused 330 of 350" in html + + +def test_agent_level_calls_have_their_own_log(agent: Source) -> None: + found = steps(agent, None) + [run] = [obj for kind, obj in found.timeline if kind == "run"] + assert (run.run["kind"], run.run["role"]) == ("classification", "classifier") + assert "harmless" in page(App([agent]), "/s/agent/item/-") + + +def test_work_from_before_recording_has_its_steps_but_no_requests( + tmp_path: Path, +) -> None: + source, store = recorded(tmp_path / "a") + # The agent restarts: a fresh, empty record; the log persists. + calls.CallRecorder(source.calls_db).close() + store.close() + found = steps(source, "w1") + assert found.before_record + # The restatement, "go" and the correction: steps, with no runs to hold them. + assert [kind for kind, _ in found.timeline] == ["event", "event", "event"] + html = page(App([source]), "/s/agent/item/w1") + assert "Recording began" in html + assert "All 8 events" in html # the log is all still there + + +def test_without_a_call_record_the_log_is_shown(tmp_path: Path) -> None: + source, store = recorded(tmp_path / "a") + store.close() + source.calls_db.unlink() + html = page(App([source]), "/s/agent/item/w1") + assert "No call record" in html and "All 8 events" in html + page(App([source]), "/s/agent/call/1", 404) + + +def test_a_run_going_in_circles_is_flagged(tmp_path: Path) -> None: + source = circling(tmp_path / "c") + found = steps(source, "pr-7") + [run] = [obj for kind, obj in found.timeline if kind == "run"] + [circle] = run.circles + assert (circle.kind, circle.turns) == ("repeated reply", (0, 1, 2, 3)) + assert run.turns[0].flags == ["repeated reply"] and not run.turns[4].flags + assert "Going in circles: repeated reply at turns 1, 2, 3, 4" in page( + App([source]), "/s/circling/item/pr-7" + ) + assert view(source, 2).flags == ["repeated reply"] + + +# --- one turn --------------------------------------------------------------------- + + +def test_each_message_is_shown_beside_the_log_events_it_came_from( + agent: Source, +) -> None: + turn = view(agent, 2) + assert turn.match_note is None and turn.unmatched == [] + roles = [m.role for m in turn.messages] + assert roles == ["system", "user", "assistant", "tool"] + # The restatement and "go" fold into one user message. + assert [s.id for s in turn.messages[1].events] == [1, 2] + assert [s.id for s in turn.messages[2].events] == [3] + assert [s.id for s in turn.messages[3].events] == [4] + assert all(s.tags == ["unchanged"] for m in turn.messages for s in m.events) + assert calls.NOTICE in turn.messages[0].text + + +def test_what_changed_since_the_turn_before_is_marked(agent: Source) -> None: + first, second = view(agent, 1), view(agent, 2) + assert not first.compared and not any(m.new for m in first.messages) + assert second.compared + assert [m.new for m in second.messages] == [False, False, True, True] + assert [m.first_change for m in second.messages] == [False, False, True, False] + assert not second.tools_changed + assert (second.previous, second.following) == (1, 3) + + +def test_the_turn_page_shows_the_reply_reasoning_and_raw_json(agent: Source) -> None: + html = page(App([agent]), "/s/agent/call/1") + assert "First, read the parser." in html # the reasoning + assert "Request JSON, exactly as sent" in html + assert "local-backing" in html # the model that answered + assert "reused" not in html or "0 of 300" in html + + +def test_how_rendering_changed_an_event_is_named() -> None: + call = {"shortened": {"5": 1200}} + logged = walk.Event(5, "t", "tool_result", {"result": "long"}, "w", "driver") + sent = {"result": "lo"} + assert walk._tags([5, "driver", "tool_result", "h"], sent, logged, call) == [ + "shortened by 1,200 characters" + ] + told = walk.Event(6, "t", "tool_call", {"tool": "x"}, "w", "helper") + narrated = {"text": "x → ok", "source": "view:helper"} + assert walk._tags( + [6, "helper", "user_message", "h"], narrated, told, {"shortened": {}} + ) == ["narrated from tool call by helper"] + fact = walk.Event(8, "t", "fact", {"text": "agreed"}, "w", "orchestrator") + as_said = {"text": "[orchestrator] agreed", "source": "view:orchestrator"} + assert walk._tags( + [8, "orchestrator", "user_message", "h"], as_said, fact, {"shortened": {}} + ) == ["labelled for this role (a fact by orchestrator)"] + said = walk.Event( + 7, "t", "user_message", {"text": "hi", "source": "m"}, "w", "operator" + ) + labelled = {"text": "[operator, matrix] hi", "source": "view:operator"} + assert walk._tags( + [7, "operator", "user_message", "h"], labelled, said, {"shortened": {}} + ) == ["labelled for this role"] + assert walk._tags( + [0, "harness", "fact", "h"], {"text": "x"}, None, {"shortened": {}} + ) == ["made for this call, not in the log"] + assert walk._tags( + [9, "driver", "compaction", "h"], {"replaces": [2, 8]}, None, {"shortened": {}} + ) == ["compaction of events 2–8"] + + +def test_a_reply_reads_from_either_route() -> None: + chat = { + "choices": [ + { + "message": { + "content": "ok", + "reasoning_content": "hm", + "tool_calls": [{"function": {"name": "f", "arguments": "{}"}}], + }, + "finish_reason": "tool_calls", + } + ] + } + assert walk.reply_of(chat) == walk.Reply("ok", "hm", (("f", "{}"),), "tool_calls") + messages = { + "content": [ + {"type": "thinking", "thinking": "hm"}, + {"type": "text", "text": "ok"}, + {"type": "tool_use", "name": "f", "input": {"a": 1}}, + ], + "stop_reason": "tool_use", + } + assert walk.reply_of(messages) == walk.Reply( + "ok", "hm", (("f", '{"a": 1}'),), "tool_use" + ) + assert walk.reply_of("garbage") == walk.Reply() + + +# --- untrusted text and read-only ------------------------------------------------- + + +def test_everything_shown_is_escaped_and_no_script_may_run(agent: Source) -> None: + app = App([agent]) + for path in ( + "/s/agent/item/w1", + "/s/agent/call/2", + "/s/agent/call/3", + "/s/agent/event/4", + ): + response = app.handle(path) + html = response.body.decode() + assert "" +READ = tool("read_file", "read a file", path="the file") +POST = tool("post_comment", "post a comment", body="the comment") +FINISH = tool("finish", "finish the work", summary="what was done") + + +def reply( + content: str = "", + *, + call: tuple[str, dict[str, Any]] | None = None, + reasoning: str = "", + timings: dict[str, int] | None = None, +) -> dict[str, Any]: + message: dict[str, Any] = {"role": "assistant", "content": content} + if reasoning: + message["reasoning_content"] = reasoning + if call: + message["tool_calls"] = [ + { + "id": f"x{abs(hash(json.dumps(call)))}", + "type": "function", + "function": {"name": call[0], "arguments": json.dumps(call[1])}, + } + ] + data: dict[str, Any] = { + "model": "local-backing", + "choices": [{"message": message, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 120, "completion_tokens": 7}, + } + if timings is not None: + data["timings"] = timings + return data + + +class Server: + """A fake LiteLLM answering each request in turn.""" + + def __init__(self, *answers: dict[str, Any]) -> None: + self.answers = list(answers) + + def __call__(self, request: httpx.Request) -> httpx.Response: + data = self.answers.pop(0) if self.answers else reply("ok") + return httpx.Response(200, json=data) + + +def client(server: Server, recorder: calls.CallRecorder) -> BudgetedClient: + inner = LiteLLMClient( + "https://litellm.test", TOKEN, transport=httpx.MockTransport(server) + ) + return BudgetedClient(inner, lambda m: (32768, 1024), call_recorder=recorder) + + +def store_at(root: Path, store_class: type[StateStore] = StateStore) -> StateStore: + return store_class(root / "state.db", oplog=OpLog("test", io.StringIO())) + + +def recorded(root: Path) -> tuple[Source, StateStore]: + """One work item, one driver run of three turns: a tool call whose + result is hostile text, a prose reply the driver corrects, then the + finish. And one agent-level call. The store stays open, as a live + agent's does.""" + root.mkdir(parents=True, exist_ok=True) + recorder = calls.CallRecorder(calls.path_for(root / "state.db")) + store = store_at(root) + store.open_work_item("w1", title="Fix the parser", position="RUNNING", cause="t") + store.transcript("w1", "orchestrator").append( + Fact(text="The operator agreed to this restatement: fix the parser.") + ) + store.transcript("w1", "operator").append(UserMessage(text="go", source="m:1")) + server = Server( + reply( + call=("read_file", {"path": "parser.py"}), + reasoning="First, read the parser.", + timings={"cache_n": 0, "prompt_n": 300}, + ), + reply( + "I believe the parser is fine now.", + timings={"cache_n": 280, "prompt_n": 60}, + ), + reply( + call=("finish", {"summary": "fixed"}), + timings={"cache_n": 330, "prompt_n": 20}, + ), + ) + Driver(client(server, recorder), "local").run( + store.transcript("w1", "driver"), + system="You are the driver.", + tools=[READ, FINISH], + waiting=("finish",), + workspace=None, + handlers={"read_file": lambda args: (f"contents {HOSTILE}", True)}, + ) + store.transcript(None, "operator").append(UserMessage(text="hello", source="m:9")) + with calls.run(recorder, work_item=None, role="classifier", kind="classification"): + client(Server(reply("harmless")), recorder).complete( + store.transcript(None, "operator").read(), None, "local", system="Classify." + ) + recorder.close() + return Source("agent", root / "state.db", "agent"), store + + +def circling(root: Path) -> Source: + """A run that posts the same comment over and over, as Flash-Next did + on 2026-10-24, until the finish.""" + root.mkdir(parents=True, exist_ok=True) + recorder = calls.CallRecorder(calls.path_for(root / "state.db")) + store = store_at(root) + store.open_work_item("pr-7", title="Review #7", position="RUNNING", cause="t") + store.transcript("pr-7", "operator").append(UserMessage(text="review", source="m")) + answers = [ + reply(call=("post_comment", {"body": f"Duplicate, please disregard ({n})."})) + for n in range(4) + ] + Driver( + client(Server(*answers, reply(call=("finish", {"summary": "x"}))), recorder), + "local", + ).run( + store.transcript("pr-7", "driver"), + system="You review.", + tools=[POST, FINISH], + waiting=("finish",), + workspace=None, + handlers={"post_comment": lambda args: ("posted", True)}, + ) + recorder.close() + store.close() + return Source("circling", root / "state.db", "agent") diff --git a/pyproject.toml b/pyproject.toml index afc718a..8529d54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.uv.workspace] -members = ["nisse-core", "nisse-dev", "nisse-ops", "nisse-review"] +members = ["nisse-core", "nisse-dev", "nisse-ops", "nisse-review", "nisse-ui"] [dependency-groups] dev = [ @@ -14,6 +14,7 @@ testpaths = [ "nisse-dev/tests", "nisse-ops/tests", "nisse-review/tests", + "nisse-ui/tests", "tests", ] # The seed is a test project's own code, pushed to each run's repository. @@ -33,4 +34,4 @@ extend-exclude = ["tests/integration/seed"] # a test project's own code extend-select = ["I"] [tool.ruff.lint.isort] -known-first-party = ["nisse_core", "nisse_dev", "nisse_ops", "nisse_review"] +known-first-party = ["nisse_core", "nisse_dev", "nisse_ops", "nisse_review", "nisse_ui"] diff --git a/uv.lock b/uv.lock index 277c437..53cf264 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,7 @@ members = [ "nisse-dev", "nisse-ops", "nisse-review", + "nisse-ui", ] [manifest.dependency-groups] @@ -112,6 +113,131 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + [[package]] name = "nisse-core" version = "0.1.0" @@ -166,6 +292,21 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "nisse-core", editable = "nisse-core" }] +[[package]] +name = "nisse-ui" +version = "0.1.0" +source = { editable = "nisse-ui" } +dependencies = [ + { name = "jinja2" }, + { name = "nisse-core" }, +] + +[package.metadata] +requires-dist = [ + { name = "jinja2", specifier = ">=3.1" }, + { name = "nisse-core", editable = "nisse-core" }, +] + [[package]] name = "packaging" version = "26.3"