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