From 839145179ca318b3c906fbad3f597e78ea7acd70 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Tue, 18 Aug 2026 16:07:37 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A7=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BA=20=D0=BE=D1=82=D0=B4=D0=B0=D1=91=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=88=D0=B8=D0=BD=D0=BD=D0=BE;=20=D1=81=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Формат text записывает спорное слово как {основной|вариант|вариант} - ровно тот выбор, что стоял перед человеком. Языковая модель разрешает такие места по смыслу фразы: распознавание слышит слово отдельно, модель видит предложение целиком. Формат json отдаёт голоса как есть, порог выбирает вызывающая сторона. Промпт и порядок опроса записаны в docs. Там же оговорено, чего приём не делает: пропущенное на записи он не восстанавливает, и дописывать слова модели прямо запрещено. Со страниц записи и черновика не было пути назад, кроме кнопки браузера. Co-Authored-By: Claude Opus 5 (1M context) --- app/draft.py | 38 +++++++++++++++++++++++- app/main.py | 14 +++++++-- app/view.py | 9 ++++++ docs/ИНТЕГРАЦИЯ-TALKSCORE.md | 44 ++++++++++++++++++++++++++++ docs/ПРОМПТЫ.md | 57 ++++++++++++++++++++++++++++++++++++ tests/test_draft.py | 54 ++++++++++++++++++++++++++++++++++ 6 files changed, 213 insertions(+), 3 deletions(-) diff --git a/app/draft.py b/app/draft.py index b0d0957..63adade 100644 --- a/app/draft.py +++ b/app/draft.py @@ -12,7 +12,7 @@ import difflib import logging import re -__all__ = ["EXTRA_MODELS", "normalize", "disputed", "build_draft"] +__all__ = ["EXTRA_MODELS", "normalize", "disputed", "build_draft", "draft_text"] log = logging.getLogger("talkscore-asr") @@ -168,3 +168,39 @@ def _gaps(turns: list[dict], duration: float) -> list[dict]: if duration - previous >= GAP_NOTICE_SEC: out.append({"start": round(previous, 1), "end": round(duration, 1)}) return out + + +def draft_text(draft: dict, need: int | None = None) -> str: + """Черновик строкой для языковой модели. + + Спорное слово записывается как {основной|вариант|вариант}: модель видит + ровно тот выбор, который стоял перед человеком. Знак «—» значит, что + другая модель промолчала, «+слово» - что услышала лишнее. + + Разметка выбрана однословной и без переносов: длинный разговор целиком + влезает в запрос, а ответ можно сверить с исходником по позициям. + """ + need = max(1, draft.get("compared", 1)) if need is None else need + lines, previous = [], 0.0 + for turn in draft["turns"]: + if turn["start"] - previous >= GAP_NOTICE_SEC: + lines.append(f"[{_stamp(previous)}-{_stamp(turn['start'])}] " + f"(речь не распознана, {turn['start'] - previous:.0f} с)") + previous = max(previous, turn["end"]) + parts = [] + for word in turn["words"]: + votes = word.get("votes") or {} + if sum(votes.values()) >= need: + parts.append("{" + "|".join([word["text"], *sorted(votes)]) + "}") + else: + parts.append(word["text"]) + lines.append(f"[{_stamp(turn['start'])}] " + " ".join(parts)) + tail = draft.get("duration_sec", 0.0) - previous + if tail >= GAP_NOTICE_SEC: + lines.append(f"[{_stamp(previous)}-{_stamp(draft['duration_sec'])}] " + f"(речь не распознана, {tail:.0f} с)") + return "\n".join(lines) + + +def _stamp(seconds: float) -> str: + return f"{int(seconds) // 60:02d}:{int(seconds) % 60:02d}" diff --git a/app/main.py b/app/main.py index 27c168d..a94a5a5 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,8 @@ from urllib.parse import quote from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.utils import get_openapi -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse, + PlainTextResponse) from fastapi.security import HTTPBearer from app.config import ConfigError, Settings, load_settings @@ -366,7 +367,8 @@ def _build_draft(job_id: str, audio: Path) -> None: @app.get("/v1/jobs/{job_id}/draft", include_in_schema=False) -def job_draft(job_id: str, request: Request, strict: bool = False): +def job_draft(job_id: str, request: Request, strict: bool = False, + format: str = "html"): """Черновик для правки на слух. Первый запрос запускает сборку.""" token = page_guard(request) with _draft_lock: @@ -375,6 +377,14 @@ def job_draft(job_id: str, request: Request, strict: bool = False): # Известное состояние отвечает само за себя: запись могли удалить по # сроку, пока черновик собирался, и отвечать тогда 404 неправильно. if state.get("status") == "ready": + need = 1 if strict else None + if format == "json": + # Голоса как есть: сервис сам решит, какой порог ему нужен. + return JSONResponse(state["draft"]) + if format == "text": + from app.draft import draft_text + + return PlainTextResponse(draft_text(state["draft"], need)) return HTMLResponse(render_draft(state["draft"], job_id, token, strict)) if state.get("status") == "failed": raise HTTPException(status_code=500, detail=state.get("error", "не собрался")) diff --git a/app/view.py b/app/view.py index 8031101..6433cbf 100644 --- a/app/view.py +++ b/app/view.py @@ -164,6 +164,12 @@ def render_draft(draft: dict, job_id: str, token: str = "", mark {{ background:#ffe89a; color:#22201d; padding:0 2px; border-radius:2px; }} .alt {{ color:var(--dim); font-size:13px; margin-left:3px; }} .gap {{ color:var(--warn); font-size:13px; padding:6px 0; font-weight:600; }} + .nav {{ font-size:13px; margin:-4px 0 10px; }} + .nav a {{ color:var(--dim); }} + .nav a:hover {{ color:var(--fg); }} + .nav {{ font-size:13px; margin:-4px 0 10px; }} + .nav a {{ color:var(--dim); }} + .nav a:hover {{ color:var(--fg); }} .sticky {{ position:sticky; top:0; z-index:5; background:var(--bg); padding:8px 0 6px; border-bottom:1px solid var(--line); }} audio {{ width:100%; margin:0; }} @@ -173,6 +179,8 @@ def render_draft(draft: dict, job_id: str, token: str = "",

