Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b27fba316b | |||
| 648698d202 | |||
| 3e88eecd9c | |||
| e33d1c782f | |||
| 1568c25ac4 | |||
| 97ee455ab4 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20000
|
||||
versionName "0.2.0.0"
|
||||
versionCode 20001
|
||||
versionName "0.2.0.1"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.0.0",
|
||||
"version": "0.2.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict, deque
|
||||
from pathlib import Path
|
||||
|
||||
@@ -32,13 +33,30 @@ logger = logging.getLogger("aria.migrate.backfill_projectid")
|
||||
|
||||
CONVERSATION_FILE = Path(os.environ.get("CONVERSATION_FILE", "/data/conversation.jsonl"))
|
||||
CHAT_BACKUP_FILE = Path(os.environ.get("CHAT_BACKUP_FILE", "/shared/config/chat_backup.jsonl"))
|
||||
MARKER_FILE = Path("/shared/config/.chat_backup_projectid_backfill_v1")
|
||||
# v2: robusterer Match (Marker-Strip + Praefix). v1 verlangte exakte Gleichheit
|
||||
# von text==content und verfehlte damit alle Nachrichten bei denen die Bridge
|
||||
# den Brain-Text anreichert (GPS/Barge-In-Hints prepended) oder cleant
|
||||
# (FILE-Marker entfernt). Neuer Marker → laeuft einmal neu, fuellt die Luecken.
|
||||
MARKER_FILE = Path("/shared/config/.chat_backup_projectid_backfill_v2")
|
||||
|
||||
# _build_core_text (Bridge) PREPENDT bei User-Nachrichten Hinweis-/GPS-Bloecke
|
||||
# in eckigen Klammern vor den eigentlichen Text; conversation.jsonl speichert
|
||||
# diesen angereicherten Text, chat_backup nur den rohen. FILE-Marker stehen in
|
||||
# conversation-Assistant-Turns, sind in chat_backup aber schon rausgecleant.
|
||||
_FILE_MARKER_RE = re.compile(r"\[FILE:\s*/shared/uploads/[^\]]+\]", re.IGNORECASE)
|
||||
_LEADING_BRACKET_RE = re.compile(r"^\s*(?:\[[^\]]*\]\s*)+")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
"""Match-Key: getrimmt + auf 500 Zeichen begrenzt. Reicht um Turns eindeutig
|
||||
zu unterscheiden, ist aber tolerant gegen minimale Trailing-Unterschiede."""
|
||||
return (text or "").strip()[:500]
|
||||
"""Match-Key: FILE-Marker + fuehrende [Hinweis]/[GPS]-Bloecke entfernen,
|
||||
Whitespace kollabieren, auf 120-Zeichen-Praefix kuerzen. Toleriert damit
|
||||
die Anreicherungs-/Cleaning-Unterschiede zwischen conversation und backup,
|
||||
bleibt durch den 120er-Praefix aber spezifisch genug gegen Fehl-Matches."""
|
||||
t = _FILE_MARKER_RE.sub("", text or "")
|
||||
t = _LEADING_BRACKET_RE.sub("", t)
|
||||
t = _WS_RE.sub(" ", t).strip()
|
||||
return t[:120]
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
@@ -112,7 +130,7 @@ def run() -> dict:
|
||||
|
||||
# 4) Sicherung + atomarer Rewrite.
|
||||
try:
|
||||
bak = CHAT_BACKUP_FILE.with_suffix(".jsonl.pre-backfill-v1.bak")
|
||||
bak = CHAT_BACKUP_FILE.with_suffix(".jsonl.pre-backfill-v2.bak")
|
||||
if not bak.exists():
|
||||
bak.write_bytes(CHAT_BACKUP_FILE.read_bytes())
|
||||
tmp = CHAT_BACKUP_FILE.with_suffix(".jsonl.tmp")
|
||||
|
||||
+22
-2
@@ -1228,6 +1228,10 @@ class ARIABridge:
|
||||
server_path = f.get("serverPath")
|
||||
if turn_pid and server_path:
|
||||
self._tag_file_to_project(server_path, turn_pid)
|
||||
# projectId mitschicken, damit App+Diagnostic die Datei-Bubble dem
|
||||
# richtigen Kontext zuordnen (sonst faellt sie im Diagnostic-Focus-
|
||||
# Filter durch = wird nur im Hauptchat angezeigt).
|
||||
f["projectId"] = turn_pid
|
||||
await self._broadcast_aria_file(f)
|
||||
# Bei fehlenden Files: User informieren (sonst sieht er nur stille
|
||||
# Verluste — ARIA hat den Marker hingeschrieben aber das File nicht
|
||||
@@ -2807,9 +2811,25 @@ class ARIABridge:
|
||||
return
|
||||
|
||||
elif msg_type == "stt_stream_end":
|
||||
# Session vorbei — Focus-Tracking fuer diese requestId aufraeumen.
|
||||
# Session vorbei — ABER nicht sofort aufraeumen: nach einem manuellen
|
||||
# Stop folgt noch der finale stt_endpoint (Whisper-Final-Transcribe),
|
||||
# der die Focus-projectId aus dieser Registry braucht. Wuerden wir hier
|
||||
# sofort poppen, bekaeme der Endpoint "" → die Nachricht (und ARIA's
|
||||
# Antwort) landet im Hauptchat statt im fokussierten Projekt. Der
|
||||
# stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup
|
||||
# als Leak-Schutz, falls gar kein Endpoint mehr kommt.
|
||||
req_id = payload.get("requestId", "") or ""
|
||||
self._stt_stream_projects.pop(req_id, None)
|
||||
if req_id:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.call_later(
|
||||
20.0,
|
||||
lambda rid=req_id: self._stt_stream_projects.pop(rid, None),
|
||||
)
|
||||
except Exception:
|
||||
# Kein laufender Loop (sollte im ws-Handler nie passieren) —
|
||||
# dann lieber gar nicht aufraeumen als crashen.
|
||||
pass
|
||||
return
|
||||
|
||||
elif msg_type == "stt_endpoint":
|
||||
|
||||
+18
-1
@@ -1910,7 +1910,7 @@
|
||||
const m = msg.messages[mi];
|
||||
try {
|
||||
if (m.type === 'aria_file') {
|
||||
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted });
|
||||
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted, projectId: m.projectId });
|
||||
continue;
|
||||
}
|
||||
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
|
||||
@@ -1928,6 +1928,11 @@
|
||||
const el = document.createElement('div');
|
||||
el.className = `chat-msg ${m.type}`;
|
||||
if (m.ts) el.dataset.ts = String(m.ts);
|
||||
// Multi-Threading: Kontext-Zuordnung fuer den Focus-Filter.
|
||||
// Ohne das landete beim Reload JEDE Bubble im Hauptchat
|
||||
// (dataset.projectId undefined → '' → nur bei Hauptchat-Focus
|
||||
// sichtbar), Projekte blieben leer.
|
||||
el.dataset.projectId = m.projectId || '';
|
||||
el.innerHTML = innerHtml;
|
||||
b.appendChild(el);
|
||||
}
|
||||
@@ -1949,6 +1954,9 @@
|
||||
}
|
||||
for (const b of boxes) b.scrollTop = b.scrollHeight;
|
||||
}
|
||||
// Nach dem Neuaufbau den aktuellen Kontext-Focus anwenden: Bubbles
|
||||
// die nicht zum fokussierten Projekt gehoeren ausblenden.
|
||||
updateChatVisibilityByFocus();
|
||||
if (errorCount > 0) {
|
||||
console.warn(`chat_history: ${errorCount} Bubble(s) konnten nicht gerendert werden`);
|
||||
}
|
||||
@@ -2335,6 +2343,15 @@
|
||||
const el = document.createElement('div');
|
||||
el.className = 'chat-msg received';
|
||||
el.dataset.ariaFilePath = serverPath;
|
||||
// Kontext-Zuordnung fuer den Focus-Filter — ohne das wurde die
|
||||
// Datei-Bubble beim Reload ausgeblendet wenn ein Projekt fokussiert war.
|
||||
const filePid = p.projectId || '';
|
||||
el.dataset.projectId = filePid;
|
||||
// Beim Live-Anhaengen den aktuellen Focus respektieren (wie addChat),
|
||||
// sonst blitzt eine Projekt-Datei kurz im Hauptchat auf.
|
||||
if (typeof focusedContextId === 'string' && filePid !== focusedContextId) {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
if (deleted) el.dataset.deleted = '1';
|
||||
el.innerHTML = html;
|
||||
box.appendChild(el);
|
||||
|
||||
@@ -2746,8 +2746,9 @@ async function handleLoadChatHistory(clientWs) {
|
||||
if (obj.role !== "user" && obj.role !== "assistant") continue;
|
||||
const ts = obj.ts || 0;
|
||||
const text = String(obj.text || "");
|
||||
const projectId = String(obj.project_id || ""); // Multi-Threading: Kontext-Zuordnung
|
||||
if (obj.role === "user") {
|
||||
if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts });
|
||||
if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts, projectId });
|
||||
continue;
|
||||
}
|
||||
// assistant: nach FILE-Markern scannen, eigene aria_file-Eintraege pro Datei
|
||||
@@ -2769,9 +2770,10 @@ async function handleLoadChatHistory(clientWs) {
|
||||
size,
|
||||
ts,
|
||||
deleted: wasDeleted || !exists,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
if (text) messages.push({ type: "received", text, meta: "chat:final", ts });
|
||||
if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId });
|
||||
}
|
||||
|
||||
clientWs.send(JSON.stringify({ type: "chat_history", messages }));
|
||||
|
||||
+61
-12
@@ -69,6 +69,18 @@ STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
|
||||
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
|
||||
STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
|
||||
|
||||
# Akustisches Endpointing (ergaenzt die rein-semantische Stagnation).
|
||||
# Motivation: der reine „Transkript waechst nicht mehr"-Endpoint feuert zu
|
||||
# frueh (kurze Sprech-Pausen, beam_size=1-Instabilitaet) oder gar nicht
|
||||
# (Whisper oszilliert/halluziniert). Echte akustische Stille ist das robuste
|
||||
# „User hat aufgehoert"-Signal.
|
||||
STREAM_ENERGY_WINDOW_MS = 300 # RMS ueber die letzten 300ms Audio messen
|
||||
STREAM_VOICE_RMS_THRESHOLD = 0.012 # RMS darueber = Sprache (haelt Session am Leben)
|
||||
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
|
||||
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
|
||||
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
|
||||
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
|
||||
|
||||
|
||||
class WhisperRunner:
|
||||
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
|
||||
@@ -310,6 +322,7 @@ class StreamSession:
|
||||
last_partial: str = ""
|
||||
last_growth_at: float = 0.0
|
||||
last_transcribe_at: float = 0.0
|
||||
last_voice_at: float = 0.0 # letzter Tick mit akustischer Sprach-Energie
|
||||
closed: bool = False # nach stream_end gesetzt
|
||||
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
||||
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
||||
@@ -533,6 +546,35 @@ class SessionManager:
|
||||
if audio_ms < STREAM_MIN_AUDIO_MS:
|
||||
return
|
||||
|
||||
# Akustische Sprach-Aktivitaet JEDEN Tick (~200ms) messen — unabhaengig
|
||||
# vom Transcribe-Throttle. Solange wirklich gesprochen wird, bleibt die
|
||||
# Session am Leben, auch wenn Whisper gerade keinen neuen Text liefert.
|
||||
if self._tail_rms(sess) >= STREAM_VOICE_RMS_THRESHOLD:
|
||||
sess.last_voice_at = now
|
||||
|
||||
# Endpoint-Entscheidung JEDEN Tick, sobald ueberhaupt Text erkannt wurde:
|
||||
# (a) akustisch: seit endpoint_ms keine Sprach-Energie mehr → User ist
|
||||
# fertig. Das ist der robuste Primaerpfad gegen „hoert nach zwei
|
||||
# Worten auf" (waehrend echten Sprechens ist Energie da → kein Cut).
|
||||
# (b) semantisch (Backstop): Transkript stagniert deutlich laenger als
|
||||
# endpoint_ms — fuer laute Umgebungen wo die Energie nie faellt.
|
||||
if sess.last_growth_at > 0.0 and not sess.endpoint_sent:
|
||||
acoustic_silence_ms = (now - sess.last_voice_at) * 1000.0 if sess.last_voice_at > 0 else 0.0
|
||||
semantic_silence_ms = (now - sess.last_growth_at) * 1000.0
|
||||
acoustic_done = sess.last_voice_at > 0 and acoustic_silence_ms >= sess.endpoint_ms
|
||||
semantic_done = semantic_silence_ms >= sess.endpoint_ms * STREAM_SEMANTIC_BACKUP_FACTOR
|
||||
if acoustic_done or semantic_done:
|
||||
logger.info(
|
||||
"Stream %s: Endpoint (%s) — akustisch %dms / semantisch %dms — Text=%r",
|
||||
sess.request_id[:8],
|
||||
"akustisch" if acoustic_done else "semantisch",
|
||||
int(acoustic_silence_ms), int(semantic_silence_ms),
|
||||
sess.last_partial[:80],
|
||||
)
|
||||
await self._finalize(sess, ws,
|
||||
reason="endpoint" if acoustic_done else "endpoint_semantic")
|
||||
return
|
||||
|
||||
# Transcribe-Throttling
|
||||
since_last = (now - sess.last_transcribe_at) * 1000.0
|
||||
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
|
||||
@@ -568,18 +610,8 @@ class SessionManager:
|
||||
})
|
||||
await _debug_log(ws, "stream.partial",
|
||||
f"id={sess.request_id[:12]} text={text[:80]!r}")
|
||||
else:
|
||||
# Stagnation pruefen — Endpoint-Bedingung
|
||||
if sess.last_growth_at == 0.0:
|
||||
# Noch gar kein Text erkannt. Wenn der User gar nichts sagt
|
||||
# springt Brain irgendwann aus eigenem Conversation-Window-
|
||||
# Timeout in der App raus; wir machen hier nix.
|
||||
return
|
||||
silence_ms = (now - sess.last_growth_at) * 1000.0
|
||||
if silence_ms >= sess.endpoint_ms and not sess.endpoint_sent:
|
||||
logger.info("Stream %s: Endpoint nach %dms ohne neuen Text — Text=%r",
|
||||
sess.request_id[:8], int(silence_ms), sess.last_partial[:80])
|
||||
await self._finalize(sess, ws, reason="endpoint")
|
||||
# else: kein neuer Text — die Endpoint-Entscheidung (akustisch +
|
||||
# semantischer Backstop) laeuft oben pro Tick, hier nichts mehr zu tun.
|
||||
|
||||
def _buffer_duration_ms(self, sess: StreamSession) -> float:
|
||||
# 16-bit s16le mono → 2 bytes pro Sample
|
||||
@@ -588,6 +620,23 @@ class SessionManager:
|
||||
return 0.0
|
||||
return (samples / sess.sample_rate) * 1000.0
|
||||
|
||||
def _tail_rms(self, sess: StreamSession) -> float:
|
||||
"""RMS-Energie der letzten STREAM_ENERGY_WINDOW_MS des Audio-Buffers.
|
||||
Dient als akustisches „redet noch / ist still"-Signal."""
|
||||
win_bytes = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2
|
||||
if win_bytes <= 0:
|
||||
return 0.0
|
||||
tail = sess.pcm_buffer[-win_bytes:]
|
||||
if len(tail) < 2:
|
||||
return 0.0
|
||||
try:
|
||||
arr = pcm_s16le_to_float32(bytes(tail))
|
||||
except Exception:
|
||||
return 0.0
|
||||
if arr.size == 0:
|
||||
return 0.0
|
||||
return float(np.sqrt(np.mean(arr * arr)))
|
||||
|
||||
async def _finalize(self, sess: StreamSession, ws, reason: str) -> None:
|
||||
"""Endgueltige Transkription auf dem vollen Buffer (beam_size=5),
|
||||
feuert stt_endpoint + stt_stream_done, droppt Session."""
|
||||
|
||||
Reference in New Issue
Block a user