Add per-segment review reasons for plugins (#1600)
Some checks are pending
CI / test (macos-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build (macos-latest) (push) Waiting to run
CI / build (ubuntu-latest) (push) Waiting to run
CI / build (windows-latest) (push) Waiting to run
CI / build_wheels (macos-latest) (push) Waiting to run
CI / build_wheels (ubuntu-latest) (push) Waiting to run
CI / build_wheels (windows-latest) (push) Waiting to run
CI / publish_pypi (push) Blocked by required conditions
CI / release (macos-latest) (push) Blocked by required conditions
CI / release (windows-latest) (push) Blocked by required conditions
GitHub Pages / Deploy (push) Waiting to run
Snapcraft / build (push) Waiting to run
Snapcraft / upload-edge (push) Blocked by required conditions
Snapcraft / upload-stable (push) Blocked by required conditions

Co-authored-by: Raivis Dejus <raivisd@scandiweb.com>
This commit is contained in:
Dicur3x 2026-09-12 00:13:38 +03:00 • committed by GitHub
parent f70a02fcd5
commit 2d7d9dd795
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 203 additions and 89 deletions

View file

@ -33,6 +33,7 @@ class Transcription(Entity):
url: str | None = None
name: str | None = None
notes: str | None = None
metadata: str = "[]"
@property
def id_as_uuid(self):

View file

@ -12,3 +12,4 @@ class TranscriptionSegment(Entity):
transcription_id: str
speaker: str = ""
id: int = -1
metadata: str = "[]"

View file

@ -1,3 +1,4 @@
import json
import os
from datetime import datetime
from sqlite3 import Connection
@ -55,9 +56,9 @@ def copy_transcriptions_from_json_to_sqlite(conn: Connection):
"""
INSERT INTO transcription_segment (
end_time, start_time, text, translation,
transcription_id, speaker
transcription_id, speaker, metadata
)
VALUES (?, ?, ?, ?, ?, ?);
VALUES (?, ?, ?, ?, ?, ?, ?);
""",
(
segment.end,
@ -66,6 +67,10 @@ def copy_transcriptions_from_json_to_sqlite(conn: Connection):
segment.translation,
transcription_id,
getattr(segment, "speaker", ""),
json.dumps(
getattr(segment, "metadata", []),
ensure_ascii=False,
),
),
)
# os.remove(cache.tasks_list_file_path)

View file

@ -1,3 +1,4 @@
import json
from typing import List
from uuid import UUID
@ -36,6 +37,9 @@ class TranscriptionService:
def update_transcription_as_completed(self, id: UUID, segments: List[Segment]):
self.transcription_dao.update_transcription_as_completed(id)
self._insert_segments(id, segments)
def _insert_segments(self, id: UUID, segments: List[Segment]):
for segment in segments:
self.transcription_segment_dao.insert(
TranscriptionSegment(
@ -45,6 +49,7 @@ class TranscriptionService:
translation='',
transcription_id=str(id),
speaker=segment.speaker,
metadata=json.dumps(segment.metadata, ensure_ascii=False),
)
)
@ -62,17 +67,7 @@ class TranscriptionService:
def update_transcription_as_skipped(self, id: UUID, segments: List[Segment]):
self.transcription_dao.update_transcription_as_skipped(id)
for segment in segments:
self.transcription_segment_dao.insert(
TranscriptionSegment(
start_time=segment.start,
end_time=segment.end,
text=segment.text,
translation='',
transcription_id=str(id),
speaker=segment.speaker,
)
)
self._insert_segments(id, segments)
def find_completed_transcription_by_filename(self, filename: str):
return self.transcription_dao.find_completed_transcription_by_filename(filename)
@ -82,17 +77,7 @@ class TranscriptionService:
def replace_transcription_segments(self, id: UUID, segments: List[Segment]):
self.transcription_segment_dao.delete_segments(id)
for segment in segments:
self.transcription_segment_dao.insert(
TranscriptionSegment(
start_time=segment.start,
end_time=segment.end,
text=segment.text,
translation='',
transcription_id=str(id),
speaker=segment.speaker,
)
)
self._insert_segments(id, segments)
def get_transcription_segments(self, transcription_id: UUID):
return self.transcription_segment_dao.get_segments(transcription_id)

View file

@ -158,6 +158,10 @@ class BuzzPlugin:
) -> List["Segment"]:
"""Modify or replace the result segments before they are saved.
Append ``{"key": "value"}`` dicts to a segment's ``metadata`` list to
attach extra information to that row without changing or deleting its
text. The transcription viewer shows it as a ``key: value`` tooltip.
Must return a list of segments (return the input unchanged to do nothing).
Runs on a background thread.
"""