Черновик {html.escape(job_id[:8])}

+
Моделей{draft.get("models", 1)}
Слов{total}
@@ -314,6 +322,7 @@ def render_job(job: dict, token: str = "") -> str: button.copy:hover {{ border-color:var(--dim); }}

Расшифровка {html.escape(str(job.get("job_id", ""))[:8])} - {html.escape(str(job.get("filename", "")))}

+
{_stats_row(job)}
{player} diff --git a/docs/ИНТЕГРАЦИЯ-TALKSCORE.md b/docs/ИНТЕГРАЦИЯ-TALKSCORE.md index 4a5094a..568d74e 100644 --- a/docs/ИНТЕГРАЦИЯ-TALKSCORE.md +++ b/docs/ИНТЕГРАЦИЯ-TALKSCORE.md @@ -371,3 +371,47 @@ Sonnet 5 и Gemini 3.1 Pro. сбои не заходя на машину. - **Описание методов**: `http://АДРЕС:8756/docs` в браузере, открывается с разрешённых адресов. + + +## Черновик с вариантами нескольких моделей + +Обычная расшифровка - выбор одной модели. Черновик показывает, где модели +разошлись, и позволяет выбрать вариант заново - человеком или языковой +моделью. + +``` +GET /v1/jobs/{id}/draft?build=1 сборка (минуты, идёт фоном) +GET /v1/jobs/{id}/draft?format=json голоса как есть +GET /v1/jobs/{id}/draft?format=text строкой для языковой модели +``` + +Первый запрос запускает сборку и отвечает 202. Готовый черновик отдаётся +кодом 200. Порядок для сервиса: + +```python +import time + +import requests + +head = {"Authorization": f"Bearer {token}"} +while True: + r = requests.get(f"{base}/v1/jobs/{job_id}/draft?format=text", headers=head) + if r.status_code == 200: + text = r.text + break + if r.status_code >= 400: + raise RuntimeError(r.text) + time.sleep(10) # 202: ещё собирается +``` + +В формате `text` спорное слово записано как `{основной|вариант}`. Промпт для +выбора варианта - в `docs/ПРОМПТЫ.md`, раздел «Сборка чистого текста». + +В формате `json` каждое слово несёт словарь голосов +`{"вариант": сколько моделей его предложили}`. Пустой словарь - все модели +согласны. Порог сервис выбирает сам: голос одной модели из двух означает, +что большинство за основным вариантом. + +Сборка занимает от минуты и требует, чтобы запись хранилась на диске +(`keep_records = true`). Модели подтягиваются при первом обращении, +около 500 МБ. diff --git a/docs/ПРОМПТЫ.md b/docs/ПРОМПТЫ.md index fd0bc62..7433c45 100644 --- a/docs/ПРОМПТЫ.md +++ b/docs/ПРОМПТЫ.md @@ -196,3 +196,60 @@ Отдельно: этим моделям нельзя задавать большой `max_tokens` - они выбирают его целиком на размышления и до ответа не доходят. У Sonnet 5 такого поведения не наблюдалось. + +## Сборка чистого текста из вариантов нескольких моделей + +Черновик отдаётся в машинном виде по адресу +`GET /v1/jobs/{id}/draft?format=text&token=...` + +Спорное слово записано как `{основной|вариант|вариант}` - ровно тот выбор, +который стоял перед человеком. Знак `—` значит, что другая модель промолчала, +`+слово` - что услышала лишнее. + +``` +[00:23] Угу По вторникам и четвергам {А|+А-а|—} в мае или {В|—} июне +[00:41] Стоимости по {пакетным|пакет обсуждения} {обсуждения|пакет обсуждения} +[03:23-03:32] (речь не распознана, 9 с) +``` + +Языковая модель выбирает вариант там, где акустика неоднозначна, а смысл +фразы - нет. Это её сильная сторона: распознавание слышит слово отдельно, +модель видит предложение целиком. + +### Промпт + +``` +Ты редактор расшифровок телефонных разговоров на русском языке. + +Текст распознан тремя моделями. Там, где они разошлись, вариант записан +в фигурных скобках: {основной|другой|третий}. Первый вариант - основной, +остальные предложили другие модели. Знак — означает, что модель промолчала, ++слово - что она услышала лишнее слово. + +Твоя задача: выбрать по смыслу наиболее вероятный вариант в каждых скобках +и вернуть связный текст без разметки. + +Правила: +- Выбирай только из предложенных вариантов. Не придумывай слова, которых + нет ни в одном варианте. +- Если ни один вариант не подходит по смыслу, оставь основной. +- Числа приводи к цифрам: «двадцать четыре тысячи» -> «24 000». +- Не исправляй разговорные обороты и повторы: это живая речь, а не текст. +- Строки вида [03:23-03:32] (речь не распознана, 9 с) сохраняй как есть - + там пропущен кусок разговора. +- Тайм-код в начале строки сохраняй. +- Ничего не сокращай и не пересказывай. Число строк на выходе должно + совпадать с числом строк на входе. + +Верни только текст, без пояснений. +``` + +Разговор длиннее 20 минут стоит резать по строкам на части по 150-200 строк: +так ответ не упрётся в предел длины, а по тайм-кодам части легко сшить. + +### Чего этот приём не делает + +Он не восстанавливает пропущенное. Если все три модели промолчали, в скобках +не появится ничего - строка о пропуске означает, что кусок разговора потерян +на записи, а не в разметке. Языковая модель на таком месте начнёт выдумывать, +поэтому в промпте прямо запрещено дописывать слова. diff --git a/tests/test_draft.py b/tests/test_draft.py index 42d98a3..ce41cc8 100644 --- a/tests/test_draft.py +++ b/tests/test_draft.py @@ -57,3 +57,57 @@ class TestGaps: def test_overlapping_turns_do_not_make_negative_gap(self): assert _gaps([self.turn(0, 30), self.turn(10, 20)], 30) == [] + + +class TestDraftText: + """Машинный вид: сервис Talkscore отдаёт его языковой модели.""" + + @staticmethod + def draft(): + return {"turns": [ + {"start": 3.0, "end": 5.0, "words": [ + {"text": "Стоимость", "votes": {}}, + {"text": "пакетным", "votes": {"пакет обсуждения": 2}}]}, + {"start": 30.0, "end": 33.0, "words": [ + {"text": "Да", "votes": {"—": 1}}]}], + "duration_sec": 40.0, "compared": 2} + + def test_disputed_word_carries_its_variants(self): + from app.draft import draft_text + + assert "{пакетным|пакет обсуждения}" in draft_text(self.draft()) + + def test_agreed_word_stays_plain(self): + """Согласованное слово без скобок: модели нечего выбирать.""" + from app.draft import draft_text + + assert "{Стоимость" not in draft_text(self.draft()) + + def test_single_vote_hidden_by_default_shown_in_strict(self): + from app.draft import draft_text + + assert "{Да|—}" not in draft_text(self.draft()) + assert "{Да|—}" in draft_text(self.draft(), need=1) + + def test_gap_between_turns_is_stated(self): + from app.draft import draft_text + + assert "[00:05-00:30] (речь не распознана, 25 с)" in draft_text(self.draft()) + + def test_tail_gap_is_stated(self): + from app.draft import draft_text + + assert "[00:33-00:40]" in draft_text(self.draft()) + + def test_timecode_starts_every_turn(self): + from app.draft import draft_text + + lines = [ln for ln in draft_text(self.draft()).splitlines() + if "не распознана" not in ln] + assert [ln[:8] for ln in lines] == ["[00:03] ", "[00:30] "] + + def test_leading_silence_is_reported(self): + """Запись может начаться с молчания - это тоже надо знать.""" + from app.draft import draft_text + + assert draft_text(self.draft()).startswith("[00:00-00:03] ")