voice: звук из TG-видео (t-261004152714) + tools/tui_chat.py в репо (t-261004153216)

- 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 агентов.
This commit is contained in:
Simbiot Harness 2026-10-04 16:08:52 +00:00
parent 7e19e1b3f6
commit cbacc99b7b
3 changed files with 263 additions and 10 deletions

View file

@ -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 "",

View file

@ -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 → <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", {

201
tools/tui_chat.py Normal file
View file

@ -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())