"""Simbiot Harness — CLI: таски, статус, ручные тики.""" import argparse import json import sys import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from harness import commands, es def main(): ap = argparse.ArgumentParser(prog="simbiot", description="Simbiot Harness CLI") sub = ap.add_subparsers(dest="cmd", required=True) p_task = sub.add_parser("task", help="подать задачу") p_task.add_argument("title") p_task.add_argument("-d", "--details", default="") p_task.add_argument("-m", "--minutes", type=int, default=5, help="оценка длительности (класс S≤5м M≤60м L≤8ч XL)") p_task.add_argument("-a", "--await-delivery", action="store_true", help="результат доставить в TG-чат автоматически при готовности") p_pol = sub.add_parser("policy", help="задать политику") p_pol.add_argument("--identity", default=None) p_pol.add_argument("--cpu-threshold", type=int, default=None) p_rq = sub.add_parser("requeue", help="вернуть failed-задачу в очередь") p_rq.add_argument("task_id") p_rq.add_argument("-r", "--reason", default="manual requeue") p_pr = sub.add_parser("promote", help="промоушен ready-задачи на уровень выше (S→M→L)") p_pr.add_argument("task_id") p_pr.add_argument("-r", "--reason", default="manual promote") p_tgr = sub.add_parser("tg-reply", help="ответить на входящее TG-сообщение") p_tgr.add_argument("msg_id", help="id входящего (m-NNN)") p_tgr.add_argument("text") p_taw = sub.add_parser("task-await", help="пометить задачу: результат доставить в TG при готовности") p_taw.add_argument("task_id") p_taw.add_argument("-r", "--reason", default="заказано из TG") p_tgt = sub.add_parser("tg-transcribe", help="расшифровать голосовое (m-NNN) вручную") p_tgt.add_argument("msg_id", help="id входящего (m-NNN)") p_tgt.add_argument("--force", action="store_true", help="перерасшифровать заново") p_tgl = sub.add_parser("tg-list", help="список входящих TG-сообщений (inbox)") p_tgl.add_argument("--new", action="store_true", help="только без ответа") p_tgl.add_argument("-n", "--limit", type=int, default=25, help="сколько последних показать") p_tgl.add_argument("--all", action="store_true", help="без ограничения по количеству") p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации") p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)") p_not.add_argument("text") p_tgc = sub.add_parser("tg-commands", help="команды бота: setMyCommands + список") p_tgc.add_argument("--register", action="store_true", help="(re)зарегистрировать setMyCommands (кириллица, фолбэк — транслит)") p_tgc.add_argument("--list", action="store_true", help="показать текущие команды бота") p_esc = sub.add_parser("escalate", help="эскалация важного на уровень выше (min→hour→day)") p_esc.add_argument("reason") p_esc.add_argument("-f", "--from-level", default="min", choices=["min", "hour"], help="кто эскалирует") p_esc.add_argument("-c", "--context", default="", help="контекст для верхнего уровня") p_esc.add_argument("-s", "--severity", default="warn", choices=["info", "warn", "critical"]) p_esc.add_argument("-t", "--task-id", default=None, help="связанная задача") p_escl = sub.add_parser("escalations", help="список эскалаций") p_escl.add_argument("--open", action="store_true", help="только открытые") p_ack = sub.add_parser("escalation-ack", help="закрыть эскалацию (принята уровнем выше)") p_ack.add_argument("esc_id") p_ack.add_argument("-l", "--level", default="hour", help="кто принял") p_ack.add_argument("-n", "--note", default="", help="что сделали с эскалацией") sub.add_parser("status", help="состояние харнеса") sub.add_parser("tasks", help="доска задач") p_ev = sub.add_parser("events", help="хвост стора (последние N событий)") p_ev.add_argument("-n", type=int, default=15) p_ev.add_argument("--after", type=int, default=None, help="вывести события ПОСЛЕ этого id (с головы, по возрастанию)") p_init = sub.add_parser("init", help="инициализация стора") p_init.add_argument("--identity", default="Simbiot Harness — ритмичный агент кооператива") p_rag = sub.add_parser("rag-query", help="семантический поиск по базе кооператива (вендор /root/rag)") p_rag.add_argument("question") p_rag.add_argument("-k", type=int, default=5, help="сколько чанков (<=5)") p_rag.add_argument("--db", default=None, help="база RAG (по умолчанию rag.db; hermes.db — общая)") p_rag.add_argument("--json", action="store_true", help="вывести машинно-читаемо") args = ap.parse_args() if args.cmd == "init": es.init_db() commands.set_policy(identity=args.identity) print("OK: стор инициализирован, политика записана") elif args.cmd == "task": tid, size = commands.submit_task(args.title, args.details, args.minutes) if args.await_delivery: commands.mark_awaiting_delivery(tid, reason="подана из TG с автодоставкой") print(f"OK: задача {tid} [{size}] подана (результат уйдёт в TG при готовности)") else: print(f"OK: задача {tid} [{size}] подана") elif args.cmd == "task-await": es.init_db() commands.mark_awaiting_delivery(args.task_id, reason=args.reason) print(f"OK: {args.task_id} помечена — результат будет доставлен в TG при готовности") elif args.cmd == "policy": pol = commands.set_policy(identity=args.identity, cpu_alert_threshold=args.cpu_threshold) print("OK:", json.dumps(pol, ensure_ascii=False)) elif args.cmd == "requeue": es.init_db() tid = commands.requeue_task(args.task_id, args.reason) print(f"OK: задача {tid} возвращена в очередь") elif args.cmd == "promote": es.init_db() tid, new_size = commands.promote_task(args.task_id, args.reason) print(f"OK: задача {tid} повышена до [{new_size}]") elif args.cmd == "tg-reply": es.init_db() from harness import tg_inbox low = args.text.strip().lower() if low in ("/лестница", "/ladder", "/статус", "/status", "лестница", "status"): # срез лестницы по запросу из TG — видимостью управляет сам харнес args.text = "📊 " + tg_inbox.ladder_snapshot() mid = tg_inbox.send_reply(args.msg_id, args.text) print(f"OK: ответ на {mid} отправлен") elif args.cmd == "tg-transcribe": es.init_db() from harness import voice from harness import tg_inbox model = es.run_projections() m = (model.get("inbox") or {}).get(args.msg_id) if m is None: print(f"ERROR: входящее {args.msg_id} не найдено") sys.exit(1) if args.force: # сбрасываем прошлую расшифровку в read model (событие поверх) # — transcribe_message идемпотентен по флагу transcribed es.append_event("inbox", args.msg_id, "MessageTranscribed", { "transcript": "", "engine": None, "reason": None, "reset": True, }) print("(force: прошлый транскрипт сброшен)") if m.get("transcribed") and not args.force: print(f"Уже расшифровано ({m.get('engine')}): {m.get('transcript')}") else: text, engine = voice.transcribe_message( args.msg_id, file_id=m.get("file_id") or "", file_unique_id=m.get("file_unique_id") or "", duration=m.get("duration"), kind=m.get("kind") or "voice") if text: print(f"OK ({engine}): {text}") else: reason = m.get("transcribe_reason") print(f"ПУСТО (engine={engine})" + (f", причина: {reason}" if reason else "") + " — транскрипт записан в стор как пустой") elif args.cmd == "tg-list": es.init_db() model = es.run_projections() inbox = model.get("inbox") or {} rows = sorted(inbox.items(), key=lambda kv: int(kv[0].split("-")[1]) if kv[0].split("-")[1].isdigit() else 10**9) total = len(rows) fresh = [kv for kv in rows if not kv[1].get("replied") and not kv[1].get("ignored")] print(f"всего: {total} | без ответа: {len(fresh)} | " f"отвечено: {sum(1 for _, v in rows if v.get('replied'))} | " f"ignored: {sum(1 for _, v in rows if v.get('ignored'))}") shown = [kv for kv in rows if not args.new or (not kv[1].get("replied") and not kv[1].get("ignored"))] if not args.all: shown = shown[-args.limit:] if not shown: print("(нет сообщений по фильтру)") for mid, v in shown: who = v.get("username") or v.get("from_name") or v.get("chat_id") or "?" st = ("replied" if v.get("replied") else "ignored" if v.get("ignored") else "transcribed" if v.get("transcribed") else "new") body = v.get("transcript") or v.get("text") or "" print(f"{mid:12} {str(who)[:12]:12} {st:11} {str(v.get('kind') or 'text'):5} {str(body)[:100]}") elif args.cmd == "ladder": es.init_db() from harness import tg_inbox print(tg_inbox.ladder_snapshot()) elif args.cmd == "tg-notify": es.init_db() from harness import tg_inbox r = tg_inbox.notify_chat(args.text) print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)") elif args.cmd == "tg-commands": es.init_db() from harness import tg_commands if args.register: mode = tg_commands.register_bot_commands() print(f"OK: команды бота зарегистрированы ({mode})" if mode else "ERROR: setMyCommands не прошёл (нет токена / API недоступен)") if args.list or not args.register: cmds = tg_commands.get_my_commands() if not cmds: print("(у бота нет зарегистрированных команд)") for name, desc in cmds: print(f"/{name} — {desc}") elif args.cmd == "rag-query": # без init_db: RAG не пишет в read model, только трейс по уровню env from harness import rag_lookup chunks = rag_lookup.rag_query(args.question, k=args.k, db=args.db) if args.json: print(json.dumps(chunks, ensure_ascii=False)) elif not chunks: print("(ничего не найдено или вендор недоступен)") for c in chunks: print(f"\n═══ {c['score']:.3f} | {c['source']} ═══") print(c["text"][:rag_lookup.TIMEOUT * 100 or 900]) elif args.cmd == "escalate": es.init_db() eid, to_level = commands.raise_escalation( args.from_level, args.reason, args.context, args.severity, args.task_id) print(f"OK: эскалация {eid} → {to_level} ({args.severity})") elif args.cmd == "escalations": es.init_db() rows = commands.list_escalations(only_open=args.open) if not rows: print("(эскалаций нет)") for e in rows: ctx = f" | {e.get('context','')[:60]}" if e.get("context") else "" print(f"{e['id']} [{e.get('severity','info'):8}] {e.get('status','?'):5} " f"{e.get('from_level')} → {e.get('to_level')} " f"{e.get('reason','')[:70]}{ctx}") elif args.cmd == "escalation-ack": es.init_db() eid = commands.acknowledge_escalation(args.esc_id, args.level, args.note) print(f"OK: эскалация {eid} закрыта ({args.level})") elif args.cmd == "status": es.init_db() print(json.dumps(commands.status(), ensure_ascii=False, indent=1)) elif args.cmd == "tasks": es.init_db() for t in commands.list_tasks(): print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}") elif args.cmd == "events": es.init_db() if args.after is None: # хвост стора: последние N по возрастанию id total = es.count_events() evs = es.get_events(max(0, total - args.n), args.n) else: evs = es.get_events(args.after, args.n) for ev in evs: d = ev["data"][:110] print(f"{ev['id']:4} {ev['timestamp'][11:19]} {ev['aggregate']:8} {ev['event_type']:16} {d}") if __name__ == "__main__": main()