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:
parent
7e19e1b3f6
commit
cbacc99b7b
3 changed files with 263 additions and 10 deletions
|
|
@ -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:
|
if fobj:
|
||||||
try:
|
try:
|
||||||
from . import voice
|
from . import voice
|
||||||
kind = ("voice" if msg.get("voice") else
|
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(
|
text, engine = voice.transcribe_message(
|
||||||
mid,
|
mid,
|
||||||
file_id=fobj.get("file_id") or "",
|
file_id=fobj.get("file_id") or "",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
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_DIR = _paths.voice_dir() # SIMBIOT_HOME → <home>/voice; legacy без хома
|
||||||
# что расшифровываем: voice — главное; audio/video_note — та же речь
|
# что расшифровываем: voice — главное; audio/video_note/video — та же речь
|
||||||
TRANSCRIBABLE_KINDS = {"voice", "audio", "video_note"}
|
# (t-261004152714: видео-вложения Alex получали not_transcribable — звук
|
||||||
|
# из контейнера вообще не извлекался; теперь тянем дорожку ffmpeg'ом)
|
||||||
|
TRANSCRIBABLE_KINDS = {"voice", "audio", "video_note", "video"}
|
||||||
# длина, сек: длиннее не тянем (поллер не должен вязнуть в 10-мин озвучках)
|
# длина, сек: длиннее не тянем (поллер не должен вязнуть в 10-мин озвучках)
|
||||||
MAX_DURATION = 300
|
MAX_DURATION = 300
|
||||||
# аудио-кэш: file_unique_id → ogg-файл (путь задаёт paths.voice_dir выше)
|
# аудио-кэш: file_unique_id → ogg-файл (путь задаёт paths.voice_dir выше)
|
||||||
|
|
@ -104,6 +106,42 @@ def _download_voice(file_id: str, file_unique_id: str = ""):
|
||||||
return audio, cache
|
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 ярусы ──────────────────────────────────
|
# ─────────────────────────────── ASR ярусы ──────────────────────────────────
|
||||||
|
|
||||||
def transcribe_bytes(audio: bytes, filename: str = "audio.ogg",
|
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
|
return "", None
|
||||||
|
|
||||||
audio, _path = _download_voice(file_id, file_unique_id)
|
src_audio, _path = _download_voice(file_id, file_unique_id)
|
||||||
if audio is None:
|
if src_audio is None:
|
||||||
es.append_event("inbox", msg_id, "MessageTranscribed", {
|
es.append_event("inbox", msg_id, "MessageTranscribed", {
|
||||||
"transcript": "", "engine": None, "reason": "download_failed",
|
"transcript": "", "engine": None, "reason": "download_failed",
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
})
|
})
|
||||||
return None, None
|
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-модель коверкает («Туше, что ты тебя
|
# короткие русские фразы base-модель коверкает («Туше, что ты тебя
|
||||||
# слышишь» вместо «Ты теперь слышишь») — для voice ≤30с сразу идём
|
# слышишь» вместо «Ты теперь слышишь») — для voice/video_note/video ≤60с
|
||||||
# в точный large-v3-turbo, long-записи и audio — как получится
|
# сразу идём в точный large-v3-turbo, long-записи — как получится
|
||||||
short_voice = kind == "voice" and (duration or 0) <= 30
|
short_speech = kind in ("voice", "video_note", "video") and (duration or 0) <= 60
|
||||||
if short_voice:
|
if short_speech:
|
||||||
text, engine = _asr_faster_whisper(audio, "audio.ogg"), "faster-whisper-large-v3-turbo"
|
text, engine = _asr_faster_whisper(audio, "audio.ogg"), "faster-whisper-large-v3-turbo"
|
||||||
if text: # точный путь сработал
|
if text: # точный путь сработал
|
||||||
es.append_event("inbox", msg_id, "MessageTranscribed", {
|
es.append_event("inbox", msg_id, "MessageTranscribed", {
|
||||||
|
|
|
||||||
201
tools/tui_chat.py
Normal file
201
tools/tui_chat.py
Normal 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())
|
||||||
Loading…
Reference in a new issue