mirror of
https://github.com/Jeuners/astra-vision.git
synced 2026-09-09 15:02:35 +02:00
The model reliably tacked a filler question ("Möchtest du...?", "Gefällt
dir...?") onto nearly every reply regardless of "Stelle bei Bedarf eine
kurze Rückfrage." Verified against live Ollama across several prompts:
sharpened the instruction to end with a statement unless a concrete
piece of information is actually missing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
158 lines
7.1 KiB
Python
158 lines
7.1 KiB
Python
"""Configuration and pure request policy, independent of audio hardware."""
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Settings:
|
|
model: str = "qwen3.5:latest"
|
|
ollama_url: str = "http://127.0.0.1:11434"
|
|
stt_model: str = "mlx-community/nemotron-3.5-asr-streaming-0.6b-8bit"
|
|
tts_language: str = "german"
|
|
voice: str = "alba"
|
|
port: int = 7860
|
|
context_tokens: int = 4096
|
|
tailnet_host: str | None = None
|
|
comfyui_url: str = "http://100.125.107.123:8000"
|
|
|
|
@classmethod
|
|
def from_env(cls):
|
|
return cls(
|
|
model=os.getenv("ASTRA_MODEL", cls.model),
|
|
ollama_url=os.getenv("ASTRA_OLLAMA_URL", cls.ollama_url).rstrip("/"),
|
|
stt_model=os.getenv("ASTRA_STT_MODEL", cls.stt_model),
|
|
tts_language=os.getenv("ASTRA_TTS_LANGUAGE", cls.tts_language),
|
|
voice=os.getenv("ASTRA_VOICE", cls.voice),
|
|
port=int(os.getenv("ASTRA_PORT", cls.port)),
|
|
tailnet_host=os.getenv("ASTRA_TAILNET_HOST", cls.tailnet_host),
|
|
comfyui_url=os.getenv("ASTRA_COMFYUI_URL", cls.comfyui_url).rstrip("/"),
|
|
)
|
|
|
|
|
|
VOICES = (
|
|
{"name": "alba", "display_name": "Alba", "gender": "weiblich"},
|
|
{"name": "anna", "display_name": "Anna", "gender": "weiblich"},
|
|
{"name": "azelma", "display_name": "Azelma", "gender": "weiblich"},
|
|
{"name": "bill_boerst", "display_name": "Bill Boerst", "gender": "männlich"},
|
|
{"name": "caro_davy", "display_name": "Caro Davy", "gender": "weiblich"},
|
|
{"name": "charles", "display_name": "Charles", "gender": "männlich"},
|
|
{"name": "cosette", "display_name": "Cosette", "gender": "weiblich"},
|
|
{"name": "eponine", "display_name": "Eponine", "gender": "weiblich"},
|
|
{"name": "estelle", "display_name": "Estelle", "gender": "weiblich"},
|
|
{"name": "eve", "display_name": "Eve", "gender": "weiblich"},
|
|
{"name": "fantine", "display_name": "Fantine", "gender": "weiblich"},
|
|
{"name": "george", "display_name": "George", "gender": "männlich"},
|
|
{"name": "giovanni", "display_name": "Giovanni", "gender": "männlich"},
|
|
{"name": "jane", "display_name": "Jane", "gender": "weiblich"},
|
|
{"name": "javert", "display_name": "Javert", "gender": "männlich"},
|
|
{"name": "jean", "display_name": "Jean", "gender": "männlich"},
|
|
{"name": "juergen", "display_name": "Juergen", "gender": "männlich"},
|
|
{"name": "lola", "display_name": "Lola", "gender": "weiblich"},
|
|
{"name": "marius", "display_name": "Marius", "gender": "männlich"},
|
|
{"name": "mary", "display_name": "Mary", "gender": "weiblich"},
|
|
{"name": "michael", "display_name": "Michael", "gender": "männlich"},
|
|
{"name": "paul", "display_name": "Paul", "gender": "männlich"},
|
|
{"name": "peter_yearsley", "display_name": "Peter Yearsley", "gender": "männlich"},
|
|
{"name": "rafael", "display_name": "Rafael", "gender": "männlich"},
|
|
{"name": "stuart_bell", "display_name": "Stuart Bell", "gender": "männlich"},
|
|
{"name": "vera", "display_name": "Vera", "gender": "weiblich"},
|
|
)
|
|
VOICE_NAMES = frozenset(voice["name"] for voice in VOICES)
|
|
|
|
|
|
SYSTEM_PROMPT = (
|
|
"Du bist Astra, ein freundlicher deutschsprachiger Gesprächsassistent. "
|
|
"Antworte natürlich und knapp, normalerweise in ein bis drei kurzen Sätzen. "
|
|
"Deine Antwort wird vorgelesen: kein Markdown, keine Sternchen, keine Listen. "
|
|
"Sprich Zahlen und Abkürzungen verständlich aus. "
|
|
"Beende Antworten normalerweise mit einer Aussage, nicht mit einer Frage. "
|
|
"Stelle nur dann eine Rückfrage, wenn dir für die aktuelle Aufgabe eine konkrete "
|
|
"Information wirklich fehlt. Häng keine Angebotsfragen wie 'Möchtest du...?', "
|
|
"'Soll ich...?' oder 'Gefällt dir...?' an, wenn niemand danach gefragt hat. "
|
|
"Nutze verfügbare Werkzeuge, wenn sie zur Anfrage passen, statt Inhalte selbst zu erfinden "
|
|
"oder nur anzukündigen, dass du etwas tun wirst. Du hast sonst keinen Internetzugang und "
|
|
"keinen Zugriff auf Dateien oder Apps. Behaupte nicht, andere Aktionen ausgeführt zu haben. "
|
|
"Wenn der Nutzer ein Bild, ein Dokument oder ein PDF hochlädt, geht dessen Inhalt oder "
|
|
"eine Textzusammenfassung als Nachricht in dieses Gespräch ein."
|
|
)
|
|
|
|
|
|
def trim_messages(messages: list[dict], max_chars: int = 10000) -> list[dict]:
|
|
"""Retain recent whole turns within a conservative context character budget.
|
|
|
|
Preserves tool round-trips (assistant tool_calls + matching tool result)
|
|
and vision attachments (an "images" field) intact instead of reducing
|
|
every kept message down to a bare role/content pair.
|
|
"""
|
|
system = [dict(m) for m in messages if m["role"] == "system"][:1]
|
|
if system:
|
|
system[0]["content"] = system[0]["content"][: max_chars // 2]
|
|
budget = max_chars - sum(len(m["content"]) for m in system)
|
|
turns = []
|
|
for message in reversed(messages):
|
|
if message["role"] not in ("user", "assistant", "tool"):
|
|
continue
|
|
content = message.get("content")
|
|
text = content if isinstance(content, str) else ""
|
|
if not text and not message.get("tool_calls") and not message.get("images"):
|
|
continue
|
|
if len(text) > budget:
|
|
if not turns:
|
|
turns.append({**message, "content": text[-budget:]})
|
|
break
|
|
turns.append(message)
|
|
budget -= len(text)
|
|
turns.reverse()
|
|
while turns and turns[0]["role"] != "user":
|
|
turns.pop(0)
|
|
return system + turns
|
|
|
|
|
|
def normalize_tool_calls(messages: list[dict]) -> list[dict]:
|
|
"""Ollama's native /api/chat rejects tool_calls whose function.arguments
|
|
is a JSON string (400 "Value looks like object..."); it wants an object.
|
|
Pipecat's context stores tool_calls OpenAI-style, arguments as a string,
|
|
so every request has to convert it back before it reaches Ollama.
|
|
"""
|
|
normalized = []
|
|
for message in messages:
|
|
tool_calls = message.get("tool_calls")
|
|
if not tool_calls:
|
|
normalized.append(message)
|
|
continue
|
|
new_calls = []
|
|
for call in tool_calls:
|
|
function = call.get("function", {})
|
|
arguments = function.get("arguments")
|
|
if isinstance(arguments, str):
|
|
try:
|
|
arguments = json.loads(arguments)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
new_calls.append({**call, "function": {**function, "arguments": arguments}})
|
|
normalized.append({**message, "tool_calls": new_calls})
|
|
return normalized
|
|
|
|
|
|
def build_request(settings: Settings, messages: list[dict]) -> dict:
|
|
return {
|
|
"model": settings.model,
|
|
"messages": normalize_tool_calls(trim_messages(messages)),
|
|
"think": False,
|
|
"stream": True,
|
|
"keep_alive": -1,
|
|
"options": {
|
|
"num_ctx": settings.context_tokens,
|
|
"num_predict": 256,
|
|
"temperature": 0.4,
|
|
},
|
|
}
|
|
|
|
|
|
def local_origin_allowed(origin: str, port: int = 7860, tailnet_host: str | None = None) -> bool:
|
|
allowed = {f"http://localhost:{port}", f"http://127.0.0.1:{port}"}
|
|
if tailnet_host:
|
|
allowed.add(f"https://{tailnet_host}")
|
|
return origin in allowed
|