simbiot-harness/harness/es.py
Simbiot Harness e580b15653 feat(voice): транскрипция голосовых TG в рантайме — полный путь file_id→download→ASR→MessageTranscribed (t-260929171234-cad7af)
- voice.py: download по file_id (getFile→file, кэш ogg по file_unique_id),
  два ASR-яруса: whisper-asr-webservice base (быстро ~1с) → fallback
  faster-whisper large-v3-turbo (CPU int8, ~11с, точный на русском;
  короткие voice ≤30с идут сразу в точный ярус — base коверкает фразы)
- _fw_asr_worker.py: faster-whisper в системном python3 (в venv нет),
  subprocess JSON-обёртка (stdin JSON → stdout JSON)
- tg_inbox.py: file_unique_id в MessageReceived + расшифровка сразу
  при приёме (поллер пишет MessageTranscribed до первого мин-тика;
  сбой транскрипции не валит поллер) + video_note как транскрибируемый kind
- es.py: проекция MessageTranscribed (transcript→text, original_text,
  reset для --force), cli tg-transcribe <id> [--force] — ручная расшифровка
- scheduler.py: голосовые в контексте мин-уровня даже с пустым текстом
  (неотвеченное не исчезает): пометки «расшифровано/не расшифровано/
  расшифровка не удалась», сортировка по ts, срез 10

Е2Е проверено: m-516173163 — кэш-хит → turbo → «Ты теперь слышишь мои
голосовые, да?» в стор; base на том же файле давал «Туше, что ты тебя
слышишь» — выбор ярусов подтверждён фактом. Воркер past-hour умер по
2880с-таймауту до коммита; WIP доведён, протестирован, закоммичен.
2026-09-30 00:02:30 +00:00

