mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Transcribe and clean up on this machine, without installing anything first
whisper-server is started on --inference-path /v1/audio/transcriptions, which is exactly the path api.py already builds for the hosted providers, and llama-server answers /chat/completions the way OpenRouter does. So the local half is one more base URL rather than a second code path: worker.py, filetranscribe.py and meeting.py are untouched, and dictation, subtitles and meetings all work here on the first try. Three findings worth naming, none of them in the new code: whisper.cpp cuts segments on tokens, which in Turkish lands inside a word about as often as between two. Pasted raw that gives "akraba değ\niller."; in a subtitle it gives a cue reading "değ". Whisper marks the start of a word with a leading space, so a piece that does not begin with one continues the word above it. A small model will repeat the transcript until the context is full, and every one of those tokens is a second of somebody waiting: measured at 206 seconds, and 25 with a ceiling on the reply. Hosted models are left alone, where the same runaway is rare and a ceiling would cut the minutes short. A server outlives SIGTERM and SIGKILL holding its model in memory. Signals are now turned into an event Qt delivers, since Qt blocks in C where a Python handler never runs, and a pid file lets the next start sweep up what a SIGKILL left behind. The minutes keep their own provider rather than following cleanup's. The two jobs are not the same size: a 4B model here will strip the filler words out of a dictation and will not write up an hour long meeting. The suite runs offline now: a test that reaches the network says so instead of quietly going there.
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
# Dikte
|
# Dikte
|
||||||
|
|
||||||
Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI or OpenRouter
|
Press `Ctrl+Space`, talk, press again. The recording is transcribed on this
|
||||||
for transcription, a model on OpenRouter cleans it up (dropping the *uh*s, the
|
machine by default, a model cleans it up (dropping the *uh*s, the restarts, the
|
||||||
restarts, the missing punctuation), and the result lands in your clipboard and
|
missing punctuation), and the result lands in your clipboard and is pasted into
|
||||||
is pasted into whatever window you were typing in.
|
whatever window you were typing in.
|
||||||
|
|
||||||
Built for KDE Plasma 6 on Wayland. No dependencies beyond system packages:
|
Built for KDE Plasma 6 on Wayland. No dependencies beyond system packages:
|
||||||
just the Python standard library and PyQt6.
|
just the Python standard library and PyQt6.
|
||||||
@@ -39,10 +39,12 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
|||||||
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
|
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
|
||||||
settings window installs a GNOME or KDE global shortcut.
|
settings window installs a GNOME or KDE global shortcut.
|
||||||
|
|
||||||
Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text
|
Speech to text and cleanup each pick a provider in the settings window. Both can
|
||||||
runs on either one (`gpt-4o-transcribe` by default), cleanup always on
|
run here, on whisper.cpp and llama.cpp: the program and the model are downloaded
|
||||||
OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can
|
from that window (checksummed, into `~/.local/share/dikte`), so nothing has to be
|
||||||
cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are
|
installed first and nothing leaves the machine. The hosted alternatives want a
|
||||||
|
key: **OpenAI** or **OpenRouter** for speech to text, **OpenRouter** for cleanup
|
||||||
|
(`google/gemini-3.5-flash-lite`), so a single OpenRouter key can cover both. They fall back to `OPENAI_API_KEY` and `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
|
||||||
set next to it.
|
set next to it.
|
||||||
@@ -144,7 +146,9 @@ ipc.py one request and one reply over the local socket
|
|||||||
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
|
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 and cleanup on any provider (stdlib only)
|
||||||
|
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
|
||||||
|
hub.py what GitHub and Hugging Face have on offer today
|
||||||
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
|
||||||
|
|||||||
+14
-9
@@ -1,9 +1,8 @@
|
|||||||
# 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 varsayılan olarak bu makinede yazıya
|
||||||
yazıya çevrilir, OpenRouter'daki bir model transkripti temizler (ıı'lar,
|
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
|
||||||
tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye
|
sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır.
|
||||||
yapıştırılır.
|
|
||||||
|
|
||||||
KDE Plasma 6 / Wayland için yazıldı. Sistem paketleri dışında bağımlılığı yok:
|
KDE Plasma 6 / Wayland için yazıldı. Sistem paketleri dışında bağımlılığı yok:
|
||||||
sadece Python standart kütüphanesi ve PyQt6.
|
sadece Python standart kütüphanesi ve PyQt6.
|
||||||
@@ -39,10 +38,14 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
|||||||
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
|
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
|
||||||
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
|
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
|
||||||
|
|
||||||
Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi
|
Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı
|
||||||
yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`),
|
seçer. İkisi de burada çalışabilir, whisper.cpp ve llama.cpp üzerinde: program
|
||||||
temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir
|
da model de o pencereden indirilir (sha256 doğrulamasıyla,
|
||||||
OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY` ve
|
`~/.local/share/dikte` altına), yani önceden hiçbir şey kurman gerekmez ve
|
||||||
|
makineden hiçbir şey çıkmaz. Bulut seçenekleri anahtar ister: yazıya çevirme
|
||||||
|
için **OpenAI** ya da **OpenRouter**, temizleme için **OpenRouter**
|
||||||
|
(`google/gemini-3.5-flash-lite`), yani tek bir OpenRouter anahtarı ikisine de
|
||||||
|
yeter. Boş bırakırsan `OPENAI_API_KEY` ve
|
||||||
`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
|
`OPENROUTER_API_KEY` kullanılır; 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
|
||||||
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
||||||
@@ -142,7 +145,9 @@ ipc.py yerel sokette bir istek, bir cevap
|
|||||||
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
|
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 her sağlayıcıda transkript ve temizleme (yalnız stdlib)
|
||||||
|
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
|
||||||
|
hub.py GitHub ve Hugging Face'te bugün ne olduğu
|
||||||
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ı
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""OpenAI and OpenRouter calls, stdlib only.
|
"""OpenAI, OpenRouter and this machine, stdlib only.
|
||||||
|
|
||||||
Transcription runs on either provider: OpenRouter mirrors OpenAI's
|
Transcription runs on any of three providers and cleanup on two, and none of
|
||||||
/audio/transcriptions endpoint field for field, so one multipart request serves
|
them needs code of its own. OpenRouter mirrors OpenAI's /audio/transcriptions
|
||||||
both and only the key, the base URL and the model id change. Cleanup is always
|
endpoint field for field, and ggml.py starts whisper.cpp on that same path, so
|
||||||
OpenRouter.
|
one multipart request serves all three; llama.cpp answers /chat/completions the
|
||||||
|
way OpenRouter does, so one JSON request serves both. What changes between them
|
||||||
|
is the key, the base URL and the model id.
|
||||||
|
|
||||||
|
The local ones have no key, and their base URL is not known until a server is
|
||||||
|
up, which is the one thing this module has to fill in for them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
@@ -14,6 +19,7 @@ import secrets
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
import ggml
|
||||||
from i18n import t
|
from i18n import t
|
||||||
|
|
||||||
APP_URL = "https://github.com/yusufipk/dikte"
|
APP_URL = "https://github.com/yusufipk/dikte"
|
||||||
@@ -21,14 +27,29 @@ USER_AGENT = f"dikte/1.0 (+{APP_URL})"
|
|||||||
OPENAI_URL = "https://api.openai.com/v1"
|
OPENAI_URL = "https://api.openai.com/v1"
|
||||||
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
# Where a transcription request goes; built by config.Config.transcribe_target().
|
# The floor for a local request. The timeouts elsewhere are sized for a hosted
|
||||||
# `service` is the name the user sees in an error, `provider` the one the code
|
# API, where a slow answer is a bill running; here the only thing being spent is
|
||||||
# branches on.
|
# time, and a long recording on a machine without a graphics card takes a good
|
||||||
Target = collections.namedtuple("Target", "provider service api_key base_url model")
|
# deal of it. Cutting that off would throw the work away for nothing.
|
||||||
|
LOCAL_TIMEOUT = 3600
|
||||||
|
|
||||||
|
# Where a request goes; built by config.Config's *_target() methods. `service`
|
||||||
|
# is the name the user sees in an error, `provider` the one the code branches
|
||||||
|
# on. `reasoning` is only read by cleanup, which is the only job with a model
|
||||||
|
# that might think about anything.
|
||||||
|
Target = collections.namedtuple(
|
||||||
|
"Target", "provider service api_key base_url model reasoning", defaults=("",))
|
||||||
|
|
||||||
|
|
||||||
def timestamp_model(provider):
|
def timestamp_model(provider, model):
|
||||||
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id."""
|
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id.
|
||||||
|
|
||||||
|
Whisper is what the local server runs whatever the file is called, so there
|
||||||
|
it stays on the model that is already loaded; asking for another one would
|
||||||
|
name a model that server has never heard of.
|
||||||
|
"""
|
||||||
|
if provider == "local":
|
||||||
|
return model
|
||||||
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +127,11 @@ def _multipart(fields, file_field, file_path):
|
|||||||
|
|
||||||
|
|
||||||
def _headers(provider, api_key, content_type=None):
|
def _headers(provider, api_key, content_type=None):
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT}
|
headers = {"User-Agent": USER_AGENT}
|
||||||
|
# A server on this machine has nothing to authorise, and sending it a
|
||||||
|
# bearer token would only be a made-up one.
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
if content_type:
|
if content_type:
|
||||||
headers["Content-Type"] = content_type
|
headers["Content-Type"] = content_type
|
||||||
if provider == "openrouter":
|
if provider == "openrouter":
|
||||||
@@ -116,17 +141,44 @@ def _headers(provider, api_key, content_type=None):
|
|||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _serving(target, server, timeout):
|
||||||
|
"""A local target with the address of a running server in it.
|
||||||
|
|
||||||
|
The server is started on demand and picks its own port, so this is the first
|
||||||
|
moment its address exists. serve() is idempotent: once it is up this costs
|
||||||
|
nothing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return target._replace(base_url=server.serve()), max(timeout, LOCAL_TIMEOUT)
|
||||||
|
except ggml.LocalError as exc:
|
||||||
|
raise ApiError(str(exc)) from None
|
||||||
|
|
||||||
|
|
||||||
|
def _local_failure(target, server, exc):
|
||||||
|
"""A server that died mid-request, explained by its own output.
|
||||||
|
|
||||||
|
Without this the message is that the connection dropped, when the reason for
|
||||||
|
it was printed by the process at the other end.
|
||||||
|
"""
|
||||||
|
detail = server.error()
|
||||||
|
return ApiError(f"{target.service}: {exc}" + (f" ({detail})" if detail else ""),
|
||||||
|
exc.status)
|
||||||
|
|
||||||
|
|
||||||
def _transcribe_request(target, wav_path, language, prompt, response_format,
|
def _transcribe_request(target, wav_path, language, prompt, response_format,
|
||||||
granularity=None, timeout=300):
|
granularity=None, timeout=300):
|
||||||
if not target.api_key:
|
if target.provider == "local":
|
||||||
|
target, timeout = _serving(target, ggml.whisper, timeout)
|
||||||
|
elif not target.api_key:
|
||||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||||
service=target.service))
|
service=target.service))
|
||||||
fields = [("model", target.model), ("response_format", response_format)]
|
fields = [("model", target.model), ("response_format", response_format)]
|
||||||
if language and language != "auto":
|
if language and language != "auto":
|
||||||
fields.append(("language", language))
|
fields.append(("language", language))
|
||||||
# OpenRouter takes the hint field and throws it away, so spare it the bytes.
|
# OpenRouter takes the hint field and throws it away, so spare it the bytes.
|
||||||
# The same words still reach the cleanup model as a glossary.
|
# The same words still reach the cleanup model as a glossary. whisper.cpp
|
||||||
if prompt and target.provider == "openai":
|
# takes it as the initial prompt, the way OpenAI does.
|
||||||
|
if prompt and target.provider in ("openai", "local"):
|
||||||
fields.append(("prompt", prompt))
|
fields.append(("prompt", prompt))
|
||||||
if granularity:
|
if granularity:
|
||||||
fields.append(("timestamp_granularities[]", granularity))
|
fields.append(("timestamp_granularities[]", granularity))
|
||||||
@@ -137,14 +189,58 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
|
|||||||
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
||||||
)
|
)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
|
if target.provider == "local":
|
||||||
|
raise _local_failure(target, ggml.whisper, exc) from None
|
||||||
raise explain(exc, target.service) from None
|
raise explain(exc, target.service) from None
|
||||||
|
|
||||||
|
|
||||||
|
# Whisper marks the start of a word with a leading space, so a piece of text
|
||||||
|
# that does not begin with one continues the word before it rather than starting
|
||||||
|
# a new one. Both helpers below turn on that.
|
||||||
|
def _continues_a_word(previous, following):
|
||||||
|
return bool(previous) and not previous[-1:].isspace() and not following[:1].isspace()
|
||||||
|
|
||||||
|
|
||||||
|
def _local_text(text):
|
||||||
|
"""whisper.cpp's segments, joined back into the flowing line OpenAI returns.
|
||||||
|
|
||||||
|
Its plain text puts one segment per line, and a segment boundary falls
|
||||||
|
wherever the tokens fell, which in Turkish lands inside a word about as
|
||||||
|
often as between two. Nothing takes the line break's place: whisper's own
|
||||||
|
leading spaces are what separate the words, and a break inside "değ|iller"
|
||||||
|
has nothing on either side of it worth keeping.
|
||||||
|
"""
|
||||||
|
return "".join(text.split("\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_word_splits(segments):
|
||||||
|
"""Fold a segment that begins mid-word into the one it continues.
|
||||||
|
|
||||||
|
The hosted whisper-1 hands back segments cut on sentences; whisper.cpp cuts
|
||||||
|
them on tokens, and a subtitle cue reading "değ" is not a cue. The times are
|
||||||
|
joined along with the text, so the merged segment still covers the whole
|
||||||
|
word.
|
||||||
|
"""
|
||||||
|
merged = []
|
||||||
|
for seg in segments:
|
||||||
|
text = seg.get("text") or ""
|
||||||
|
if merged and _continues_a_word(merged[-1]["text"], text):
|
||||||
|
merged[-1]["text"] += text
|
||||||
|
merged[-1]["end"] = seg.get("end") or merged[-1]["end"]
|
||||||
|
continue
|
||||||
|
merged.append({"text": text, "start": seg.get("start") or 0.0,
|
||||||
|
"end": seg.get("end") or 0.0})
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def transcribe(target, wav_path, language="", prompt="", timeout=300):
|
def transcribe(target, wav_path, language="", prompt="", timeout=300):
|
||||||
data = _transcribe_request(
|
data = _transcribe_request(
|
||||||
target, wav_path, language, prompt, "json", timeout=timeout
|
target, wav_path, language, prompt, "json", timeout=timeout
|
||||||
)
|
)
|
||||||
text = (data.get("text") or "").strip()
|
text = data.get("text") or ""
|
||||||
|
if target.provider == "local":
|
||||||
|
text = _local_text(text)
|
||||||
|
text = text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise ApiError(t("Transcript came back empty."))
|
raise ApiError(t("Transcript came back empty."))
|
||||||
return text
|
return text
|
||||||
@@ -153,11 +249,13 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300):
|
|||||||
def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
||||||
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
||||||
data = _transcribe_request(
|
data = _transcribe_request(
|
||||||
target._replace(model=timestamp_model(target.provider)),
|
target._replace(model=timestamp_model(target.provider, target.model)),
|
||||||
wav_path, language, prompt, "verbose_json",
|
wav_path, language, prompt, "verbose_json",
|
||||||
granularity="segment", timeout=timeout,
|
granularity="segment", timeout=timeout,
|
||||||
)
|
)
|
||||||
segments = data.get("segments") or []
|
segments = data.get("segments") or []
|
||||||
|
if target.provider == "local":
|
||||||
|
segments = _merge_word_splits(segments)
|
||||||
out = []
|
out = []
|
||||||
for seg in segments:
|
for seg in segments:
|
||||||
text = (seg.get("text") or "").strip()
|
text = (seg.get("text") or "").strip()
|
||||||
@@ -166,45 +264,91 @@ def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
|||||||
end = float(seg.get("end") or 0.0)
|
end = float(seg.get("end") or 0.0)
|
||||||
out.append((start, max(end, start), text))
|
out.append((start, max(end, start), text))
|
||||||
if not out:
|
if not out:
|
||||||
text = (data.get("text") or "").strip()
|
text = data.get("text") or ""
|
||||||
|
if target.provider == "local":
|
||||||
|
text = _local_text(text)
|
||||||
|
text = text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise ApiError(t("Transcript came back empty."))
|
raise ApiError(t("Transcript came back empty."))
|
||||||
out = [(0.0, 0.0, text)]
|
out = [(0.0, 0.0, text)]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def cleanup(text, api_key, model, system_prompt, reasoning="",
|
def _thinking(target, payload):
|
||||||
base_url=OPENROUTER_URL, timeout=180):
|
"""Ask for as much thinking as this provider understands, or for none.
|
||||||
if not api_key:
|
|
||||||
|
An empty level means "whatever the model does on its own", so nothing is
|
||||||
|
sent. The two providers mean opposite things by that, which is why the
|
||||||
|
setting is kept per provider: OpenRouter's cleanup models answer straight
|
||||||
|
away, while a local model that was trained to think will think, and cleanup
|
||||||
|
is punctuation rather than a job worth thinking about.
|
||||||
|
"""
|
||||||
|
if not target.reasoning:
|
||||||
|
return
|
||||||
|
if target.provider == "local-llm":
|
||||||
|
# What llama.cpp passes to the chat template. The models that think
|
||||||
|
# read it; the ones that do not ignore it.
|
||||||
|
payload["chat_template_kwargs"] = {
|
||||||
|
"enable_thinking": target.reasoning != "none"}
|
||||||
|
return
|
||||||
|
if target.reasoning != "none":
|
||||||
|
# The thinking itself is never shown, so ask for it to be left out.
|
||||||
|
payload["reasoning"] = {"effort": target.reasoning, "exclude": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _local_ceiling(text):
|
||||||
|
"""How much of a reply is worth waiting for from a model on this machine.
|
||||||
|
|
||||||
|
Cleanup gives back what it was given, near enough, so a reply several times
|
||||||
|
the length of the transcript is a model that has lost the thread rather than
|
||||||
|
one doing the job. A small one will happily repeat the transcript until the
|
||||||
|
context is full, and every one of those tokens is a second of somebody
|
||||||
|
waiting. A hosted model is left alone: there the same runaway is rare, and a
|
||||||
|
ceiling would cut the minutes short instead.
|
||||||
|
"""
|
||||||
|
return max(512, len(text))
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup(target, text, system_prompt, timeout=180):
|
||||||
|
if target.provider == "local-llm":
|
||||||
|
target, timeout = _serving(target, ggml.llm, timeout)
|
||||||
|
elif not target.api_key:
|
||||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||||
service="OpenRouter"))
|
service=target.service))
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": target.model,
|
||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": f"<transcript>\n{text}\n</transcript>"},
|
{"role": "user", "content": f"<transcript>\n{text}\n</transcript>"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
# An empty level means "whatever the model does on its own"; anything else is
|
if target.provider == "local-llm":
|
||||||
# one of OpenRouter's efforts. The thinking itself is never shown, so ask for
|
payload["max_tokens"] = _local_ceiling(text)
|
||||||
# it to be left out of the reply.
|
_thinking(target, payload)
|
||||||
if reasoning:
|
|
||||||
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
|
||||||
try:
|
try:
|
||||||
data = _request(
|
data = _request(
|
||||||
f"{base_url.rstrip('/')}/chat/completions",
|
f"{target.base_url.rstrip('/')}/chat/completions",
|
||||||
json.dumps(payload).encode("utf-8"),
|
json.dumps(payload).encode("utf-8"),
|
||||||
_headers("openrouter", api_key, "application/json"),
|
_headers(target.provider, target.api_key, "application/json"),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
raise explain(exc, "OpenRouter") from None
|
if target.provider == "local-llm":
|
||||||
|
raise _local_failure(target, ggml.llm, exc) from None
|
||||||
|
raise explain(exc, target.service) from None
|
||||||
choices = data.get("choices") or []
|
choices = data.get("choices") or []
|
||||||
if not choices:
|
if not choices:
|
||||||
raise ApiError(_extract_error(json.dumps(data)))
|
raise ApiError(_extract_error(json.dumps(data)))
|
||||||
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
message = choices[0].get("message") or {}
|
||||||
|
content = (message.get("content") or "").strip()
|
||||||
if not content:
|
if not content:
|
||||||
|
# A thinking model can spend the whole reply on the thinking and leave
|
||||||
|
# nothing to paste. Worth naming, because the fix is a setting rather
|
||||||
|
# than a retry: cleanup is not a job that wants thinking.
|
||||||
|
if message.get("reasoning_content") or message.get("reasoning"):
|
||||||
|
raise ApiError(t("The cleanup model spent its whole reply on "
|
||||||
|
"thinking. Set Thinking to “Off”."))
|
||||||
raise ApiError(t("The cleanup model returned an empty reply."))
|
raise ApiError(t("The cleanup model returned an empty reply."))
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
import api
|
import api
|
||||||
|
import ggml
|
||||||
import i18n
|
import i18n
|
||||||
|
from i18n import t
|
||||||
|
|
||||||
|
|
||||||
def _xdg(var, default):
|
def _xdg(var, default):
|
||||||
@@ -365,14 +367,46 @@ DEFAULTS = {
|
|||||||
"openai_base_url": "https://api.openai.com/v1",
|
"openai_base_url": "https://api.openai.com/v1",
|
||||||
"openrouter_api_key": "",
|
"openrouter_api_key": "",
|
||||||
"openrouter_base_url": "https://openrouter.ai/api/v1",
|
"openrouter_base_url": "https://openrouter.ai/api/v1",
|
||||||
"transcribe_provider": "openai", # openai | openrouter
|
"transcribe_provider": "local", # local | openai | openrouter
|
||||||
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
||||||
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
|
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
|
||||||
"language": "tr",
|
"language": "tr",
|
||||||
"transcribe_prompt": "",
|
"transcribe_prompt": "",
|
||||||
|
|
||||||
|
# --- whisper.cpp, on this machine ---------------------------------------
|
||||||
|
# The program and the model are both fetched from Settings; empty means
|
||||||
|
# nothing has been downloaded yet, which is what opens Settings on a first
|
||||||
|
# run.
|
||||||
|
# Pointed at the suggestion rather than at nothing, so the settings window
|
||||||
|
# opens with the Download button already on the right model.
|
||||||
|
"local_model": ggml.SUGGESTED_WHISPER,
|
||||||
|
"local_threads": 0, # 0 -> whisper.cpp picks
|
||||||
|
"local_gpu": True,
|
||||||
|
"local_preload": True, # load the model while Dikte starts, rather
|
||||||
|
# than on the first dictation
|
||||||
|
"local_binary": "", # empty -> whichever copy ggml.py finds
|
||||||
|
|
||||||
"cleanup_enabled": True,
|
"cleanup_enabled": True,
|
||||||
|
"cleanup_provider": "openrouter", # openrouter | local
|
||||||
"cleanup_model": "google/gemini-3.5-flash-lite",
|
"cleanup_model": "google/gemini-3.5-flash-lite",
|
||||||
"cleanup_reasoning": "", # empty -> whatever the model does by default
|
"cleanup_reasoning": "", # empty -> whatever the model does by default
|
||||||
|
|
||||||
|
# --- llama.cpp, on this machine -----------------------------------------
|
||||||
|
# Kept apart from the meeting settings on purpose. Cleanup is punctuation
|
||||||
|
# and filler words, which a small model does in a moment; the minutes are a
|
||||||
|
# summary of an hour, which it does not.
|
||||||
|
"local_llm_model": "", # a file name, e.g. gemma-3-4b-it-Q4_K_M.gguf
|
||||||
|
# Where the model list is read from; the settings window offers the
|
||||||
|
# publishers ggml.py knows of and takes any other one that is typed in.
|
||||||
|
"local_llm_repo": ggml.SUGGESTED_LLM[0],
|
||||||
|
"local_llm_threads": 0,
|
||||||
|
"local_llm_gpu": True,
|
||||||
|
"local_llm_context": 8192,
|
||||||
|
"local_llm_binary": "",
|
||||||
|
"local_llm_preload": False, # heavier than whisper, so only when asked
|
||||||
|
# Off rather than empty: a model trained to think will, and 300 tokens of
|
||||||
|
# reasoning about a comma is 300 tokens of waiting.
|
||||||
|
"local_llm_reasoning": "none",
|
||||||
"cleanup_prompt": "", # empty -> language-specific default
|
"cleanup_prompt": "", # empty -> language-specific default
|
||||||
"auto_paste": True,
|
"auto_paste": True,
|
||||||
"paste_shortcut": "ctrl+v",
|
"paste_shortcut": "ctrl+v",
|
||||||
@@ -400,6 +434,7 @@ DEFAULTS = {
|
|||||||
"meeting_language": "", # empty -> the dictation speech language
|
"meeting_language": "", # empty -> the dictation speech language
|
||||||
"meeting_max_seconds": 14400, # 4 hours
|
"meeting_max_seconds": 14400, # 4 hours
|
||||||
"meeting_cleanup": True,
|
"meeting_cleanup": True,
|
||||||
|
"meeting_provider": "openrouter", # openrouter | local
|
||||||
"meeting_model": "google/gemini-3.5-flash",
|
"meeting_model": "google/gemini-3.5-flash",
|
||||||
"meeting_reasoning": "",
|
"meeting_reasoning": "",
|
||||||
"meeting_prompt": "", # empty -> language-specific default
|
"meeting_prompt": "", # empty -> language-specific default
|
||||||
@@ -495,14 +530,84 @@ class Config:
|
|||||||
return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip()
|
return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip()
|
||||||
|
|
||||||
def transcribe_target(self):
|
def transcribe_target(self):
|
||||||
"""Key, endpoint and model for whichever provider does speech to text."""
|
"""Key, endpoint and model for whichever provider does speech to text.
|
||||||
if self["transcribe_provider"] == "openrouter":
|
|
||||||
|
The local one leaves its base URL empty on purpose: the server picks a
|
||||||
|
port when it starts, and starting it here would make reading a setting
|
||||||
|
launch a process. api.py fills the address in when it is about to send
|
||||||
|
the request, which is the moment the server is needed anyway.
|
||||||
|
"""
|
||||||
|
provider = self["transcribe_provider"]
|
||||||
|
if provider == "local":
|
||||||
|
return api.Target("local", t("Local whisper"), "", "",
|
||||||
|
self["local_model"])
|
||||||
|
if provider == "openrouter":
|
||||||
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
||||||
self["openrouter_base_url"],
|
self["openrouter_base_url"],
|
||||||
self["openrouter_transcribe_model"])
|
self["openrouter_transcribe_model"])
|
||||||
return api.Target("openai", "OpenAI", self.openai_key(),
|
return api.Target("openai", "OpenAI", self.openai_key(),
|
||||||
self["openai_base_url"], self["transcribe_model"])
|
self["openai_base_url"], self["transcribe_model"])
|
||||||
|
|
||||||
|
def cleanup_target(self):
|
||||||
|
"""The same, for the model that tidies a transcript up."""
|
||||||
|
if self["cleanup_provider"] == "local":
|
||||||
|
return api.Target("local-llm", t("Local model"), "", "",
|
||||||
|
self["local_llm_model"], self["local_llm_reasoning"])
|
||||||
|
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
||||||
|
self["openrouter_base_url"], self["cleanup_model"],
|
||||||
|
self["cleanup_reasoning"])
|
||||||
|
|
||||||
|
def minutes_target(self):
|
||||||
|
"""The same again, for the minutes.
|
||||||
|
|
||||||
|
Its own provider rather than the cleanup one. The two jobs are not the
|
||||||
|
same size: a 4B model on this machine will strip the filler words out of
|
||||||
|
a dictation perfectly well and will not write up an hour long meeting,
|
||||||
|
so choosing it for the first must not quietly choose it for the second.
|
||||||
|
"""
|
||||||
|
if self["meeting_provider"] == "local":
|
||||||
|
return api.Target("local-llm", t("Local model"), "", "",
|
||||||
|
self["local_llm_model"], self["local_llm_reasoning"])
|
||||||
|
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
|
||||||
|
self["openrouter_base_url"], self["meeting_model"],
|
||||||
|
self["meeting_reasoning"])
|
||||||
|
|
||||||
|
def transcribe_ready(self):
|
||||||
|
"""Whether speech to text could run right now, without opening Settings."""
|
||||||
|
if self["transcribe_provider"] == "local":
|
||||||
|
return self.local_whisper_ready()
|
||||||
|
return bool(self.transcribe_target().api_key)
|
||||||
|
|
||||||
|
def local_whisper_ready(self):
|
||||||
|
return bool(ggml.program_path(ggml.WHISPER, self["local_binary"])
|
||||||
|
and self["local_model"]
|
||||||
|
and ggml.have_model(ggml.whisper_model_path(self["local_model"])))
|
||||||
|
|
||||||
|
def local_llm_ready(self):
|
||||||
|
return bool(ggml.program_path(ggml.LLAMA, self["local_llm_binary"])
|
||||||
|
and self["local_llm_model"]
|
||||||
|
and ggml.have_model(ggml.llm_model_path(self["local_llm_model"])))
|
||||||
|
|
||||||
|
def apply_local(self):
|
||||||
|
"""Hand the local settings to the servers, restarting what they change."""
|
||||||
|
ggml.whisper.configure(
|
||||||
|
model=self["local_model"],
|
||||||
|
threads=int(self["local_threads"]),
|
||||||
|
gpu=bool(self["local_gpu"]),
|
||||||
|
binary=self["local_binary"],
|
||||||
|
)
|
||||||
|
ggml.llm.configure(
|
||||||
|
model=self["local_llm_model"],
|
||||||
|
threads=int(self["local_llm_threads"]),
|
||||||
|
gpu=bool(self["local_llm_gpu"]),
|
||||||
|
binary=self["local_llm_binary"],
|
||||||
|
context=int(self["local_llm_context"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def uses_local_llm(self):
|
||||||
|
"""Whether anything is set to run the local cleanup model."""
|
||||||
|
return "local" in (self["cleanup_provider"], self["meeting_provider"])
|
||||||
|
|
||||||
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
|
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
|
||||||
subtitles=False):
|
subtitles=False):
|
||||||
turkish = i18n.language() == "tr"
|
turkish = i18n.language() == "tr"
|
||||||
|
|||||||
@@ -7,16 +7,20 @@ terminal talks to. Every verb it answers is in cli.py, which is also what runs
|
|||||||
command line says "there is no instance to talk to, so be one".
|
command line says "there is no instance to talk to, so be one".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
# A Wayland client cannot place a window in a screen corner, so the indicator
|
# A Wayland client cannot place a window in a screen corner, so the indicator
|
||||||
# is drawn through XWayland.
|
# is drawn through XWayland.
|
||||||
if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"):
|
if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"):
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
|
os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
|
||||||
|
|
||||||
from PyQt6.QtCore import QTimer, QElapsedTimer # noqa: E402
|
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
|
||||||
from PyQt6.QtGui import QAction, QIcon # noqa: E402
|
from PyQt6.QtGui import QAction, QIcon # noqa: E402
|
||||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||||
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
|
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
|
||||||
@@ -25,6 +29,7 @@ import assistant # noqa: E402
|
|||||||
import audio # noqa: E402
|
import audio # noqa: E402
|
||||||
import cli # noqa: E402
|
import cli # noqa: E402
|
||||||
import config as cfg # noqa: E402
|
import config as cfg # noqa: E402
|
||||||
|
import ggml # noqa: E402
|
||||||
import hotkey # noqa: E402
|
import hotkey # noqa: E402
|
||||||
import i18n # noqa: E402
|
import i18n # noqa: E402
|
||||||
import ipc # noqa: E402
|
import ipc # noqa: E402
|
||||||
@@ -92,6 +97,9 @@ class Dikte:
|
|||||||
self.meeting_recorder = audio.MeetingRecorder()
|
self.meeting_recorder = audio.MeetingRecorder()
|
||||||
self.meetings = MeetingPipeline(self.conf)
|
self.meetings = MeetingPipeline(self.conf)
|
||||||
self.evdev = hotkey.EvdevHotkey()
|
self.evdev = hotkey.EvdevHotkey()
|
||||||
|
# Before anything of ours is started: a server from a Dikte that was
|
||||||
|
# killed outright is still holding a model in memory.
|
||||||
|
ggml.sweep()
|
||||||
|
|
||||||
self.recorder.level.connect(self._on_level)
|
self.recorder.level.connect(self._on_level)
|
||||||
self.recorder.stopped.connect(self._on_recorded)
|
self.recorder.stopped.connect(self._on_recorded)
|
||||||
@@ -802,9 +810,44 @@ class Dikte:
|
|||||||
# Don't drop the object while its own signal is still being delivered.
|
# Don't drop the object while its own signal is still being delivered.
|
||||||
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
|
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
|
||||||
|
|
||||||
|
def _apply_local(self):
|
||||||
|
"""Pass the local settings on, and hold the models ready if asked to.
|
||||||
|
|
||||||
|
Loading a model takes a second or two for whisper and longer for an LLM.
|
||||||
|
Doing it while Dikte starts rather than on the first dictation is the
|
||||||
|
whole reason a server is kept alive instead of running the program once
|
||||||
|
per recording; the checkboxes are there for the machine whose memory is
|
||||||
|
wanted elsewhere.
|
||||||
|
"""
|
||||||
|
self.conf.apply_local()
|
||||||
|
wanted = []
|
||||||
|
if self.conf["transcribe_provider"] == "local":
|
||||||
|
if self.conf["local_preload"] and self.conf.local_whisper_ready():
|
||||||
|
wanted.append((ggml.whisper, "whisper"))
|
||||||
|
else:
|
||||||
|
ggml.whisper.stop() # give the memory back when it is not in use
|
||||||
|
if self.conf.uses_local_llm():
|
||||||
|
if self.conf["local_llm_preload"] and self.conf.local_llm_ready():
|
||||||
|
wanted.append((ggml.llm, "llama"))
|
||||||
|
else:
|
||||||
|
ggml.llm.stop()
|
||||||
|
|
||||||
|
def warm():
|
||||||
|
for server, name in wanted:
|
||||||
|
try:
|
||||||
|
server.serve()
|
||||||
|
except ggml.LocalError as exc:
|
||||||
|
# Not worth an indicator: the first dictation raises the
|
||||||
|
# same thing where the user can act on it.
|
||||||
|
print(f"dikte: {name}: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
if wanted:
|
||||||
|
threading.Thread(target=warm, daemon=True).start()
|
||||||
|
|
||||||
def _apply_settings(self):
|
def _apply_settings(self):
|
||||||
self.overlay.corner = self.conf["overlay_corner"]
|
self.overlay.corner = self.conf["overlay_corner"]
|
||||||
self.ask_overlay.corner = self.conf["overlay_corner"]
|
self.ask_overlay.corner = self.conf["overlay_corner"]
|
||||||
|
self._apply_local()
|
||||||
self._build_tray()
|
self._build_tray()
|
||||||
self._refresh_tray()
|
self._refresh_tray()
|
||||||
if self.conf["evdev_hotkey"]:
|
if self.conf["evdev_hotkey"]:
|
||||||
@@ -834,6 +877,9 @@ class Dikte:
|
|||||||
self.meeting_recorder.stop()
|
self.meeting_recorder.stop()
|
||||||
self.overlay.dismiss()
|
self.overlay.dismiss()
|
||||||
self.ask_overlay.dismiss()
|
self.ask_overlay.dismiss()
|
||||||
|
# Also on the restart path, which replaces the process without ever
|
||||||
|
# reaching atexit and would otherwise leave the models in memory.
|
||||||
|
ggml.stop_all()
|
||||||
self.tray.hide()
|
self.tray.hide()
|
||||||
|
|
||||||
|
|
||||||
@@ -872,6 +918,41 @@ def main():
|
|||||||
return run_app([arg for arg in argv if arg != "--gui"])
|
return run_app([arg for arg in argv if arg != "--gui"])
|
||||||
|
|
||||||
|
|
||||||
|
def install_signal_handlers(app):
|
||||||
|
"""Quit properly on the signals a session sends, rather than dying where we stand.
|
||||||
|
|
||||||
|
Qt spends its time blocked inside C, and a Python signal handler only runs
|
||||||
|
between bytecodes, so on its own it would not run until the next event
|
||||||
|
arrived, which for an idle tray icon may be never. set_wakeup_fd writes the
|
||||||
|
signal number to a socket instead, and a notifier turns that into an event
|
||||||
|
Qt does deliver.
|
||||||
|
|
||||||
|
Worth the trouble because of what shutdown() does: a logout sends SIGTERM,
|
||||||
|
and without this a whisper.cpp or llama.cpp server outlives the session
|
||||||
|
holding its model in memory. SIGKILL cannot be caught at all, which is what
|
||||||
|
ggml.sweep() is for.
|
||||||
|
|
||||||
|
Returns the objects it made; they have to stay alive to keep working.
|
||||||
|
"""
|
||||||
|
reader, writer = socket.socketpair()
|
||||||
|
reader.setblocking(False)
|
||||||
|
writer.setblocking(False)
|
||||||
|
signal.set_wakeup_fd(writer.fileno())
|
||||||
|
notifier = QSocketNotifier(reader.fileno(), QSocketNotifier.Type.Read)
|
||||||
|
|
||||||
|
def woken():
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
reader.recv(64)
|
||||||
|
app.quit() # aboutToQuit runs shutdown()
|
||||||
|
|
||||||
|
notifier.activated.connect(woken)
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||||
|
# A handler that does nothing, so that the default action, stopping the
|
||||||
|
# process where it stands, is replaced by the wakeup above.
|
||||||
|
signal.signal(sig, lambda *_: None)
|
||||||
|
return reader, writer, notifier
|
||||||
|
|
||||||
|
|
||||||
def run_app(args):
|
def run_app(args):
|
||||||
command = args[0] if args else ""
|
command = args[0] if args else ""
|
||||||
|
|
||||||
@@ -879,6 +960,12 @@ def run_app(args):
|
|||||||
app.setApplicationName("Dikte")
|
app.setApplicationName("Dikte")
|
||||||
app.setDesktopFileName("dikte")
|
app.setDesktopFileName("dikte")
|
||||||
app.setQuitOnLastWindowClosed(False)
|
app.setQuitOnLastWindowClosed(False)
|
||||||
|
# Before Dikte is built, because building it is what may start a server, and
|
||||||
|
# a signal arriving in the middle of that would otherwise take the default
|
||||||
|
# action and leave the server behind. A signal this early lands in the
|
||||||
|
# socket and is delivered as soon as the event loop starts. Held in a name
|
||||||
|
# so that the notifier and its socket outlive this function.
|
||||||
|
signal_plumbing = install_signal_handlers(app) # noqa: F841
|
||||||
|
|
||||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||||
print("dikte: no system tray found, running anyway")
|
print("dikte: no system tray found, running anyway")
|
||||||
@@ -927,7 +1014,10 @@ def run_app(args):
|
|||||||
|
|
||||||
# No key for the chosen transcription provider means nothing can work yet,
|
# No key for the chosen transcription provider means nothing can work yet,
|
||||||
# so the settings window is the only useful thing to open.
|
# so the settings window is the only useful thing to open.
|
||||||
if command == "settings" or not dikte.conf.transcribe_target().api_key:
|
# A transcription provider that cannot run yet, whether that is a missing
|
||||||
|
# API key or a model nobody has downloaded, means nothing can work, so the
|
||||||
|
# settings window is the only useful thing to open.
|
||||||
|
if command == "settings" or not dikte.conf.transcribe_ready():
|
||||||
dikte.open_settings()
|
dikte.open_settings()
|
||||||
elif command == "toggle":
|
elif command == "toggle":
|
||||||
QTimer.singleShot(0, dikte.toggle)
|
QTimer.singleShot(0, dikte.toggle)
|
||||||
|
|||||||
+2
-8
@@ -127,17 +127,11 @@ class FileTranscriber(QObject):
|
|||||||
def _cleanup(self, text, timestamps):
|
def _cleanup(self, text, timestamps):
|
||||||
conf = self.conf
|
conf = self.conf
|
||||||
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
|
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
|
||||||
|
target = conf.cleanup_target()
|
||||||
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(api.cleanup(target, block, 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -116,5 +116,6 @@ fi
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
ok "Done. Start it with: dikte"
|
ok "Done. Start it with: dikte"
|
||||||
say "The settings window opens on first run; add an OpenAI, Groq or OpenRouter key."
|
say "The settings window opens on first run: download a speech model, or add"
|
||||||
|
say "an OpenAI or OpenRouter key instead."
|
||||||
echo
|
echo
|
||||||
|
|||||||
+6
-18
@@ -118,18 +118,12 @@ class MeetingPipeline(QObject):
|
|||||||
|
|
||||||
self._check()
|
self._check()
|
||||||
self._say(t("Writing the minutes…"))
|
self._say(t("Writing the minutes…"))
|
||||||
minutes = api.cleanup(
|
writer = self.conf.minutes_target()
|
||||||
transcript,
|
minutes = api.cleanup(writer, transcript, self.conf.meeting_prompt(),
|
||||||
self.conf.openrouter_key(),
|
timeout=600)
|
||||||
self.conf["meeting_model"],
|
|
||||||
self.conf.meeting_prompt(),
|
|
||||||
reasoning=self.conf["meeting_reasoning"],
|
|
||||||
base_url=self.conf["openrouter_base_url"],
|
|
||||||
timeout=600,
|
|
||||||
)
|
|
||||||
title = self._write(doc_path, minutes, transcript, entry)
|
title = self._write(doc_path, minutes, transcript, entry)
|
||||||
cfg.update_meeting(base, status="done", error="", title=title,
|
cfg.update_meeting(base, status="done", error="", title=title,
|
||||||
model=self.conf["meeting_model"])
|
model=writer.model)
|
||||||
self._discard_audio(wav_path)
|
self._discard_audio(wav_path)
|
||||||
self.finished.emit(base, title)
|
self.finished.emit(base, title)
|
||||||
|
|
||||||
@@ -201,6 +195,7 @@ class MeetingPipeline(QObject):
|
|||||||
def _cleanup(self, transcript):
|
def _cleanup(self, transcript):
|
||||||
conf = self.conf
|
conf = self.conf
|
||||||
prompt = conf.cleanup_prompt(with_timestamps=True, with_speakers=True)
|
prompt = conf.cleanup_prompt(with_timestamps=True, with_speakers=True)
|
||||||
|
target = conf.cleanup_target()
|
||||||
out = []
|
out = []
|
||||||
blocks = filetranscribe.split_text(transcript, True)
|
blocks = filetranscribe.split_text(transcript, True)
|
||||||
for index, block in enumerate(blocks, start=1):
|
for index, block in enumerate(blocks, start=1):
|
||||||
@@ -208,14 +203,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(api.cleanup(target, block, prompt))
|
||||||
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):
|
||||||
|
|||||||
+437
-13
@@ -18,6 +18,7 @@ import assistant
|
|||||||
import audio
|
import audio
|
||||||
import config as cfg
|
import config as cfg
|
||||||
import filetranscribe
|
import filetranscribe
|
||||||
|
import ggml
|
||||||
import hotkey
|
import hotkey
|
||||||
import meeting
|
import meeting
|
||||||
from filetranscribe import FileTranscriber
|
from filetranscribe import FileTranscriber
|
||||||
@@ -29,7 +30,10 @@ LANGUAGES = [
|
|||||||
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
|
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
|
||||||
]
|
]
|
||||||
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
|
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
|
||||||
TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("OpenRouter", "openrouter")]
|
TRANSCRIBE_PROVIDERS = [("This machine (whisper.cpp)", "local"),
|
||||||
|
("OpenAI", "openai"), ("OpenRouter", "openrouter")]
|
||||||
|
CLEANUP_PROVIDERS = [("OpenRouter", "openrouter"),
|
||||||
|
("This machine (llama.cpp)", "local")]
|
||||||
# Starting points for the model box; "Fetch model list" replaces them with
|
# Starting points for the model box; "Fetch model list" replaces them with
|
||||||
# whatever the provider offers today.
|
# whatever the provider offers today.
|
||||||
TRANSCRIBE_MODELS = {
|
TRANSCRIBE_MODELS = {
|
||||||
@@ -103,6 +107,316 @@ AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
|
|||||||
"*.mp4 *.mkv *.webm *.mov *.avi")
|
"*.mp4 *.mkv *.webm *.mov *.avi")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalModelBox(QGroupBox):
|
||||||
|
"""The program, the model, and the two downloads that put them there.
|
||||||
|
|
||||||
|
One class for whisper.cpp and llama.cpp, because the job is the same one
|
||||||
|
twice: say whether the program is here, offer the models somebody publishes,
|
||||||
|
fetch the chosen one, and stay usable while a gigabyte arrives. Nothing is
|
||||||
|
listed in the source; `repos` and `models` are asked at the moment the box is
|
||||||
|
opened, so a model published this morning is in the list this afternoon.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_listed = pyqtSignal(list, str)
|
||||||
|
_quants = pyqtSignal(list, str)
|
||||||
|
_progress = pyqtSignal(int, int)
|
||||||
|
_finished = pyqtSignal(str, str)
|
||||||
|
_installed = pyqtSignal(str, str)
|
||||||
|
|
||||||
|
changed = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, program, title, models, model_path, repos=None, parent=None):
|
||||||
|
super().__init__(title, parent)
|
||||||
|
self.program = program
|
||||||
|
self._models = models # () -> [hub.Item], or (repo) -> [hub.Item]
|
||||||
|
self._model_path = model_path # (name) -> Path
|
||||||
|
self._repos = repos # None, or () -> [repo id]
|
||||||
|
self._downloading = False
|
||||||
|
self._pending = False
|
||||||
|
self._stop = False
|
||||||
|
self._wanted = "" # the model to select once a list arrives
|
||||||
|
|
||||||
|
form = QFormLayout(self)
|
||||||
|
|
||||||
|
self.program_label = QLabel("")
|
||||||
|
self.program_label.setWordWrap(True)
|
||||||
|
self.install_button = QPushButton(t("Download"))
|
||||||
|
self.install_button.clicked.connect(self._install_program)
|
||||||
|
form.addRow(t("Program"), self._side_by_side(self.program_label,
|
||||||
|
self.install_button))
|
||||||
|
|
||||||
|
if self._repos is not None:
|
||||||
|
self.repo = QComboBox()
|
||||||
|
self.repo.setEditable(True)
|
||||||
|
self.repo.setToolTip(t("A Hugging Face repository of GGUF files. The "
|
||||||
|
"list is fetched; any other one can be typed in."))
|
||||||
|
self.repo.currentTextChanged.connect(self._repo_changed)
|
||||||
|
form.addRow(t("Publisher"), self.repo)
|
||||||
|
|
||||||
|
self.model = QComboBox()
|
||||||
|
self.download_button = QPushButton(t("Download"))
|
||||||
|
self.download_button.clicked.connect(self._download)
|
||||||
|
self.delete_button = QPushButton(t("Delete"))
|
||||||
|
self.delete_button.clicked.connect(self._delete)
|
||||||
|
form.addRow(t("Model"), self._side_by_side(self.model,
|
||||||
|
self.download_button,
|
||||||
|
self.delete_button))
|
||||||
|
self.model.currentIndexChanged.connect(self._model_changed)
|
||||||
|
|
||||||
|
self.status = QLabel("")
|
||||||
|
self.status.setWordWrap(True)
|
||||||
|
form.addRow(self.status)
|
||||||
|
|
||||||
|
self._listed.connect(self._on_listed)
|
||||||
|
self._quants.connect(self._on_listed)
|
||||||
|
self._progress.connect(self._on_progress)
|
||||||
|
self._finished.connect(self._on_finished)
|
||||||
|
self._installed.connect(self._on_installed)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _side_by_side(*widgets):
|
||||||
|
layout = QHBoxLayout()
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
for index, widget in enumerate(widgets):
|
||||||
|
layout.addWidget(widget, 1 if index == 0 else 0)
|
||||||
|
holder = QWidget()
|
||||||
|
holder.setLayout(layout)
|
||||||
|
return holder
|
||||||
|
|
||||||
|
# ---- what is here ----------------------------------------------------
|
||||||
|
|
||||||
|
def selected(self):
|
||||||
|
return self.model.currentData() or ""
|
||||||
|
|
||||||
|
def repository(self):
|
||||||
|
return self.repo.currentText().strip() if self._repos is not None else ""
|
||||||
|
|
||||||
|
def load(self, model, repo=""):
|
||||||
|
"""Show what is stored. What else is on offer is asked for on the way up.
|
||||||
|
|
||||||
|
Nothing is fetched here: building the settings window is not the same as
|
||||||
|
opening it, and a list nobody is looking at is not worth a request. What
|
||||||
|
is already on this disk is shown straight away either way.
|
||||||
|
"""
|
||||||
|
self._wanted = model
|
||||||
|
self._pending = True
|
||||||
|
self._show_program()
|
||||||
|
if self._repos is not None:
|
||||||
|
self.repo.blockSignals(True)
|
||||||
|
self.repo.clear()
|
||||||
|
self.repo.addItems(list(ggml.SUGGESTED_LLM))
|
||||||
|
self.repo.setCurrentText(repo or ggml.SUGGESTED_LLM[0])
|
||||||
|
self.repo.blockSignals(False)
|
||||||
|
self._fill_models([])
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
super().showEvent(event)
|
||||||
|
if self._pending:
|
||||||
|
self._pending = False
|
||||||
|
if self._repos is not None:
|
||||||
|
self._fill_repos(self.repository())
|
||||||
|
self._fetch_models(self.repository())
|
||||||
|
|
||||||
|
def _show_program(self):
|
||||||
|
path = ggml.program_path(self.program)
|
||||||
|
if not path:
|
||||||
|
self.program_label.setText(t("Not installed."))
|
||||||
|
self.install_button.setVisible(True)
|
||||||
|
return
|
||||||
|
self.install_button.setVisible(not ggml.installed_program(self.program)
|
||||||
|
and not ggml.system_program(self.program))
|
||||||
|
if ggml.system_program(self.program):
|
||||||
|
# Worth saying which one is running: a distribution package is built
|
||||||
|
# for this machine and may reach the graphics card, while the
|
||||||
|
# released binaries carry processor backends only.
|
||||||
|
self.program_label.setText(t("Installed on the system: {path}", path=path))
|
||||||
|
else:
|
||||||
|
self.program_label.setText(
|
||||||
|
t("Downloaded, version {version}.",
|
||||||
|
version=ggml.installed_version(self.program) or "?"))
|
||||||
|
|
||||||
|
# ---- the lists -------------------------------------------------------
|
||||||
|
|
||||||
|
def _fill_repos(self, current):
|
||||||
|
def work():
|
||||||
|
self._listed.emit([("repos", ggml.llm_repos())], "")
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _repo_changed(self):
|
||||||
|
if not self._downloading:
|
||||||
|
self._fetch_models(self.repository())
|
||||||
|
|
||||||
|
def _fetch_models(self, repo=""):
|
||||||
|
self.status.setText(t("Fetching the model list…"))
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
found = self._models(repo) if self._repos is not None else self._models()
|
||||||
|
self._quants.emit([("models", found)], "")
|
||||||
|
except ggml.LocalError as exc:
|
||||||
|
self._quants.emit([], str(exc))
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_listed(self, payload, error):
|
||||||
|
if error:
|
||||||
|
self.status.setText(error)
|
||||||
|
self._refresh_buttons()
|
||||||
|
return
|
||||||
|
kind, found = payload[0]
|
||||||
|
if kind == "repos":
|
||||||
|
current = self.repo.currentText()
|
||||||
|
self.repo.blockSignals(True)
|
||||||
|
self.repo.clear()
|
||||||
|
self.repo.addItems(found)
|
||||||
|
self.repo.setCurrentText(current)
|
||||||
|
self.repo.blockSignals(False)
|
||||||
|
return
|
||||||
|
self._fill_models(found)
|
||||||
|
|
||||||
|
def _fill_models(self, items):
|
||||||
|
"""One row per model, saying what it weighs and whether it is here."""
|
||||||
|
wanted = self._wanted or self.selected()
|
||||||
|
here = [name for name in (self._model_path(i.name).name for i in items)]
|
||||||
|
self.model.blockSignals(True)
|
||||||
|
self.model.clear()
|
||||||
|
for item, name in zip(items, here):
|
||||||
|
mark = (t("downloaded") if ggml.have_model(self._model_path(item.name))
|
||||||
|
else ggml.human_size(item.size))
|
||||||
|
self.model.addItem(f"{name} ({mark})", name)
|
||||||
|
self.model.setItemData(self.model.count() - 1, item, Qt.ItemDataRole.UserRole + 1)
|
||||||
|
# A model that was downloaded and then dropped from the list upstream is
|
||||||
|
# still on this disk and still works, so it stays on offer.
|
||||||
|
for name in self._on_disk():
|
||||||
|
if self.model.findData(name) < 0:
|
||||||
|
self.model.addItem(f"{name} ({t('downloaded')})", name)
|
||||||
|
# And one that is chosen but not here, because the file was deleted from
|
||||||
|
# underneath or the settings came from another machine, stays chosen:
|
||||||
|
# Save reads this box, and a row missing here would quietly empty the
|
||||||
|
# setting rather than showing that the model needs downloading again.
|
||||||
|
if wanted and self.model.findData(wanted) < 0:
|
||||||
|
self.model.addItem(f"{wanted} ({t('not downloaded')})", wanted)
|
||||||
|
index = self.model.findData(wanted)
|
||||||
|
self.model.setCurrentIndex(max(index, 0))
|
||||||
|
self.model.blockSignals(False)
|
||||||
|
self._wanted = ""
|
||||||
|
self._model_changed()
|
||||||
|
|
||||||
|
def _on_disk(self):
|
||||||
|
return (ggml.installed_whisper_models() if self.program is ggml.WHISPER
|
||||||
|
else ggml.installed_llm_models())
|
||||||
|
|
||||||
|
# ---- fetching --------------------------------------------------------
|
||||||
|
|
||||||
|
def _install_program(self):
|
||||||
|
self.install_button.setEnabled(False)
|
||||||
|
self.program_label.setText(t("Downloading…"))
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
ggml.install_program(self.program, on_progress=self._report)
|
||||||
|
self._installed.emit("", "")
|
||||||
|
except ggml.LocalError as exc:
|
||||||
|
self._installed.emit("", str(exc))
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_installed(self, _, error):
|
||||||
|
self.install_button.setEnabled(True)
|
||||||
|
self._show_program()
|
||||||
|
if error:
|
||||||
|
self.program_label.setText(error)
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _current_item(self):
|
||||||
|
return self.model.currentData(Qt.ItemDataRole.UserRole + 1)
|
||||||
|
|
||||||
|
def _download(self):
|
||||||
|
if self._downloading:
|
||||||
|
self._stop = True
|
||||||
|
return
|
||||||
|
item = self._current_item()
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
self._downloading, self._stop = True, False
|
||||||
|
self._refresh_buttons()
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
landed = ggml.download(item, self._model_path(item.name),
|
||||||
|
on_progress=self._report,
|
||||||
|
should_stop=lambda: self._stop)
|
||||||
|
self._finished.emit(item.name if landed else "", "")
|
||||||
|
except ggml.LocalError as exc:
|
||||||
|
self._finished.emit("", str(exc))
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _report(self, done, total):
|
||||||
|
self._progress.emit(done, total)
|
||||||
|
|
||||||
|
def _on_progress(self, done, total):
|
||||||
|
share = f" ({done * 100 // total}%)" if total else ""
|
||||||
|
text = t("Downloading: {done} of {total}{share}",
|
||||||
|
done=ggml.human_size(done), total=ggml.human_size(total or done),
|
||||||
|
share=share)
|
||||||
|
if self._downloading:
|
||||||
|
self.status.setText(text)
|
||||||
|
else:
|
||||||
|
self.program_label.setText(text)
|
||||||
|
|
||||||
|
def _on_finished(self, name, error):
|
||||||
|
self._downloading = False
|
||||||
|
if error:
|
||||||
|
self.status.setText(error)
|
||||||
|
elif not name:
|
||||||
|
self.status.setText(t("Download stopped."))
|
||||||
|
self._fill_models_from_current()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _fill_models_from_current(self):
|
||||||
|
"""Redraw the rows without asking anybody anything again."""
|
||||||
|
items = [self.model.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||||
|
for i in range(self.model.count())]
|
||||||
|
self._wanted = self.selected()
|
||||||
|
self._fill_models([i for i in items if i is not None])
|
||||||
|
|
||||||
|
def _delete(self):
|
||||||
|
name = self.selected()
|
||||||
|
if not name or not ggml.have_model(self._model_path(name)):
|
||||||
|
return
|
||||||
|
if QMessageBox.question(self, t("Delete model"),
|
||||||
|
t("Delete {name} from this machine?", name=name)) \
|
||||||
|
!= QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ggml.delete_model(self._model_path(name))
|
||||||
|
except ggml.LocalError as exc:
|
||||||
|
self.status.setText(str(exc))
|
||||||
|
self._fill_models_from_current()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _model_changed(self):
|
||||||
|
self._refresh_buttons()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
def _refresh_buttons(self):
|
||||||
|
name = self.selected()
|
||||||
|
here = bool(name) and ggml.have_model(self._model_path(name))
|
||||||
|
self.delete_button.setEnabled(here and not self._downloading)
|
||||||
|
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
|
||||||
|
self.download_button.setEnabled(self._downloading or (bool(name) and not here))
|
||||||
|
if self._downloading:
|
||||||
|
return
|
||||||
|
if not name:
|
||||||
|
self.status.setText(t("Nothing downloaded yet."))
|
||||||
|
elif here:
|
||||||
|
self.status.setText(t("Ready: {name}.", name=name))
|
||||||
|
else:
|
||||||
|
self.status.setText(t("{name} has not been downloaded yet.", name=name))
|
||||||
|
|
||||||
|
|
||||||
class SettingsWindow(QDialog):
|
class SettingsWindow(QDialog):
|
||||||
applied = pyqtSignal()
|
applied = pyqtSignal()
|
||||||
|
|
||||||
@@ -127,9 +441,9 @@ class SettingsWindow(QDialog):
|
|||||||
self.setWindowTitle(t("Dikte Settings"))
|
self.setWindowTitle(t("Dikte Settings"))
|
||||||
self.resize(680, 640)
|
self.resize(680, 640)
|
||||||
|
|
||||||
tabs = QTabWidget(self)
|
tabs = self.tabs = QTabWidget(self)
|
||||||
tabs.addTab(self._general_tab(), t("General"))
|
tabs.addTab(self._general_tab(), t("General"))
|
||||||
tabs.addTab(self._api_tab(), t("API and models"))
|
self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models"))
|
||||||
tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
|
tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
|
||||||
tabs.addTab(self._assistant_tab(), t("Agent"))
|
tabs.addTab(self._assistant_tab(), t("Agent"))
|
||||||
tabs.addTab(self._meeting_tab(), t("Meeting"))
|
tabs.addTab(self._meeting_tab(), t("Meeting"))
|
||||||
@@ -161,6 +475,10 @@ class SettingsWindow(QDialog):
|
|||||||
self.meetings.finished.connect(self._on_minutes_finished)
|
self.meetings.finished.connect(self._on_minutes_finished)
|
||||||
self.meetings.failed.connect(self._on_minutes_failed)
|
self.meetings.failed.connect(self._on_minutes_failed)
|
||||||
self._load()
|
self._load()
|
||||||
|
# On a machine where nothing can transcribe yet, this window was opened
|
||||||
|
# because of that, so open it on the tab that fixes it.
|
||||||
|
if not conf.transcribe_ready():
|
||||||
|
self.tabs.setCurrentIndex(self.api_tab_index)
|
||||||
|
|
||||||
# ---- tabs ----------------------------------------------------------
|
# ---- tabs ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -269,20 +587,55 @@ class SettingsWindow(QDialog):
|
|||||||
stt_form = QFormLayout(stt)
|
stt_form = QFormLayout(stt)
|
||||||
self.transcribe_provider = QComboBox()
|
self.transcribe_provider = QComboBox()
|
||||||
for label, value in TRANSCRIBE_PROVIDERS:
|
for label, value in TRANSCRIBE_PROVIDERS:
|
||||||
self.transcribe_provider.addItem(label, value)
|
self.transcribe_provider.addItem(t(label), value)
|
||||||
stt_form.addRow(t("Provider"), self.transcribe_provider)
|
stt_form.addRow(t("Provider"), self.transcribe_provider)
|
||||||
|
|
||||||
|
# The hosted providers take any model id that is typed at them; the
|
||||||
|
# local one offers what has been published, so the two are separate
|
||||||
|
# blocks and only one of them is ever visible.
|
||||||
|
self.hosted_stt = QWidget()
|
||||||
|
hosted_form = QFormLayout(self.hosted_stt)
|
||||||
|
hosted_form.setContentsMargins(0, 0, 0, 0)
|
||||||
self.transcribe_model = QComboBox()
|
self.transcribe_model = QComboBox()
|
||||||
self.transcribe_model.setEditable(True)
|
self.transcribe_model.setEditable(True)
|
||||||
self.refresh_transcribe_models = QPushButton(t("Fetch model list"))
|
self.refresh_transcribe_models = QPushButton(t("Fetch model list"))
|
||||||
self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models)
|
self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models)
|
||||||
stt_form.addRow(t("Model"),
|
hosted_form.addRow(t("Model"),
|
||||||
self._row(self.transcribe_model, self.refresh_transcribe_models))
|
self._row(self.transcribe_model,
|
||||||
|
self.refresh_transcribe_models))
|
||||||
# A spanning row: in the narrow field column a wrapped label gets a
|
# A spanning row: in the narrow field column a wrapped label gets a
|
||||||
# height that fits one line, and the rest of the text is cut off.
|
# height that fits one line, and the rest of the text is cut off.
|
||||||
self.transcribe_status = QLabel("")
|
self.transcribe_status = QLabel("")
|
||||||
self.transcribe_status.setWordWrap(True)
|
self.transcribe_status.setWordWrap(True)
|
||||||
stt_form.addRow(self.transcribe_status)
|
hosted_form.addRow(self.transcribe_status)
|
||||||
|
stt_form.addRow(self.hosted_stt)
|
||||||
|
|
||||||
|
self.local_whisper = LocalModelBox(
|
||||||
|
ggml.WHISPER, t("On this machine"),
|
||||||
|
ggml.whisper_models, ggml.whisper_model_path)
|
||||||
|
stt_form.addRow(self.local_whisper)
|
||||||
|
|
||||||
|
self.local_gpu = QCheckBox(t("Use the graphics card"))
|
||||||
|
self.local_gpu.setToolTip(
|
||||||
|
t("whisper.cpp reaches the card through CUDA, ROCm or Vulkan when the "
|
||||||
|
"build it is running was made with one. A build without any of them "
|
||||||
|
"runs on the processor whatever this says."))
|
||||||
|
self.local_preload = QCheckBox(t("Load the model when Dikte starts"))
|
||||||
|
self.local_preload.setToolTip(
|
||||||
|
t("A large model takes a second or two to load. Loading it up front "
|
||||||
|
"spends that once instead of on the first dictation, at the cost of "
|
||||||
|
"the memory it sits in."))
|
||||||
|
self.local_threads = QSpinBox()
|
||||||
|
self.local_threads.setRange(0, 64)
|
||||||
|
self.local_threads.setSpecialValueText(t("Automatic"))
|
||||||
|
self.local_options = QWidget()
|
||||||
|
options_form = QFormLayout(self.local_options)
|
||||||
|
options_form.setContentsMargins(0, 0, 0, 0)
|
||||||
|
options_form.addRow("", self.local_gpu)
|
||||||
|
options_form.addRow("", self.local_preload)
|
||||||
|
options_form.addRow(t("Threads"), self.local_threads)
|
||||||
|
stt_form.addRow(self.local_options)
|
||||||
|
|
||||||
self.transcribe_provider.currentIndexChanged.connect(self._provider_changed)
|
self.transcribe_provider.currentIndexChanged.connect(self._provider_changed)
|
||||||
outer.addWidget(stt)
|
outer.addWidget(stt)
|
||||||
|
|
||||||
@@ -291,12 +644,22 @@ class SettingsWindow(QDialog):
|
|||||||
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.currentIndexChanged.connect(self._cleanup_provider_changed)
|
||||||
|
orr_form.addRow(t("Provider"), self.cleanup_provider)
|
||||||
|
|
||||||
|
self.hosted_cleanup = QWidget()
|
||||||
|
cleanup_form = QFormLayout(self.hosted_cleanup)
|
||||||
|
cleanup_form.setContentsMargins(0, 0, 0, 0)
|
||||||
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))
|
cleanup_form.addRow(t("Model"), self._row(self.cleanup_model,
|
||||||
|
self.refresh_models))
|
||||||
|
|
||||||
self.cleanup_reasoning = QComboBox()
|
self.cleanup_reasoning = QComboBox()
|
||||||
for label, value in REASONING_LEVELS:
|
for label, value in REASONING_LEVELS:
|
||||||
@@ -306,11 +669,38 @@ class SettingsWindow(QDialog):
|
|||||||
"a light job, so more thinking mostly costs time and tokens. Models "
|
"a light job, so more thinking mostly costs time and tokens. Models "
|
||||||
"that cannot think ignore this.")
|
"that cannot think ignore this.")
|
||||||
)
|
)
|
||||||
orr_form.addRow(t("Thinking"), self.cleanup_reasoning)
|
cleanup_form.addRow(t("Thinking"), self.cleanup_reasoning)
|
||||||
|
|
||||||
self.models_label = QLabel(t("Runs on OpenRouter."))
|
self.models_label = QLabel(t("Runs on OpenRouter."))
|
||||||
self.models_label.setWordWrap(True)
|
self.models_label.setWordWrap(True)
|
||||||
orr_form.addRow(self.models_label)
|
cleanup_form.addRow(self.models_label)
|
||||||
|
orr_form.addRow(self.hosted_cleanup)
|
||||||
|
|
||||||
|
self.local_llm = LocalModelBox(
|
||||||
|
ggml.LLAMA, t("On this machine"),
|
||||||
|
ggml.llm_quants, ggml.llm_model_path, repos=ggml.llm_repos)
|
||||||
|
orr_form.addRow(self.local_llm)
|
||||||
|
|
||||||
|
self.local_llm_gpu = QCheckBox(t("Use the graphics card"))
|
||||||
|
self.local_llm_preload = QCheckBox(t("Load the model when Dikte starts"))
|
||||||
|
self.local_llm_preload.setToolTip(
|
||||||
|
t("An LLM is slower to load than a whisper model and sits in more "
|
||||||
|
"memory. Off means it is loaded on the first cleanup instead."))
|
||||||
|
self.local_llm_reasoning = QComboBox()
|
||||||
|
for label, value in REASONING_LEVELS:
|
||||||
|
self.local_llm_reasoning.addItem(t(label), value)
|
||||||
|
self.local_llm_reasoning.setToolTip(
|
||||||
|
t("A model trained to think will think unless it is told not to, and "
|
||||||
|
"spending 300 tokens of reasoning on a comma is 300 tokens of "
|
||||||
|
"waiting. Off is what cleanup wants."))
|
||||||
|
self.local_llm_options = QWidget()
|
||||||
|
llm_form = QFormLayout(self.local_llm_options)
|
||||||
|
llm_form.setContentsMargins(0, 0, 0, 0)
|
||||||
|
llm_form.addRow("", self.local_llm_gpu)
|
||||||
|
llm_form.addRow("", self.local_llm_preload)
|
||||||
|
llm_form.addRow(t("Thinking"), self.local_llm_reasoning)
|
||||||
|
orr_form.addRow(self.local_llm_options)
|
||||||
|
|
||||||
outer.addWidget(orr)
|
outer.addWidget(orr)
|
||||||
outer.addStretch(1)
|
outer.addStretch(1)
|
||||||
return page
|
return page
|
||||||
@@ -926,9 +1316,20 @@ class SettingsWindow(QDialog):
|
|||||||
self._shown_provider = ""
|
self._shown_provider = ""
|
||||||
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
||||||
self._provider_changed() # selecting index 0 fires no signal
|
self._provider_changed() # selecting index 0 fires no signal
|
||||||
|
self.local_gpu.setChecked(conf["local_gpu"])
|
||||||
|
self.local_preload.setChecked(conf["local_preload"])
|
||||||
|
self.local_threads.setValue(int(conf["local_threads"]))
|
||||||
|
self.local_whisper.load(conf["local_model"])
|
||||||
|
|
||||||
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
|
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
|
||||||
|
self._select_data(self.cleanup_provider, conf["cleanup_provider"])
|
||||||
|
self._cleanup_provider_changed()
|
||||||
self.cleanup_model.setCurrentText(conf["cleanup_model"])
|
self.cleanup_model.setCurrentText(conf["cleanup_model"])
|
||||||
self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
|
self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
|
||||||
|
self.local_llm_gpu.setChecked(conf["local_llm_gpu"])
|
||||||
|
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
||||||
|
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
||||||
|
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
||||||
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(
|
||||||
conf["file_cleanup_prompt"] or cfg.default_file_cleanup_prompt()
|
conf["file_cleanup_prompt"] or cfg.default_file_cleanup_prompt()
|
||||||
@@ -1003,16 +1404,27 @@ class SettingsWindow(QDialog):
|
|||||||
conf["openai_api_key"] = self.openai_key.text().strip()
|
conf["openai_api_key"] = self.openai_key.text().strip()
|
||||||
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
|
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
|
||||||
|
|
||||||
provider = self.transcribe_provider.currentData() or "openai"
|
provider = self.transcribe_provider.currentData() or "local"
|
||||||
|
if provider in TRANSCRIBE_MODELS:
|
||||||
self._models[provider] = self.transcribe_model.currentText().strip()
|
self._models[provider] = self.transcribe_model.currentText().strip()
|
||||||
conf["transcribe_provider"] = provider
|
conf["transcribe_provider"] = provider
|
||||||
for key, name in (("openai", "transcribe_model"),
|
for key, name in (("openai", "transcribe_model"),
|
||||||
("openrouter", "openrouter_transcribe_model")):
|
("openrouter", "openrouter_transcribe_model")):
|
||||||
conf[name] = self._models[key].strip() or cfg.DEFAULTS[name]
|
conf[name] = self._models[key].strip() or cfg.DEFAULTS[name]
|
||||||
|
conf["local_model"] = self.local_whisper.selected()
|
||||||
|
conf["local_gpu"] = self.local_gpu.isChecked()
|
||||||
|
conf["local_preload"] = self.local_preload.isChecked()
|
||||||
|
conf["local_threads"] = self.local_threads.value()
|
||||||
|
|
||||||
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_reasoning"] = self.cleanup_reasoning.currentData() or ""
|
conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or ""
|
||||||
|
conf["local_llm_model"] = self.local_llm.selected()
|
||||||
|
conf["local_llm_repo"] = self.local_llm.repository()
|
||||||
|
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
|
||||||
|
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
||||||
|
conf["local_llm_reasoning"] = self.local_llm_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
|
||||||
# interface language also switches the prompt language.
|
# interface language also switches the prompt language.
|
||||||
@@ -1093,15 +1505,27 @@ class SettingsWindow(QDialog):
|
|||||||
|
|
||||||
def _provider_changed(self):
|
def _provider_changed(self):
|
||||||
"""Swap the model box over to the newly chosen provider's own model."""
|
"""Swap the model box over to the newly chosen provider's own model."""
|
||||||
if self._shown_provider:
|
if self._shown_provider in TRANSCRIBE_MODELS:
|
||||||
self._models[self._shown_provider] = self.transcribe_model.currentText().strip()
|
self._models[self._shown_provider] = self.transcribe_model.currentText().strip()
|
||||||
provider = self.transcribe_provider.currentData() or "openai"
|
provider = self.transcribe_provider.currentData() or "local"
|
||||||
self._shown_provider = provider
|
self._shown_provider = provider
|
||||||
|
local = provider == "local"
|
||||||
|
self.hosted_stt.setVisible(not local)
|
||||||
|
self.local_whisper.setVisible(local)
|
||||||
|
self.local_options.setVisible(local)
|
||||||
|
if local:
|
||||||
|
return
|
||||||
self.transcribe_model.clear()
|
self.transcribe_model.clear()
|
||||||
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
|
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
|
||||||
self.transcribe_model.setCurrentText(self._models[provider])
|
self.transcribe_model.setCurrentText(self._models[provider])
|
||||||
self.transcribe_status.setText("")
|
self.transcribe_status.setText("")
|
||||||
|
|
||||||
|
def _cleanup_provider_changed(self):
|
||||||
|
local = (self.cleanup_provider.currentData() or "openrouter") == "local"
|
||||||
|
self.hosted_cleanup.setVisible(not local)
|
||||||
|
self.local_llm.setVisible(local)
|
||||||
|
self.local_llm_options.setVisible(local)
|
||||||
|
|
||||||
def _load_transcribe_models(self):
|
def _load_transcribe_models(self):
|
||||||
"""The model list of whichever provider is selected."""
|
"""The model list of whichever provider is selected."""
|
||||||
provider = self.transcribe_provider.currentData() or "openai"
|
provider = self.transcribe_provider.currentData() or "openai"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
import wave
|
import wave
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -40,6 +41,12 @@ linux_only = unittest.skipUnless(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_network(*args, **kwargs):
|
||||||
|
raise AssertionError(
|
||||||
|
"a test reached the network; wrap the call in support.fake_urlopen"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _no_exec(*args, **kwargs):
|
def _no_exec(*args, **kwargs):
|
||||||
raise AssertionError(
|
raise AssertionError(
|
||||||
"a test reached os.execv, which would replace the test process with the "
|
"a test reached os.execv, which would replace the test process with the "
|
||||||
@@ -78,6 +85,11 @@ class DikteTest(unittest.TestCase):
|
|||||||
# with it and hang, so it fails loudly here instead.
|
# with it and hang, so it fails loudly here instead.
|
||||||
self.patch_attr(os, "execv", _no_exec)
|
self.patch_attr(os, "execv", _no_exec)
|
||||||
|
|
||||||
|
# Every way out of here goes through urllib, so closing it is enough to
|
||||||
|
# keep the suite offline. A test that means to answer a request patches
|
||||||
|
# this again through fake_urlopen.
|
||||||
|
self.patch_attr(urllib.request, "urlopen", _no_network)
|
||||||
|
|
||||||
# ---- helpers ---------------------------------------------------------
|
# ---- helpers ---------------------------------------------------------
|
||||||
|
|
||||||
def path(self, *parts):
|
def path(self, *parts):
|
||||||
|
|||||||
+182
-10
@@ -10,6 +10,7 @@ import os
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import api
|
import api
|
||||||
|
import ggml
|
||||||
from tests.support import (
|
from tests.support import (
|
||||||
DikteTest,
|
DikteTest,
|
||||||
fake_urlopen,
|
fake_urlopen,
|
||||||
@@ -27,10 +28,18 @@ OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
|
|||||||
|
|
||||||
class TimestampModel(unittest.TestCase):
|
class TimestampModel(unittest.TestCase):
|
||||||
def test_only_whisper_returns_segment_times(self):
|
def test_only_whisper_returns_segment_times(self):
|
||||||
self.assertEqual(api.timestamp_model("openai"), "whisper-1")
|
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
||||||
|
"whisper-1")
|
||||||
|
|
||||||
def test_openrouter_namespaces_the_id(self):
|
def test_openrouter_namespaces_the_id(self):
|
||||||
self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1")
|
self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe"),
|
||||||
|
"openai/whisper-1")
|
||||||
|
|
||||||
|
def test_the_local_server_stays_on_the_model_it_loaded(self):
|
||||||
|
# Asking it for whisper-1 would name a model it has never heard of, and
|
||||||
|
# it is running whisper whatever the file is called.
|
||||||
|
self.assertEqual(api.timestamp_model("local", "ggml-base.bin"),
|
||||||
|
"ggml-base.bin")
|
||||||
|
|
||||||
|
|
||||||
class Explain(DikteTest):
|
class Explain(DikteTest):
|
||||||
@@ -278,10 +287,15 @@ def chat_reply(content):
|
|||||||
return {"choices": [{"message": {"content": content}}]}
|
return {"choices": [{"message": {"content": content}}]}
|
||||||
|
|
||||||
|
|
||||||
|
def openrouter(model="some/model", key="sk-or-test", reasoning="",
|
||||||
|
base_url="https://openrouter.ai/api/v1"):
|
||||||
|
return api.Target("openrouter", "OpenRouter", key, base_url, model, reasoning)
|
||||||
|
|
||||||
|
|
||||||
class Cleanup(DikteTest):
|
class Cleanup(DikteTest):
|
||||||
def call(self, replies, **kwargs):
|
def call(self, replies, target=None, **kwargs):
|
||||||
with fake_urlopen(replies) as calls:
|
with fake_urlopen(replies) as calls:
|
||||||
result = api.cleanup("uh, hello", "sk-or-test", "some/model",
|
result = api.cleanup(target or openrouter(), "uh, hello",
|
||||||
"you clean up text", **kwargs)
|
"you clean up text", **kwargs)
|
||||||
return result, calls
|
return result, calls
|
||||||
|
|
||||||
@@ -311,32 +325,34 @@ class Cleanup(DikteTest):
|
|||||||
self.assertNotIn("reasoning", sent_json(calls[0]))
|
self.assertNotIn("reasoning", sent_json(calls[0]))
|
||||||
|
|
||||||
def test_an_effort_is_passed_on_and_the_thinking_left_out(self):
|
def test_an_effort_is_passed_on_and_the_thinking_left_out(self):
|
||||||
_, calls = self.call(chat_reply("Hello."), reasoning="high")
|
_, calls = self.call(chat_reply("Hello."),
|
||||||
|
target=openrouter(reasoning="high"))
|
||||||
self.assertEqual(sent_json(calls[0])["reasoning"],
|
self.assertEqual(sent_json(calls[0])["reasoning"],
|
||||||
{"effort": "high", "exclude": True})
|
{"effort": "high", "exclude": True})
|
||||||
|
|
||||||
def test_a_local_base_url(self):
|
def test_a_local_base_url(self):
|
||||||
_, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1")
|
_, calls = self.call(chat_reply("Hello."),
|
||||||
|
target=openrouter(base_url="http://localhost:1234/v1"))
|
||||||
self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions")
|
self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions")
|
||||||
|
|
||||||
def test_no_key(self):
|
def test_no_key(self):
|
||||||
with self.assertRaises(api.ApiError):
|
with self.assertRaises(api.ApiError):
|
||||||
api.cleanup("hello", "", "some/model", "prompt")
|
api.cleanup(openrouter(key=""), "hello", "prompt")
|
||||||
|
|
||||||
def test_a_reply_with_no_choices_says_why(self):
|
def test_a_reply_with_no_choices_says_why(self):
|
||||||
with fake_urlopen({"error": {"message": "model is offline"}}), \
|
with fake_urlopen({"error": {"message": "model is offline"}}), \
|
||||||
self.assertRaises(api.ApiError) as caught:
|
self.assertRaises(api.ApiError) as caught:
|
||||||
api.cleanup("hello", "k", "m", "p")
|
api.cleanup(openrouter(), "hello", "p")
|
||||||
self.assertIn("model is offline", str(caught.exception))
|
self.assertIn("model is offline", str(caught.exception))
|
||||||
|
|
||||||
def test_an_empty_answer(self):
|
def test_an_empty_answer(self):
|
||||||
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
||||||
api.cleanup("hello", "k", "m", "p")
|
api.cleanup(openrouter(), "hello", "p")
|
||||||
|
|
||||||
def test_a_rate_limit_is_explained(self):
|
def test_a_rate_limit_is_explained(self):
|
||||||
with fake_urlopen(http_error(429)), \
|
with fake_urlopen(http_error(429)), \
|
||||||
self.assertRaises(api.ApiError) as caught:
|
self.assertRaises(api.ApiError) as caught:
|
||||||
api.cleanup("hello", "k", "m", "p")
|
api.cleanup(openrouter(), "hello", "p")
|
||||||
self.assertIn("OpenRouter", str(caught.exception))
|
self.assertIn("OpenRouter", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
@@ -435,3 +451,159 @@ class ModelLists(DikteTest):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeServer:
|
||||||
|
"""A ggml.Server as far as api.py is concerned."""
|
||||||
|
|
||||||
|
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""):
|
||||||
|
self.url = url
|
||||||
|
self.fails = fails
|
||||||
|
self.log = log
|
||||||
|
self.starts = 0
|
||||||
|
|
||||||
|
def serve(self):
|
||||||
|
self.starts += 1
|
||||||
|
if self.fails:
|
||||||
|
raise ggml.LocalError(self.fails)
|
||||||
|
return self.url
|
||||||
|
|
||||||
|
def error(self):
|
||||||
|
return self.log
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
|
||||||
|
LOCAL_LLM = api.Target("local-llm", "Local model", "", "", "gemma.gguf", "none")
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeHere(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.wav = str(self.path("clip.wav"))
|
||||||
|
os.makedirs(self.root, exist_ok=True)
|
||||||
|
with open(self.wav, "wb") as fh:
|
||||||
|
fh.write(b"RIFFfake")
|
||||||
|
self.server = FakeServer()
|
||||||
|
self.patch_attr(ggml, "whisper", self.server)
|
||||||
|
|
||||||
|
def test_the_address_comes_from_the_server_it_starts(self):
|
||||||
|
with fake_urlopen({"text": "hello"}) as calls:
|
||||||
|
api.transcribe(LOCAL, self.wav)
|
||||||
|
self.assertEqual(self.server.starts, 1)
|
||||||
|
self.assertEqual(calls[0].full_url,
|
||||||
|
"http://127.0.0.1:9999/v1/audio/transcriptions")
|
||||||
|
|
||||||
|
def test_nothing_local_is_authorised(self):
|
||||||
|
with fake_urlopen({"text": "hello"}) as calls:
|
||||||
|
api.transcribe(LOCAL, self.wav)
|
||||||
|
self.assertNotIn("Authorization", calls[0].headers)
|
||||||
|
|
||||||
|
def test_a_server_that_will_not_start_is_the_error_shown(self):
|
||||||
|
self.patch_attr(ggml, "whisper", FakeServer(fails="no model downloaded"))
|
||||||
|
with self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.transcribe(LOCAL, self.wav)
|
||||||
|
self.assertIn("no model downloaded", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_server_that_dies_mid_request_says_what_it_printed(self):
|
||||||
|
self.patch_attr(ggml, "whisper", FakeServer(log="out of memory"))
|
||||||
|
with fake_urlopen(url_error("connection reset")):
|
||||||
|
with self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.transcribe(LOCAL, self.wav)
|
||||||
|
self.assertIn("out of memory", str(caught.exception))
|
||||||
|
|
||||||
|
def test_the_hint_reaches_whisper_as_its_initial_prompt(self):
|
||||||
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
|
api.transcribe(LOCAL, self.wav, prompt="Dikte, Paraşüt")
|
||||||
|
self.assertEqual(multipart_fields(calls[0])["prompt"], "Dikte, Paraşüt")
|
||||||
|
|
||||||
|
def test_a_word_broken_over_two_lines_is_put_back_together(self):
|
||||||
|
# whisper.cpp cuts on tokens and writes one segment per line, which in
|
||||||
|
# Turkish lands inside a word about as often as between two.
|
||||||
|
with fake_urlopen({"text": "Onlar akraba değ\niller. Ve\n devamı."}):
|
||||||
|
# The line break inside a word leaves nothing in its place; the
|
||||||
|
# one between two words is where whisper's own leading space is.
|
||||||
|
self.assertEqual(api.transcribe(LOCAL, self.wav),
|
||||||
|
"Onlar akraba değiller. Ve devamı.")
|
||||||
|
|
||||||
|
def test_a_local_timeout_is_not_a_hosted_one(self):
|
||||||
|
# Nothing is being spent but time, and a long file on a machine without
|
||||||
|
# a graphics card takes a good deal of it.
|
||||||
|
with fake_urlopen({"text": "hi"}):
|
||||||
|
api.transcribe(LOCAL, self.wav, timeout=300)
|
||||||
|
self.assertGreaterEqual(api.LOCAL_TIMEOUT, 600)
|
||||||
|
|
||||||
|
def test_segments_that_continue_a_word_are_merged(self):
|
||||||
|
reply = {"segments": [
|
||||||
|
{"start": 0.0, "end": 1.0, "text": " Onlar akraba değ"},
|
||||||
|
{"start": 1.0, "end": 1.4, "text": "iller."},
|
||||||
|
{"start": 2.0, "end": 3.0, "text": " Başka bir cümle."},
|
||||||
|
]}
|
||||||
|
with fake_urlopen(reply):
|
||||||
|
out = api.transcribe_segments(LOCAL, self.wav)
|
||||||
|
self.assertEqual([text for _, _, text in out],
|
||||||
|
["Onlar akraba değiller.", "Başka bir cümle."])
|
||||||
|
self.assertEqual(out[0][1], 1.4) # the merged cue covers the whole word
|
||||||
|
|
||||||
|
def test_the_loaded_model_is_the_one_asked_for_again(self):
|
||||||
|
with fake_urlopen({"segments": [{"start": 0, "end": 1, "text": " hi"}]}) as calls:
|
||||||
|
api.transcribe_segments(LOCAL, self.wav)
|
||||||
|
self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin")
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupHere(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.server = FakeServer("http://127.0.0.1:8888/v1")
|
||||||
|
self.patch_attr(ggml, "llm", self.server)
|
||||||
|
|
||||||
|
def test_it_goes_to_the_server_it_starts(self):
|
||||||
|
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||||
|
result = api.cleanup(LOCAL_LLM, "uh, hello", "clean it up")
|
||||||
|
self.assertEqual(result, "Hello.")
|
||||||
|
self.assertEqual(calls[0].full_url,
|
||||||
|
"http://127.0.0.1:8888/v1/chat/completions")
|
||||||
|
|
||||||
|
def test_no_key_is_wanted_and_none_is_sent(self):
|
||||||
|
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||||
|
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||||
|
self.assertNotIn("Authorization", calls[0].headers)
|
||||||
|
|
||||||
|
def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self):
|
||||||
|
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||||
|
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||||
|
self.assertEqual(sent_json(calls[0])["chat_template_kwargs"],
|
||||||
|
{"enable_thinking": False})
|
||||||
|
|
||||||
|
def test_the_models_own_default_asks_for_nothing(self):
|
||||||
|
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||||
|
api.cleanup(LOCAL_LLM._replace(reasoning=""), "hello", "prompt")
|
||||||
|
self.assertNotIn("chat_template_kwargs", sent_json(calls[0]))
|
||||||
|
|
||||||
|
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
|
||||||
|
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
||||||
|
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||||
|
self.assertIn("Thinking", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_reply_longer_than_the_transcript_is_cut_off(self):
|
||||||
|
# A small model will repeat the transcript until the context is full,
|
||||||
|
# and every one of those tokens is a second of somebody waiting.
|
||||||
|
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||||
|
api.cleanup(LOCAL_LLM, "x" * 4000, "prompt")
|
||||||
|
self.assertEqual(sent_json(calls[0])["max_tokens"], 4000)
|
||||||
|
|
||||||
|
def test_a_short_dictation_still_gets_room_to_answer(self):
|
||||||
|
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||||
|
api.cleanup(LOCAL_LLM, "uh, hi", "prompt")
|
||||||
|
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
|
||||||
|
|
||||||
|
def test_a_hosted_model_is_left_to_answer_at_length(self):
|
||||||
|
with fake_urlopen(chat_reply("Hello.")) as calls:
|
||||||
|
api.cleanup(openrouter(), "uh, hi", "prompt")
|
||||||
|
self.assertNotIn("max_tokens", sent_json(calls[0]))
|
||||||
|
|
||||||
|
def test_a_server_that_will_not_start_is_the_error_shown(self):
|
||||||
|
self.patch_attr(ggml, "llm", FakeServer(fails="llama.cpp is not installed"))
|
||||||
|
with self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.cleanup(LOCAL_LLM, "hello", "prompt")
|
||||||
|
self.assertIn("llama.cpp", str(caught.exception))
|
||||||
|
|||||||
+84
-3
@@ -13,6 +13,7 @@ from unittest import mock
|
|||||||
|
|
||||||
import api
|
import api
|
||||||
import config as cfg
|
import config as cfg
|
||||||
|
import ggml
|
||||||
import i18n
|
import i18n
|
||||||
from tests.support import DikteTest
|
from tests.support import DikteTest
|
||||||
|
|
||||||
@@ -127,8 +128,17 @@ class Keys(DikteTest):
|
|||||||
|
|
||||||
|
|
||||||
class TranscribeTarget(DikteTest):
|
class TranscribeTarget(DikteTest):
|
||||||
def test_openai_by_default(self):
|
def test_this_machine_by_default(self):
|
||||||
target = self.config(openai_api_key="sk-test").transcribe_target()
|
target = cfg.Config().transcribe_target()
|
||||||
|
self.assertEqual(target.provider, "local")
|
||||||
|
self.assertEqual(target.api_key, "")
|
||||||
|
# Empty on purpose: the server picks a port when it starts, and reading
|
||||||
|
# a setting must not be what starts it.
|
||||||
|
self.assertEqual(target.base_url, "")
|
||||||
|
|
||||||
|
def test_openai_when_it_is_picked(self):
|
||||||
|
target = self.config(transcribe_provider="openai",
|
||||||
|
openai_api_key="sk-test").transcribe_target()
|
||||||
self.assertEqual(target.provider, "openai")
|
self.assertEqual(target.provider, "openai")
|
||||||
self.assertEqual(target.service, "OpenAI")
|
self.assertEqual(target.service, "OpenAI")
|
||||||
self.assertEqual(target.api_key, "sk-test")
|
self.assertEqual(target.api_key, "sk-test")
|
||||||
@@ -146,7 +156,8 @@ class TranscribeTarget(DikteTest):
|
|||||||
self.assertEqual(target.model, "openai/whisper-1")
|
self.assertEqual(target.model, "openai/whisper-1")
|
||||||
|
|
||||||
def test_a_self_hosted_endpoint(self):
|
def test_a_self_hosted_endpoint(self):
|
||||||
conf = self.config(openai_base_url="http://localhost:8080/v1")
|
conf = self.config(transcribe_provider="openai",
|
||||||
|
openai_base_url="http://localhost:8080/v1")
|
||||||
self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1")
|
self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1")
|
||||||
|
|
||||||
|
|
||||||
@@ -425,3 +436,73 @@ class Defaults(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class LocalTargets(DikteTest):
|
||||||
|
def test_cleanup_can_run_here_while_the_minutes_do_not(self):
|
||||||
|
# The two jobs are not the same size: a small model on this machine
|
||||||
|
# strips filler words perfectly well and will not write up an hour.
|
||||||
|
conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf")
|
||||||
|
self.assertEqual(conf.cleanup_target().provider, "local-llm")
|
||||||
|
self.assertEqual(conf.minutes_target().provider, "openrouter")
|
||||||
|
self.assertEqual(conf.minutes_target().model, cfg.DEFAULTS["meeting_model"])
|
||||||
|
|
||||||
|
def test_the_minutes_can_run_here_on_their_own(self):
|
||||||
|
conf = self.config(meeting_provider="local", local_llm_model="gemma.gguf")
|
||||||
|
self.assertEqual(conf.minutes_target().model, "gemma.gguf")
|
||||||
|
self.assertEqual(conf.cleanup_target().provider, "openrouter")
|
||||||
|
|
||||||
|
def test_the_local_cleanup_target_carries_the_thinking_level(self):
|
||||||
|
conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf",
|
||||||
|
local_llm_reasoning="none")
|
||||||
|
target = conf.cleanup_target()
|
||||||
|
self.assertEqual(target.reasoning, "none")
|
||||||
|
self.assertEqual(target.api_key, "")
|
||||||
|
|
||||||
|
def test_either_of_them_counts_as_using_the_local_model(self):
|
||||||
|
self.assertFalse(cfg.Config().uses_local_llm())
|
||||||
|
self.assertTrue(self.config(cleanup_provider="local").uses_local_llm())
|
||||||
|
self.assertTrue(self.config(meeting_provider="local").uses_local_llm())
|
||||||
|
|
||||||
|
|
||||||
|
class ReadyToRun(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
||||||
|
|
||||||
|
def install(self, name):
|
||||||
|
path = ggml.whisper_model_path(name)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"model")
|
||||||
|
|
||||||
|
def test_a_missing_program_is_not_ready(self):
|
||||||
|
with mock.patch("shutil.which", return_value=None):
|
||||||
|
self.install("ggml-base.bin")
|
||||||
|
conf = self.config(local_model="ggml-base.bin")
|
||||||
|
self.assertFalse(conf.transcribe_ready())
|
||||||
|
|
||||||
|
def test_a_missing_model_is_not_ready_either(self):
|
||||||
|
with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"):
|
||||||
|
conf = self.config(local_model="ggml-base.bin")
|
||||||
|
self.assertFalse(conf.transcribe_ready())
|
||||||
|
|
||||||
|
def test_both_halves_in_place(self):
|
||||||
|
with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"):
|
||||||
|
self.install("ggml-base.bin")
|
||||||
|
conf = self.config(local_model="ggml-base.bin")
|
||||||
|
self.assertTrue(conf.transcribe_ready())
|
||||||
|
|
||||||
|
def test_a_hosted_provider_is_ready_when_it_has_a_key(self):
|
||||||
|
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test")
|
||||||
|
self.assertTrue(conf.transcribe_ready())
|
||||||
|
|
||||||
|
def test_the_settings_reach_the_servers(self):
|
||||||
|
conf = self.config(local_model="ggml-base.bin", local_threads=4,
|
||||||
|
local_gpu=False, local_llm_model="gemma.gguf",
|
||||||
|
local_llm_context=4096)
|
||||||
|
conf.apply_local()
|
||||||
|
self.addCleanup(ggml.whisper.configure, model="", threads=0, gpu=True)
|
||||||
|
self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin")
|
||||||
|
self.assertEqual(ggml.whisper.settings()["threads"], 4)
|
||||||
|
self.assertFalse(ggml.whisper.settings()["gpu"])
|
||||||
|
self.assertEqual(ggml.llm.settings()["context"], 4096)
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ class Transcriber(DikteTest):
|
|||||||
|
|
||||||
def test_cleanup_is_told_it_is_writing_subtitles(self):
|
def test_cleanup_is_told_it_is_writing_subtitles(self):
|
||||||
_, _, _, cleanup_call = self.run_chain(cleanup=True)
|
_, _, _, cleanup_call = self.run_chain(cleanup=True)
|
||||||
prompt = cleanup_call.call_args.args[3]
|
prompt = cleanup_call.call_args.args[2]
|
||||||
self.assertEqual(prompt, self.conf.cleanup_prompt(subtitles=True))
|
self.assertEqual(prompt, self.conf.cleanup_prompt(subtitles=True))
|
||||||
|
|
||||||
def test_timestamps_come_back_as_segments_and_as_stamped_lines(self):
|
def test_timestamps_come_back_as_segments_and_as_stamped_lines(self):
|
||||||
|
|||||||
@@ -41,8 +41,18 @@ CHANGED = {
|
|||||||
"transcribe_model": "whisper-1",
|
"transcribe_model": "whisper-1",
|
||||||
"openrouter_transcribe_model": "openai/whisper-1",
|
"openrouter_transcribe_model": "openai/whisper-1",
|
||||||
"cleanup_enabled": False,
|
"cleanup_enabled": False,
|
||||||
|
"cleanup_provider": "local",
|
||||||
"cleanup_model": "some/other-model",
|
"cleanup_model": "some/other-model",
|
||||||
"cleanup_reasoning": "high",
|
"cleanup_reasoning": "high",
|
||||||
|
"local_model": "ggml-small.bin",
|
||||||
|
"local_gpu": False,
|
||||||
|
"local_preload": False,
|
||||||
|
"local_threads": 6,
|
||||||
|
"local_llm_model": "gemma-3-4b-it-Q4_K_M.gguf",
|
||||||
|
"local_llm_repo": "ggml-org/gemma-4-E2B-it-GGUF",
|
||||||
|
"local_llm_gpu": False,
|
||||||
|
"local_llm_preload": True,
|
||||||
|
"local_llm_reasoning": "low",
|
||||||
"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.",
|
||||||
"transcribe_prompt": "Paraşüt, OpenFrame",
|
"transcribe_prompt": "Paraşüt, OpenFrame",
|
||||||
@@ -259,3 +269,53 @@ class Overlay(DikteTest):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class LocalModels(DikteTest):
|
||||||
|
"""The download boxes, without a network and without either program."""
|
||||||
|
|
||||||
|
def window(self, conf):
|
||||||
|
window = settings_ui.SettingsWindow(conf, "dikte toggle")
|
||||||
|
self.addCleanup(window.deleteLater)
|
||||||
|
self.addCleanup(window.close)
|
||||||
|
return window
|
||||||
|
|
||||||
|
def test_it_opens_where_the_missing_model_is_fixed(self):
|
||||||
|
# Nothing can transcribe on a fresh install, which is why this window
|
||||||
|
# was opened at all.
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
self.assertEqual(window.tabs.currentIndex(), window.api_tab_index)
|
||||||
|
|
||||||
|
def test_it_opens_where_it_was_left_when_everything_works(self):
|
||||||
|
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test")
|
||||||
|
self.assertEqual(self.window(conf).tabs.currentIndex(), 0)
|
||||||
|
|
||||||
|
def test_a_model_that_is_not_here_yet_survives_a_save(self):
|
||||||
|
# The box is filled from what is on this disk, so a model that was
|
||||||
|
# deleted from underneath is not in the list. Dropping it on save would
|
||||||
|
# quietly empty the setting instead of asking for the download again.
|
||||||
|
conf = self.config(local_model="ggml-large-v3-turbo-q5_0.bin")
|
||||||
|
with mock.patch.object(QMessageBox, "information"):
|
||||||
|
self.window(conf)._save()
|
||||||
|
self.assertEqual(conf["local_model"], "ggml-large-v3-turbo-q5_0.bin")
|
||||||
|
|
||||||
|
def test_nothing_is_fetched_for_a_window_nobody_opened(self):
|
||||||
|
# DikteTest closes the network, so a request would fail the test. The
|
||||||
|
# lists are asked for when the box is shown, not when it is built.
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
self.assertTrue(window.local_whisper._pending)
|
||||||
|
|
||||||
|
def test_the_hosted_boxes_go_away_when_the_work_happens_here(self):
|
||||||
|
window = self.window(self.config(transcribe_provider="openai"))
|
||||||
|
self.assertTrue(window.hosted_stt.isVisibleTo(window))
|
||||||
|
self.assertFalse(window.local_whisper.isVisibleTo(window))
|
||||||
|
window._select_data(window.transcribe_provider, "local")
|
||||||
|
self.assertFalse(window.hosted_stt.isVisibleTo(window))
|
||||||
|
self.assertTrue(window.local_whisper.isVisibleTo(window))
|
||||||
|
|
||||||
|
def test_the_same_for_cleanup(self):
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
self.assertTrue(window.hosted_cleanup.isVisibleTo(window))
|
||||||
|
window._select_data(window.cleanup_provider, "local")
|
||||||
|
self.assertTrue(window.local_llm.isVisibleTo(window))
|
||||||
|
self.assertFalse(window.hosted_cleanup.isVisibleTo(window))
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ class Chain(DikteTest):
|
|||||||
self.assertEqual(row["raw"], "uh, book it for Thursday")
|
self.assertEqual(row["raw"], "uh, book it for Thursday")
|
||||||
self.assertEqual(row["text"], "Book it for Thursday.")
|
self.assertEqual(row["text"], "Book it for Thursday.")
|
||||||
self.assertEqual(row["duration"], 2.0)
|
self.assertEqual(row["duration"], 2.0)
|
||||||
self.assertEqual(row["model"], self.conf["transcribe_model"])
|
self.assertEqual(row["model"], self.conf.transcribe_target().model)
|
||||||
self.assertEqual(row["mode"], "")
|
self.assertEqual(row["mode"], "")
|
||||||
|
|
||||||
def test_a_command_is_recorded_as_one(self):
|
def test_a_command_is_recorded_as_one(self):
|
||||||
|
|||||||
@@ -106,20 +106,14 @@ class Pipeline(QObject):
|
|||||||
|
|
||||||
text = raw
|
text = raw
|
||||||
warning = ""
|
warning = ""
|
||||||
|
cleaner = conf.cleanup_target()
|
||||||
# Claude reads through “eee” and “hani” without help, so a dictation
|
# Claude reads through “eee” and “hani” without help, so a dictation
|
||||||
# on its way there is normally sent as it was heard, one API call and
|
# on its way there is normally sent as it was heard, one API call and
|
||||||
# a second or two lighter.
|
# a second or two lighter.
|
||||||
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 = api.cleanup(cleaner, raw, 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": cleaner.model 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