View file

@ -19,7 +19,8 @@ CREATE TABLE transcription (
word_level_timings BOOLEAN DEFAULT FALSE,
extract_speech BOOLEAN DEFAULT FALSE,
name TEXT,
notes TEXT
notes TEXT,
metadata TEXT DEFAULT '[]'
);
CREATE TABLE transcription_segment (
@ -30,6 +31,7 @@ CREATE TABLE transcription_segment (
translation TEXT DEFAULT '',
transcription_id TEXT,
speaker TEXT DEFAULT '',
metadata TEXT DEFAULT '[]',
FOREIGN KEY (transcription_id) REFERENCES transcription(id) ON DELETE CASCADE
);
CREATE INDEX idx_transcription_id ON transcription_segment(transcription_id);

View file

@ -5,7 +5,7 @@ import os
import uuid
from dataclasses import dataclass, field
from random import randint
from typing import List, Optional, Tuple, Set
from typing import Dict, List, Optional, Tuple, Set
from dataclasses_json import dataclass_json, config, Exclude
@ -35,6 +35,7 @@ class Segment:
text: str
translation: str = ""
speaker: str = ""
metadata: List[Dict[str, str]] = field(default_factory=list)
LANGUAGES = {

View file

@ -1,4 +1,5 @@
import enum
import json
import logging
from dataclasses import dataclass
from typing import Optional
@ -51,6 +52,7 @@ class Column(enum.Enum):
TRANSLATION = enum.auto()
TRANSCRIPTION_ID = enum.auto()
SPEAKER = enum.auto()
METADATA = enum.auto()
@dataclass
@ -334,6 +336,39 @@ class TranscriptionSegmentModel(QSqlTableModel):
self.setEditStrategy(QSqlTableModel.EditStrategy.OnFieldChange)
self.setFilter(f"transcription_id = '{transcription_id}'")
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if index.isValid() and role == Qt.ItemDataRole.ToolTipRole:
metadata = self._metadata(index.row())
if metadata:
return "\n".join(f"{key}: {value}" for key, value in metadata)
return super().data(index, role)
def _metadata(self, row: int) -> list[tuple[str, str]]:
"""Return the segment metadata as a flat list of key-value pairs."""
raw_metadata = self.record(row).value("metadata")
if not raw_metadata:
return []
try:
metadata = json.loads(raw_metadata)
except (TypeError, json.JSONDecodeError):
return []
if not isinstance(metadata, list):
return []
pairs = []
for entry in metadata:
if not isinstance(entry, dict):
continue
for key, value in entry.items():
key = str(key).strip()
value = str(value).strip()
if key:
pairs.append((key, value))
return pairs
class TranscriptionSegmentsEditorWidget(QTableView):
PARENT_PADDINGS = 40

View file

@ -8,7 +8,7 @@ Buzz can use any number of custom [Whisper.cpp](https://github.com/ggerganov/whi
models in addition to the built-in ones. This is useful when you have several
fine-tuned models — for example a fast model you use by default and a more accurate
model you fall back to for difficult recordings — and want to switch between them
without re-downloading anything.
without re-downloading anything. This feature is available since 1.4.6.
## Adding a custom model

View file

@ -62,7 +62,7 @@ For Whisper.cpp you can also add any number of custom models. Enter a name and t
add a model file that is already on your computer or download one from a URL to its `.bin`
file (use the link from the "download" button on Huggingface). Each custom model appears by
name in the model list so you can switch between them. See
[Custom Whisper.cpp models](./custom_whisper_cpp_models.md) for details.
[Custom Whisper.cpp models](./custom_whisper_cpp_models.md) for details. This feature is available since 1.4.6.
To improve transcription speed and memory usage you can select a quantized version of some
larger model. For example `q_5` version. Whisper.cpp base models in different quantizations are [available here](https://huggingface.co/ggerganov/whisper.cpp/tree/main). See also [custom models](https://github.com/chidiwilliams/buzz/discussions/866) discussion page for custom models in different languages.

View file

@ -34,7 +34,10 @@ from buzz.widgets.application import Application
@pytest.fixture()
def db() -> QSqlDatabase:
def db(qapp) -> QSqlDatabase:
# Qt's SQL module needs a QCoreApplication to be alive; opening a
# QSqlDatabase without one segfaults, so depend on the app fixture even
# though these tests never touch a widget.
db = setup_test_db()
yield db
db.close()

View file

@ -1,57 +1,11 @@
import pytest
from unittest.mock import Mock, patch
from uuid import UUID, uuid4
from PyQt6.QtSql import QSqlDatabase, QSqlQuery
from PyQt6.QtSql import QSqlQuery
from buzz.db.dao.transcription_dao import TranscriptionDAO
from buzz.db.entity.transcription import Transcription
@pytest.fixture
def db():
"""Create an in-memory SQLite database for testing"""
db = QSqlDatabase.addDatabase("QSQLITE")
db.setDatabaseName(":memory:")
assert db.open()
# Create the transcription table with the new schema
query = QSqlQuery(db)
query.exec("""
CREATE TABLE transcription (
id TEXT PRIMARY KEY,
error_message TEXT,
export_formats TEXT,
file TEXT,
output_folder TEXT,
progress DOUBLE PRECISION DEFAULT 0.0,
language TEXT,
model_type TEXT,
source TEXT,
status TEXT,
task TEXT,
time_ended TIMESTAMP,
time_queued TIMESTAMP NOT NULL,
time_started TIMESTAMP,
url TEXT,
whisper_model_size TEXT,
hugging_face_model_id TEXT,
word_level_timings BOOLEAN DEFAULT FALSE,
extract_speech BOOLEAN DEFAULT FALSE,
name TEXT,
notes TEXT
)
""")
yield db
db.close()
@pytest.fixture
def transcription_dao(db):
"""Create a TranscriptionDAO instance for testing"""
return TranscriptionDAO(db)
@pytest.fixture
def sample_transcription():
"""Create a sample transcription for testing"""

View file

@ -1,9 +1,12 @@
import pytest
import json
from unittest.mock import Mock, patch
from uuid import UUID, uuid4
import pytest
from buzz.db.service.transcription_service import TranscriptionService
from buzz.db.entity.transcription import Transcription
from buzz.transcriber.transcriber import Segment
@pytest.fixture
@ -40,6 +43,35 @@ def sample_transcription():
class TestTranscriptionService:
@pytest.mark.parametrize(
"method_name",
[
"update_transcription_as_completed",
"update_transcription_as_skipped",
"replace_transcription_segments",
],
)
def test_segment_metadata_is_persisted(
self,
transcription_service,
mock_transcription_segment_dao,
method_name,
):
segment = Segment(
start=100,
end=500,
text="Repeated phrase",
metadata=[
{"review_reason": "Possible repetition loop"},
{"confidence": "0.21"},
],
)
getattr(transcription_service, method_name)(uuid4(), [segment])
saved_segment = mock_transcription_segment_dao.insert.call_args.args[0]
assert json.loads(saved_segment.metadata) == segment.metadata
def test_update_transcription_name(self, transcription_service, mock_transcription_dao):
"""Test updating transcription name through service"""
transcription_id = uuid4()

View file

@ -60,3 +60,48 @@ def test_speaker_column_migration_preserves_existing_transcript_text(tmp_path):
).fetchone()
connection.close()
assert row == ("Nyomi: keep this exact text", "")
def test_metadata_migration_preserves_existing_transcript_text(tmp_path):
database_path = tmp_path / "old-buzz.sqlite"
connection = sqlite3.connect(database_path)
connection.executescript(
"""
CREATE TABLE transcription (
id TEXT PRIMARY KEY,
time_queued TIMESTAMP NOT NULL
);
CREATE TABLE transcription_segment (
id INTEGER PRIMARY KEY,
end_time INT DEFAULT 0,
start_time INT DEFAULT 0,
text TEXT NOT NULL,
translation TEXT DEFAULT '',
transcription_id TEXT,
speaker TEXT DEFAULT '',
FOREIGN KEY (transcription_id) REFERENCES transcription(id)
ON DELETE CASCADE
);
CREATE INDEX idx_transcription_id
ON transcription_segment(transcription_id);
INSERT INTO transcription (id, time_queued)
VALUES ('transcript-1', '2026-08-30');
INSERT INTO transcription_segment (
id, start_time, end_time, text, transcription_id
) VALUES (
1, 0, 1000, 'Keep this exact text', 'transcript-1'
);
"""
)
run_sqlite_migrations(connection)
segment_row = connection.execute(
"SELECT text, metadata FROM transcription_segment WHERE id = 1"
).fetchone()
transcription_row = connection.execute(
"SELECT metadata FROM transcription WHERE id = 'transcript-1'"
).fetchone()
connection.close()
assert segment_row == ("Keep this exact text", "[]")
assert transcription_row == ("[]",)

View file

@ -25,6 +25,18 @@ class TestModelsPreferencesWidget:
if path and os.path.isfile(path):
os.remove(path)
@staticmethod
def get_child_names(group_item) -> list[str]:
return [group_item.child(i).text(0) for i in range(group_item.childCount())]
@classmethod
def get_child_named(cls, group_item, name: str):
for i in range(group_item.childCount()):
child = group_item.child(i)
if child.text(0) == name:
return child
return None
def test_should_show_model_list(self, qtbot):
widget = ModelsPreferencesWidget()
qtbot.add_widget(widget)
@ -61,8 +73,10 @@ class TestModelsPreferencesWidget:
available_item = widget.model_list_widget.topLevelItem(1)
assert available_item.text(0) == _("Available for Download")
tiny_item = available_item.child(0)
assert tiny_item.text(0) == "Tiny"
# Other model sizes may already be cached on the machine running the
# tests, so look Tiny up by name instead of assuming a list position.
tiny_item = self.get_child_named(available_item, "Tiny")
assert tiny_item is not None
tiny_item.setSelected(True)
download_button = widget.findChild(QPushButton, "DownloadButton")
@ -75,14 +89,10 @@ class TestModelsPreferencesWidget:
assert not download_button.isVisible()
_downloaded_item = widget.model_list_widget.topLevelItem(0)
assert _downloaded_item.childCount() > 0
assert _downloaded_item.child(0).text(0) == "Tiny"
assert "Tiny" in self.get_child_names(_downloaded_item)
_available_item = widget.model_list_widget.topLevelItem(1)
assert (
_available_item.childCount() == 0
or _available_item.child(0).text(0) != "Tiny"
)
assert "Tiny" not in self.get_child_names(_available_item)
assert os.path.isfile(widget.model.get_local_model_path())
@ -97,11 +107,11 @@ class TestModelsPreferencesWidget:
widget.show()
qtbot.add_widget(widget)
available_item = widget.model_list_widget.topLevelItem(0)
assert available_item.text(0) == _("Downloaded")
downloaded_item = widget.model_list_widget.topLevelItem(0)
assert downloaded_item.text(0) == _("Downloaded")
tiny_item = available_item.child(0)
assert tiny_item.text(0) == "Tiny"
tiny_item = self.get_child_named(downloaded_item, "Tiny")
assert tiny_item is not None
tiny_item.setSelected(True)
delete_button = widget.findChild(QPushButton, "DeleteButton")

View file

@ -272,6 +272,42 @@ class TestTranscriptionSegmentModel:
assert model.tableName() == "transcription_segment"
assert model.editStrategy() == model.EditStrategy.OnFieldChange
def test_metadata_is_shown_as_key_value_tooltip(
self,
transcription_dao,
transcription_segment_dao,
):
transcription_id = uuid.uuid4()
transcription_dao.insert(
Transcription(
id=str(transcription_id),
status="completed",
file=test_audio_path,
task=Task.TRANSCRIBE.value,
model_type=ModelType.WHISPER.value,
whisper_model_size=WhisperModelSize.TINY.value,
)
)
transcription_segment_dao.insert(
TranscriptionSegment(
40,
299,
"Repeated phrase",
"",
str(transcription_id),
metadata='[{"review_reason": "Possible repetition loop"}, '
'{"confidence": "0.21"}]',
)
)
model = TranscriptionSegmentModel(transcription_id)
model.select()
index = model.index(0, Column.TEXT.value)
tooltip = model.data(index, Qt.ItemDataRole.ToolTipRole)
assert tooltip == (
"review_reason: Possible repetition loop\nconfidence: 0.21"
)
class TestTranscriptionSegmentsEditorWidget:
"""Test the TranscriptionSegmentsEditorWidget"""