Switch from Bifrost to LiteLLM; add Matrix channel; update rules

Infrastructure:
- docker-compose.yml: replace bifrost container with LiteLLM proxy
  (host.docker.internal:4000); complex model → deepseek-r1:free via
  OpenRouter; add Matrix URL env var; mount logs volume
- bifrost-config.json: add auth_config + postgres config_store (archived)

Routing:
- router.py: full semantic 3-tier classifier rewrite — nomic-embed-text
  centroids for light/medium/complex; regex pre-classifiers for all tiers;
  Russian utterance sets expanded
- agent.py: wire LiteLLM URL; add dry_run support; add Matrix channel

Channels:
- channels.py: add Matrix adapter (_matrix_send via mx- session prefix)

Rules / docs:
- agent-pipeline.md: remove /think prefix requirement; document automatic
  complex tier classification
- llm-inference.md: update BIFROST_URL → LITELLM_URL references; add
  remote model note for complex tier
- ARCHITECTURE.md: deleted (superseded by README.md)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alvis
2026-03-24 02:14:13 +00:00
parent 54cb940279
commit 1f5e272600
8 changed files with 730 additions and 406 deletions

439
router.py
View File

@@ -1,11 +1,38 @@
import asyncio
import re
import math
from typing import Optional
from openai import AsyncOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
from fast_tools import FastToolRunner
# ── Regex pre-classifier ─────────────────────────────────────────────────────
# Catches obvious light-tier patterns before calling the LLM.
# Keyed by regex → compiled pattern.
# ── Regex pre-classifiers ─────────────────────────────────────────────────────
# Complex: keyword triggers that reliably signal deep multi-source research
_COMPLEX_PATTERNS = re.compile(
r"(?:^|\s)("
r"research|investigate|deep.dive|think carefully"
r"|write a (?:detailed|comprehensive|full|thorough|complete)"
r"|compare all|find and (?:compare|summarize|analyze)"
r"|in[- ]depth analysis|comprehensive guide"
r"|detailed (?:report|analysis|comparison|breakdown|overview)"
r"|everything about|all (?:major|available|self-hosted|open.source)"
r"|pros and cons|with (?:sources|citations|references)"
# Russian complex research keywords (no trailing \b — stems like подробн match подробное/подробный)
r"|исследуй|изучи все|сравни все|найди и сравни|найди и опиши"
r"|напиши подробн|напиши детальн|напиши полн"
r"|подробный отчет|детальн\w+ (?:анализ|сравнение|отчет)"
r"|подробное (?:руководство|сравнение)|полное руководство"
r"|все варианты|все способы|все доступные|все самохостируемые|все платформы"
r"|лучшие практики|все инструменты|все решения|все протоколы"
r"|найди детальн|найди и кратко опиши"
r"|изучи свежие|изучи лучши|изучи все"
r"|сравни все\b"
r")",
re.IGNORECASE,
)
# Light: trivial queries that need no tools or memory
_LIGHT_PATTERNS = re.compile(
r"^("
# Greetings / farewells
@@ -15,35 +42,301 @@ _LIGHT_PATTERNS = re.compile(
r"|thanks?|thank you|thx|ty|ok|okay|k|cool|great|awesome|perfect|sounds good|got it|nice|sure"
r"|how are you|how are you\?|how are you doing(\s+today)?[?!.]*"
r"|what.?s up"
# Calendar facts: "what day comes after X?" / "what comes after X?"
# Calendar facts
r"|what\s+day\s+(comes\s+after|follows|is\s+after)\s+\w+[?!.]*"
r"|what\s+comes\s+after\s+\w+[?!.]*"
# Acronym expansions: "what does X stand for?"
# Acronym expansions
r"|what\s+does\s+\w+\s+stand\s+for[?!.]*"
# Russian greetings / farewells / acknowledgements
r"|привет|пока|спасибо|здравствуй|здравствуйте|добрый день|добрый вечер|доброе утро"
r"|окей|хорошо|отлично|понятно|ок|ладно|договорились|спс|благодарю"
r"|пожалуйста|не за что|всё понятно|ясно"
r"|как дела|как ты|как жизнь|всё хорошо|всё ок"
r")[\s!.?]*$",
re.IGNORECASE,
)
# ── LLM classification prompt ─────────────────────────────────────────────────
CLASSIFY_PROMPT = """Classify the message. Output ONLY one word: light, medium, or complex.
# ── Semantic router utterances ────────────────────────────────────────────────
# These are embedded at startup. New messages are classified by cosine
# similarity — whichever tier's centroid is closest wins.
_LIGHT_UTTERANCES = [
# General facts (English)
"what is 2+2",
"what is the capital of France",
"name the three primary colors",
"tell me a short joke",
"is the sky blue",
"is water wet",
"how many days in a week",
"what is the speed of light",
"what is the boiling point of water",
"spell the word beautiful",
"what color is the ocean",
"how many inches in a foot",
"who wrote hamlet",
"what is pi",
"what year did world war two end",
"what is the largest planet",
"how many continents are there",
"what does DNA stand for",
"what language do they speak in Brazil",
"what is the square root of 144",
# Tech definitions — static knowledge (English)
"what is Docker",
"what is a VPN",
"what is SSH",
"what is a reverse proxy",
"what is an API",
"what is a firewall",
"what is a container",
"what is DNS",
"what is HTTPS",
"what is a load balancer",
"what is Kubernetes",
"what is Git",
"what is a network port",
"what is an IP address",
"what is a subnet mask",
"what is the OSI model",
"how many bits in a byte",
"how many bytes in a gigabyte",
"what is TCP",
"what is a REST API",
# Russian — static facts and definitions
"что такое IP-адрес",
"что такое VPN",
"что такое Docker",
"что такое DNS",
"что такое SSH",
"что означает API",
"сколько байт в гигабайте",
"сколько бит в байте",
"что такое Zigbee",
"что такое Z-Wave",
"что такое брандмауэр",
"что такое виртуальная машина",
"что такое обратный прокси",
"привет",
"пока",
"спасибо",
"как дела",
"что такое Matter протокол",
"сколько планет в солнечной системе",
"чему равно число Пи",
# Russian — more static definitions
"что такое TCP/IP",
"что такое подсеть",
"скорость света",
"сколько дней в году",
"что такое Kubernetes",
"что такое Git",
"что такое REST API",
"что такое TCP",
"что такое UDP",
"что такое VLAN",
"сколько мегабайт в гигабайте",
"что такое процессор",
"что такое оперативная память",
"что такое виртуализация",
"что такое Linux",
"что такое умный дом",
"что такое Home Assistant",
"что такое Matter",
]
LIGHT = answerable from general knowledge, no internet needed:
what is 2+2 / what is the capital of France / name the three primary colors
tell me a short joke / is the sky blue / is water wet
_MEDIUM_UTTERANCES = [
# English — current data, memory, actions
"what is the weather today",
"what is the bitcoin price right now",
"what are the latest news",
"what did we talk about last time",
"what is my name",
"where do I live",
"what do you know about me",
"what did I tell you before",
"what is the current temperature outside",
"remind me what I said about my project",
"search for the latest iPhone release",
"find me a restaurant nearby",
"turn on the lights in the living room",
"turn off all lights",
"set temperature to 22 degrees",
"what is the current traffic to Moscow",
"check if anyone is home",
"what devices are currently on",
"look up my public IP address",
"show me recent news about Proxmox",
# Russian — weather and commute
"какая сегодня погода в Балашихе",
"пойдет ли сегодня дождь",
"какая температура на улице сейчас",
"погода на завтра",
"будет ли снег сегодня",
"сколько ехать до Москвы сейчас",
"какие пробки на дороге до Москвы",
"время в пути на работу",
"есть ли пробки сейчас",
"стоит ли брать зонтик",
# Russian — smart home control
"включи свет в гостиной",
"выключи свет на кухне",
"какая температура дома",
"установи температуру 22 градуса",
"выключи все лампочки",
"какие устройства сейчас включены",
"включи ночной режим",
"открой шторы в гостиной",
"включи свет в спальне на 50 процентов",
"выключи свет во всём доме",
"включи вентилятор в детской",
"закрыты ли все окна",
"выключи телевизор",
"какое потребление электричества сегодня",
"включи кофемашину",
"сколько у нас датчиков движения",
"состояние всех дверных замков",
"есть ли кто-нибудь дома",
"установи будильник на 7 утра",
# Russian — personal memory
"как меня зовут",
"где я живу",
"что мы обсуждали в прошлый раз",
"что ты знаешь о моем домашнем сервере",
"напомни, какие сервисы я запускаю",
"что я просил тебя запомнить",
"что я говорил о своей сети",
# Russian — current info lookups requiring network/tools
"какой сейчас курс биткоина",
"курс доллара к рублю сейчас",
"какая последняя версия Docker",
"как перезапустить Docker контейнер",
"как посмотреть логи Docker контейнера",
"какие новые функции в Home Assistant 2024",
"есть ли проблемы у Cloudflare сегодня",
"какие новые Zigbee устройства вышли в 2024 году",
"найди хороший опенсорс менеджер фотографий",
"последние новости Proxmox",
"напиши bash команду для поиска больших файлов",
"как вывести список всех запущенных контейнеров",
"как проверить использование диска в Linux",
]
MEDIUM = requires web search or the user's stored memories:
current weather / today's news / Bitcoin price / what did we talk about
what is my name / where do I live / what is my job / do I have any pets
what do you know about me / what are my preferences / what did I tell you
_COMPLEX_UTTERANCES = [
# English
"research everything about Elon Musk's recent projects and investments",
"write a detailed report on climate change solutions with sources",
"investigate the history and current state of quantum computing",
"find and summarize the latest academic papers on transformer architectures",
"analyze in depth the pros and cons of nuclear energy with citations",
"research the background and controversies around this person",
"compare all major cloud providers with detailed pricing and features",
"write a comprehensive biography of this historical figure",
"investigate what caused the 2008 financial crisis with multiple sources",
"research the best programming languages in 2024 with detailed comparison",
"find everything published about this medical condition and treatments",
"do a deep dive into the latest developments in artificial general intelligence",
"research and compare all options for starting a business in Europe",
"investigate recent news and controversies around this company",
"write a thorough analysis of geopolitical tensions in the Middle East",
"find detailed information on the side effects and studies for this medication",
"research the top 10 JavaScript frameworks with benchmarks and community data",
"investigate who is funding AI research and what their goals are",
"write a detailed market analysis for the electric vehicle industry",
"research everything you can find about this startup or technology",
# Russian — deep research
"исследуй и сравни все варианты умного домашнего освещения",
"напиши подробный отчет о протоколах умного дома",
"изучи все самохостируемые медиасерверы и сравни их",
"исследуй лучшие практики безопасности домашнего сервера",
"сравни все системы резервного копирования для Linux",
"напиши детальное сравнение WireGuard и OpenVPN",
"исследуй все варианты голосового управления на русском языке",
"изучи все опенсорс альтернативы Google сервисам",
"напиши подробный анализ локальных языковых моделей",
"исследуй лучшие инструменты мониторинга для домашнего сервера",
# Russian — more deep research queries matching benchmark
"исследуй и сравни Proxmox, Unraid и TrueNAS для домашней лаборатории",
"напиши подробное руководство по безопасности домашнего сервера",
"исследуй все доступные дашборды для самохостинга и сравни их",
"найди детальные бенчмарки ARM одноплатных компьютеров для домашней лаборатории",
"исследуй лучший стек мониторинга для самохостинга в 2024 году",
"исследуй и сравни WireGuard, OpenVPN и Tailscale для домашней сети",
"исследуй лучшие практики сегментации домашней сети с VLAN",
"изучи все самохостируемые DNS решения и их возможности",
"исследуй и сравни все платформы умного дома: Home Assistant и другие",
"изучи лучшие Zigbee координаторы и их совместимость с Home Assistant",
"напиши детальный отчет о поддержке протокола Matter и совместимости устройств",
"исследуй все способы интеграции умных ламп с Home Assistant",
"найди и сравни все варианты датчиков движения для умного дома",
"исследуй и сравни все самохостируемые решения для хранения фотографий",
"изучи лучшие самохостируемые медиасерверы: Jellyfin, Plex и Emby",
"исследуй последние достижения в локальном LLM инференсе и обзор моделей",
"изучи лучшие опенсорс альтернативы Google сервисов для приватности",
"найди и кратко опиши все крупные самохостируемые менеджеры паролей",
"напиши детальный анализ текущего состояния AI ассистентов для самохостинга",
"исследуй и сравни все инструменты оркестрации контейнеров для домашней лаборатории",
"изучи лучшие подходы к автоматическому резервному копированию в Linux",
"исследуй и сравни все самохостируемые инструменты личных финансов",
"изучи свежие CVE и уязвимости в популярном самохостируемом ПО",
"напиши подробное руководство по настройке автоматизаций в Home Assistant",
"исследуй все варианты голосового управления умным домом на русском языке",
"сравни все системы резервного копирования для Linux: Restic, BorgBackup и другие",
"исследуй лучшие самохостируемые системы мониторинга сети: Zabbix, Grafana",
"изучи все варианты локального запуска языковых моделей на видеокарте",
"напиши подробный отчет о технологиях синтеза речи с открытым исходным кодом",
"исследуй все способы интеграции умных розеток с мониторингом потребления",
"напиши полное руководство по настройке обратного прокси Caddy",
"исследуй лучшие практики написания Docker Compose файлов для продакшена",
"сравни все самохостируемые облачные хранилища: Nextcloud, Seafile и другие",
"изучи все доступные локальные ассистенты с голосовым управлением",
"исследуй все самохостируемые решения для блокировки рекламы: Pi-hole, AdGuard",
"напиши детальное сравнение систем управления конфигурацией: Ansible, Puppet",
"исследуй все протоколы умного дома и их плюсы и минусы: Zigbee, Z-Wave, Matter",
"найди и сравни все фреймворки для создания локальных AI ассистентов",
"исследуй лучшие решения для автоматического управления медиатекой",
"изучи все варианты самохостируемых систем учёта расходов с возможностью импорта",
"напиши сравнение всех вариантов самохостинга для хранения и синхронизации файлов",
"исследуй все открытые протоколы для умного дома и их экосистемы",
"изучи лучшие инструменты для автоматизации домашней инфраструктуры",
]
COMPLEX = /think prefix only:
/think compare frameworks / /think plan a trip
Message: {message}
Output (one word only — light, medium, or complex):"""
# Medium: queries that require tools, actions, or real-time data (not static knowledge)
_MEDIUM_PATTERNS = re.compile(
r"(?:"
# Russian smart home commands — always need HA integration
r"(?:включи|выключи|открой|закрой|установи|поставь|убавь|прибавь|переключи)\s"
r"|(?:какая|какой|какое|каково)\s+(?:температура|влажность|потребление|состояние|статус)\s"
r"|(?:сколько|есть ли)\s.*(?:датчик|устройств|замк)"
# Russian memory queries
r"|как меня зовут|где я живу|что мы обсуждали|что я говорил|что я просил"
r"|напомни\b|что ты знаешь обо мне"
# Russian current info
r"|курс (?:доллара|биткоина|евро|рубл)"
r"|(?:последние |свежие )?новости\b"
r"|(?:погода|температура)\s+(?:на завтра|на неделю)"
r")",
re.IGNORECASE,
)
LIGHT_REPLY_PROMPT = """You are a helpful Telegram assistant. Answer briefly and naturally (1-3 sentences). Be friendly."""
_EMBED_MODEL = "ollama/nomic-embed-text"
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(x * x for x in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
def _centroid(embeddings: list[list[float]]) -> list[float]:
n = len(embeddings)
dim = len(embeddings[0])
return [sum(embeddings[i][d] for i in range(n)) / n for d in range(dim)]
def _format_history(history: list[dict]) -> str:
if not history:
@@ -56,71 +349,93 @@ def _format_history(history: list[dict]) -> str:
return "\n".join(lines)
def _parse_tier(text: str) -> str:
"""Extract tier from raw model output. Default to medium."""
t = text.strip().lower()
snippet = t[:60]
if "complex" in snippet:
return "complex"
if "medium" in snippet:
return "medium"
if "light" in snippet:
return "light"
# Model invented a descriptive category (e.g. "simplefact", "trivial", "basic") →
# treat as light since it recognised the question doesn't need tools
if any(w in snippet for w in ("simple", "fact", "trivial", "basic", "easy", "general")):
return "light"
return "medium" # safe default
class Router:
def __init__(self, model, fast_tool_runner: FastToolRunner | None = None):
self.model = model
def __init__(
self,
model,
embedder: AsyncOpenAI,
fast_tool_runner: FastToolRunner | None = None,
):
self.model = model # qwen2.5:1.5b — used only for generating light replies
self._embedder = embedder
self._fast_tool_runner = fast_tool_runner
self._light_centroid: list[float] | None = None
self._medium_centroid: list[float] | None = None
self._complex_centroid: list[float] | None = None
async def initialize(self) -> None:
"""Pre-compute utterance embeddings. Call once at startup. Retries until LiteLLM is ready."""
print("[router] embedding utterances for semantic classifier...", flush=True)
texts = _LIGHT_UTTERANCES + _MEDIUM_UTTERANCES + _COMPLEX_UTTERANCES
for attempt in range(10):
try:
resp = await self._embedder.embeddings.create(model=_EMBED_MODEL, input=texts)
embeddings = [item.embedding for item in resp.data]
n_light = len(_LIGHT_UTTERANCES)
n_medium = len(_MEDIUM_UTTERANCES)
self._light_centroid = _centroid(embeddings[:n_light])
self._medium_centroid = _centroid(embeddings[n_light:n_light + n_medium])
self._complex_centroid = _centroid(embeddings[n_light + n_medium:])
print("[router] semantic classifier ready (3-tier)", flush=True)
return
except Exception as e:
print(f"[router] embedding attempt {attempt+1}/10 failed: {e}", flush=True)
await asyncio.sleep(3)
print("[router] WARNING: could not initialize semantic classifier — will default to medium", flush=True)
async def _classify_by_embedding(self, message: str) -> str:
"""Embed message and return 'light', 'medium', or 'complex' based on centroid similarity."""
if self._light_centroid is None or self._medium_centroid is None or self._complex_centroid is None:
return "medium"
try:
resp = await self._embedder.embeddings.create(model=_EMBED_MODEL, input=[message])
emb = resp.data[0].embedding
score_light = _cosine(emb, self._light_centroid)
score_medium = _cosine(emb, self._medium_centroid)
score_complex = _cosine(emb, self._complex_centroid)
tier = max(
[("light", score_light), ("medium", score_medium), ("complex", score_complex)],
key=lambda x: x[1],
)[0]
print(
f"[router] semantic: light={score_light:.3f} medium={score_medium:.3f} "
f"complex={score_complex:.3f}{tier}",
flush=True,
)
return tier
except Exception as e:
print(f"[router] embedding classify error, defaulting to medium: {e}", flush=True)
return "medium"
async def route(
self,
message: str,
history: list[dict],
force_complex: bool = False,
) -> tuple[str, Optional[str]]:
"""
Returns (tier, reply_or_None).
For light tier: also generates the reply with a second call.
For light tier: also generates the reply inline.
For medium/complex: reply is None.
"""
if force_complex:
return "complex", None
# Step 0a: fast tool match — agent.py short-circuits before reaching router
# This branch is only hit if force_complex=True with a fast-tool message (rare)
if self._fast_tool_runner and self._fast_tool_runner.any_matches(message.strip()):
names = self._fast_tool_runner.matching_names(message.strip())
print(f"[router] fast_tool_match={names} → medium", flush=True)
return "medium", None
# Step 0b: regex pre-classification for obvious light patterns
if _LIGHT_PATTERNS.match(message.strip()):
print(f"[router] regex→light", flush=True)
print("[router] regex→light", flush=True)
return await self._generate_light_reply(message, history)
# Step 1: LLM classification with raw text output
try:
classify_response = await self.model.ainvoke([
HumanMessage(content=CLASSIFY_PROMPT.format(message=message)),
])
raw = classify_response.content or ""
raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
tier = _parse_tier(raw)
if _COMPLEX_PATTERNS.search(message.strip()):
print("[router] regex→complex", flush=True)
return "complex", None
if tier == "complex" and not message.startswith("/think"):
tier = "medium"
print(f"[router] raw={raw[:30]!r} → tier={tier}", flush=True)
except Exception as e:
print(f"[router] classify error, defaulting to medium: {e}", flush=True)
if _MEDIUM_PATTERNS.search(message.strip()):
print("[router] regex→medium", flush=True)
return "medium", None
tier = await self._classify_by_embedding(message)
if tier != "light":
return tier, None
@@ -129,7 +444,7 @@ class Router:
async def _generate_light_reply(
self, message: str, history: list[dict]
) -> tuple[str, Optional[str]]:
"""Generate a short reply using the router model for light-tier messages."""
"""Generate a short reply using qwen2.5:1.5b for light-tier messages."""
history_text = _format_history(history)
context = f"\nConversation history:\n{history_text}" if history else ""
try: