Merge master into the idle unload

Two conflicts, both where the local cleanup grew a second thing at once.

cleanup._local now passes the server's context alongside the timeout, and this
branch wrapped that same call in the busy() hold; the call takes both.

FakeServer gained a `context` attribute on master and a `held` counter here.
This commit is contained in:
2026-09-05 12:18:50 +03:00
7 changed files with 208 additions and 13 deletions
+37 -1
View File
@@ -464,6 +464,29 @@ class Cleanup(DikteTest):
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
api.cleanup("hello", "k", "m", "p")
def test_a_reply_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
# Half a sentence looks like a cleaned-up transcript and is not one. The
# caller keeps what it was given, which is the whole dictation.
reply = {"choices": [{"message": {"content": "Hello, and then the"},
"finish_reason": "length"}]}
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
api.cleanup("hello", "k", "m", "p")
self.assertIn("cut off", str(caught.exception))
def test_a_reply_that_stopped_on_its_own_is_kept(self):
reply = {"choices": [{"message": {"content": "Hello."},
"finish_reason": "stop"}]}
with fake_urlopen(reply):
self.assertEqual(api.cleanup("hello", "k", "m", "p"), "Hello.")
def test_all_thinking_is_named_before_the_ceiling_it_was_cut_at(self):
"""Both are true at once, and only one of them says what to change."""
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"},
"finish_reason": "length"}]}
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
api.cleanup("hello", "k", "m", "p")
self.assertIn("Thinking", str(caught.exception))
def test_a_rate_limit_is_explained(self):
with fake_urlopen(http_error(429)), \
self.assertRaises(api.ApiError) as caught:
@@ -472,6 +495,14 @@ class Cleanup(DikteTest):
class Chat(DikteTest):
def test_an_answer_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
# Half an answer reads like a whole one once it is on the screen.
reply = {"choices": [{"message": {"content": "Booked it for the"},
"finish_reason": "length"}]}
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
api.chat([{"role": "user", "content": "book it"}], "k", "m", "p")
self.assertIn("cut off", str(caught.exception))
def test_the_history_is_sent_after_the_system_prompt(self):
history = [{"role": "user", "content": "book it"},
{"role": "assistant", "content": "done"}]
@@ -617,12 +648,14 @@ if __name__ == "__main__":
class FakeServer:
"""A ggml.Server as far as api.py is concerned."""
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""):
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log="",
context=8192):
self.url = url
self.fails = fails
self.log = log
self.starts = 0
self.held = 0
self.context = context
def serve(self):
self.starts += 1
@@ -641,6 +674,9 @@ class FakeServer:
def error(self):
return self.log
def settings(self):
return {"context": self.context}
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
+46
View File
@@ -411,6 +411,52 @@ class Here(DikteTest):
cleanup.run("uh, done", self.conf, "the rules")
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
def test_thinking_is_given_room_of_its_own_rather_than_the_answer_s(self):
# llama.cpp counts the thinking towards the same ceiling, so a rung that
# took its budget out of the answer would leave a short dictation with
# nothing to reply with. On a context roomy enough that the clamp the
# top rung would otherwise meet is not what is being measured.
self.patch_attr(ggml, "llm", FakeServer(context=32768))
for rung, room in api.THINKING_ROOM.items():
with self.subTest(rung=rung):
self.conf["local_llm_reasoning"] = rung
with fake_urlopen(chat_reply("Done.")) as calls:
cleanup.run("uh, done", self.conf, "the rules")
self.assertEqual(sent_json(calls[0])["max_tokens"], 512 + room)
def test_each_rung_of_the_ladder_thinks_longer_than_the_one_below(self):
rungs = [api.THINKING_ROOM[name] for name in
("minimal", "low", "medium", "high", "xhigh", "max")]
self.assertEqual(rungs, sorted(rungs))
self.assertEqual(len(set(rungs)), len(rungs))
def test_the_models_own_default_is_given_room_to_think_in_too(self):
# Nothing is sent, so a template that thinks will think, and the ceiling
# has to survive that as well.
self.conf["local_llm_reasoning"] = ""
with fake_urlopen(chat_reply("Done.")) as calls:
cleanup.run("uh, done", self.conf, "the rules")
self.assertEqual(sent_json(calls[0])["max_tokens"],
512 + api.DEFAULT_THINKING_ROOM)
def test_the_ceiling_stays_under_the_context_the_server_was_started_with(self):
# Above the context there is no ceiling at all: the runaway would run to
# the end of the context instead of stopping where this says.
self.patch_attr(ggml, "llm", FakeServer(context=2048))
self.conf["local_llm_reasoning"] = "max"
with fake_urlopen(chat_reply("Done.")) as calls:
cleanup.run("uh, done", self.conf, "the rules")
self.assertLess(sent_json(calls[0])["max_tokens"], 2048)
def test_the_prompt_keeps_its_share_of_a_small_context(self):
self.patch_attr(ggml, "llm", FakeServer(context=2048))
self.conf["local_llm_reasoning"] = "max"
with fake_urlopen(chat_reply("Done.")) as calls:
cleanup.run("x" * 2000, self.conf, "the rules")
# 2048 less half the characters of prompt and transcript together.
self.assertEqual(sent_json(calls[0])["max_tokens"],
2048 - (len("the rules") + 2000) // 2)
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
+34 -1
View File
@@ -14,7 +14,7 @@ import unittest
from typing import ClassVar
from unittest import mock
from PyQt6.QtCore import QPoint, QPointF, Qt
from PyQt6.QtCore import QPoint, QPointF, QRect, Qt
from PyQt6.QtGui import QWheelEvent
from PyQt6.QtWidgets import QApplication, QMessageBox
@@ -246,6 +246,39 @@ class Settings(DikteTest):
label.resize(2000, line)
self.assertLessEqual(label.minimumHeight(), line)
def test_a_label_written_before_the_layout_places_it_claims_nothing(self):
# The publisher note is written while the settings window is still
# being built, when the label is a handful of pixels wide. Wrapped
# against that width the sentence became a hundred lines, and the
# minimum taken from it did not stay a minimum: QLabel folds it into
# its own cached size hints and clears that cache only when the text
# changes. The group box stood thousands of pixels tall, with the
# model box and everything under it off the bottom of the window,
# until another publisher was picked.
label = settings_ui.WrappedLabel()
self.addCleanup(label.deleteLater)
line = label.fontMetrics().height()
label.resize(8, line)
label.setText("Google Gemma 4, the small one. The default: nothing "
"else this size follows an instruction as closely, and "
"cleanup is all instruction.")
self.assertEqual(label.minimumHeight(), 0)
# Placed and shown, which is the first width worth measuring against.
# The room the wrapping needs is claimed then, and it is the lines the
# sentence takes at this width rather than at the last one. Counted
# off the font rather than written down here, because how many lines
# 400 pixels hold is a different answer on every machine.
label.resize(400, line)
label.show()
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
needed = label.fontMetrics().boundingRect(
QRect(0, 0, 400, 0), wrap, label.text()).height()
self.assertGreater(needed, line) # or the sentence never wrapped
self.assertEqual(label.minimumHeight(), needed)
# And the label's own hints are the wrapping at this width too, not
# the hundred lines the eight pixel one asked for.
self.assertLessEqual(label.sizeHint().height(), 3 * needed)
def test_saving_without_touching_anything_changes_nothing(self):
"""Every widget has to load what is stored, or Save writes its default
over it. This says so for the whole table at once."""