mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge pull request #10 from yusufipk/worktree-claude-openai-support
Clean the transcript up on the subscription you already have
This commit is contained in:
@@ -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
|
||||
|
||||
+4
-3
@@ -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ı
|
||||
|
||||
+11
-3
@@ -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 ""
|
||||
|
||||
+181
@@ -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"<transcript>\n{text}\n</transcript>"
|
||||
|
||||
|
||||
# --- 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()
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-8
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
+2
-8
@@ -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):
|
||||
|
||||
+72
-2
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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("<transcript>\nuh, book it\n</transcript>", 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<transcript>\nuh, book it\n</transcript>")
|
||||
|
||||
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()
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user