365 lines
No EOL
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Simbiot Harness — ES-спина: append-only event store + проекции.
Образец — кооп-портал (aggregate, aggregate_id, event_type, data, version).
Идемпотентные команды; тики защищены UNIQUE(level, seq).
"""
import json
import os
import sqlite3
import threading
import time
from datetime import datetime, timezone
DB_PATH = os.environ.get("SIMBIOT_DB", os.path.join(os.path.dirname(__file__), "harness.db"))
# RLock обязателен: rebuild_projections держит лок и зовёт
# run_projections, который берёт его же — обычный Lock давил сам себя.
_LOCK = threading.RLock()
_read_model = None # инкрементальный read model (глобал для инкрементальных проекций)
def _conn():
db = sqlite3.connect(DB_PATH, timeout=30)
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA busy_timeout=30000")
db.row_factory = sqlite3.Row
return db
def init_db():
with _LOCK:
db = _conn()
db.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
aggregate TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
data TEXT NOT NULL,
timestamp TEXT NOT NULL,
version INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_agg
ON events(aggregate, aggregate_id, version);
CREATE TABLE IF NOT EXISTS projection_meta (
name TEXT PRIMARY KEY,
last_event INTEGER NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS read_model (
name TEXT PRIMARY KEY,
model TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
# идемпотентность тиков: один уровень — один seq
db.executescript(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_cycle_tick
ON events(aggregate, aggregate_id) WHERE aggregate='cycle';
"""
)
# aggregate_id для cycle = "level:seq", поэтому общий уникальный индекс
# (aggregate, aggregate_id) уже даёт UNIQUE(level, seq).
db.commit()
db.close()
def append_event(aggregate: str, aggregate_id: str, event_type: str, data: dict):
"""Append-only запись события. Возвращает (event_id, version) или None при дубликате."""
with _LOCK:
db = _conn()
row = db.execute(
"SELECT MAX(version) AS v FROM events WHERE aggregate=? AND aggregate_id=?",
(aggregate, aggregate_id),
).fetchone()
version = (row["v"] or 0) + 1
ts = datetime.now(timezone.utc).isoformat()
try:
cur = db.execute(
"INSERT INTO events (aggregate,aggregate_id,event_type,data,timestamp,version) VALUES (?,?,?,?,?,?)",
(aggregate, aggregate_id, event_type,
json.dumps(data, ensure_ascii=False), ts, version),
)
db.commit()
eid = cur.lastrowid
except sqlite3.IntegrityError:
db.close()
return None # дубль (тик с тем же level:seq) — идемпотентно пропускаем
db.close()
return (eid, version)
def append_tick(level: str, seq: int):
"""CycleTicked идемпотентно: aggregate_id = 'level:seq' → UNIQUE даёт защиту."""
return append_event("cycle", f"{level}:{seq}", "CycleTicked", {"level": level, "seq": seq})
def get_events(after_id: int = 0, limit: int = 1000):
db = _conn()
rows = db.execute(
"SELECT * FROM events WHERE id > ? ORDER BY id LIMIT ?", (after_id, limit)
).fetchall()
db.close()
return [dict(r) for r in rows]
def count_events() -> int:
db = _conn()
n = db.execute("SELECT COUNT(*) AS c FROM events").fetchone()["c"]
db.close()
return n
# ────────────────────────── ПРОЕКЦИИ ──────────────────────────
def _default_model():
return {
"tasks": {}, # task_id → {title, size, status, level, session_id, result_ref, ...}
"digests": {}, # (level, period) → {summary_ref, text}
"escalations": {}, # esc_id → {from_level, to_level, reason, status: open|acked}
"cycle_state": {}, # level → {busy, current_task, last_tick}
"metrics": [], # [{level, ticks, absorbed, llm_ticks, tokens, cost}]
"agents": {}, # level → {model, calls}
}
def _load_meta(db, name):
row = db.execute("SELECT last_event FROM projection_meta WHERE name=?", (name,)).fetchone()
return row["last_event"] if row else 0
def _save_meta(db, name, last_event):
db.execute(
"INSERT INTO projection_meta (name, last_event, updated_at) VALUES (?,?,?) "
"ON CONFLICT(name) DO UPDATE SET last_event=excluded.last_event, updated_at=excluded.updated_at",
(name, last_event, datetime.now(timezone.utc).isoformat()),
)
def _apply_event(model: dict, ev: dict):
etype, data = ev["event_type"], json.loads(ev["data"])
aid = ev["aggregate_id"]
if not isinstance(data, dict):
data = {}
if etype == "TaskSubmitted":
model["tasks"][aid] = {
"id": aid, "title": data.get("title", ""), "size": data.get("size", "M"),
"status": "ready", "level": None, "session_id": None,
"result_ref": None, "created": ev["timestamp"],
# details — постановка задачи; исполнитель видит её в контексте
# тика (build_context). Без этого часовой уровень выполнял задачу
# по одному заголовку и терял принципы/нюансы постановки.
"details": (data.get("details") or "")[:2000],
}
elif etype == "TaskClaimed":
t = model["tasks"].get(aid)
if t:
t.update(status="running", level=data.get("level"), session_id=data.get("session_id"))
elif etype == "TaskCompleted":
t = model["tasks"].get(aid)
if t:
t.update(status="done", result_ref=data.get("result_ref"), finished=ev["timestamp"])
elif etype == "TaskFailed":
t = model["tasks"].get(aid)
if t:
t.update(status="failed", error=data.get("error"), finished=ev["timestamp"])
elif etype == "TaskPromoted":
t = model["tasks"].get(aid)
if t:
if data.get("from") and data.get("to") and data["from"] != data["to"]:
t.update(size=data["to"])
t.update(status="ready", level=None, session_id=None,
promoted_to=data.get("to"), promotions=(t.get("promotions") or 0) + 1)
elif etype == "DigestWritten":
key = f"{data['level']}:{data['period']}"
model["digests"][key] = {
"level": data["level"], "period": data["period"],
"summary_ref": data.get("summary_ref"),
"text": data.get("text", ""),
"ts": ev["timestamp"],
}
# кап дайджестов: держим по 40 последних на уровень —
# «наверх идут только дайджесты» не значит «все дайджесты навсегда»
if len(model["digests"]) > 400:
keep = sorted(model["digests"].items(),
key=lambda kv: kv[1].get("ts", ""), reverse=True)[:400]
model["digests"] = dict(keep)
elif etype == "EscalationRaised":
model.setdefault("escalations", {})[aid] = {
**data, "status": "open", "ts": ev["timestamp"],
}
elif etype == "EscalationAcknowledged":
e = model.setdefault("escalations", {}).get(aid)
if e:
e["status"] = "acked"
e["acked_at"] = ev["timestamp"]
e["ack_level"] = data.get("level")
else:
model["escalations"][aid] = {
"status": "acked", "ack_level": data.get("level"), "ts": ev["timestamp"],
}
elif etype == "CycleTicked":
level = data.get("level", "?")
cs = model["cycle_state"].setdefault(level, {"busy": False, "current_task": None, "ticks": 0})
cs["ticks"] = cs.get("ticks", 0) + 1
cs["last_tick"] = ev["timestamp"]
elif etype == "TickAbsorbed":
level = data.get("level", "?")
cs = model["cycle_state"].setdefault(level, {"busy": False, "current_task": None, "ticks": 0})
cs["absorbed"] = cs.get("absorbed", 0) + 1
elif etype == "LevelPaused":
model["cycle_state"].setdefault(data["level"], {})["paused"] = True
elif etype == "LevelResumed":
model["cycle_state"].setdefault(data["level"], {})["paused"] = False
elif etype == "PolicyChanged":
model.setdefault("policy", {}).update(data)
elif etype == "BudgetSpent":
spend = model.setdefault("spend", [])
spend.append(data)
if len(spend) > 500: # кап: не растить модель линейно
model["spend"] = spend[-500:]
elif etype == "MessageReceived":
model.setdefault("inbox", {})[aid] = {
**data, "replied": False, "ignored": False,
"ts": ev["timestamp"],
}
inbox = model["inbox"]
if len(inbox) > 100: # кап инбокса: последние 100 входящих
keep = sorted(inbox.items(), key=lambda kv: kv[1].get("ts", ""))[-100:]
model["inbox"] = dict(keep)
elif etype == "ReplySent":
m = model.setdefault("inbox", {}).get(aid)
if m:
m["replied"] = True
m["reply_text"] = data.get("text", "")
elif etype == "MessageTranscribed":
# голосовое → текст: минутный уровень видит его как обычное
# входящее (kind=voice, text=транскрипт) и отвечает по смыслу.
# reset=True — сброс прошлой расшифровки (cli tg-transcribe --force).
if data.get("reset"):
m = model.setdefault("inbox", {}).get(aid)
if m:
m["transcribed"] = False
m.pop("transcript", None)
m.pop("transcribe_reason", None)
# текст возвращаем к исходнику (пустой у голосовых),
# чтобы не отвечали на устаревший транскрипт
m["text"] = (m.get("original_text") or "")[:4000]
return
m = model.setdefault("inbox", {}).get(aid)
if m:
if not m.get("original_text_recorded"):
m["original_text"] = m.get("text") or ""
m["original_text_recorded"] = True
m["transcribed"] = True
m["transcript"] = data.get("transcript") or ""
m["engine"] = data.get("engine")
if data.get("reason"):
m["transcribe_reason"] = data.get("reason")
else:
m.pop("transcribe_reason", None)
if data.get("transcript"):
m["text"] = data["transcript"][:4000] # расшифровка = текст сообщения
elif not m.get("original_text"):
# пустая расшифровка затирает старый транскрипт из text
m["text"] = ""
else:
model["inbox"][aid] = {
"transcribed": True, "transcript": data.get("transcript") or "",
"engine": data.get("engine"), "replied": False, "ignored": False,
"ts": ev["timestamp"],
}
elif etype == "MessageIgnored":
m = model.setdefault("inbox", {}).get(aid)
if m:
m["ignored"] = True
else:
model["inbox"][aid] = {"ignored": True, "reason": data.get("reason"), "ts": ev["timestamp"]}
elif etype == "CycleError":
# счётчик ошибок по уровням — материал для эскалаций (демон/CLI)
cs = model.setdefault("cycle_state", {}).setdefault(
data.get("level", "?"), {"busy": False, "current_task": None, "ticks": 0})
cs["errors"] = cs.get("errors", 0) + 1
cs["last_error"] = (data.get("error") or "")[:300]
# trace-события (InvocationStarted/ToolCalled/ToolResult) не материализуем — они для дебага
def _load_model(db):
"""Персистентный read model: без него каждый новый процесс
стартовал с пустой моделью при уже продвинутом last_event —
и терял TaskSubmitted/PolicyChanged навсегда."""
row = db.execute("SELECT model FROM read_model WHERE name='main'").fetchone()
if row:
try:
return json.loads(row["model"])
except (ValueError, TypeError):
pass
return None
def _save_model(db, model):
db.execute(
"INSERT INTO read_model (name, model, updated_at) VALUES ('main', ?, ?) "
"ON CONFLICT(name) DO UPDATE SET model=excluded.model, updated_at=excluded.updated_at",
(json.dumps(model, ensure_ascii=False, default=str), datetime.now(timezone.utc).isoformat()),
)
def run_projections():
"""Инкрементальная проекция с персистентным read model.
Гонка устранена: модель ВСЕГДА стартует с персистентного снапшота
(read_model-таблица), затем применяется дельта событий. In-memory
кэш между процессами не доверяем — два процесса с общим чекпоинтом
затирали друг друга (демон не видел задачу, поданную из CLI).
"""
global _read_model
with _LOCK:
db = _conn()
last = _load_meta(db, "main")
# ВСЕГДА стартуем со снапшота на диске — устраняет межпроцессную гонку
_read_model = _load_model(db) or _default_model()
# снапшот мог быть записан другим процессом на большем id, чем meta
snap_last = int(_read_model.get("_last_event", 0))
if snap_last > last:
last = snap_last
rows = db.execute("SELECT * FROM events WHERE id > ? ORDER BY id", (last,)).fetchall()
if not rows:
db.close()
return _read_model
for r in rows:
_apply_event(_read_model, dict(r))
new_last = rows[-1]["id"]
_read_model["_last_event"] = new_last
_save_meta(db, "main", new_last)
_save_model(db, _read_model)
db.commit()
db.close()
return _read_model
def rebuild_projections():
"""Полный replay с нуля (проекции одноразовые, стор — истина)."""
global _read_model
with _LOCK:
db = _conn()
db.executescript(
"""
CREATE TABLE IF NOT EXISTS projection_meta (
name TEXT PRIMARY KEY, last_event INTEGER NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS read_model (
name TEXT PRIMARY KEY, model TEXT NOT NULL, updated_at TEXT NOT NULL
);
DELETE FROM projection_meta;
DELETE FROM read_model;
"""
)
db.commit()
db.close()
_read_model = None
return run_projections()