tuieval 0.2.0.dev108__tar.gz → 0.2.0.dev112__tar.gz
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.
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/CHANGELOG.md +2 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/PKG-INFO +2 -2
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/README.md +1 -1
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/_version.py +2 -2
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/pta.py +9 -4
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/tui.py +106 -16
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/tests/test_tuieval.py +86 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/.gitignore +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/LICENSE +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/RELEASING.md +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/README.md +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/favicon.ico +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/readme-header.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/social-preview.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-1024.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-128.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-16.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-16.svg +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-256.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-32.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-48.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-512.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-64.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-animated.svg +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon.svg +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/tui-answer-reasoning.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/tui-run-reasoning.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/tui-setup.png +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/models.md +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/writing-packs.md +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/pyproject.toml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/__init__.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/__main__.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/builtin.toml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/cli.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/client.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/compare.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/doctor.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/engine.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/export.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/__init__.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/answer.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/code.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/rag.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/reply.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/tool_call.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/insights.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/logs.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/machines.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/onboard.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/packs.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/profiles.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/remove.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/run_evals.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/scaffold.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/selftest.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/models.toml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/answer/pack.toml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/answer/system.txt +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/code/pack.toml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/code/system.txt +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/code/tests.yaml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/rag/pack.toml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/rag/system.txt +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/reply/pack.toml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/reply/system.txt +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/tune.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/verdict.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/watch_proxy.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/workspace.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/yamlout.py +0 -0
- {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/tests/mock_server.py +0 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Copy reasoning: `y` in the answer view copies its full reasoning, `a` the answer; `y` on the run screen copies the reasoning streamed so far. Uses pbcopy, wl-copy, xclip, xsel or clip, else the terminal (OSC 52).
|
|
6
|
+
- PTA index: click a model's name to remove it from view (every Results tab follows); click it under the bars to bring it back, or show all.
|
|
5
7
|
- The Models heading shows how many are selected, naming any the filter hides; Start asks first when some are hidden.
|
|
6
8
|
- Results → Failures: a Models button filters the list (the same pick as the other tabs), with the number of failures shown.
|
|
7
9
|
- Fix: an answer followed by a stray `</think>` (or a stray code fence after it) was graded as empty; the answer before the tag is graded now. The code grader also reads a doubled ```` ```python ```` fence as one.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tuieval
|
|
3
|
-
Version: 0.2.0.
|
|
3
|
+
Version: 0.2.0.dev112
|
|
4
4
|
Summary: Evaluate local and frontier LLMs on your own questions: accuracy, speed, tokens and PASS/FAIL verdicts, in the terminal.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ashe-wb/tuieval
|
|
6
6
|
Project-URL: Issues, https://github.com/ashe-wb/tuieval/issues
|
|
@@ -66,7 +66,7 @@ While a run goes, you watch each question's reasoning and answer stream in:
|
|
|
66
66
|
|
|
67
67
|

|
|
68
68
|
|
|
69
|
-
Afterwards, every answer keeps its reasoning. Enter on a result opens it with the grade and each check, the full reasoning and the answer; `[` and `]` step through the others:
|
|
69
|
+
Afterwards, every answer keeps its reasoning. Enter on a result opens it with the grade and each check, the full reasoning and the answer; `[` and `]` step through the others, `y` copies the reasoning and `a` the answer (on the run screen, `y` copies the reasoning streamed so far):
|
|
70
70
|
|
|
71
71
|

|
|
72
72
|
|
|
@@ -40,7 +40,7 @@ While a run goes, you watch each question's reasoning and answer stream in:
|
|
|
40
40
|
|
|
41
41
|

|
|
42
42
|
|
|
43
|
-
Afterwards, every answer keeps its reasoning. Enter on a result opens it with the grade and each check, the full reasoning and the answer; `[` and `]` step through the others:
|
|
43
|
+
Afterwards, every answer keeps its reasoning. Enter on a result opens it with the grade and each check, the full reasoning and the answer; `[` and `]` step through the others, `y` copies the reasoning and `a` the answer (on the run screen, `y` copies the reasoning streamed so far):
|
|
44
44
|
|
|
45
45
|

|
|
46
46
|
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.2.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2, 0, '
|
|
21
|
+
__version__ = version = '0.2.0.dev112'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev112')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -117,12 +117,13 @@ def _blocks(width, v, most):
|
|
|
117
117
|
return max(1, min(width, round(width * v / most)))
|
|
118
118
|
|
|
119
119
|
|
|
120
|
-
def bars(result, width=20, markup=True):
|
|
120
|
+
def bars(result, width=20, markup=True, click=None):
|
|
121
121
|
"""Lines with a row per model and a bar each for P, T and A: the at-a-glance view. The P and T bars
|
|
122
122
|
are proportional to tokens and time (the longest is the model that used most; shorter is better),
|
|
123
123
|
with how many times the leanest / fastest model's beside them; the A bar is the share of answers
|
|
124
124
|
right (longer is better, full only when every answer passed), then the share cut off at max_tokens in
|
|
125
|
-
orange (▓ in plain text) and "(n% truncated)" beside it. markup=False gives plain text
|
|
125
|
+
orange (▓ in plain text) and "(n% truncated)" beside it. markup=False gives plain text; click (an
|
|
126
|
+
action, e.g. "screen.remove") makes each model's name a link calling it with the row's index."""
|
|
126
127
|
if not result["models"]:
|
|
127
128
|
return []
|
|
128
129
|
name_w = max(len(x["model"]) for x in result["models"])
|
|
@@ -133,7 +134,7 @@ def bars(result, width=20, markup=True):
|
|
|
133
134
|
sub = pad + "".join(f"{better:<{cell_w}}" for _, _, better, *_ in LETTERS)
|
|
134
135
|
lines = [f"[b]{head.rstrip()}[/b]" if markup else head.rstrip(), f"[dim]{sub.rstrip()}[/dim]" if markup else sub.rstrip()]
|
|
135
136
|
most = {m: max((x.get(m) or 0 for x in result["models"]), default=0) for _, _, _, m, _, _ in LETTERS}
|
|
136
|
-
for x in result["models"]:
|
|
137
|
+
for row, x in enumerate(result["models"]):
|
|
137
138
|
cells = []
|
|
138
139
|
for k, _, _, measure, shown, color in LETTERS:
|
|
139
140
|
if k == "A":
|
|
@@ -155,7 +156,11 @@ def bars(result, width=20, markup=True):
|
|
|
155
156
|
if markup else bar + cut_bar + rest)
|
|
156
157
|
cells.append(f"{bar} {num:>6}" + " " * max(0, cell_w - width - 1 - max(6, len(num))))
|
|
157
158
|
name = f"{x['model']:<{name_w}}"
|
|
158
|
-
|
|
159
|
+
if markup:
|
|
160
|
+
pad_w = name_w - len(x["model"])
|
|
161
|
+
name = (f"[@click={click}({row})]{rich_escape(x['model'])}[/]" + " " * pad_w if click
|
|
162
|
+
else rich_escape(name))
|
|
163
|
+
lines.append((f"{name} " + "".join(cells)).rstrip())
|
|
159
164
|
return lines
|
|
160
165
|
|
|
161
166
|
|
|
@@ -24,6 +24,8 @@ import glob
|
|
|
24
24
|
import json
|
|
25
25
|
import os
|
|
26
26
|
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
27
29
|
import threading
|
|
28
30
|
import time
|
|
29
31
|
|
|
@@ -289,6 +291,37 @@ class StreamView(TextArea):
|
|
|
289
291
|
self.call_after_refresh(lambda: self.scroll_to(y=max(0, self.max_scroll_y - from_end), animate=False))
|
|
290
292
|
|
|
291
293
|
|
|
294
|
+
CLIPBOARD_TOOLS = (["pbcopy"], ["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"],
|
|
295
|
+
["clip"])
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def copy_text(app, text):
|
|
299
|
+
"""Put text on the system clipboard with the first tool here that takes it (pbcopy, wl-copy, xclip, xsel,
|
|
300
|
+
clip). Without one, the terminal is asked to (OSC 52), which not every terminal allows. True if a tool did."""
|
|
301
|
+
for cmd in CLIPBOARD_TOOLS:
|
|
302
|
+
if not shutil.which(cmd[0]):
|
|
303
|
+
continue
|
|
304
|
+
try:
|
|
305
|
+
subprocess.run(cmd, input=text.encode("utf-16" if cmd[0] == "clip" else "utf-8"),
|
|
306
|
+
capture_output=True, check=True, timeout=10)
|
|
307
|
+
return True
|
|
308
|
+
except (OSError, subprocess.SubprocessError):
|
|
309
|
+
continue # e.g. xclip without a display: try the next
|
|
310
|
+
app.copy_to_clipboard(text)
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def notify_copied(app, what, text, note=""):
|
|
315
|
+
if not text:
|
|
316
|
+
app.notify(f"No {what} to copy.", severity="warning")
|
|
317
|
+
elif copy_text(app, text):
|
|
318
|
+
app.notify(f"Copied {what}{note}: {len(text):,} chars.")
|
|
319
|
+
else:
|
|
320
|
+
app.notify(f"Sent {what}{note} ({len(text):,} chars) to the terminal's clipboard. If nothing pastes, "
|
|
321
|
+
"the terminal doesn't allow it: install a clipboard tool (wl-copy, xclip or xsel).",
|
|
322
|
+
severity="warning", timeout=10)
|
|
323
|
+
|
|
324
|
+
|
|
292
325
|
# ------------------------------------------------------------------ answer detail
|
|
293
326
|
def answer_entry(row):
|
|
294
327
|
"""An answer for AnswerScreen from a compare row (None when the row has no stored answer)."""
|
|
@@ -451,7 +484,7 @@ with the model's reasoning and answer as they stream, then recent results with t
|
|
|
451
484
|
PTA index parsimony (tokens), time and accuracy per model, a bar each: tokens and time
|
|
452
485
|
as long as they took (shorter is better; 2.4× = 2.4 times the best), accuracy
|
|
453
486
|
longer is better. Per-answer numbers, tok/s, TTFT and memory in the table.
|
|
454
|
-
Results open here.
|
|
487
|
+
Results open here. Click a model's name to remove it from view (every tab).
|
|
455
488
|
Production readiness one verdict per model and use case; the table says why and what to run next
|
|
456
489
|
Per question every question, model by model (filters: pack, level, Models disagree)
|
|
457
490
|
Failures every wrong answer with the grader's reason (filter: Models); enter opens it
|
|
@@ -511,11 +544,14 @@ class AnswerScreen(ModalScreen):
|
|
|
511
544
|
Binding("q", "close", "Close", show=False, priority=True),
|
|
512
545
|
Binding("left_square_bracket", "step(-1)", "Previous", priority=True),
|
|
513
546
|
Binding("right_square_bracket", "step(1)", "Next", priority=True),
|
|
547
|
+
Binding("y", "copy('reasoning')", "Copy reasoning", priority=True),
|
|
548
|
+
Binding("a", "copy('answer')", "Copy answer", priority=True),
|
|
514
549
|
]
|
|
515
550
|
|
|
516
551
|
def __init__(self, entries, index=0, what="answer"):
|
|
517
552
|
super().__init__()
|
|
518
553
|
self.entries, self.index, self.what = entries, index, what
|
|
554
|
+
self.texts = {} # what's shown: "reasoning", "answer" -> text, for the copy keys
|
|
519
555
|
|
|
520
556
|
def check_action(self, action, parameters):
|
|
521
557
|
return len(self.entries) > 1 if action == "step" else True
|
|
@@ -550,6 +586,9 @@ class AnswerScreen(ModalScreen):
|
|
|
550
586
|
self.index = (self.index + delta) % len(self.entries)
|
|
551
587
|
self.show()
|
|
552
588
|
|
|
589
|
+
def action_copy(self, what):
|
|
590
|
+
notify_copied(self.app, what, self.texts.get(what, ""))
|
|
591
|
+
|
|
553
592
|
def show(self):
|
|
554
593
|
e = self.app.engine
|
|
555
594
|
entry = self.entries[self.index]
|
|
@@ -608,6 +647,7 @@ class AnswerScreen(ModalScreen):
|
|
|
608
647
|
answer += "\n\n=== TOOL CALLS ===\n" + json.dumps(rec["tool_calls"], indent=2)
|
|
609
648
|
for wid, text in (("#answer-reasoning", reasoning), ("#answer-answer", answer or "(empty)")):
|
|
610
649
|
self.query_one(wid, TextArea).load_text(text)
|
|
650
|
+
self.texts = {"reasoning": reasoning, "answer": answer}
|
|
611
651
|
|
|
612
652
|
question, checked = "(test not found)", "(test not found)"
|
|
613
653
|
if test:
|
|
@@ -2163,6 +2203,7 @@ class RunScreen(Screen):
|
|
|
2163
2203
|
Binding("c", "cancel_all", "Cancel all", priority=True),
|
|
2164
2204
|
Binding("f", "toggle_follow", "Pause scroll", priority=True),
|
|
2165
2205
|
Binding("v", "watch_next", "Next model", priority=True),
|
|
2206
|
+
Binding("y", "copy_reasoning", "Copy reasoning", priority=True),
|
|
2166
2207
|
Binding("n", "new_run", "More evals", priority=True),
|
|
2167
2208
|
Binding("r", "results", "Results", priority=True),
|
|
2168
2209
|
Binding("w", "runs", "Runs", priority=True),
|
|
@@ -2182,6 +2223,8 @@ class RunScreen(Screen):
|
|
|
2182
2223
|
self.follow = True
|
|
2183
2224
|
self.buf = {"reasoning": [], "answer": []}
|
|
2184
2225
|
self.buf_lock = threading.Lock()
|
|
2226
|
+
self.streamed = {"reasoning": [], "answer": []} # the watched answer so far, untrimmed (for y)
|
|
2227
|
+
self.streamed_whole = False # from its start (not joined midway with v)
|
|
2185
2228
|
self.req_live = {} # model label -> its request now: phase, ~tokens, started, last token
|
|
2186
2229
|
self.started = time.time()
|
|
2187
2230
|
self.req_started = None
|
|
@@ -2344,6 +2387,7 @@ class RunScreen(Screen):
|
|
|
2344
2387
|
v.clear()
|
|
2345
2388
|
for stream, text in parts.items():
|
|
2346
2389
|
if text:
|
|
2390
|
+
self.streamed[stream].append(text)
|
|
2347
2391
|
self.query_one(f"#{stream}", StreamView).append(text, self.follow)
|
|
2348
2392
|
self.req_tokens += max(1, len(text) // 4)
|
|
2349
2393
|
if self.req_first is None and self.req_started:
|
|
@@ -2364,6 +2408,7 @@ class RunScreen(Screen):
|
|
|
2364
2408
|
self.flush_streams()
|
|
2365
2409
|
self.query_one("#reasoning", StreamView).clear()
|
|
2366
2410
|
self.query_one("#answer", StreamView).clear()
|
|
2411
|
+
self.streamed, self.streamed_whole = {"reasoning": [], "answer": []}, True
|
|
2367
2412
|
self.req_started, self.req_tokens, self.req_first = time.time(), 0, None
|
|
2368
2413
|
job = d["job"]
|
|
2369
2414
|
rep_note = f" · repeat {d['repeat'] + 1}/{job.repeat}" if job.repeat > 1 else ""
|
|
@@ -2731,6 +2776,7 @@ class RunScreen(Screen):
|
|
|
2731
2776
|
v.clear()
|
|
2732
2777
|
self.query_one("#reasoning", StreamView).clear()
|
|
2733
2778
|
self.query_one("#answer", StreamView).clear()
|
|
2779
|
+
self.streamed, self.streamed_whole = {"reasoning": [], "answer": []}, False
|
|
2734
2780
|
self.req_started = None
|
|
2735
2781
|
self.current = f"Streaming [b]{self.watch}[/b] from its next answer…"
|
|
2736
2782
|
self.refresh_current()
|
|
@@ -2748,6 +2794,12 @@ class RunScreen(Screen):
|
|
|
2748
2794
|
self.follow = not self.follow
|
|
2749
2795
|
self.update_overall()
|
|
2750
2796
|
|
|
2797
|
+
def action_copy_reasoning(self):
|
|
2798
|
+
"""The streaming answer's reasoning so far, all of it (the pane drops the oldest text when it's long)."""
|
|
2799
|
+
self.flush_streams()
|
|
2800
|
+
notify_copied(self.app, "reasoning", "".join(self.streamed["reasoning"]),
|
|
2801
|
+
"" if self.streamed_whole else " (from midway: streaming joined after it began)")
|
|
2802
|
+
|
|
2751
2803
|
@on(DataTable.RowSelected, "#recent")
|
|
2752
2804
|
def recent_selected(self, event):
|
|
2753
2805
|
keys = [k for k in event.data_table.rows if k in self.recent_entries]
|
|
@@ -3032,22 +3084,51 @@ class ResultsScreen(Screen):
|
|
|
3032
3084
|
models = sorted(counts, key=lambda m: (-counts[m], m))
|
|
3033
3085
|
|
|
3034
3086
|
def picked(chosen):
|
|
3035
|
-
if chosen is None:
|
|
3036
|
-
|
|
3037
|
-
first = chosen and not self.app.pq_models
|
|
3038
|
-
self.app.pq_models = chosen # kept for this session, so reopening Results keeps it
|
|
3039
|
-
if first: # a fair comparison needs the same questions: turn it on (you can untick it)
|
|
3040
|
-
with self.query_one("#pq-shared", Checkbox).prevent(Checkbox.Changed):
|
|
3041
|
-
self.query_one("#pq-shared", Checkbox).value = True
|
|
3042
|
-
# after the picker has closed: a table rebuilt under a modal keeps its header-only widths
|
|
3043
|
-
self.call_after_refresh(self.fill_per_question)
|
|
3044
|
-
self.call_after_refresh(self.fill_pta)
|
|
3045
|
-
self.call_after_refresh(self.fill_failures)
|
|
3046
|
-
self.insights_stale = True
|
|
3047
|
-
if self.query_one(TabbedContent).active == "tab-insights":
|
|
3048
|
-
self.call_after_refresh(self.fill_insights)
|
|
3087
|
+
if chosen is not None:
|
|
3088
|
+
self.set_models(chosen)
|
|
3049
3089
|
self.app.push_screen(ModelPickScreen(models, counts, self.app.pq_models), picked)
|
|
3050
3090
|
|
|
3091
|
+
def set_models(self, chosen):
|
|
3092
|
+
"""The models every Results tab compares (none: all of them)."""
|
|
3093
|
+
first = chosen and not self.app.pq_models
|
|
3094
|
+
self.app.pq_models = chosen # kept for this session, so reopening Results keeps it
|
|
3095
|
+
if first: # a fair comparison needs the same questions: turn it on (you can untick it)
|
|
3096
|
+
with self.query_one("#pq-shared", Checkbox).prevent(Checkbox.Changed):
|
|
3097
|
+
self.query_one("#pq-shared", Checkbox).value = True
|
|
3098
|
+
# after a picker has closed: a table rebuilt under a modal keeps its header-only widths
|
|
3099
|
+
self.call_after_refresh(self.fill_per_question)
|
|
3100
|
+
self.call_after_refresh(self.fill_pta)
|
|
3101
|
+
self.call_after_refresh(self.fill_failures)
|
|
3102
|
+
self.insights_stale = True
|
|
3103
|
+
if self.query_one(TabbedContent).active == "tab-insights":
|
|
3104
|
+
self.call_after_refresh(self.fill_insights)
|
|
3105
|
+
|
|
3106
|
+
def action_pta_remove(self, row):
|
|
3107
|
+
"""A click on a model's name in the PTA bars: leave it out of every tab (click it again below to bring it back)."""
|
|
3108
|
+
shown = [x["model"] for x in getattr(self, "pta_result", {}).get("models", [])]
|
|
3109
|
+
if not 0 <= row < len(shown):
|
|
3110
|
+
return
|
|
3111
|
+
if len(shown) == 1:
|
|
3112
|
+
self.notify("That's the last model shown.", severity="warning")
|
|
3113
|
+
return
|
|
3114
|
+
model = shown[row]
|
|
3115
|
+
self.app.pta_removed = [m for m in getattr(self.app, "pta_removed", []) if m != model] + [model]
|
|
3116
|
+
self.set_models((self.app.pq_models or set(shown)) - {model})
|
|
3117
|
+
|
|
3118
|
+
def action_pta_restore(self, row):
|
|
3119
|
+
removed = self.pta_removed_now()
|
|
3120
|
+
if 0 <= row < len(removed):
|
|
3121
|
+
self.app.pta_removed.remove(removed[row])
|
|
3122
|
+
self.set_models(self.app.pq_models | {removed[row]})
|
|
3123
|
+
|
|
3124
|
+
def action_pta_restore_all(self):
|
|
3125
|
+
self.app.pta_removed = []
|
|
3126
|
+
self.set_models(set())
|
|
3127
|
+
|
|
3128
|
+
def pta_removed_now(self):
|
|
3129
|
+
"""Models removed by a click that are still left out (the Models button may have put them back)."""
|
|
3130
|
+
return [m for m in getattr(self.app, "pta_removed", []) if self.app.pq_models and m not in self.app.pq_models]
|
|
3131
|
+
|
|
3051
3132
|
# -- insights: charts with what to do next (insights.py); built when the tab is first shown
|
|
3052
3133
|
@on(TabbedContent.TabActivated)
|
|
3053
3134
|
def tab_activated(self, event):
|
|
@@ -3127,7 +3208,16 @@ class ResultsScreen(Screen):
|
|
|
3127
3208
|
# none picked: every model with enough answers (pta.index leaves out the rest, and says so)
|
|
3128
3209
|
result = pta.index(rows, sorted(chosen) or None, pta.memory_gb(self.infos))
|
|
3129
3210
|
self.query_one("#pta-note", Static).update(pta.scope_note(result, sorted(present)))
|
|
3130
|
-
self.
|
|
3211
|
+
self.pta_result = result
|
|
3212
|
+
removed = [m for m in self.pta_removed_now() if m in present]
|
|
3213
|
+
self.query_one("#pta-bars", Static).update(
|
|
3214
|
+
"\n".join(pta.bars(result, click="screen.pta_remove")
|
|
3215
|
+
+ ([f"\n[dim]Click a name to remove it. Removed:[/dim] "
|
|
3216
|
+
+ " ".join(f"[@click=screen.pta_restore({self.pta_removed_now().index(m)})]{rich_escape(m)}[/]"
|
|
3217
|
+
for m in removed)
|
|
3218
|
+
+ " [dim]·[/dim] [@click=screen.pta_restore_all]show all[/]"] if removed
|
|
3219
|
+
else ["\n[dim]Click a name to remove it from view.[/dim]"]))
|
|
3220
|
+
if result["questions"] else "")
|
|
3131
3221
|
self.query_one("#pta-left-out", Static).update(
|
|
3132
3222
|
"\n".join(f"[dim]{rich_escape(line)}[/dim]" for line in pta.left_out_lines(result)))
|
|
3133
3223
|
header, table = pta.table(result)
|
|
@@ -1130,6 +1130,70 @@ class AddToRun(unittest.TestCase):
|
|
|
1130
1130
|
self.assertEqual(fewer, 4) # its 4 questions are no longer counted
|
|
1131
1131
|
self.assertEqual(out["running"], [False, True])
|
|
1132
1132
|
|
|
1133
|
+
def test_tui_copies_reasoning_and_answer(self):
|
|
1134
|
+
path = os.path.join(self.ws, "models.toml")
|
|
1135
|
+
write(path, read(path).replace('"--delay", "0.2"', '"--delay", "1.5"')) # reasoning first, then a wait
|
|
1136
|
+
fake = os.path.join(self.tmp.name, "bin")
|
|
1137
|
+
os.makedirs(fake)
|
|
1138
|
+
write(os.path.join(fake, "pbcopy"), '#!/bin/sh\ncat > "$CLIP_OUT"\n')
|
|
1139
|
+
os.chmod(os.path.join(fake, "pbcopy"), 0o755)
|
|
1140
|
+
clip = os.path.join(self.tmp.name, "clip.txt")
|
|
1141
|
+
code = textwrap.dedent("""
|
|
1142
|
+
import asyncio, json, os, time
|
|
1143
|
+
from tuieval.tui import AnswerScreen, EvalsApp, RunScreen
|
|
1144
|
+
from textual.widgets import SelectionList
|
|
1145
|
+
def pasted():
|
|
1146
|
+
with open(os.environ["CLIP_OUT"]) as f:
|
|
1147
|
+
text = f.read()
|
|
1148
|
+
os.remove(os.environ["CLIP_OUT"])
|
|
1149
|
+
return text
|
|
1150
|
+
async def go():
|
|
1151
|
+
app = EvalsApp({})
|
|
1152
|
+
async with app.run_test(size=(160, 50)) as pilot:
|
|
1153
|
+
await pilot.pause()
|
|
1154
|
+
s = app.screen
|
|
1155
|
+
s.query_one("#suites", SelectionList).select("apps")
|
|
1156
|
+
s.selected_models = {"a"}
|
|
1157
|
+
s.refresh_models()
|
|
1158
|
+
s.query_one("#tier-certify").value = True
|
|
1159
|
+
await pilot.pause()
|
|
1160
|
+
s.action_start()
|
|
1161
|
+
deadline = time.time() + 180 # CI machines can take long to start servers
|
|
1162
|
+
while time.time() < deadline:
|
|
1163
|
+
await pilot.pause(0.1)
|
|
1164
|
+
if isinstance(app.screen, RunScreen) and app.screen.req_started:
|
|
1165
|
+
break
|
|
1166
|
+
run, out = app.screen, {}
|
|
1167
|
+
await pilot.pause(0.5) # the reasoning has streamed; the answer waits 1.5 s
|
|
1168
|
+
await pilot.press("y")
|
|
1169
|
+
await pilot.pause()
|
|
1170
|
+
out["live"] = pasted()
|
|
1171
|
+
deadline = time.time() + 120
|
|
1172
|
+
while run.running and time.time() < deadline:
|
|
1173
|
+
await pilot.pause(0.2)
|
|
1174
|
+
app.push_screen(AnswerScreen(list(run.recent_entries.values())))
|
|
1175
|
+
await pilot.pause()
|
|
1176
|
+
for key in "ya":
|
|
1177
|
+
await pilot.press(key)
|
|
1178
|
+
await pilot.pause()
|
|
1179
|
+
out[key] = pasted()
|
|
1180
|
+
print(json.dumps(out))
|
|
1181
|
+
asyncio.run(go())
|
|
1182
|
+
""")
|
|
1183
|
+
p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600,
|
|
1184
|
+
env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS="", CLIP_OUT=clip,
|
|
1185
|
+
PATH=fake + os.pathsep + os.environ["PATH"]))
|
|
1186
|
+
out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
|
|
1187
|
+
self.assertEqual(out, {"live": "Thinking it over. ", "y": "Thinking it over. ", "a": "ANSWER: 1"}, out)
|
|
1188
|
+
|
|
1189
|
+
def test_copy_falls_back_to_the_terminal(self):
|
|
1190
|
+
from unittest import mock
|
|
1191
|
+
from tuieval.tui import copy_text
|
|
1192
|
+
app = mock.Mock()
|
|
1193
|
+
with mock.patch.dict(os.environ, {"PATH": self.tmp.name}): # no clipboard tool here
|
|
1194
|
+
self.assertFalse(copy_text(app, "some reasoning"))
|
|
1195
|
+
app.copy_to_clipboard.assert_called_once_with("some reasoning")
|
|
1196
|
+
|
|
1133
1197
|
def test_tui_counts_ticked_models_and_names_filtered_ones_before_start(self):
|
|
1134
1198
|
code = textwrap.dedent("""
|
|
1135
1199
|
import asyncio, json
|
|
@@ -1609,6 +1673,23 @@ class PTAIndex(unittest.TestCase):
|
|
|
1609
1673
|
app.screen.fill_pta() # rebuilt: still sorted
|
|
1610
1674
|
await pilot.pause(0.2)
|
|
1611
1675
|
sorts["kept"] = [str(pta_t.get_row_at(r)[0]) for r in range(pta_t.row_count)]
|
|
1676
|
+
bars_w = app.screen.query_one("#pta-bars", Static)
|
|
1677
|
+
shown = [x["model"] for x in app.screen.pta_result["models"]]
|
|
1678
|
+
await pilot.click(bars_w, offset=(1, 1 + 2 + shown.index("hosted"))) # a click on its name
|
|
1679
|
+
await pilot.pause(0.3)
|
|
1680
|
+
clicks = {"removed": [sorted(app.pq_models), [x["model"] for x in app.screen.pta_result["models"]],
|
|
1681
|
+
"Removed: hosted" in str(bars_w.render())]}
|
|
1682
|
+
await pilot.click(bars_w, offset=(1, 1 + 2)) # the last one stays
|
|
1683
|
+
await pilot.pause(0.3)
|
|
1684
|
+
clicks["last"] = sorted(app.pq_models)
|
|
1685
|
+
app.screen.action_pta_restore(0)
|
|
1686
|
+
await pilot.pause(0.3)
|
|
1687
|
+
clicks["restored"] = [sorted(app.pq_models), len(app.screen.pta_result["models"])]
|
|
1688
|
+
app.screen.action_pta_remove(shown.index("hosted"))
|
|
1689
|
+
app.screen.action_pta_restore_all()
|
|
1690
|
+
await pilot.pause(0.3)
|
|
1691
|
+
clicks["all"] = [sorted(app.pq_models), "Removed" in str(bars_w.render())]
|
|
1692
|
+
sorts["clicks"] = clicks
|
|
1612
1693
|
app.screen.query_one(TabbedContent).active = "tab-failures"
|
|
1613
1694
|
await pilot.pause(0.3)
|
|
1614
1695
|
f = app.screen.query_one("#failures", DataTable)
|
|
@@ -1647,6 +1728,11 @@ class PTAIndex(unittest.TestCase):
|
|
|
1647
1728
|
s = got["sorts"] # a click on a column name sorts by it, a second click reverses
|
|
1648
1729
|
self.assertEqual((s["up"], s["down"], s["kept"]), (["hosted", "good"], ["good", "hosted"], ["good", "hosted"]))
|
|
1649
1730
|
self.assertIn("answers right ▼", s["labels"])
|
|
1731
|
+
c = s["clicks"] # a click on a name in the bars leaves it out of every tab; the last one stays
|
|
1732
|
+
self.assertEqual(c["removed"], [["good"], ["good"], True])
|
|
1733
|
+
self.assertEqual(c["last"], ["good"])
|
|
1734
|
+
self.assertEqual(c["restored"], [["good", "hosted"], 2])
|
|
1735
|
+
self.assertEqual(c["all"], [[], False])
|
|
1650
1736
|
self.assertEqual((s["tests"][0], s["opened"]), ("apps: d", "d"))
|
|
1651
1737
|
fails = got["fails"]
|
|
1652
1738
|
self.assertEqual(fails["all"], [4, "4 failures"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/pack.toml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/system.txt
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/tests.yaml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/tools.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|