nookku 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nookku/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Relay a test conversation between a person and a chat agent byte for byte, and audit it."""
2
+
3
+ __version__ = "0.4.0"
nookku/__main__.py ADDED
@@ -0,0 +1,21 @@
1
+ """The `nookku` command. A hook event takes a short path that does not import the CLI (#214)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Sequence
7
+
8
+
9
+ def main(argv: Sequence[str] | None = None) -> int:
10
+ args = sys.argv[1:] if argv is None else list(argv)
11
+ if args[:1] == ["hook"]:
12
+ from nookku.hook import main as hook
13
+
14
+ return hook(args[1:])
15
+ from nookku.cli import main as cli
16
+
17
+ return cli(args)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ sys.exit(main())
nookku/adapters.py ADDED
@@ -0,0 +1,214 @@
1
+ """Adapters of SPEC.md section 4.1: find the message in a request and the reply in a response."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, Protocol
8
+
9
+
10
+ class AdapterError(ValueError):
11
+ """The adapter cannot find the message or the reply."""
12
+
13
+
14
+ class StreamError(ValueError):
15
+ """The agent's stream failed: it ended early, sent an error, or sent a malformed chunk."""
16
+
17
+
18
+ # One earlier turn of the session: (tester message, agent reply).
19
+ History = list[tuple[str, str]]
20
+
21
+
22
+ class Adapter(Protocol):
23
+ def request(self, message: str, history: History) -> bytes: ...
24
+ def accepts(self, method: str, path: str) -> bool: ...
25
+ def message(self, body: bytes) -> str: ...
26
+ def reply(self, body: bytes) -> str: ...
27
+ def stream_reply(self, body: bytes) -> str: ...
28
+
29
+
30
+ def is_stream(content_type: str | None) -> bool:
31
+ """True if a Content-Type is text/event-stream (SPEC.md section 4.1)."""
32
+ return (content_type or "").split(";")[0].strip().lower() == "text/event-stream"
33
+
34
+
35
+ def sse_events(body: bytes) -> list[tuple[str, str]]:
36
+ """The (name, data) of each event of an SSE body, in the order of the body.
37
+
38
+ The rules are those of the WHATWG HTML standard: a blank line ends an event, a line that starts
39
+ with a colon is a comment, and an event without a data line is not an event. The last event
40
+ does not need a blank line after it. The `openai` adapter and the model API proxies
41
+ (`model_api.events`) use this one parser.
42
+ """
43
+ try:
44
+ text = body.decode("utf-8").removeprefix("")
45
+ except UnicodeDecodeError:
46
+ raise StreamError("the stream is not UTF-8") from None
47
+ events: list[tuple[str, str]] = []
48
+ name, data = "", list[str]()
49
+ for line in [*re.split(r"\r\n|\r|\n", text), ""]:
50
+ if line == "":
51
+ if data:
52
+ events.append((name or "message", "\n".join(data)))
53
+ name, data = "", []
54
+ elif not line.startswith(":"):
55
+ key, _, value = line.partition(":")
56
+ value = value.removeprefix(" ")
57
+ if key == "data":
58
+ data.append(value)
59
+ elif key == "event":
60
+ name = value
61
+ return events
62
+
63
+
64
+ def _json(body: bytes, what: str) -> Any:
65
+ try:
66
+ return json.loads(body.decode("utf-8"))
67
+ except (UnicodeDecodeError, json.JSONDecodeError):
68
+ raise AdapterError(f"the {what} body is not UTF-8 JSON") from None
69
+
70
+
71
+ def pick(data: Any, path: str) -> Any:
72
+ """Follow a dot path such as `choices.0.message.content`."""
73
+ for key in path.split("."):
74
+ if isinstance(data, list) and key.isdigit() and int(key) < len(data):
75
+ data = data[int(key)]
76
+ elif isinstance(data, dict) and key in data:
77
+ data = data[key]
78
+ else:
79
+ raise AdapterError(f"no value at {path!r}")
80
+ return data
81
+
82
+
83
+ def _text(value: Any, path: str) -> str:
84
+ if not isinstance(value, str):
85
+ raise AdapterError(f"the value at {path!r} is not a string")
86
+ return value
87
+
88
+
89
+ class JsonAdapter:
90
+ """Any POST. The message and the reply are at field paths of JSON bodies."""
91
+
92
+ def __init__(self, message_field: str = "text", reply_field: str = "reply") -> None:
93
+ self.message_field, self.reply_field = message_field, reply_field
94
+
95
+ def request(self, message: str, history: History) -> bytes:
96
+ body: Any = message
97
+ for key in reversed(self.message_field.split(".")):
98
+ body = {key: body}
99
+ return json.dumps(body, ensure_ascii=False).encode()
100
+
101
+ def accepts(self, method: str, path: str) -> bool:
102
+ return method == "POST"
103
+
104
+ def message(self, body: bytes) -> str:
105
+ return _text(pick(_json(body, "request"), self.message_field), self.message_field)
106
+
107
+ def reply(self, body: bytes) -> str:
108
+ return _text(pick(_json(body, "response"), self.reply_field), self.reply_field)
109
+
110
+ def stream_reply(self, body: bytes) -> str:
111
+ raise AdapterError("the json adapter does not read a streamed response")
112
+
113
+
114
+ class OpenAIAdapter:
115
+ """A POST to an OpenAI-compatible `/chat/completions` endpoint. The reply can be a stream."""
116
+
117
+ def __init__(self, model: str = "", stream: bool = False) -> None:
118
+ self.model, self.stream = model, stream
119
+
120
+ def request(self, message: str, history: History) -> bytes:
121
+ messages = []
122
+ for said, reply in history:
123
+ messages += [{"role": "user", "content": said}, {"role": "assistant", "content": reply}]
124
+ messages.append({"role": "user", "content": message})
125
+ # With the option openai_stream, the request asks for a stream (SPEC.md section 5).
126
+ body: dict[str, Any] = {"messages": messages, "stream": self.stream}
127
+ if self.model:
128
+ body["model"] = self.model
129
+ return json.dumps(body, ensure_ascii=False).encode()
130
+
131
+ def accepts(self, method: str, path: str) -> bool:
132
+ return method == "POST" and path.split("?")[0].rstrip("/").endswith("/chat/completions")
133
+
134
+ def message(self, body: bytes) -> str:
135
+ messages = pick(_json(body, "request"), "messages")
136
+ if not isinstance(messages, list):
137
+ raise AdapterError("'messages' is not a list")
138
+ users = [m for m in messages if isinstance(m, dict) and m.get("role") == "user"]
139
+ if not users:
140
+ raise AdapterError("the request has no message with role 'user'")
141
+ content = users[-1].get("content")
142
+ if isinstance(content, list):
143
+ texts = [p for p in content if isinstance(p, dict) and p.get("type") == "text"]
144
+ if len(texts) != 1 or len(content) != 1:
145
+ raise AdapterError("the user content must be a string or exactly one text part")
146
+ content = texts[0].get("text")
147
+ return _text(content, "messages[-1].content")
148
+
149
+ def reply(self, body: bytes) -> str:
150
+ path = "choices.0.message.content"
151
+ return _text(pick(_json(body, "response"), path), path)
152
+
153
+ def stream_reply(self, body: bytes) -> str:
154
+ """Join the `choices[0].delta.content` texts of a stream (SPEC.md section 4.1).
155
+
156
+ Raise StreamError if the stream has no `data: [DONE]` event, has an event after it, has an
157
+ error, or has a malformed chunk. Raise AdapterError if no chunk has a content text.
158
+ """
159
+ parts: list[str] = []
160
+ done = False
161
+ for name, data in sse_events(body):
162
+ if done:
163
+ raise StreamError("the stream has an event after data: [DONE]")
164
+ if data == "[DONE]":
165
+ done = True
166
+ continue
167
+ if name == "error":
168
+ raise StreamError(f"the agent sent an error event: {data}")
169
+ try:
170
+ chunk = json.loads(data)
171
+ except json.JSONDecodeError:
172
+ raise StreamError(f"a chunk is not JSON: {data[:80]!r}") from None
173
+ if not isinstance(chunk, dict):
174
+ raise StreamError(f"a chunk is not a JSON object: {data[:80]!r}")
175
+ if chunk.get("error") is not None:
176
+ raise StreamError(f"the agent sent an error: {json.dumps(chunk['error'])}")
177
+ choices = chunk.get("choices", [])
178
+ if not isinstance(choices, list):
179
+ raise StreamError("the 'choices' of a chunk is not a list")
180
+ for choice in choices:
181
+ delta = choice.get("delta", {}) if isinstance(choice, dict) else None
182
+ if not isinstance(delta, dict):
183
+ raise StreamError("a choice of a chunk has no 'delta' object")
184
+ if choice.get("index", 0) != 0:
185
+ continue
186
+ content = delta.get("content")
187
+ if content is not None and not isinstance(content, str):
188
+ raise StreamError("the 'delta.content' of a chunk is not a string")
189
+ if content is not None:
190
+ parts.append(content)
191
+ if not done:
192
+ raise StreamError("the stream ended before data: [DONE]")
193
+ if not parts:
194
+ raise AdapterError("no chunk of the stream has a 'delta.content' text")
195
+ # A chunk can end in the middle of a surrogate pair. Join each pair into one character, as
196
+ # a JavaScript string does. A lone surrogate is not a Unicode scalar value (SPEC.md 2).
197
+ try:
198
+ return "".join(parts).encode("utf-16-le", "surrogatepass").decode("utf-16-le")
199
+ except UnicodeDecodeError:
200
+ raise StreamError("the stream reply has a lone surrogate") from None
201
+
202
+
203
+ def make(
204
+ name: str,
205
+ message_field: str = "text",
206
+ reply_field: str = "reply",
207
+ model: str = "",
208
+ stream: bool = False,
209
+ ) -> Adapter:
210
+ if name == "json":
211
+ return JsonAdapter(message_field, reply_field)
212
+ if name == "openai":
213
+ return OpenAIAdapter(model, stream)
214
+ raise ValueError(f"unknown adapter {name!r}")
nookku/agent.py ADDED
@@ -0,0 +1,51 @@
1
+ """A helper for an entry in Python: speak the agent contract (SPEC.md section 6) for a function.
2
+
3
+ from nookku.agent import serve
4
+
5
+ def reply(message: str, history: list[tuple[str, str]]) -> str:
6
+ return shop.answer(message)
7
+
8
+ serve(reply)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ from collections.abc import Callable
17
+
18
+ from nookku import contract
19
+ from nookku.adapters import History
20
+
21
+
22
+ def _line(rid: str, **fields: str) -> bytes:
23
+ return json.dumps({"v": contract.VERSION, "id": rid, **fields}, ensure_ascii=False).encode()
24
+
25
+
26
+ def serve(reply: Callable[[str, History], str]) -> None:
27
+ """Answer each input line on stdin with one output line, until stdin closes.
28
+
29
+ Stdout carries only contract lines. All other output of the process, from print() or from a
30
+ child process, goes to stderr. This includes text that the process printed before serve() and
31
+ that is still in the stdout buffer.
32
+ """
33
+ out = os.fdopen(os.dup(1), "wb", buffering=0)
34
+ os.dup2(2, 1)
35
+ # Flush after dup2, so that the buffered text goes to stderr and not to the contract channel.
36
+ sys.stdout.flush()
37
+ for raw in sys.stdin.buffer:
38
+ line = raw.removesuffix(b"\n").removesuffix(b"\r")
39
+ try:
40
+ rid, message, history = contract.parse_request(line)
41
+ except contract.ContractError as e:
42
+ out.write(_line("", error=f"nookku agent: {e}") + b"\n")
43
+ continue
44
+ try:
45
+ text = reply(message, history)
46
+ if not isinstance(text, str):
47
+ raise TypeError(f"reply() returned {type(text).__name__}, not str")
48
+ result = _line(rid, reply=text)
49
+ except Exception as e: # the error goes back to the tester as an error turn
50
+ result = _line(rid, error=f"{type(e).__name__}: {e}")
51
+ out.write(result + b"\n")
nookku/audit.py ADDED
@@ -0,0 +1,279 @@
1
+ """The audit of SPEC.md section 3: compare the relay record with the tap record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from itertools import pairwise
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from nookku.record import (
11
+ BlockedCall,
12
+ Exchange,
13
+ ModelSession,
14
+ RecordError,
15
+ Turn,
16
+ Unparsed,
17
+ read_relay,
18
+ read_tap,
19
+ )
20
+
21
+ CHECKS = [
22
+ "altered_input",
23
+ "injected_input",
24
+ "duplicate_send",
25
+ "out_of_order",
26
+ "not_delivered",
27
+ "altered_reply",
28
+ "unshown_reply",
29
+ "script_mismatch",
30
+ ]
31
+ EXCERPT = 24
32
+
33
+
34
+ @dataclass
35
+ class Finding:
36
+ kind: str
37
+ relay_line: int | None
38
+ tap_line: int | None
39
+ evidence: dict[str, Any] = field(default_factory=dict)
40
+
41
+ def as_dict(self) -> dict[str, Any]:
42
+ return {
43
+ "class": self.kind,
44
+ "relay_line": self.relay_line,
45
+ "tap_line": self.tap_line,
46
+ "evidence": self.evidence,
47
+ }
48
+
49
+
50
+ @dataclass
51
+ class Report:
52
+ exit: int
53
+ turns: int = 0
54
+ exchanges: int = 0
55
+ blocked_calls: int = 0
56
+ model_sessions: int = 0
57
+ breaks: list[Finding] = field(default_factory=list)
58
+ notes: list[Finding] = field(default_factory=list)
59
+ errors: list[dict[str, str]] = field(default_factory=list)
60
+ skipped: list[str] = field(default_factory=list)
61
+
62
+ def as_dict(self) -> dict[str, Any]:
63
+ return {
64
+ "v": "0.2",
65
+ "exit": self.exit,
66
+ "turns": self.turns,
67
+ "exchanges": self.exchanges,
68
+ "blocked_calls": self.blocked_calls,
69
+ "model_sessions": self.model_sessions,
70
+ "breaks": [b.as_dict() for b in self.breaks],
71
+ "notes": [n.as_dict() for n in self.notes],
72
+ "errors": self.errors,
73
+ "skipped": self.skipped,
74
+ }
75
+
76
+
77
+ def difference(expected: str, actual: str) -> dict[str, Any]:
78
+ """Locate the first character where two texts differ, with an excerpt of each side."""
79
+ i = next(
80
+ (k for k, (a, b) in enumerate(zip(expected, actual, strict=False)) if a != b),
81
+ min(len(expected), len(actual)),
82
+ )
83
+ start = max(0, i - EXCERPT // 2)
84
+ return {
85
+ "first_difference": i,
86
+ "expected": expected[start : start + EXCERPT],
87
+ "actual": actual[start : start + EXCERPT],
88
+ "expected_length": len(expected),
89
+ "actual_length": len(actual),
90
+ }
91
+
92
+
93
+ def _anchors(turns: list[Turn], exchanges: list[Exchange]) -> list[tuple[int, int]]:
94
+ """Return index pairs of a longest common subsequence, built in file order (SPEC 3.2 step 1)."""
95
+ n, m = len(turns), len(exchanges)
96
+ longest = [[0] * (m + 1) for _ in range(n + 1)]
97
+ for i in range(n - 1, -1, -1):
98
+ for j in range(m - 1, -1, -1):
99
+ if turns[i].said == exchanges[j].input:
100
+ longest[i][j] = 1 + longest[i + 1][j + 1]
101
+ else:
102
+ longest[i][j] = max(longest[i + 1][j], longest[i][j + 1])
103
+ pairs, i, j = [], 0, 0
104
+ while i < n and j < m:
105
+ if turns[i].said == exchanges[j].input and longest[i][j] == 1 + longest[i + 1][j + 1]:
106
+ pairs.append((i, j))
107
+ i, j = i + 1, j + 1
108
+ elif longest[i + 1][j] == longest[i][j]:
109
+ i += 1
110
+ else:
111
+ j += 1
112
+ return pairs
113
+
114
+
115
+ def compare(turns: list[Turn], exchanges: list[Exchange]) -> tuple[list[Finding], list[Finding]]:
116
+ """Return (breaks, notes) for two valid records."""
117
+ breaks: list[Finding] = []
118
+ anchors = _anchors(turns, exchanges)
119
+ pairs = list(anchors)
120
+ free_t = [i for i in range(len(turns)) if i not in {a for a, _ in anchors}]
121
+ free_e = [j for j in range(len(exchanges)) if j not in {b for _, b in anchors}]
122
+
123
+ # Step 2: a message that arrived at another position.
124
+ for i in list(free_t):
125
+ j = next((j for j in free_e if exchanges[j].input == turns[i].said), None)
126
+ if j is not None:
127
+ free_t.remove(i)
128
+ free_e.remove(j)
129
+ pairs.append((i, j))
130
+ breaks.append(Finding("out_of_order", turns[i].line, exchanges[j].line))
131
+
132
+ # Step 3: an extra copy of a message that the tester typed.
133
+ said = {t.said for t in turns}
134
+ for j in [j for j in free_e if exchanges[j].input in said]:
135
+ free_e.remove(j)
136
+ breaks.append(Finding("duplicate_send", None, exchanges[j].line))
137
+
138
+ # Step 4: pair what is left in each gap between adjacent anchors as changed input.
139
+ bounds = [(-1, -1), *anchors, (len(turns), len(exchanges))]
140
+ for (ti, ej), (tk, ek) in pairwise(bounds):
141
+ gap_t = [i for i in free_t if ti < i < tk]
142
+ gap_e = [j for j in free_e if ej < j < ek]
143
+ for i, j in zip(gap_t, gap_e, strict=False):
144
+ free_t.remove(i)
145
+ free_e.remove(j)
146
+ pairs.append((i, j))
147
+ breaks.append(
148
+ Finding(
149
+ "altered_input",
150
+ turns[i].line,
151
+ exchanges[j].line,
152
+ difference(turns[i].said, exchanges[j].input),
153
+ )
154
+ )
155
+
156
+ # Step 5: what is still left.
157
+ breaks += [Finding("injected_input", None, exchanges[j].line) for j in free_e]
158
+ breaks += [Finding("not_delivered", turns[i].line, None) for i in free_t]
159
+
160
+ # Step 6: the reply of each pair.
161
+ notes: list[Finding] = []
162
+ for i, j in pairs:
163
+ t, e = turns[i], exchanges[j]
164
+ # A null reply: a status that is not 2xx, no response, or a failed stream.
165
+ if e.reply is None:
166
+ notes.append(Finding("agent_error", t.line, e.line, {"status": e.status}))
167
+ elif t.shown is None:
168
+ breaks.append(Finding("unshown_reply", t.line, e.line))
169
+ elif t.shown != e.reply:
170
+ breaks.append(Finding("altered_reply", t.line, e.line, difference(e.reply, t.shown)))
171
+
172
+ def order(f: Finding) -> tuple[int, int, int]:
173
+ big = 1 << 30
174
+ return (
175
+ f.relay_line or big,
176
+ f.tap_line or big,
177
+ CHECKS.index(f.kind) if f.kind in CHECKS else 0,
178
+ )
179
+
180
+ return sorted(breaks, key=order), sorted(notes, key=order)
181
+
182
+
183
+ def compare_script(turns: list[Turn], messages: list[str]) -> list[Finding]:
184
+ """The script_mismatch breaks of a scripted test (SPEC.md section 3.2, step 7). Turn n must
185
+ be a script turn whose said is script message n, and the counts must be equal."""
186
+ found = []
187
+ for n in range(max(len(turns), len(messages))):
188
+ turn = turns[n] if n < len(turns) else None
189
+ message = messages[n] if n < len(messages) else None
190
+ evidence: dict[str, Any] = {"script_message": n + 1 if message is not None else None}
191
+ if turn is None:
192
+ found.append(Finding("script_mismatch", None, None, evidence))
193
+ elif message is None or turn.harness != "script":
194
+ evidence["harness"] = turn.harness
195
+ found.append(Finding("script_mismatch", turn.line, None, evidence))
196
+ elif turn.said != message:
197
+ evidence.update(difference(message, turn.said))
198
+ found.append(Finding("script_mismatch", turn.line, None, evidence))
199
+ return found
200
+
201
+
202
+ def audit(tap_path: Path, relay_path: Path, script_path: Path | None = None) -> Report:
203
+ """Compare the records. With a script, also compare the relay record with the script."""
204
+ from nookku import script
205
+
206
+ errors, tap_rows, relay_rows = [], None, None
207
+ for name, path, reader in (("tap", tap_path, read_tap), ("relay", relay_path, read_relay)):
208
+ try:
209
+ rows = reader(path)
210
+ except RecordError as e:
211
+ errors.append({"class": e.kind, "record": name, "detail": str(e)})
212
+ continue
213
+ if name == "tap":
214
+ tap_rows = rows
215
+ else:
216
+ relay_rows = rows
217
+ for row in tap_rows or []:
218
+ if isinstance(row, Unparsed):
219
+ errors.append(
220
+ {
221
+ "class": "tap_unparsed",
222
+ "record": "tap",
223
+ "detail": f"line {row.line}: {row.method} {row.path}: {row.error}",
224
+ }
225
+ )
226
+ messages: list[str] = []
227
+ if script_path is not None:
228
+ try:
229
+ messages = script.read(script_path)[0]
230
+ except script.ScriptError as e:
231
+ kind = "record_missing" if e.unreadable else "record_invalid"
232
+ errors.append({"class": kind, "record": "script", "detail": str(e)})
233
+ if errors:
234
+ skipped = CHECKS if script_path is not None else CHECKS[:-1]
235
+ return Report(exit=2, errors=errors, skipped=list(skipped))
236
+ assert tap_rows is not None and relay_rows is not None
237
+ turns = [r for r in relay_rows if isinstance(r, Turn)]
238
+ exchanges = [r for r in tap_rows if isinstance(r, Exchange)]
239
+ breaks, notes = compare(turns, exchanges)
240
+ if script_path is not None:
241
+ breaks += compare_script(turns, messages)
242
+ return Report(
243
+ exit=1 if breaks else 0,
244
+ turns=len(turns),
245
+ exchanges=len(exchanges),
246
+ blocked_calls=sum(isinstance(r, BlockedCall) for r in relay_rows),
247
+ model_sessions=sum(isinstance(r, ModelSession) for r in tap_rows),
248
+ breaks=breaks,
249
+ notes=notes,
250
+ )
251
+
252
+
253
+ def render(report: Report) -> str:
254
+ """A short text report for a terminal."""
255
+ out = []
256
+ if report.errors:
257
+ out += [f"ERROR {e['class']}: {e['detail']}" for e in report.errors]
258
+ out.append("Skipped checks: " + ", ".join(report.skipped))
259
+ out.append("Result: the audit cannot run (exit 2)")
260
+ return "\n".join(out)
261
+ for b in report.breaks:
262
+ where = f"relay line {b.relay_line or '-'}, tap line {b.tap_line or '-'}"
263
+ line = f"BREAK {b.kind:<15} {where}"
264
+ if "first_difference" in b.evidence:
265
+ ev = b.evidence
266
+ line += (
267
+ f": first difference at character {ev['first_difference']}:"
268
+ f" expected {ev['expected']!r}, got {ev['actual']!r}"
269
+ )
270
+ out.append(line)
271
+ for n in report.notes:
272
+ out.append(f"NOTE {n.kind:<15} relay line {n.relay_line}, tap line {n.tap_line}")
273
+ out.append(
274
+ f"{report.turns} turns, {report.exchanges} exchanges, "
275
+ f"{report.blocked_calls} blocked model calls, {report.model_sessions} model sessions, "
276
+ f"{len(report.breaks)} breaks"
277
+ )
278
+ out.append("Result: " + ("clean (exit 0)" if report.exit == 0 else "breaks found (exit 1)"))
279
+ return "\n".join(out)