diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 15:20:46 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 15:20:46 +0200 |
| commit | b81d322fc61e0be533912b074ef17f0f72311760 (patch) | |
| tree | 2f37f44650ac66217f7836bef3fca1b5eaacdac2 | |
| parent | 056853313dd73975a8af52e151af5eed33253fc7 (diff) | |
| download | desktop-assistant-b81d322fc61e0be533912b074ef17f0f72311760.tar.gz desktop-assistant-b81d322fc61e0be533912b074ef17f0f72311760.zip | |
Page renders never upscale; a non-stream answer is an error
-scale-to 2048 also enlarges: a 600x300 pt scan went out as 2048x1024
beside a second image, and llama-server's model process died on it. The
router then answered with a plain 'proxy error: Failed to read
connection', which the parser skipped as a non-data line, so the turn
finished as an empty reply. Pages now render at 150 dpi, lowered only for
a page whose longest side would pass 1600 px (size from pdfinfo), and an
answer with no data lines raises with its first line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | assistant.py | 53 |
1 files changed, 42 insertions, 11 deletions
diff --git a/assistant.py b/assistant.py index 6d054b8..1341ddb 100644 --- a/assistant.py +++ b/assistant.py @@ -235,6 +235,7 @@ IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"} MAX_FILES = 8 MAX_IMAGE = 20 << 20 MAX_PAGE_IMAGES = 4 # scanned PDF pages sent as images +PAGE_PX = 1600 # longest side of a page render class Refused(Exception): @@ -339,11 +340,19 @@ def resolve(part, images=True): if "page" in part: if sha256(part["path"]) != part["sha256"]: return gone - # Bounded: at -r 150 a large page renders huge (a 3000 pt page - # was 6250x6250 and 171 MB), past the cap an image file has. + # 150 dpi, lowered for a large page so its longest side stays + # within PAGE_PX: unbounded, a 3000 pt page rendered 6250x6250 at + # 171 MB. Never raised: -scale-to also upscales, and a 600x300 pt + # scan sent as 2048x1024 with a second image took llama-server's + # model process down. n = str(part["page"]) - out = subprocess.run(["pdftoppm", "-f", n, "-l", n, "-r", "150", "-scale-to", "2048", - "-png", part["path"]], capture_output=True, timeout=60) + info = subprocess.run(["pdfinfo", "-f", n, "-l", n, part["path"]], + capture_output=True, text=True, timeout=30).stdout + size = re.search(r"size:\s+([\d.]+) x ([\d.]+)", info) + longest = max(map(float, size.groups())) if size else 842.0 # A4 if unreported + dpi = min(150.0, PAGE_PX * 72 / longest) + out = subprocess.run(["pdftoppm", "-f", n, "-l", n, "-r", f"{dpi:.2f}", "-png", + part["path"]], capture_output=True, timeout=60) if out.returncode or not out.stdout or len(out.stdout) > MAX_IMAGE: return gone data, mime = out.stdout, "image/png" @@ -660,8 +669,15 @@ def ask(messages, calls, tools=None): raise RuntimeError(f"llama-server {e.code}: " f"{e.read().decode(errors='replace')[:300]}") from None with r: + streamed, other = False, b"" for line in r: - if not line.startswith(b"data: ") or line.strip() == b"data: [DONE]": + if not line.startswith(b"data: "): + # The router answers a crashed model with a plain line, e.g. + # "proxy error: Failed to read connection", not a stream. + other = other or line.strip() + continue + streamed = True + if line.strip() == b"data: [DONE]": continue chunk = json.loads(line[6:]) if "error" in chunk: # a failure mid-stream arrives as a chunk @@ -681,6 +697,8 @@ def ask(messages, calls, tools=None): yield "think", delta["reasoning_content"] if delta.get("content"): yield "token", delta["content"] + if not streamed: + raise RuntimeError(other.decode(errors="replace")[:300] or "llama-server sent nothing") def say(mine, kind, value, done=False): @@ -986,12 +1004,16 @@ def test(): assert all(x["type"] == "image_url" for x in sent) assert sent[1]["image_url"]["url"].startswith("data:image/png;base64,") page = base64.b64decode(sent[0]["image_url"]["url"].split(",", 1)[1]) - # Page 2, the square one, bounded to 2048: page 1 would not be square. - assert page[:4] == b"\x89PNG" and struct.unpack(">II", page[16:24]) == (2048, 2048) + # Page 2, the square one, bounded to PAGE_PX: page 1 would not be square. + w, h = struct.unpack(">II", page[16:24]) + assert page[:4] == b"\x89PNG" and w == h and PAGE_PX - 2 <= w <= PAGE_PX, (w, h) scans = os.path.join(fd, "scans.pdf") - tiny_pdf(scans, ["cover"] + [""] * 6) + tiny_pdf(scans, ["cover"] + [("", 300, 150)] * 6) b, r, c = attach([scans], 100000) assert len(r) == 4 and c[0]["truncated"] and "[scanned pages not shown: 6, 7]" in b[0] + small = base64.b64decode(resolve(r[0])["image_url"]["url"].split(",", 1)[1]) + # A small page stays at 150 dpi, not raised to the bound: 300 pt is 625 px. + assert struct.unpack(">II", small[16:24]) == (625, 313), struct.unpack(">II", small[16:24]) with open(os.path.join(fd, "exact.txt"), "w") as f: f.write("x" * 24) with open(os.path.join(fd, "over.txt"), "w") as f: @@ -1056,14 +1078,17 @@ def test(): self.send_response(200) self.end_headers() try: - for delta in script["replies"].pop(0): + reply = script["replies"].pop(0) + for delta in reply: self.wfile.write(delta.encode() if isinstance(delta, str) else b"data: " + json.dumps({"choices": [{"delta": delta}]}) .encode() + b"\n\n") self.wfile.flush() time.sleep(script["delay"]) - self.wfile.write(b'data: {"choices": [], "usage": {"prompt_tokens": 42}}\n\n' - b"data: [DONE]\n\n") + # A plain line stands alone, as the router sends it. + if not any(isinstance(d, str) and not d.startswith("data:") for d in reply): + self.wfile.write(b'data: {"choices": [], "usage": {"prompt_tokens": 42}}\n\n' + b"data: [DONE]\n\n") except BrokenPipeError: # a stopped reply hangs up mid-stream pass @@ -1117,6 +1142,12 @@ def test(): # The tool result went to the model but history keeps a placeholder. assert load_conv(DB, conv)[0][0][2] == {"role": "tool", "tool_call_id": "c1", "content": "(shown to the user at the time)"} + # A plain line instead of a stream (the router, when the model died) + # is an error carrying that line, not an empty reply. + script["replies"] = [["proxy error: Failed to read connection\n"]] + send(type="ask", text="anyone?") + e = until("error")[-1] + assert e["message"] == "proxy error: Failed to read connection" and e["turn"] is True # An error chunk mid-stream reaches the client with its message. script["replies"] = [['data: {"error": {"message": "context full"}}\n\n']] CFG["thinking"] = False |
