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
|
# Dikte
|
||||||
|
|
||||||
Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI or OpenRouter
|
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
|
restarts, the missing punctuation), and the result lands in your clipboard and
|
||||||
is pasted into whatever window you were typing in.
|
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**.
|
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
|
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
|
on OpenRouter (`google/gemini-3.5-flash-lite`) or, when either is installed, on
|
||||||
can cover both. They fall back to `OPENAI_API_KEY`, `GROQ_API_KEY` and
|
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
|
`OPENROUTER_API_KEY`, and are
|
||||||
stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off,
|
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
|
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
|
meeting.py channel split, speaker labelling, cleanup, minutes
|
||||||
assistant.py running a dictation through Claude Code, Codex or OpenRouter
|
assistant.py running a dictation through Claude Code, Codex or OpenRouter
|
||||||
api.py transcription on either provider, OpenRouter cleanup (stdlib only)
|
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
|
worker.py transcribe → clean up → clipboard → paste
|
||||||
vad.py deciding whether a recording holds speech at all
|
vad.py deciding whether a recording holds speech at all
|
||||||
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
||||||
|
|||||||
+4
-3
@@ -1,7 +1,7 @@
|
|||||||
# Dikte
|
# Dikte
|
||||||
|
|
||||||
`Ctrl+Space`'e bas, konuş, tekrar bas. Ses OpenAI'ye ya da OpenRouter'a gidip
|
`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
|
tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye
|
||||||
yapıştırılır.
|
yapıştırılır.
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ diktelerine dokunmaz.
|
|||||||
|
|
||||||
Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**.
|
Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**.
|
||||||
Sesi yazıya çevirme üçünden birinde çalışır (varsayılan `gpt-4o-transcribe`),
|
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
|
temizleme OpenRouter'da (`google/gemini-3.5-flash-lite`) ya da kuruluysa Claude
|
||||||
OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY`,
|
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;
|
`GROQ_API_KEY` ve `OPENROUTER_API_KEY` kullanılır;
|
||||||
anahtarlar `~/.config/dikte/config.json`
|
anahtarlar `~/.config/dikte/config.json`
|
||||||
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript
|
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
|
meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak
|
||||||
assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme
|
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)
|
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
|
worker.py transkript → temizleme → pano → yapıştırma
|
||||||
vad.py kayıtta gerçekten konuşma var mı kararı
|
vad.py kayıtta gerçekten konuşma var mı kararı
|
||||||
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
|
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",
|
CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low",
|
||||||
"medium": "medium", "high": "high", "xhigh": "xhigh",
|
"medium": "medium", "high": "high", "xhigh": "xhigh",
|
||||||
"max": "max"}
|
"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",
|
"medium": "medium", "high": "high", "xhigh": "high",
|
||||||
"max": "high"}
|
"max": "high"}
|
||||||
|
|
||||||
@@ -421,7 +424,7 @@ def _conclude(found, code, stderr, session, service):
|
|||||||
if code != 0 and not found["answer"]:
|
if code != 0 and not found["answer"]:
|
||||||
if session and _session_missing(stderr):
|
if session and _session_missing(stderr):
|
||||||
raise _SessionGone()
|
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))
|
"{service} exited with code {code}.", service=service, code=code))
|
||||||
if found["failure"] and not found["answer"]:
|
if found["failure"] and not found["answer"]:
|
||||||
raise AssistantError(found["failure"])
|
raise AssistantError(found["failure"])
|
||||||
@@ -480,6 +483,11 @@ def _finish(proc):
|
|||||||
return stderr
|
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()]
|
lines = [line for line in (text or "").splitlines() if line.strip()]
|
||||||
return lines[-1].strip() if lines else ""
|
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 api
|
||||||
import assistant
|
import assistant
|
||||||
import audio
|
import audio
|
||||||
|
import cleanup
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import filetranscribe
|
import filetranscribe
|
||||||
import hotkey
|
import hotkey
|
||||||
@@ -764,16 +765,18 @@ def cmd_status(opts):
|
|||||||
def cmd_doctor(opts):
|
def cmd_doctor(opts):
|
||||||
"""What the settings window checks behind its buttons, in one pass."""
|
"""What the settings window checks behind its buttons, in one pass."""
|
||||||
conf = cfg.Config()
|
conf = cfg.Config()
|
||||||
programs = {name: shutil.which(name) or ""
|
wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6",
|
||||||
for name in ("pw-record", "wl-copy", "ydotool", "ffmpeg",
|
assistant.executable(assistant.provider(conf)) or "claude",
|
||||||
"pactl", "kwriteconfig6",
|
cleanup.executable(cleanup.provider(conf))]
|
||||||
assistant.executable(assistant.provider(conf)) or "claude")}
|
programs = {name: shutil.which(name) or "" for name in wanted if name}
|
||||||
target = conf.transcribe_target()
|
target = conf.transcribe_target()
|
||||||
|
cleaner = cleanup.provider(conf)
|
||||||
checks = {
|
checks = {
|
||||||
"programs": programs,
|
"programs": programs,
|
||||||
"transcription": {"provider": target.provider, "model": target.model,
|
"transcription": {"provider": target.provider, "model": target.model,
|
||||||
"key": bool(target.api_key)},
|
"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())},
|
"key": bool(conf.openrouter_key())},
|
||||||
"agent": {"provider": assistant.provider(conf),
|
"agent": {"provider": assistant.provider(conf),
|
||||||
"directory": assistant.working_dir(conf)},
|
"directory": assistant.working_dir(conf)},
|
||||||
@@ -784,8 +787,11 @@ def cmd_doctor(opts):
|
|||||||
lines += [
|
lines += [
|
||||||
f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on "
|
f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on "
|
||||||
f"{target.model}",
|
f"{target.model}",
|
||||||
f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on "
|
# Cleanup on a CLI needs no key, so what is checked is the program.
|
||||||
f"{conf['cleanup_model']}",
|
(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 "
|
f"{'✓' if checks['running'] else '·'} application "
|
||||||
+ ("running" if checks["running"] else "not running"),
|
+ ("running" if checks["running"] else "not running"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -375,7 +375,10 @@ DEFAULTS = {
|
|||||||
"language": "tr",
|
"language": "tr",
|
||||||
"transcribe_prompt": "",
|
"transcribe_prompt": "",
|
||||||
"cleanup_enabled": True,
|
"cleanup_enabled": True,
|
||||||
|
"cleanup_provider": "openrouter", # openrouter | claude | codex
|
||||||
"cleanup_model": "google/gemini-3.5-flash-lite",
|
"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_reasoning": "", # empty -> whatever the model does by default
|
||||||
"cleanup_prompt": "", # empty -> language-specific default
|
"cleanup_prompt": "", # empty -> language-specific default
|
||||||
"auto_paste": True,
|
"auto_paste": True,
|
||||||
|
|||||||
+2
-8
@@ -17,6 +17,7 @@ import wave
|
|||||||
from PyQt6.QtCore import QObject, pyqtSignal
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
import api
|
import api
|
||||||
|
import cleanup
|
||||||
from i18n import t
|
from i18n import t
|
||||||
|
|
||||||
CHUNK_SECONDS = 600 # 10 min ≈ 19 MB at 16 kHz mono s16
|
CHUNK_SECONDS = 600 # 10 min ≈ 19 MB at 16 kHz mono s16
|
||||||
@@ -130,14 +131,7 @@ class FileTranscriber(QObject):
|
|||||||
out = []
|
out = []
|
||||||
for block in split_text(text, timestamps):
|
for block in split_text(text, timestamps):
|
||||||
self._check()
|
self._check()
|
||||||
out.append(api.cleanup(
|
out.append(cleanup.run(block, conf, prompt))
|
||||||
block,
|
|
||||||
conf.openrouter_key(),
|
|
||||||
conf["cleanup_model"],
|
|
||||||
prompt,
|
|
||||||
reasoning=conf["cleanup_reasoning"],
|
|
||||||
base_url=conf["openrouter_base_url"],
|
|
||||||
))
|
|
||||||
return ("\n" if timestamps else "\n\n").join(out)
|
return ("\n" if timestamps else "\n\n").join(out)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,18 @@ TR = {
|
|||||||
"Connection works. {count} audio models visible.":
|
"Connection works. {count} audio models visible.":
|
||||||
"Bağlantı tamam. {count} ses modeli görünüyor.",
|
"Bağlantı tamam. {count} ses modeli görünüyor.",
|
||||||
"Clean the transcript with a model": "Transkripti bir modelle temizle",
|
"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",
|
"Thinking": "Düşünme",
|
||||||
"Model's own default": "Modelin kendi varsayılanı",
|
"Model's own default": "Modelin kendi varsayılanı",
|
||||||
"Off": "Kapalı",
|
"Off": "Kapalı",
|
||||||
@@ -380,6 +392,14 @@ TR = {
|
|||||||
"It was not allowed to use: {tools}": "Şunları kullanmasına izin yoktu: {tools}",
|
"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ü.",
|
"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 ------------------------------------------------
|
# --- settings: the agent ------------------------------------------------
|
||||||
"Agent": "Ajan",
|
"Agent": "Ajan",
|
||||||
"This shortcut records the same way dictation does, but the transcript is "
|
"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
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
import api
|
import api
|
||||||
|
import cleanup
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import filetranscribe
|
import filetranscribe
|
||||||
import vad
|
import vad
|
||||||
@@ -208,14 +209,7 @@ class MeetingPipeline(QObject):
|
|||||||
if len(blocks) > 1:
|
if len(blocks) > 1:
|
||||||
self._say(t("Cleaning up {index}/{count}…",
|
self._say(t("Cleaning up {index}/{count}…",
|
||||||
index=index, count=len(blocks)))
|
index=index, count=len(blocks)))
|
||||||
out.append(api.cleanup(
|
out.append(cleanup.run(block, conf, prompt, timeout=600))
|
||||||
block,
|
|
||||||
conf.openrouter_key(),
|
|
||||||
conf["cleanup_model"],
|
|
||||||
prompt,
|
|
||||||
reasoning=conf["cleanup_reasoning"],
|
|
||||||
base_url=conf["openrouter_base_url"],
|
|
||||||
))
|
|
||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
def _write(self, doc_path, minutes, transcript, entry):
|
def _write(self, doc_path, minutes, transcript, entry):
|
||||||
|
|||||||
+72
-2
@@ -16,6 +16,7 @@ from PyQt6.QtWidgets import (
|
|||||||
import api
|
import api
|
||||||
import assistant
|
import assistant
|
||||||
import audio
|
import audio
|
||||||
|
import cleanup
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import filetranscribe
|
import filetranscribe
|
||||||
import hotkey
|
import hotkey
|
||||||
@@ -49,6 +50,15 @@ CLEANUP_MODELS = [
|
|||||||
"google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5",
|
"google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5",
|
||||||
"openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct",
|
"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
|
# 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.
|
# and turned into decisions, so the starting points are the larger models.
|
||||||
MEETING_MODELS = [
|
MEETING_MODELS = [
|
||||||
@@ -282,16 +292,42 @@ class SettingsWindow(QDialog):
|
|||||||
outer.addWidget(stt)
|
outer.addWidget(stt)
|
||||||
|
|
||||||
orr = QGroupBox(t("Transcript cleanup"))
|
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"))
|
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
|
||||||
orr_form.addRow("", self.cleanup_enabled)
|
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 = QComboBox()
|
||||||
self.cleanup_model.setEditable(True)
|
self.cleanup_model.setEditable(True)
|
||||||
self.cleanup_model.addItems(CLEANUP_MODELS)
|
self.cleanup_model.addItems(CLEANUP_MODELS)
|
||||||
self.refresh_models = QPushButton(t("Fetch model list"))
|
self.refresh_models = QPushButton(t("Fetch model list"))
|
||||||
self.refresh_models.clicked.connect(self._load_models)
|
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()
|
self.cleanup_reasoning = QComboBox()
|
||||||
for label, value in REASONING_LEVELS:
|
for label, value in REASONING_LEVELS:
|
||||||
@@ -951,6 +987,12 @@ class SettingsWindow(QDialog):
|
|||||||
self._provider_changed() # selecting index 0 fires no signal
|
self._provider_changed() # selecting index 0 fires no signal
|
||||||
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
|
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
|
||||||
self.cleanup_model.setCurrentText(conf["cleanup_model"])
|
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._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
|
||||||
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
|
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
|
||||||
self.file_cleanup_prompt.setPlainText(
|
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[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
|
||||||
|
|
||||||
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
|
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_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 ""
|
conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or ""
|
||||||
|
|
||||||
# Store an empty prompt when it matches the default, so switching the
|
# Store an empty prompt when it matches the default, so switching the
|
||||||
@@ -1347,6 +1396,27 @@ class SettingsWindow(QDialog):
|
|||||||
else missing
|
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):
|
def _assistant_provider_changed(self):
|
||||||
provider = self.assistant_provider.currentData() or "claude"
|
provider = self.assistant_provider.currentData() or "claude"
|
||||||
self.claude_box.setVisible(provider == "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["xhigh"], "high")
|
||||||
self.assertEqual(assistant.CODEX_EFFORT["max"], "high")
|
self.assertEqual(assistant.CODEX_EFFORT["max"], "high")
|
||||||
|
|
||||||
def test_claude_has_no_rung_below_low(self):
|
def test_neither_one_asks_for_a_rung_below_low(self):
|
||||||
self.assertEqual(assistant.CLAUDE_EFFORT["none"], "low")
|
# Claude has none; Codex has one, but calls it "minimal" on the older
|
||||||
self.assertEqual(assistant.CLAUDE_EFFORT["minimal"], "low")
|
# 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):
|
def test_an_empty_setting_asks_for_nothing(self):
|
||||||
self.assertEqual(assistant.CLAUDE_EFFORT.get("", ""), "")
|
self.assertEqual(assistant.CLAUDE_EFFORT.get("", ""), "")
|
||||||
@@ -232,10 +235,10 @@ class SessionMissing(unittest.TestCase):
|
|||||||
self.assertFalse(assistant._session_missing(text))
|
self.assertFalse(assistant._session_missing(text))
|
||||||
|
|
||||||
def test_the_last_line_is_the_one_worth_showing(self):
|
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")
|
"real error")
|
||||||
self.assertEqual(assistant._last_line(""), "")
|
self.assertEqual(assistant.last_line(""), "")
|
||||||
self.assertEqual(assistant._last_line(None), "")
|
self.assertEqual(assistant.last_line(None), "")
|
||||||
|
|
||||||
|
|
||||||
class Conclude(DikteTest):
|
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)
|
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):
|
class Finding(DikteTest):
|
||||||
def test_no_history_at_all(self):
|
def test_no_history_at_all(self):
|
||||||
self.assertIsNone(cli._find_history("last"))
|
self.assertIsNone(cli._find_history("last"))
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ CHANGED = {
|
|||||||
"groq_transcribe_model": "whisper-large-v3",
|
"groq_transcribe_model": "whisper-large-v3",
|
||||||
"openrouter_transcribe_model": "openai/whisper-1",
|
"openrouter_transcribe_model": "openai/whisper-1",
|
||||||
"cleanup_enabled": False,
|
"cleanup_enabled": False,
|
||||||
|
"cleanup_provider": "claude",
|
||||||
"cleanup_model": "some/other-model",
|
"cleanup_model": "some/other-model",
|
||||||
|
"cleanup_claude_model": "opus",
|
||||||
|
"cleanup_codex_model": "gpt-5",
|
||||||
"cleanup_reasoning": "high",
|
"cleanup_reasoning": "high",
|
||||||
"cleanup_prompt": "Only fix the punctuation.",
|
"cleanup_prompt": "Only fix the punctuation.",
|
||||||
"file_cleanup_prompt": "Keep the stamps where they are.",
|
"file_cleanup_prompt": "Keep the stamps where they are.",
|
||||||
@@ -128,6 +131,20 @@ class Settings(DikteTest):
|
|||||||
with self.subTest(key=key):
|
with self.subTest(key=key):
|
||||||
self.assertEqual(stored[key], value)
|
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):
|
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."""
|
"""A tab nobody wrote must not reset what the command line set."""
|
||||||
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
|
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 api
|
||||||
import assistant
|
import assistant
|
||||||
import audio
|
import audio
|
||||||
|
import cleanup
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import i18n
|
import i18n
|
||||||
import paste
|
import paste
|
||||||
@@ -112,14 +113,7 @@ class Pipeline(QObject):
|
|||||||
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
|
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
|
||||||
self.stage.emit(t("Cleaning up…"))
|
self.stage.emit(t("Cleaning up…"))
|
||||||
try:
|
try:
|
||||||
text = api.cleanup(
|
text = cleanup.run(raw, conf, conf.cleanup_prompt())
|
||||||
raw,
|
|
||||||
conf.openrouter_key(),
|
|
||||||
conf["cleanup_model"],
|
|
||||||
conf.cleanup_prompt(),
|
|
||||||
reasoning=conf["cleanup_reasoning"],
|
|
||||||
base_url=conf["openrouter_base_url"],
|
|
||||||
)
|
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
# Keep the transcript, but never let the failure pass unseen:
|
# Keep the transcript, but never let the failure pass unseen:
|
||||||
# a rejected key would otherwise look like working dictation.
|
# a rejected key would otherwise look like working dictation.
|
||||||
@@ -159,7 +153,7 @@ class Pipeline(QObject):
|
|||||||
"duration": round(duration, 1),
|
"duration": round(duration, 1),
|
||||||
"elapsed": round(time.monotonic() - started, 1),
|
"elapsed": round(time.monotonic() - started, 1),
|
||||||
"model": target.model,
|
"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,
|
"cleanup_error": warning,
|
||||||
"mode": "ask" if ask else "",
|
"mode": "ask" if ask else "",
|
||||||
"question": question,
|
"question": question,
|
||||||
|
|||||||
Reference in New Issue
Block a user