From cbacc99b7b316be47d5b732205f8dcff61998691 Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sun, 4 Oct 2026 16:08:52 +0000 Subject: [PATCH] =?UTF-8?q?voice:=20=D0=B7=D0=B2=D1=83=D0=BA=20=D0=B8?= =?UTF-8?q?=D0=B7=20TG-=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=20(t-261004152714)?= =?UTF-8?q?=20+=20tools/tui=5Fchat.py=20=D0=B2=20=D1=80=D0=B5=D0=BF=D0=BE?= =?UTF-8?q?=20(t-261004153216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - voice.py: kind=video в TRANSCRIBABLE_KINDS; extract_audio_track() — ffmpeg вытягивает дорожку (моно 16к opus) из видеоконтейнера; речь ≤60с (voice/video_note/video) сразу идёт в faster-whisper-large-v3-turbo (base на видео давал кашу). E2E: m-516173698 расшифрован. - tg_inbox.py: поллер транскрибирует и msg.video, не только voice/audio/video_note. - tools/tui_chat.py: TUI-чат (t-261004132716) лежал только у Volya (33301d3) — в origin его не было, из-за этого терминал не грузился у 6 агентов. --- harness/tg_inbox.py | 7 +- harness/voice.py | 65 ++++++++++++-- tools/tui_chat.py | 201 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 tools/tui_chat.py diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index b3b52e5..6661bfb 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -392,12 +392,15 @@ def poll_loop(): # голосовые/аудио/видео-заметки: расшифровка сразу при приёме, # чтобы минутный уровень уже видел текст (не качает файлы сам — # таймаут тика мал). Сбой транскрипции не валит поллер. - fobj = (msg.get("voice") or msg.get("audio") or msg.get("video_note")) + fobj = (msg.get("voice") or msg.get("audio") + or msg.get("video_note") or msg.get("video")) if fobj: try: from . import voice kind = ("voice" if msg.get("voice") else - "audio" if msg.get("audio") else "video_note") + "audio" if msg.get("audio") else + "video_note" if msg.get("video_note") else + "video") text, engine = voice.transcribe_message( mid, file_id=fobj.get("file_id") or "", diff --git a/harness/voice.py b/harness/voice.py index 4682095..ba0c8ef 100644 --- a/harness/voice.py +++ b/harness/voice.py @@ -37,8 +37,10 @@ _FW_PYTHON = "/usr/bin/python3" # faster-whisper живёт в системно ASR_URL = os.environ.get("SIMBIOT_ASR_URL", "http://127.0.0.1:8084") VOICE_DIR = _paths.voice_dir() # SIMBIOT_HOME → /voice; legacy без хома -# что расшифровываем: voice — главное; audio/video_note — та же речь -TRANSCRIBABLE_KINDS = {"voice", "audio", "video_note"} +# что расшифровываем: voice — главное; audio/video_note/video — та же речь +# (t-261004152714: видео-вложения Alex получали not_transcribable — звук +# из контейнера вообще не извлекался; теперь тянем дорожку ffmpeg'ом) +TRANSCRIBABLE_KINDS = {"voice", "audio", "video_note", "video"} # длина, сек: длиннее не тянем (поллер не должен вязнуть в 10-мин озвучках) MAX_DURATION = 300 # аудио-кэш: file_unique_id → ogg-файл (путь задаёт paths.voice_dir выше) @@ -104,6 +106,42 @@ def _download_voice(file_id: str, file_unique_id: str = ""): return audio, cache +# ────────────── извлечение аудио из видеоконтейнера ───────────────────────── + +def extract_audio_track(container: bytes): + """Видеоконтейнер (mp4 и т.п.) → ogg/opus аудиодорожка через ffmpeg. + + Возвращает bytes дорожки или None (нет дорожки / ffmpeg отказал). + ffmpeg есть на хосте (6.1.1) — тянуть в venv нечего. t-261004152714 + """ + import tempfile + src = dst = None + try: + with tempfile.NamedTemporaryFile(prefix="simbiot-v_", delete=False) as tf: + tf.write(container) + src = tf.name + # -vn: только звук; моно 16к — формат, который любят оба ASR-яруса + proc = subprocess.run( + ["ffmpeg", "-v", "error", "-i", src, "-vn", "-ac", "1", + "-ar", "16000", "-f", "ogg", "-c:a", "libopus", "-"], + capture_output=True, timeout=180) + if proc.returncode != 0 or not proc.stdout: + err = (proc.stderr or b"").decode()[:200] + print(f"[voice] ffmpeg: дорожка не извлечена ({err})", flush=True) + return None + return proc.stdout + except Exception as e: + print(f"[voice] ffmpeg error: {e}", flush=True) + return None + finally: + for p in (src, dst): + if p and os.path.exists(p): + try: + os.unlink(p) + except OSError: + pass + + # ─────────────────────────────── ASR ярусы ────────────────────────────────── def transcribe_bytes(audio: bytes, filename: str = "audio.ogg", @@ -208,18 +246,29 @@ def transcribe_message(msg_id: str, file_id: str, file_unique_id: str = "", }) return "", None - audio, _path = _download_voice(file_id, file_unique_id) - if audio is None: + src_audio, _path = _download_voice(file_id, file_unique_id) + if src_audio is None: es.append_event("inbox", msg_id, "MessageTranscribed", { "transcript": "", "engine": None, "reason": "download_failed", "duration": duration, }) return None, None + # видео-контейнер: речь лежит в дорожке — вынимаем её ffmpeg'ом + if kind == "video": + audio = extract_audio_track(src_audio) + if audio is None: + es.append_event("inbox", msg_id, "MessageTranscribed", { + "transcript": "", "engine": None, "reason": "audio_track_failed", + "duration": duration, + }) + return None, None + else: + audio = src_audio # короткие русские фразы base-модель коверкает («Туше, что ты тебя - # слышишь» вместо «Ты теперь слышишь») — для voice ≤30с сразу идём - # в точный large-v3-turbo, long-записи и audio — как получится - short_voice = kind == "voice" and (duration or 0) <= 30 - if short_voice: + # слышишь» вместо «Ты теперь слышишь») — для voice/video_note/video ≤60с + # сразу идём в точный large-v3-turbo, long-записи — как получится + short_speech = kind in ("voice", "video_note", "video") and (duration or 0) <= 60 + if short_speech: text, engine = _asr_faster_whisper(audio, "audio.ogg"), "faster-whisper-large-v3-turbo" if text: # точный путь сработал es.append_event("inbox", msg_id, "MessageTranscribed", { diff --git a/tools/tui_chat.py b/tools/tui_chat.py new file mode 100644 index 0000000..34599c8 --- /dev/null +++ b/tools/tui_chat.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""TUI-чат с работающим агентом в веб-терминале (t-261004132716). + +Задача Alex (m-516173688): терминал портала должен сразу давать полноценный +чат с агентом (как Claude Code/Clide/Hermes), не требуя запуска чего-то +руками. Агент уже работает демоном (systemd simbiot-harness) — TUI к нему +НЕ лезет процессом: он кладёт сообщение в event store агента (чат 'tui:*'), +мин-тик агента (unreplied_inbox → critical-сигнал гейта) отвечает через +tg_inbox.send_reply → ReplySent пишется в стор, TUI его читает и печатает. + +Транспорт: только локальный event store. Никаких внешних API. +Выход: Ctrl-D / 'exit' (после выхода в терминале остаётся bash). +""" +import os +import select +import sys +import time +import uuid +from datetime import datetime + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from harness import es # noqa: E402 + +AGENT = os.environ.get("SIMBIOT_AGENT_NAME") or "agent" +TUI_CHAT = "tui:local" +POLL_SEC = 2.0 +REPLY_TIMEOUT = 240 # сек ожидания ответа на своё сообщение +WRAP = 100 # ширина переноса + +C_BOLD, C_DIM, C_CYAN, C_GREEN, C_YEL = "\033[1m", "\033[2m", "\033[36m", "\033[32m", "\033[33m" +C_RST = "\033[0m" + + +def _wrap(text, width=WRAP, indent=" "): + out, line = [], "" + for word in str(text or "").split(): + if len(line) + len(word) + 1 > width - len(indent): + out.append(indent + line) + line = word + else: + line = (line + " " + word).strip() + if line: + out.append(indent + line) + return "\n".join(out) + + +def _model(): + return es.run_projections() + + +def _inbox(model): + return model.get("inbox") or {} + + +def _ts_sort(v): + return v.get("ts") or "" + + +def show_history(limit=3): + model = _model() + items = [(k, v) for k, v in _inbox(model).items() + if str(v.get("chat_id") or "").startswith(("tui:", "portal:"))] + items.sort(key=lambda kv: _ts_sort(kv[1])) + if not items: + return + print(f"{C_DIM}— последние сообщения чата с агентом —{C_RST}") + for mid, v in items[-limit * 2:]: + who = "ты" if (v.get("kind") == "tui") else (v.get("username") or "портал") + txt = (v.get("text") or "").replace("\n", " ")[:90] + print(f"{C_DIM}{mid}{C_RST} {C_YEL}{who}▸{C_RST} {txt}") + if v.get("replied") and v.get("reply_text") and not v["reply_text"].startswith("(сводный"): + print(_wrap(v["reply_text"][:200], indent=" ")) + print() + + +def resolve_reply(model, mid): + """Текст ответа: у головного полный, у остальных — ссылка на головной.""" + m = _inbox(model).get(mid) + if not m or not m.get("replied"): + return None + txt = m.get("reply_text") or "" + if txt.startswith("(сводный"): + import re + mt = re.search(r"см\. (\S+)", txt) + if mt: + head = _inbox(model).get(mt.group(1)) + if head and head.get("reply_text"): + return head["reply_text"] + return txt or "(пустой ответ)" + + +def send_message(text): + mid = f"tui-{datetime.utcnow().strftime('%y%m%d%H%M%S')}-{uuid.uuid4().hex[:4]}" + es.append_event("inbox", mid, "MessageReceived", { + "update_id": mid, "chat_id": TUI_CHAT, "user_id": "alex", + "username": "alex · tui", "first_name": "Alex", + "text": str(text)[:4000], "kind": "tui", + "file_id": None, "file_unique_id": None, "duration": None, + "date": int(time.time()), "source": "tui", + }) + return mid + + +def wait_replies(watch, deadline_by, stdin_fd, pending_input): + """Ждём ответы на watch (dict mid→deadline), подбирая ввод в очередь.""" + while watch: + now = time.time() + for mid in [m for m, d in watch.items() if now > d]: + print(f"{C_DIM}(ответ на {mid} не пришёл за {REPLY_TIMEOUT}s — " + f"агент молчит? systemctl status simbiot-harness){C_RST}") + watch.pop(mid) + if not watch: + break + try: + model = _model() + except Exception as e: + print(f"{C_DIM}(стор занят: {str(e)[:60]}){C_RST}") + model = None + if model: + for mid in list(watch): + txt = resolve_reply(model, mid) + if txt is not None: + print(f"{C_GREEN}{AGENT}▸{C_RST}") + print(_wrap(txt)) + watch.pop(mid) + r, _, _ = select.select([stdin_fd], [], [], POLL_SEC) + if r: + line = sys.stdin.readline() + if line == "": # EOF + return False + line = line.strip() + if line: + pending_input.append(line) + return True + + +def main(): + fd = sys.stdin.fileno() + interactive = sys.stdin.isatty() + es.init_db() + + # агент должен работать демоном; TUI сам ничего не стартует, + # но явно скажет, если демон не жив + alive = os.system("systemctl is-active --quiet simbiot-harness 2>/dev/null") == 0 \ + or bool(os.popen("pgrep -f 'harness.scheduler'").read().strip()) + print(f"{C_BOLD}╭─ {AGENT} · TUI-чат {'· агент работает' if alive else C_YEL + '· ДЕМОН НЕ ОТВЕЧАЕТ'}{C_RST}") + if not alive: + print(f"{C_YEL} демон simbiot-harness не запущен — ответы не придут.{C_RST}") + print(f"{C_YEL} запусти: systemctl start simbiot-harness{C_RST}") + print(f"{C_DIM}│ пишешь → мин-тик агента отвечает (обычно ≤ минуты; команды " + f"/статус /лестница /задачи — мгновенно){C_RST}") + print(f"{C_DIM}│ выход: Ctrl-D или 'exit'{C_RST}") + print(f"{C_BOLD}╰{'─' * 40}{C_RST}") + try: + show_history() + except Exception: + pass + + # сообщения, оставшиеся без ответа от прошлой TUI-сессии — досмотрим + watch = {} + try: + model = _model() + for mid, v in _inbox(model).items(): + if str(v.get("chat_id") or "").startswith("tui:") and not v.get("replied"): + watch[mid] = time.time() + REPLY_TIMEOUT + except Exception: + pass + + pending_input = [] + while True: + if watch and not wait_replies(watch, None, fd, pending_input): + break # EOF во время ожидания + if pending_input: + line = pending_input.pop(0) + else: + if interactive: + sys.stdout.write(f"{C_CYAN}ты▸{C_RST} ") + sys.stdout.flush() + r, _, _ = select.select([fd], [], [], POLL_SEC if watch else None) + if not r: + continue + line = sys.stdin.readline() + if line == "": + break # Ctrl-D + line = line.strip() + if not line: + continue + if line.lower() in ("exit", "quit", "/exit", "/quit"): + break + mid = send_message(line) + watch[mid] = time.time() + REPLY_TIMEOUT + print(f"{C_DIM}… {mid} (ждём мин-тик){C_RST}") + print(f"{C_DIM}пока. (агент продолжает работать демоном; bash — по Ctrl-D/exit){C_RST}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())