Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled

Transform isair/jarvis into a Discord-controlled voice assistant running on
the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact.

- bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral),
  voice channel join + voice receive/playback, pluggable VNC screen broadcast
  (selfbot live / noVNC / screenshot)
- bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS
  behind a thin localhost HTTP API
- .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite,
  docs/language-comparison.md and docs/vnc-xfce-setup.md

Language decision: hybrid (Python brain + Node/bun Discord layer) because
Discord blocks bot video; native screen broadcast only works via a Node
selfbot library.
This commit is contained in:
javis-bot
2026-06-09 14:51:05 +09:00
parent a5bf8d1826
commit c4abf63f38
308 changed files with 94135 additions and 1 deletions

View File

@@ -0,0 +1,169 @@
"""
Compound-query decomposition helper.
Small models (text-based tool calling) struggle to multi-step when a user asks
two questions joined by a conjunction — they answer one side and stop. The
engine splits such queries upfront so it can inject a targeted "still
unanswered" nudge after each tool result.
Language-aware: conjunction shape varies wildly across languages (whitespace
boundaries for Latin/Cyrillic, character-level for CJK, enclitic particles
for Arabic/Hebrew that can't be split on safely). We keep a small per-
language rule table and fall back to "no decomposition" when the language
is unknown, rather than misapplying rules from a different family.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
# Minimum length of EACH sub-clause after the split. Empirical default tuned
# against ``evals/test_complex_flows.py::TestMultiStepEntityQuery`` — filters
# out short idiomatic phrases (English "rock and roll", French "va et vient",
# German "hin und her") without dropping typical multi-part entity queries
# whose clauses usually exceed 15 characters each. CJK languages use a
# smaller threshold (see ``_RULES``) because each character carries far more
# semantic weight than a Latin letter.
DEFAULT_MIN_CLAUSE_CHARS = 9
CJK_MIN_CLAUSE_CHARS = 4
# Back-compat alias kept for existing tests that imported the original constant.
MIN_CLAUSE_CHARS = DEFAULT_MIN_CLAUSE_CHARS
@dataclass(frozen=True)
class _LangRule:
"""Splitting policy for one language.
``pattern`` matches the conjunction boundary. For languages that use
whitespace between words the pattern includes ``\\s+`` padding; for CJK
it matches the conjunction character(s) directly so "电影和音乐" splits
cleanly without requiring authors to insert spaces.
"""
pattern: re.Pattern[str]
min_clause_chars: int = DEFAULT_MIN_CLAUSE_CHARS
def _ws(words: str) -> re.Pattern[str]:
"""Whitespace-bounded conjunction pattern, case-insensitive."""
return re.compile(rf"\s+(?:{words})\s+", flags=re.IGNORECASE)
# Per-language rules. Only languages we can reasonably vouch for — either
# structurally (whitespace-separated families where the pattern is
# mechanical) or with explicit testing (see ``tests/test_compound_query.py``).
# Languages outside this table fall through to "no decomposition" rather
# than risk mis-splitting with borrowed rules.
_RULES: dict[str, _LangRule] = {
# ── Germanic / Romance (whitespace-separated) ─────────────────────────
"en": _LangRule(_ws("and")),
"es": _LangRule(_ws("y|e")), # "e" before i-/hi- words
"fr": _LangRule(_ws("et")),
"de": _LangRule(_ws("und")),
"pt": _LangRule(_ws("e")),
"it": _LangRule(_ws("e|ed")), # "ed" before vowel
"nl": _LangRule(_ws("en")),
"sv": _LangRule(_ws("och")),
"no": _LangRule(_ws("og")), # Norwegian (Bokmål)
"da": _LangRule(_ws("og")), # Danish
"fi": _LangRule(_ws("ja|sekä")), # Finnish
# ── Slavic (Cyrillic + Latin) ─────────────────────────────────────────
"ru": _LangRule(_ws("и|а также")),
"uk": _LangRule(_ws("і|та|й")), # Ukrainian — і / та / й
"be": _LangRule(_ws("і|ды")), # Belarusian
"pl": _LangRule(_ws("i|oraz")),
"cs": _LangRule(_ws("a|i")), # Czech
"sk": _LangRule(_ws("a|i")), # Slovak
"bg": _LangRule(_ws("и")), # Bulgarian
"sr": _LangRule(_ws("и|i")), # Serbian (both scripts)
"hr": _LangRule(_ws("i")), # Croatian
"sl": _LangRule(_ws("in")), # Slovenian
# ── Other European ────────────────────────────────────────────────────
"el": _LangRule(_ws("και|κι")), # Greek
"tr": _LangRule(_ws("ve")),
"hu": _LangRule(_ws("és|meg")), # Hungarian
"ro": _LangRule(_ws("și|şi")), # Romanian (both diacritics)
# ── Asian (whitespace-separated) ──────────────────────────────────────
"vi": _LangRule(_ws("")), # Vietnamese
"id": _LangRule(_ws("dan")), # Indonesian
"ms": _LangRule(_ws("dan")), # Malay
"hi": _LangRule(_ws("और|तथा")), # Hindi (Devanagari)
# ── CJK (no whitespace around conjunctions) ───────────────────────────
# Chinese: 和 / 与 / 以及 / 并且 — common coordinating conjunctions.
# Pattern matches either a character-level conjunction OR the two-char
# forms. Clause-length threshold is lowered to CJK_MIN_CLAUSE_CHARS
# because each Han character carries word-level meaning.
"zh": _LangRule(
re.compile(r"以及|并且|以及|和|与"),
min_clause_chars=CJK_MIN_CLAUSE_CHARS,
),
# Japanese: そして / および / また are freestanding sentence-level
# connectors. We intentionally avoid the enclitic particles と/や —
# they attach to nouns and splitting on them produces nonsense. Users
# who write multi-part questions typically use the freestanding forms.
"ja": _LangRule(
re.compile(r"そして|および|また|かつ"),
min_clause_chars=CJK_MIN_CLAUSE_CHARS,
),
# Korean: 그리고 / 및 are freestanding; 와/과 are postpositional
# particles attached to the preceding noun, so we avoid those for the
# same reason as Japanese. Allow optional whitespace around the
# freestanding forms since Korean usage varies.
"ko": _LangRule(
re.compile(r"\s*(?:그리고|및)\s*"),
min_clause_chars=CJK_MIN_CLAUSE_CHARS,
),
}
# Languages NOT included on purpose:
# - Arabic (ar) / Hebrew (he): the conjunction "و" / "ו" is an enclitic
# prefix attached directly to the following word (e.g. "وكتاب" = "and a
# book"). A safe split would need a morphological tokenizer; a regex
# produces silent false positives on every word starting with "و"/"ו".
# - Thai (th), Khmer (km), Lao (lo): no inter-word whitespace and the
# conjunctions overlap common syllables; same tokenizer requirement as
# above, without a cheap workaround.
def _normalise_language(language: Optional[str]) -> Optional[str]:
"""Return a lowercase ISO-639-1 code or None for unknown input.
Accepts locale-style codes like "en-US" or "zh-CN" and returns the
primary subtag. Returns None for empty strings, non-strings, or
tags whose primary subtag is not a valid ISO-639-1 alpha-2 code.
"""
if not language or not isinstance(language, str):
return None
code = language.strip().lower().split("-")[0][:2]
return code if code.isalpha() and len(code) == 2 else None
def split_compound_query(text: str, language: Optional[str] = None) -> list[str]:
"""Split a compound question into ordered sub-questions.
Returns an empty list when the query is not compound, the language is
unknown/unsupported, or either clause is shorter than the language's
minimum clause length. Callers should treat an empty list as "run the
query as a single unit" — we never guess across languages we don't
explicitly support.
"""
if not text or not isinstance(text, str):
return []
# Default to English when language is not provided (non-voice entrypoints
# like evals and text chat carry no ISO code). Voice flows always pass a
# Whisper-detected language; if that language isn't in our table, we
# return no decomposition rather than fall back to English and mis-split.
code = _normalise_language(language) or "en"
rule = _RULES.get(code)
if rule is None:
return []
parts = rule.pattern.split(text, maxsplit=1)
if len(parts) != 2:
return []
left, right = parts[0].strip(), parts[1].strip()
if len(left) < rule.min_clause_chars or len(right) < rule.min_clause_chars:
return []
return [left, right]