diff --git a/README.md b/README.md index 4b42583..0dd4540 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Dikte Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI or OpenRouter -for transcription, a model on OpenRouter cleans it up (dropping the *uh*s, the +for transcription, a model cleans it up (dropping the *uh*s, the restarts, the missing punctuation), and the result lands in your clipboard and is pasted into whatever window you were typing in. @@ -43,8 +43,8 @@ again and leaves your settings and dictations alone unless you pass `--purge`. Three keys go in the settings window: **OpenAI**, **Groq** and **OpenRouter**. Speech to text runs on any of them (`gpt-4o-transcribe` by default), cleanup -always on OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key -can cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and +on OpenRouter (`google/gemini-3.5-flash-lite`) or, when either is installed, on +Claude Code or Codex instead, so a single OpenRouter key can cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and `OPENROUTER_API_KEY`, and are stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off, in which case the raw transcript is pasted, and a thinking model's effort can be @@ -148,6 +148,7 @@ audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting meeting.py channel split, speaker labelling, cleanup, minutes assistant.py running a dictation through Claude Code, Codex or OpenRouter api.py transcription on either provider, OpenRouter cleanup (stdlib only) +cleanup.py who rewrites the transcript: OpenRouter, Claude Code or Codex worker.py transcribe → clean up → clipboard → paste vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps diff --git a/README.tr.md b/README.tr.md index 97b007d..29f2f95 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,7 +1,7 @@ # Dikte `Ctrl+Space`'e bas, konuş, tekrar bas. Ses OpenAI'ye ya da OpenRouter'a gidip -yazıya çevrilir, OpenRouter'daki bir model transkripti temizler (ıı'lar, +yazıya çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. @@ -44,8 +44,8 @@ diktelerine dokunmaz. Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**. Sesi yazıya çevirme üçünden birinde çalışır (varsayılan `gpt-4o-transcribe`), -temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir -OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY`, +temizleme OpenRouter'da (`google/gemini-3.5-flash-lite`) ya da kuruluysa Claude +Code veya Codex'te, yani tek bir OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript @@ -147,6 +147,7 @@ audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib) +cleanup.py transkripti kim temizler: OpenRouter, Claude Code ya da Codex worker.py transkript → temizleme → pano → yapıştırma vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları diff --git a/assistant.py b/assistant.py index 20339bb..f708ab3 100644 --- a/assistant.py +++ b/assistant.py @@ -75,7 +75,10 @@ CODEX_ITEMS = { CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low", "medium": "medium", "high": "high", "xhigh": "xhigh", "max": "max"} -CODEX_EFFORT = {"none": "minimal", "minimal": "minimal", "low": "low", +# "minimal" was Codex's bottom rung until the newer models replaced it with +# "none", and each of them rejects the other's word for it with a 400. "low" is +# the one every model has, so the two lowest rungs land there instead. +CODEX_EFFORT = {"none": "low", "minimal": "low", "low": "low", "medium": "medium", "high": "high", "xhigh": "high", "max": "high"} @@ -421,7 +424,7 @@ def _conclude(found, code, stderr, session, service): if code != 0 and not found["answer"]: if session and _session_missing(stderr): raise _SessionGone() - raise AssistantError(_last_line(stderr) or found["failure"] or t( + raise AssistantError(last_line(stderr) or found["failure"] or t( "{service} exited with code {code}.", service=service, code=code)) if found["failure"] and not found["answer"]: raise AssistantError(found["failure"]) @@ -480,6 +483,11 @@ def _finish(proc): return stderr -def _last_line(text): +def last_line(text): + """The line worth showing out of a CLI's stderr: the last one it wrote. + + Shared with cleanup, which runs the same two programs for a different job + and fails the same way when they are unhappy. + """ lines = [line for line in (text or "").splitlines() if line.strip()] return lines[-1].strip() if lines else "" diff --git a/cleanup.py b/cleanup.py new file mode 100644 index 0000000..f937756 --- /dev/null +++ b/cleanup.py @@ -0,0 +1,181 @@ +"""Who rewrites the transcript once it has been heard. + +Normally a small model on OpenRouter: one request, a second, a few tenths of a +cent. A machine with Claude Code or Codex on it is already paying for a model +though, and the subscription that answers "put that in my calendar on Thursday" +can just as well take the "eee"s out of a sentence. No second key, no second +bill. It costs seconds rather than one, because a CLI opens a whole session to +do it, which is the trade. + +Whoever does it, the job is the same one: no tools, no files, no memory of the +last dictation. There is nothing here to look up and nothing to carry over, and +a transcript is text from a microphone rather than an instruction, so the less +the agent can reach while it reads one, the better. +""" + +import os +import shutil +import subprocess +import tempfile + +import api +import assistant +from i18n import t + +PROVIDERS = ("openrouter", "claude", "codex") + + +class CleanupError(api.ApiError): + """What a CLI could not do. + + An ApiError because to the chain a cleanup that failed is a cleanup that + failed, whichever way it was run, and every caller already catches one and + keeps the raw transcript. + """ + + +def provider(conf): + chosen = conf["cleanup_provider"] + return chosen if chosen in PROVIDERS else "openrouter" + + +def executable(name): + """The CLI a provider runs, or "" when it needs none.""" + return {"claude": "claude", "codex": "codex"}.get(name, "") + + +def model(conf): + """Which model does the cleaning, for the history and the settings window.""" + name = provider(conf) + if name == "claude": + return conf["cleanup_claude_model"].strip() or "haiku" + if name == "codex": + # Codex is left on whatever it is set to unless a model is typed in, so + # here there is only the name of the thing that did it. + return conf["cleanup_codex_model"].strip() or "codex" + return conf["cleanup_model"] + + +def run(text, conf, system_prompt, timeout=180): + """Hand the transcript to whoever is set to clean it up.""" + name = provider(conf) + if name == "openrouter": + return api.cleanup( + text, conf.openrouter_key(), conf["cleanup_model"], system_prompt, + reasoning=conf["cleanup_reasoning"], + base_url=conf["openrouter_base_url"], timeout=timeout, + ) + runner = _claude if name == "claude" else _codex + return runner(text, conf, system_prompt, timeout) + + +def _wrap(text): + """The same fence the OpenRouter call puts around it: this is the material, + not the instruction, however much of it reads like one.""" + return f"\n{text}\n" + + +# --- Claude Code ---------------------------------------------------------- + +def _claude(text, conf, system_prompt, timeout): + cmd = [ + "claude", "-p", _wrap(text), + # --system-prompt rather than --append-system-prompt: the cleanup rules + # are the whole job, and Claude Code's own instructions are about + # working on a codebase. + "--system-prompt", system_prompt, + "--model", model(conf), + "--output-format", "text", + "--tools", "", # nothing to run + "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', + "--no-session-persistence", # nothing to resume + ] + effort = assistant.CLAUDE_EFFORT.get(conf["cleanup_reasoning"], "") + if effort: + cmd += ["--effort", effort] + + answer = _output(cmd, timeout, "Claude") + if not answer: + raise CleanupError(t("{service} answered with nothing.", service="Claude")) + return answer + + +# --- Codex ---------------------------------------------------------------- + +def _codex(text, conf, system_prompt, timeout): + # Codex takes no system prompt of its own, so the rules ride in front of the + # transcript, kept apart from it so the two are not read as one. + body = f"{system_prompt}\n\n---\n\n{_wrap(text)}" + cmd = [ + "codex", "exec", + "--sandbox", "read-only", # it has no reason to touch the disk + "--skip-git-repo-check", + "--ephemeral", # nothing to resume + "--color", "never", + "-c", 'approval_policy="never"', # there is nobody here to approve + ] + if conf["cleanup_codex_model"].strip(): + cmd += ["-m", conf["cleanup_codex_model"].strip()] + effort = assistant.CODEX_EFFORT.get(conf["cleanup_reasoning"], "") + if effort: + cmd += ["-c", f'model_reasoning_effort="{effort}"'] + + # `codex exec` prints a header, its thinking and a token count around the + # answer; the file it writes on the way out is the answer on its own. + handle, last_message = tempfile.mkstemp(prefix="dikte-cleanup-", suffix=".txt") + os.close(handle) + cmd += ["-o", last_message, body] + try: + _output(cmd, timeout, "Codex") + answer = _read(last_message) + finally: + try: + os.unlink(last_message) + except OSError: + pass + + if not answer: + raise CleanupError(t("{service} answered with nothing.", service="Codex")) + return answer + + +def _read(path): + try: + with open(path, encoding="utf-8", errors="replace") as fh: + return fh.read().strip() + except OSError: + return "" + + +# --- running a CLI -------------------------------------------------------- + +def _output(cmd, timeout, service): + """Run cmd to the end and return what it printed. + + It runs in the home directory rather than wherever the agent is pointed: a + project's instructions have opinions about how text should be written, and + none of them are about this transcript. + """ + binary = cmd[0] + if not shutil.which(binary): + raise CleanupError(t( + "{binary} not found. Install it, or have OpenRouter clean up " + "instead, under Settings → API and models.", binary=binary, + )) + try: + done = subprocess.run( + cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL, + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=timeout, + ) + except subprocess.TimeoutExpired: + raise CleanupError(t("{service} did not finish within {seconds} seconds.", + service=service, seconds=timeout)) from None + except OSError as exc: + raise CleanupError(t("Could not run {binary}: {error}", + binary=binary, error=exc)) from exc + if done.returncode != 0: + raise CleanupError(assistant.last_line(done.stderr) or t( + "{service} exited with code {code}.", + service=service, code=done.returncode)) + return (done.stdout or "").strip() diff --git a/cli.py b/cli.py index d907537..334a957 100644 --- a/cli.py +++ b/cli.py @@ -25,6 +25,7 @@ from PyQt6.QtCore import QCoreApplication, QTimer import api import assistant import audio +import cleanup import config as cfg import filetranscribe import hotkey @@ -764,16 +765,18 @@ def cmd_status(opts): def cmd_doctor(opts): """What the settings window checks behind its buttons, in one pass.""" conf = cfg.Config() - programs = {name: shutil.which(name) or "" - for name in ("pw-record", "wl-copy", "ydotool", "ffmpeg", - "pactl", "kwriteconfig6", - assistant.executable(assistant.provider(conf)) or "claude")} + wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6", + assistant.executable(assistant.provider(conf)) or "claude", + cleanup.executable(cleanup.provider(conf))] + programs = {name: shutil.which(name) or "" for name in wanted if name} target = conf.transcribe_target() + cleaner = cleanup.provider(conf) checks = { "programs": programs, "transcription": {"provider": target.provider, "model": target.model, "key": bool(target.api_key)}, - "cleanup": {"enabled": conf["cleanup_enabled"], "model": conf["cleanup_model"], + "cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner, + "model": cleanup.model(conf), "key": bool(conf.openrouter_key())}, "agent": {"provider": assistant.provider(conf), "directory": assistant.working_dir(conf)}, @@ -784,8 +787,11 @@ def cmd_doctor(opts): lines += [ f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on " f"{target.model}", - f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on " - f"{conf['cleanup_model']}", + # Cleanup on a CLI needs no key, so what is checked is the program. + (f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on " + f"{conf['cleanup_model']}") if cleaner == "openrouter" else + (f"{'✓' if programs[cleanup.executable(cleaner)] else '✗'} " + f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"), f"{'✓' if checks['running'] else '·'} application " + ("running" if checks["running"] else "not running"), ] diff --git a/config.py b/config.py index 6d052b0..6476bad 100644 --- a/config.py +++ b/config.py @@ -375,7 +375,10 @@ DEFAULTS = { "language": "tr", "transcribe_prompt": "", "cleanup_enabled": True, + "cleanup_provider": "openrouter", # openrouter | claude | codex "cleanup_model": "google/gemini-3.5-flash-lite", + "cleanup_claude_model": "haiku", # Claude Code: an alias, or a full model id + "cleanup_codex_model": "", # empty -> whatever Codex is set to "cleanup_reasoning": "", # empty -> whatever the model does by default "cleanup_prompt": "", # empty -> language-specific default "auto_paste": True, diff --git a/filetranscribe.py b/filetranscribe.py index 4839d13..f797d58 100644 --- a/filetranscribe.py +++ b/filetranscribe.py @@ -17,6 +17,7 @@ import wave from PyQt6.QtCore import QObject, pyqtSignal import api +import cleanup from i18n import t CHUNK_SECONDS = 600 # 10 min ≈ 19 MB at 16 kHz mono s16 @@ -130,14 +131,7 @@ class FileTranscriber(QObject): out = [] for block in split_text(text, timestamps): self._check() - out.append(api.cleanup( - block, - conf.openrouter_key(), - conf["cleanup_model"], - prompt, - reasoning=conf["cleanup_reasoning"], - base_url=conf["openrouter_base_url"], - )) + out.append(cleanup.run(block, conf, prompt)) return ("\n" if timestamps else "\n\n").join(out) diff --git a/i18n.py b/i18n.py index 7466143..c414e34 100644 --- a/i18n.py +++ b/i18n.py @@ -186,6 +186,18 @@ TR = { "Connection works. {count} audio models visible.": "Bağlantı tamam. {count} ses modeli görünüyor.", "Clean the transcript with a model": "Transkripti bir modelle temizle", + "OpenRouter is the quickest and the only one that needs nothing installed. " + "Claude Code and Codex clean up on the subscription you already have, " + "without a second key, and take a few seconds longer because each one opens " + "a session to do it.": + "En hızlısı OpenRouter'dır ve kurulu bir program istemeyen tek seçenektir. " + "Claude Code ile Codex, temizliği hâlihazırda ödediğin abonelik üzerinden " + "yapar, ikinci bir anahtar istemez; her biri bunun için bir oturum açtığından " + "birkaç saniye daha uzun sürer.", + "{binary} is not on your PATH, so cleanup would fail and the raw transcript " + "would be pasted. Install it, or pick another one above.": + "{binary} PATH'te değil; temizleme başarısız olur ve ham transkript " + "yapıştırılır. Kur ya da yukarıdan başka birini seç.", "Thinking": "Düşünme", "Model's own default": "Modelin kendi varsayılanı", "Off": "Kapalı", @@ -380,6 +392,14 @@ TR = { "It was not allowed to use: {tools}": "Şunları kullanmasına izin yoktu: {tools}", "The model returned an empty reply.": "Model boş cevap döndürdü.", + # --- cleanup, when a CLI does it ---------------------------------------- + "{binary} not found. Install it, or have OpenRouter clean up instead, " + "under Settings → API and models.": + "{binary} bulunamadı. Kur ya da Ayarlar → API ve modeller sekmesinden " + "temizliği OpenRouter'a bırak.", + "{service} did not finish within {seconds} seconds.": + "{service} {seconds} saniye içinde bitmedi.", + # --- settings: the agent ------------------------------------------------ "Agent": "Ajan", "This shortcut records the same way dictation does, but the transcript is " diff --git a/meeting.py b/meeting.py index c0713c6..c178668 100644 --- a/meeting.py +++ b/meeting.py @@ -25,6 +25,7 @@ import wave from PyQt6.QtCore import QObject, pyqtSignal import api +import cleanup import config as cfg import filetranscribe import vad @@ -208,14 +209,7 @@ class MeetingPipeline(QObject): if len(blocks) > 1: self._say(t("Cleaning up {index}/{count}…", index=index, count=len(blocks))) - out.append(api.cleanup( - block, - conf.openrouter_key(), - conf["cleanup_model"], - prompt, - reasoning=conf["cleanup_reasoning"], - base_url=conf["openrouter_base_url"], - )) + out.append(cleanup.run(block, conf, prompt, timeout=600)) return "\n".join(out) def _write(self, doc_path, minutes, transcript, entry): diff --git a/settings_ui.py b/settings_ui.py index 3a36c7c..c3002f9 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -16,6 +16,7 @@ from PyQt6.QtWidgets import ( import api import assistant import audio +import cleanup import config as cfg import filetranscribe import hotkey @@ -49,6 +50,15 @@ CLEANUP_MODELS = [ "google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5", "openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct", ] +# The same two CLIs the agent can run on, doing the smaller job instead. They +# are offered second: a request to OpenRouter is over in a second, and a CLI +# opens a session first. +CLEANUP_PROVIDERS = [ + ("OpenRouter", "openrouter"), ("Claude Code", "claude"), ("Codex", "codex"), +] +# Cleaning up a sentence is the lightest thing either of them will ever be +# asked, so the small model comes first. +CLEANUP_CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"] # Minutes are a harder job than cleanup: an hour of talk has to be read whole # and turned into decisions, so the starting points are the larger models. MEETING_MODELS = [ @@ -282,16 +292,42 @@ class SettingsWindow(QDialog): outer.addWidget(stt) orr = QGroupBox(t("Transcript cleanup")) - orr_form = QFormLayout(orr) + orr_form = self.cleanup_form = QFormLayout(orr) self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model")) orr_form.addRow("", self.cleanup_enabled) + self.cleanup_provider = QComboBox() + for label, value in CLEANUP_PROVIDERS: + self.cleanup_provider.addItem(t(label), value) + self.cleanup_provider.setToolTip(t( + "OpenRouter is the quickest and the only one that needs nothing " + "installed. Claude Code and Codex clean up on the subscription you " + "already have, without a second key, and take a few seconds longer " + "because each one opens a session to do it." + )) + self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed) + orr_form.addRow(t("Runs on"), self.cleanup_provider) + self.cleanup_model = QComboBox() self.cleanup_model.setEditable(True) self.cleanup_model.addItems(CLEANUP_MODELS) self.refresh_models = QPushButton(t("Fetch model list")) self.refresh_models.clicked.connect(self._load_models) - orr_form.addRow(t("Model"), self._row(self.cleanup_model, self.refresh_models)) + self.cleanup_model_row = self._row(self.cleanup_model, self.refresh_models) + orr_form.addRow(t("Model"), self.cleanup_model_row) + + # One row per provider rather than one box that means a different thing + # in each: an OpenRouter id and a Claude alias do not belong in the same + # field, and only the row of whoever is chosen is on screen. + self.cleanup_claude_model = QComboBox() + self.cleanup_claude_model.setEditable(True) + self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS) + orr_form.addRow(t("Model"), self.cleanup_claude_model) + + self.cleanup_codex_model = QComboBox() + self.cleanup_codex_model.setEditable(True) + self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS) + orr_form.addRow(t("Model"), self.cleanup_codex_model) self.cleanup_reasoning = QComboBox() for label, value in REASONING_LEVELS: @@ -951,6 +987,12 @@ class SettingsWindow(QDialog): self._provider_changed() # selecting index 0 fires no signal self.cleanup_enabled.setChecked(conf["cleanup_enabled"]) self.cleanup_model.setCurrentText(conf["cleanup_model"]) + self.cleanup_claude_model.setCurrentText(conf["cleanup_claude_model"]) + self.cleanup_codex_model.setCurrentText( + conf["cleanup_codex_model"] or t("Codex's own default") + ) + self._select_data(self.cleanup_provider, conf["cleanup_provider"]) + self._cleanup_provider_changed() # selecting index 0 fires no signal self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"]) self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt()) self.file_cleanup_prompt.setPlainText( @@ -1029,7 +1071,14 @@ class SettingsWindow(QDialog): conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model] conf["cleanup_enabled"] = self.cleanup_enabled.isChecked() + conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter" conf["cleanup_model"] = self.cleanup_model.currentText().strip() + conf["cleanup_claude_model"] = (self.cleanup_claude_model.currentText().strip() + or cfg.DEFAULTS["cleanup_claude_model"]) + codex_cleanup_model = self.cleanup_codex_model.currentText().strip() + conf["cleanup_codex_model"] = ( + "" if codex_cleanup_model == t("Codex's own default") else codex_cleanup_model + ) conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or "" # Store an empty prompt when it matches the default, so switching the @@ -1347,6 +1396,27 @@ class SettingsWindow(QDialog): else missing ) + def _cleanup_provider_changed(self): + provider = self.cleanup_provider.currentData() or "openrouter" + self.cleanup_form.setRowVisible(self.cleanup_model_row, + provider == "openrouter") + self.cleanup_form.setRowVisible(self.cleanup_claude_model, + provider == "claude") + self.cleanup_form.setRowVisible(self.cleanup_codex_model, + provider == "codex") + binary = cleanup.executable(provider) + found = shutil.which(binary) if binary else "" + if not binary: + self.models_label.setText(t("Runs on OpenRouter.")) + elif found: + self.models_label.setText(t("Found: {path}", path=found)) + else: + self.models_label.setText(t( + "{binary} is not on your PATH, so cleanup would fail and the raw " + "transcript would be pasted. Install it, or pick another one " + "above.", binary=binary, + )) + def _assistant_provider_changed(self): provider = self.assistant_provider.currentData() or "claude" self.claude_box.setVisible(provider == "claude") diff --git a/tests/test_assistant.py b/tests/test_assistant.py index 8fee724..c57cfbe 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -79,9 +79,12 @@ class Effort(unittest.TestCase): self.assertEqual(assistant.CODEX_EFFORT["xhigh"], "high") self.assertEqual(assistant.CODEX_EFFORT["max"], "high") - def test_claude_has_no_rung_below_low(self): - self.assertEqual(assistant.CLAUDE_EFFORT["none"], "low") - self.assertEqual(assistant.CLAUDE_EFFORT["minimal"], "low") + def test_neither_one_asks_for_a_rung_below_low(self): + # Claude has none; Codex has one, but calls it "minimal" on the older + # models and "none" on the newer ones, and refuses the wrong word. + for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT): + self.assertEqual(scale["none"], "low") + self.assertEqual(scale["minimal"], "low") def test_an_empty_setting_asks_for_nothing(self): self.assertEqual(assistant.CLAUDE_EFFORT.get("", ""), "") @@ -232,10 +235,10 @@ class SessionMissing(unittest.TestCase): self.assertFalse(assistant._session_missing(text)) def test_the_last_line_is_the_one_worth_showing(self): - self.assertEqual(assistant._last_line("warning\n\nreal error\n"), + self.assertEqual(assistant.last_line("warning\n\nreal error\n"), "real error") - self.assertEqual(assistant._last_line(""), "") - self.assertEqual(assistant._last_line(None), "") + self.assertEqual(assistant.last_line(""), "") + self.assertEqual(assistant.last_line(None), "") class Conclude(DikteTest): diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py new file mode 100644 index 0000000..84d56b4 --- /dev/null +++ b/tests/test_cleanup.py @@ -0,0 +1,211 @@ +"""Who cleans the transcript up, and what they are asked. + +The CLIs are faked at subprocess.run: what the tests read is the argument list +each one is given, where the answer is picked up from, and what happens to the +chain when the program is missing, slow or unhappy. The OpenRouter path is the +one that was always there and is checked here only for still being taken. +""" + +import os +import subprocess +import unittest +from unittest import mock + +import api +import cleanup +from tests.support import DikteTest + + +def fake_run(stdout="", code=0, stderr="", last_message=""): + """Stand in for subprocess.run, writing the file Codex would have written.""" + calls = [] + + def run(cmd, **kwargs): + calls.append(cmd) + if last_message and "-o" in cmd: + with open(cmd[cmd.index("-o") + 1], "w", encoding="utf-8") as fh: + fh.write(last_message) + return subprocess.CompletedProcess(cmd, code, stdout, stderr) + + return mock.patch.object(subprocess, "run", side_effect=run), calls + + +class Provider(DikteTest): + def test_the_default_is_still_openrouter(self): + self.assertEqual(cleanup.provider(self.config()), "openrouter") + + def test_a_provider_this_version_does_not_have(self): + self.assertEqual( + cleanup.provider(self.config(cleanup_provider="ollama")), "openrouter") + + def test_each_one_is_recognised(self): + for name in cleanup.PROVIDERS: + with self.subTest(name=name): + self.assertEqual( + cleanup.provider(self.config(cleanup_provider=name)), name) + + def test_what_each_one_runs(self): + self.assertEqual(cleanup.executable("claude"), "claude") + self.assertEqual(cleanup.executable("codex"), "codex") + self.assertEqual(cleanup.executable("openrouter"), "") + + def test_the_model_named_in_the_history_is_the_one_that_did_it(self): + self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")), + "some/model") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="claude")), "haiku") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="claude", + cleanup_claude_model="opus")), "opus") + # Codex on its own default has no model id to report, only a name. + self.assertEqual( + cleanup.model(self.config(cleanup_provider="codex")), "codex") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="codex", + cleanup_codex_model="gpt-5.4")), "gpt-5.4") + + +class OpenRouter(DikteTest): + def test_it_is_still_one_request_with_the_settings_as_they_were(self): + conf = self.config(openrouter_api_key="sk-or-test", + cleanup_model="some/model", cleanup_reasoning="low") + with mock.patch.object(api, "cleanup", return_value="Done.") as call: + self.assertEqual(cleanup.run("uh, done", conf, "the rules"), "Done.") + text, key, model, prompt = call.call_args.args + self.assertEqual((text, key, model, prompt), + ("uh, done", "sk-or-test", "some/model", "the rules")) + self.assertEqual(call.call_args.kwargs["reasoning"], "low") + + def test_no_cli_is_started_for_it(self): + conf = self.config(openrouter_api_key="sk-or-test") + patcher, calls = fake_run(stdout="never") + with patcher, mock.patch.object(api, "cleanup", return_value="Done."): + cleanup.run("uh, done", conf, "the rules") + self.assertEqual(calls, []) + + +class ClaudeCode(DikteTest): + def setUp(self): + super().setUp() + self.conf = self.config(cleanup_provider="claude") + self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}") + + def run_cleanup(self, text="uh, book it", **kwargs): + patcher, calls = fake_run(**kwargs) + with patcher: + answer = cleanup.run(text, self.conf, "the rules") + return answer, calls[0] + + def test_the_transcript_goes_in_fenced_and_the_rules_go_in_as_the_prompt(self): + answer, cmd = self.run_cleanup(stdout="Book it.\n") + self.assertEqual(answer, "Book it.") + self.assertEqual(cmd[0], "claude") + self.assertIn("\nuh, book it\n", cmd) + self.assertEqual(cmd[cmd.index("--system-prompt") + 1], "the rules") + self.assertEqual(cmd[cmd.index("--model") + 1], "haiku") + + def test_it_is_given_nothing_to_run_and_nothing_to_remember(self): + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertEqual(cmd[cmd.index("--tools") + 1], "") + self.assertIn("--strict-mcp-config", cmd) + self.assertIn("--no-session-persistence", cmd) + + def test_the_thinking_setting_is_carried_over_in_its_own_words(self): + self.conf["cleanup_reasoning"] = "none" + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertEqual(cmd[cmd.index("--effort") + 1], "low") + + def test_no_thinking_setting_means_no_flag(self): + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertNotIn("--effort", cmd) + + def test_a_model_of_your_own(self): + self.conf["cleanup_claude_model"] = "claude-sonnet-5" + _, cmd = self.run_cleanup(stdout="Book it.") + self.assertEqual(cmd[cmd.index("--model") + 1], "claude-sonnet-5") + + def test_an_answer_of_nothing_is_a_failure_rather_than_an_empty_paste(self): + with self.assertRaises(cleanup.CleanupError): + self.run_cleanup(stdout=" \n") + + def test_the_last_line_of_the_complaint_is_what_gets_shown(self): + with self.assertRaises(cleanup.CleanupError) as caught: + self.run_cleanup(code=1, stderr="a warning\nout of credit\n") + self.assertEqual(str(caught.exception), "out of credit") + + def test_a_failure_is_the_same_kind_the_chain_already_catches(self): + # worker, the file transcriber and the meeting all keep the raw + # transcript when an ApiError comes out of here. + self.assertTrue(issubclass(cleanup.CleanupError, api.ApiError)) + + def test_a_program_that_is_not_installed_says_so_before_running_anything(self): + self.patch_attr(cleanup.shutil, "which", lambda name: "") + with self.assertRaises(cleanup.CleanupError) as caught: + self.run_cleanup(stdout="Book it.") + self.assertIn("claude", str(caught.exception)) + + def test_a_run_that_never_ends(self): + def run(cmd, **kwargs): + raise subprocess.TimeoutExpired(cmd, 180) + + with mock.patch.object(subprocess, "run", side_effect=run): + with self.assertRaises(cleanup.CleanupError) as caught: + cleanup.run("uh, book it", self.conf, "the rules") + self.assertIn("180", str(caught.exception)) + + +class Codex(DikteTest): + def setUp(self): + super().setUp() + self.conf = self.config(cleanup_provider="codex") + self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}") + + def run_cleanup(self, text="uh, book it", **kwargs): + patcher, calls = fake_run(**kwargs) + with patcher: + answer = cleanup.run(text, self.conf, "the rules") + return answer, calls[0] + + def test_the_rules_ride_in_front_of_the_transcript(self): + answer, cmd = self.run_cleanup(last_message="Book it.\n") + self.assertEqual(answer, "Book it.") + self.assertEqual(cmd[:2], ["codex", "exec"]) + self.assertEqual(cmd[-1], + "the rules\n\n---\n\n\nuh, book it\n") + + def test_the_answer_is_read_from_the_file_rather_than_the_noise_on_stdout(self): + answer, _ = self.run_cleanup( + stdout="workdir: /home\nmodel: gpt-5.4\ntokens used 400\n", + last_message="Book it.", + ) + self.assertEqual(answer, "Book it.") + + def test_that_file_does_not_stay_behind(self): + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertFalse(os.path.exists(cmd[cmd.index("-o") + 1])) + + def test_it_may_read_but_not_write_and_has_nobody_to_ask(self): + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertEqual(cmd[cmd.index("--sandbox") + 1], "read-only") + self.assertIn('approval_policy="never"', cmd) + self.assertIn("--ephemeral", cmd) + + def test_the_model_is_left_alone_until_one_is_typed_in(self): + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertNotIn("-m", cmd) + self.conf["cleanup_codex_model"] = "gpt-5.4" + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertEqual(cmd[cmd.index("-m") + 1], "gpt-5.4") + + def test_the_thinking_setting_lands_on_the_nearest_rung_codex_has(self): + self.conf["cleanup_reasoning"] = "xhigh" + _, cmd = self.run_cleanup(last_message="Book it.") + self.assertIn('model_reasoning_effort="high"', cmd) + + def test_an_answer_of_nothing(self): + with self.assertRaises(cleanup.CleanupError): + self.run_cleanup(stdout="tokens used 400", last_message="") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index cb09bc0..9547df2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -366,6 +366,34 @@ class Providers(DikteTest): self.assertIn("Groq", out) +class Doctor(DikteTest): + """One pass over everything the settings window checks behind its buttons.""" + + def run_doctor(self, as_json=True, **settings): + self.write_config(settings) + with mock.patch.object(ipc, "send", return_value=None), \ + captured() as (out, _err): + cli.cmd_doctor(Options(json=as_json)) + return json.loads(out.getvalue()) if as_json else out.getvalue() + + def test_cleanup_on_openrouter_is_a_question_about_the_key(self): + reply = self.run_doctor(cleanup_model="some/model") + self.assertEqual(reply["cleanup"]["provider"], "openrouter") + self.assertEqual(reply["cleanup"]["model"], "some/model") + self.assertIn("OpenRouter key, cleaning up on some/model", + self.run_doctor(as_json=False, cleanup_model="some/model")) + + def test_cleanup_on_a_cli_is_a_question_about_the_program(self): + reply = self.run_doctor(cleanup_provider="codex", + cleanup_codex_model="gpt-5.4") + self.assertEqual(reply["cleanup"]["provider"], "codex") + self.assertEqual(reply["cleanup"]["model"], "gpt-5.4") + self.assertIn("codex", reply["programs"]) + self.assertIn("codex, cleaning up on gpt-5.4", + self.run_doctor(as_json=False, cleanup_provider="codex", + cleanup_codex_model="gpt-5.4")) + + class Finding(DikteTest): def test_no_history_at_all(self): self.assertIsNone(cli._find_history("last")) diff --git a/tests/test_ui.py b/tests/test_ui.py index 892e736..e6d1aba 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -44,7 +44,10 @@ CHANGED = { "groq_transcribe_model": "whisper-large-v3", "openrouter_transcribe_model": "openai/whisper-1", "cleanup_enabled": False, + "cleanup_provider": "claude", "cleanup_model": "some/other-model", + "cleanup_claude_model": "opus", + "cleanup_codex_model": "gpt-5", "cleanup_reasoning": "high", "cleanup_prompt": "Only fix the punctuation.", "file_cleanup_prompt": "Keep the stamps where they are.", @@ -128,6 +131,20 @@ class Settings(DikteTest): with self.subTest(key=key): self.assertEqual(stored[key], value) + def test_the_model_box_on_screen_belongs_to_whoever_cleans_up(self): + """An OpenRouter id and a Claude alias are not the same field.""" + window = self.window(cfg.Config()) + boxes = {"openrouter": window.cleanup_model_row, + "claude": window.cleanup_claude_model, + "codex": window.cleanup_codex_model} + for provider, box in boxes.items(): + with self.subTest(provider=provider): + window._select_data(window.cleanup_provider, provider) + shown = [name for name, other in boxes.items() + if not other.isHidden()] + self.assertEqual(shown, [provider]) + self.assertFalse(box.isHidden()) + def test_the_settings_the_window_does_not_show_are_left_alone(self): """A tab nobody wrote must not reset what the command line set.""" self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0, diff --git a/worker.py b/worker.py index ba27107..012ab07 100644 --- a/worker.py +++ b/worker.py @@ -18,6 +18,7 @@ from PyQt6.QtCore import QObject, pyqtSignal import api import assistant import audio +import cleanup import config as cfg import i18n import paste @@ -112,14 +113,7 @@ class Pipeline(QObject): if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]): self.stage.emit(t("Cleaning up…")) try: - text = api.cleanup( - raw, - conf.openrouter_key(), - conf["cleanup_model"], - conf.cleanup_prompt(), - reasoning=conf["cleanup_reasoning"], - base_url=conf["openrouter_base_url"], - ) + text = cleanup.run(raw, conf, conf.cleanup_prompt()) except api.ApiError as exc: # Keep the transcript, but never let the failure pass unseen: # a rejected key would otherwise look like working dictation. @@ -159,7 +153,7 @@ class Pipeline(QObject): "duration": round(duration, 1), "elapsed": round(time.monotonic() - started, 1), "model": target.model, - "cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "", + "cleanup_model": cleanup.model(conf) if conf["cleanup_enabled"] else "", "cleanup_error": warning, "mode": "ask" if ask else "", "question": question,