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
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:
169
src/jarvis/reply/compound_query.py
Normal file
169
src/jarvis/reply/compound_query.py
Normal 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("và")), # 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]
|
||||
Reference in New Issue
Block a user