v2.2 - Small improvement Interface change

This commit is contained in:
pporcheret
2026-10-29 11:13:02 +01:00
parent a17c3cb614
commit 7f26f9b015
271 changed files with 6207 additions and 16044 deletions
+45
View File
@@ -87,6 +87,27 @@ DEFAULTS.update({
from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health
# ALFRED_DUPLEX_BARGE_IN_V1: values saved with existing /config API.
FIELDS.update({
'DUPLEX_MODE': ('general', 'duplex_mode'),
'DUPLEX_MIC_THRESHOLD': ('general', 'duplex_threshold'),
})
DEFAULTS.update({'DUPLEX_MODE': 'wake', 'DUPLEX_MIC_THRESHOLD': '0.025'})
# ALFRED_DUPLEX_CONVERSATION_V2: configurable from the existing authenticated /config page.
FIELDS.update({
'DUPLEX_STOP_COMMANDS': ('general', 'boolean'),
'DUPLEX_HANDOFF': ('general', 'boolean'),
'DUPLEX_CONVERSATION': ('general', 'boolean'),
'DUPLEX_CONVERSATION_SECONDS': ('general', 'duplex_seconds'),
})
DEFAULTS.update({
'DUPLEX_STOP_COMMANDS': 'true',
'DUPLEX_HANDOFF': 'true',
'DUPLEX_CONVERSATION': 'true',
'DUPLEX_CONVERSATION_SECONDS': '6',
})
def _auth_config():
try:
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
@@ -137,6 +158,30 @@ def _validate(key, value):
if key not in FIELDS:
raise ValueError(f'Paramètre non autorisé : {key}.')
kind = FIELDS[key][1]
# ALFRED_DUPLEX_BARGE_IN_V1
# ALFRED_DUPLEX_CONVERSATION_V2
if kind == 'duplex_seconds':
try:
seconds = int(value)
except (ValueError, TypeError, OverflowError):
raise ValueError('Duree de conversation invalide.')
if isinstance(value, bool) or not 2 <= seconds <= 15:
raise ValueError('Duree de conversation : de 2 a 15 secondes.')
return str(seconds)
if kind == 'duplex_mode':
if value not in ('off', 'wake', 'natural'):
raise ValueError('Mode duplex invalide.')
return value
if kind == 'duplex_threshold':
if isinstance(value, bool):
raise ValueError('Seuil microphone invalide.')
try:
threshold = float(value)
except (ValueError, TypeError, OverflowError):
raise ValueError('Seuil microphone invalide.')
if not 0.008 <= threshold <= 0.30 or not __import__('math').isfinite(threshold):
raise ValueError('Seuil microphone compris entre 0.008 et 0.30.')
return f'{threshold:.3f}'
if kind == 'boolean':
if value not in (True, False, 'true', 'false'):
raise ValueError(f'{key} doit être vrai ou faux.')
@@ -0,0 +1,391 @@
"""Alfred Developer: safe source staging, review and controlled local deployment.
Only explicitly-allowed source extensions are staged. No generated code is
executed by the MCP. Approving a proposal never deploys it.
"""
from __future__ import annotations
import ast
from datetime import datetime, timezone
from html.parser import HTMLParser
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import subprocess
import tempfile
import threading
import uuid
ROOT = Path(os.environ.get('ALFRED_DEV_PRODUCTION', '/opt/alfred/app')).resolve()
DATA = Path(os.environ.get('ALFRED_DEV_WORKSPACE', '/opt/alfred-dev')).resolve()
BACKUPS = Path(os.environ.get('ALFRED_DEV_BACKUPS', '/opt/alfred-backups/developer')).resolve()
LOCK = threading.RLock()
MAX_FILES = 400
MAX_FILE_BYTES = 300_000
MAX_WRITE_BYTES = 180_000
MAX_DIFF_CHARS = 160_000
ALLOWED = {
'templates': {'.html'},
'static/css': {'.css'},
'static/js': {'.js'},
'services': {'.py'},
}
BLOCKED = {'services/admin_console.py','services/alfred_developer_core.py','services/alfred_developer_page.py'}
ID_RE = re.compile(r'^[a-f0-9]{12}$')
def _digest(data):
return hashlib.sha256(data).hexdigest()
def _utc():
return datetime.now(timezone.utc).isoformat(timespec='seconds')
def _validate_id(pid):
if not isinstance(pid, str) or not ID_RE.fullmatch(pid):
raise ValueError('Identifiant de proposition invalide.')
return pid
def _rel(path):
if not isinstance(path, str) or len(path)>180 or '\\' in path or '\x00' in path:
raise ValueError('Chemin de fichier invalide.')
p = PurePosixPath(path)
parts = p.parts
if not parts or p.is_absolute() or any(x in ('..','.','') or x.startswith('.') for x in parts):
raise ValueError('Chemin non autorisé.')
rel = p.as_posix()
if rel in BLOCKED:
raise ValueError('Fichier protégé : '+rel)
if p.name.endswith(('.min.js','.map','.pyc')):
raise ValueError('Fichier généré non autorisé.')
for base, suffixes in ALLOWED.items():
if rel.startswith(base+'/') and len(parts)>=2 and p.suffix in suffixes:
return rel
if rel == 'app.py':
return rel
raise ValueError('Chemin ou extension interdits : '+rel)
def _safe_file(base, rel):
"""Reject symlinks at every level, including the workspace file."""
here = base
for part in PurePosixPath(rel).parts:
here = here/part
if here.is_symlink():
raise ValueError('Lien symbolique interdit : '+rel)
if here.exists() and not here.is_file():
raise ValueError('Fichier non régulier : '+rel)
return here
def _workspace(pid):
return DATA/_validate_id(pid)
def _meta_path(pid):
return _workspace(pid)/'proposal.json'
def _load(pid):
path = _meta_path(pid)
if not path.is_file() or path.is_symlink():
raise ValueError('Proposition introuvable.')
return json.loads(path.read_text(encoding='utf-8'))
def _atomic(path, content, mode=0o600):
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix='.alfdev-',dir=str(path.parent))
try:
with os.fdopen(fd,'wb') as f:
f.write(content);f.flush();os.fsync(f.fileno())
os.chmod(tmp, mode)
os.replace(tmp, path)
finally:
if os.path.exists(tmp):os.unlink(tmp)
def _save(pid, meta):
_atomic(_meta_path(pid),json.dumps(meta, ensure_ascii=False,indent=2).encode())
def _git(wd, args, timeout=12):
env={**os.environ,'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_OPTIONAL_LOCKS':'0', 'GIT_TERMINAL_PROMPT':'0'}
cmd=['git','-c','core.hooksPath=/dev/null','-c','core.autocrlf=false','-c','user.name=Alfred Developer','-c','user.email=dev@localhost',*args]
p=subprocess.run(cmd,cwd=wd,env=env,stdin=subprocess.DEVNULL,capture_output=True,text=True,timeout=timeout,check=False)
if p.returncode:raise RuntimeError('Git : '+p.stderr.strip()[:500])
return p.stdout
def create_task(request_text):
if not isinstance(request_text,str) or not 5<=len(request_text.strip())<=2000:
raise ValueError('Décris la modification (5 à 2000 caractères).')
with LOCK:
pid=uuid.uuid4().hex[:12]
wd=_workspace(pid)
wd.mkdir(parents=True,mode=0o700,exist_ok=False)
source=wd/'source';source.mkdir(mode=0o700)
base={}
try:
paths=[]
for basepath, endings in ALLOWED.items():
d=ROOT/basepath
if not d.is_dir() or d.is_symlink():continue
for p in d.rglob('*'):
if p.is_symlink() or not p.is_file():continue
rel=p.relative_to(ROOT).as_posix()
try:_rel(rel)
except ValueError:continue
if p.stat().st_size>MAX_FILE_BYTES:continue
paths.append(rel)
if (ROOT/'app.py').is_file() and not (ROOT/'app.py').is_symlink():
paths.append('app.py')
if len(paths)>MAX_FILES:raise ValueError('Trop de fichiers pour cette V1.')
for rel in sorted(set(paths)):
p=_safe_file(ROOT,rel)
content=p.read_bytes()
if len(content)>MAX_FILE_BYTES:continue
target=_safe_file(source,rel)
target.parent.mkdir(parents=True, exist_ok=True)
_atomic(target,content,0o600)
base[rel]={'sha256':_digest(content),'mode':p.stat().st_mode & 0o777}
if not base:raise ValueError('Aucun fichier source à préparer.')
_git(source,['init','-q'])
_git(source,['add','--','.'])
_git(source,['commit','-q','-m','Base de travail (copie Alfred)'])
meta={'id':pid,'title':request_text.strip(),'created_at':_utc(), 'status':'editing',
'base':base,'approved_hash':None,'checks':None,'changed':[], 'events':[{'time':_utc(),'event':'Copie Git préparée'}]}
_save(pid,meta)
return {'id':pid,'status':'editing','files':len(base),'workspace':str(source),'note':'Copie de sources uniquement. Aucun secret ni donnée runtime copié volontairement.'}
except Exception:
shutil.rmtree(wd,ignore_errors=True)
raise
def list_source(pid, prefix=''):
_load(pid)
p=str(prefix or '')
return [x for x in sorted(_load(pid)['base']) if x.startswith(p)][:250]
def read_source(pid, rel):
meta=_load(pid); rel=_rel(rel)
if rel not in meta['base']:raise ValueError('Ce fichier n’est pas présent dans la copie.')
path=_safe_file(_workspace(pid)/'source',rel)
if not path.is_file():raise ValueError('Fichier absent.')
return {'path':rel,'text':path.read_text(encoding='utf-8'),'sha256':_digest(path.read_bytes())}
def change_source(pid, rel, content):
if not isinstance(content,str) or len(content.encode('utf-8'))>MAX_WRITE_BYTES or '\x00' in content:
raise ValueError('Contenu invalide ou supérieur à 180 Ko.')
rel=_rel(rel)
with LOCK:
meta=_load(pid)
if meta['status'] not in ('editing','tested'):raise ValueError('Proposition verrouillée ; créer une nouvelle tâche.')
if rel not in meta['base']:
# New files allowed under the same strict allowlist, not replacements of secrets.
if len(meta['base'])>=MAX_FILES:raise ValueError('Limite de fichiers atteinte.')
if _safe_file(ROOT,rel).exists():raise ValueError('Fichier présent en production : création refusée.')
meta['base'][rel]={'sha256':None,'mode':0o644}
target=_safe_file(_workspace(pid)/'source',rel)
_atomic(target,content.encode('utf-8'))
meta['status']='editing'
meta['checks']=None
meta['changed']=[]
meta['diff_sha256']=None
_save(pid,meta)
return {'ok':True,'path':rel,'bytes':len(content.encode('utf-8')),'note':'Modification dans la copie uniquement.'}
def _diff(pid):
meta=_load(pid); wd=_workspace(pid)/'source'
diffs=[];changed=[]; all_ok=True
for rel in sorted(meta['base']):
orig=meta['base'][rel]['sha256']
p=_safe_file(wd,rel)
if not p.exists():
raise ValueError('Suppression de fichier non prise en charge : '+rel)
data=p.read_bytes()
if len(data)>MAX_WRITE_BYTES and _digest(data)!=orig:
raise ValueError('Fichier modifié trop volumineux : '+rel)
if _digest(data)==orig:continue
changed.append(rel)
# Git diff --no-index avoids untracked file omission. Python stdlib difflib
import difflib
old=(_git(wd,['show','HEAD:'+rel]) if orig is not None else '')
new=data.decode('utf-8')
diffs.extend(difflib.unified_diff(old.splitlines(True),new.splitlines(True),fromfile='avant/'+rel,tofile='apres/'+rel,n=3))
diff=''.join(diffs)
if len(diff)>MAX_DIFF_CHARS:raise ValueError('Diff trop grand pour revue Web.')
return changed,diff
class _HTMLCheck(HTMLParser):
pass
def run_checks(pid):
with LOCK:
meta=_load(pid)
if meta['status'] not in ('editing','tested'):raise ValueError('Proposition verrouillée.')
changed,diff=_diff(pid)
if not changed:raise ValueError('Aucune modification à tester.')
results=[]
for rel in changed:
path=_safe_file(_workspace(pid)/'source',rel)
content=path.read_text(encoding='utf-8')
try:
if rel.endswith('.py'):
ast.parse(content, filename=rel)
verdict='Syntaxe Python : OK (aucune exécution)'
elif rel.endswith('.js'):
if not shutil.which('node'):
raise ValueError('Node.js absent : vérification JS obligatoire avant approbation.')
process=subprocess.run(['node','--check','-'],input=content,text=True,capture_output=True,timeout=6)
if process.returncode:raise ValueError(process.stderr[:600])
verdict='Syntaxe JavaScript : OK (aucune exécution)'
elif rel.endswith('.html'):
parser=_HTMLCheck();parser.feed(content);parser.close()
verdict='Analyse HTML : OK (structure complète non garantie)'
elif rel.endswith('.css'):
if content.count('{')!=content.count('}'):
raise ValueError('Nombre d’accolades CSS incohérent.')
verdict='Contrôle CSS élémentaire : OK'
else:raise ValueError('Type non testé.')
results.append({'file':rel,'ok':True,'detail':verdict})
except (SyntaxError,ValueError,subprocess.TimeoutExpired) as exc:
results.append({'file':rel,'ok':False,'detail':str(exc)[:500]})
meta['checks']=results
meta['changed']=changed
meta['diff_sha256']=_digest(diff.encode())
meta['status']='tested' if all(x['ok'] for x in results) else 'editing'
meta['events'].append({'time':_utc(),'event':'Contrôles effectués'})
_save(pid,meta)
return {'status':meta['status'],'passed':all(x['ok'] for x in results),'checks':results,'changed':changed, 'warning':'Tests statiques uniquement ; aucune exécution du nouveau code ni validation visuelle automatisée.'}
def submit(pid):
with LOCK:
meta=_load(pid)
if meta['status']!='tested':raise ValueError('Exécute et réussis les contrôles avant de soumettre.')
changed,diff=_diff(pid)
if not changed or changed!=meta['changed'] or _digest(diff.encode())!=meta.get('diff_sha256'):
raise ValueError('Code modifié depuis les contrôles. Relancer les tests.')
meta['status']='pending';meta['events'].append({'time':_utc(),'event':'En attente de validation'});_save(pid,meta)
return {'id':pid,'status':'pending','changed':changed,'note':'Approbation nécessaire dans /maintenance.'}
def list_tasks():
if not DATA.exists():return []
out=[]
for wd in sorted(DATA.iterdir(),reverse=True):
if not wd.is_dir() or wd.is_symlink() or not ID_RE.fullmatch(wd.name):continue
try:
m=_load(wd.name)
out.append({'id':m['id'],'title':m['title'],'status':m['status'],'created_at':m['created_at'],'changed':m.get('changed',[])})
except (ValueError,KeyError,OSError,json.JSONDecodeError):pass
return out[:100]
def task_detail(pid):
meta=_load(pid)
changed,diff=_diff(pid)
return {'id':pid,'title':meta['title'],'status':meta['status'],'created_at':meta['created_at'],
'changed':changed,'diff':diff,'checks':meta.get('checks'),'events':meta.get('events',[]),
'approved_hash':meta.get('approved_hash'), 'diff_sha256':_digest(diff.encode('utf-8'))}
def approve(pid, decision, expected_hash):
if decision not in ('approve','reject'):raise ValueError('Décision inconnue.')
with LOCK:
meta=_load(pid)
if meta['status']!='pending':raise ValueError('Proposition non soumise ou déjà décidée.')
changed,diff=_diff(pid)
real_hash=_digest(diff.encode())
if not isinstance(expected_hash,str) or expected_hash!=real_hash or meta.get('diff_sha256')!=real_hash:
raise ValueError('Le code a changé, recharger la proposition.')
meta['status']='approved' if decision=='approve' else 'rejected'
meta['approved_hash']=real_hash if decision=='approve' else None
meta['events'].append({'time':_utc(),'event':'Validée par un administrateur' if decision=='approve' else 'Refusée'})
_save(pid,meta)
return {'id':pid,'status':meta['status'], 'note':'Approbation enregistrée. Déploiement CLI local uniquement.'}
def deploy(pid, *, apply=False):
"""Only called by local administrator CLI, never by MCP or Flask route."""
with LOCK:
meta=_load(pid)
if meta['status']!='approved':raise ValueError('Validation Web préalable requise.')
changed,diff=_diff(pid)
if not changed or meta.get('approved_hash')!=_digest(diff.encode()):
raise ValueError('Le correctif ne correspond plus à la version approuvée.')
for rel in changed:
original=_safe_file(ROOT,rel)
current=original.read_bytes() if original.exists() else None
current_hash=_digest(current) if current is not None else None
if current_hash!=meta['base'][rel]['sha256']:
raise ValueError('Production modifiée depuis la copie : '+rel)
staged=_safe_file(_workspace(pid)/'source',rel)
if not staged.is_file():raise ValueError('Fichier de stage absent : '+rel)
if not apply:
return {'ok':True,'dry_run':True,'changed':changed,'requires_restart':any(x.endswith('.py') for x in changed)}
BACKUPS.mkdir(parents=True,exist_ok=True)
bkp=BACKUPS/(pid+'-'+datetime.now().strftime('%Y%m%d_%H%M%S'))
bkp.mkdir(mode=0o700)
manifest={'id':pid,'files':{},'created_at':_utc()}
for rel in changed:
original=_safe_file(ROOT,rel)
current=original.read_bytes() if original.exists() else None
mode=original.stat().st_mode & 0o777 if current is not None else 0o644
manifest['files'][rel]={'existed':current is not None,'mode':mode,'before_sha256':_digest(current) if current is not None else None,
'after_sha256':_digest(_safe_file(_workspace(pid)/'source',rel).read_bytes())}
if current is not None:
dest=bkp/'original'/rel
_atomic(dest,current,0o600)
_atomic(bkp/'manifest.json',json.dumps(manifest,ensure_ascii=False,indent=2).encode())
done=[]
try:
for rel in changed:
path=_safe_file(ROOT,rel)
code=_safe_file(_workspace(pid)/'source',rel).read_bytes()
_atomic(path,code,manifest['files'][rel]['mode']);done.append(rel)
except BaseException:
for rel in reversed(done):
i=manifest['files'][rel];path=_safe_file(ROOT,rel)
if i['existed']:_atomic(path,(bkp/'original'/rel).read_bytes(),i['mode'])
elif path.exists():path.unlink()
raise
meta['status']='deployed'
meta['backup']=str(bkp)
meta['events'].append({'time':_utc(),'event':'Déployé depuis CLI administrateur'})
_save(pid,meta)
return {'ok':True,'changed':changed,'backup':str(bkp),'requires_restart':any(x.endswith('.py') for x in changed)}
def rollback(pid, *, apply=False):
with LOCK:
meta=_load(pid)
if meta['status']!='deployed':raise ValueError('Aucune version déployée à restaurer.')
bkp=Path(meta.get('backup',''))
if not bkp.is_dir() or bkp.is_symlink() or bkp.parent!=BACKUPS:
raise ValueError('Sauvegarde introuvable ou invalide.')
manifest=json.loads((bkp/'manifest.json').read_text())
for rel, info in manifest['files'].items():
rel=_rel(rel); current=_safe_file(ROOT,rel)
data=current.read_bytes() if current.exists() else None
if (_digest(data) if data is not None else None)!=info['after_sha256']:
raise ValueError('Fichier modifié depuis le déploiement : '+rel)
if not apply:return {'ok':True,'dry_run':True,'files':list(manifest['files'])}
for rel,info in manifest['files'].items():
path=_safe_file(ROOT,rel)
if info['existed']:_atomic(path,(bkp/'original'/rel).read_bytes(),info['mode'])
elif path.exists():path.unlink()
meta['status']='rolled_back';meta['events'].append({'time':_utc(),'event':'Retour arrière depuis CLI'})
_save(pid,meta)
return {'ok':True,'files':list(manifest['files'])}
@@ -0,0 +1,37 @@
"""Admin-only review UI for staged Alfred Developer proposals.
No web endpoint can deploy, restart, execute code or run shell commands.
"""
from flask import Blueprint, jsonify, render_template, request
from services import admin_console
from services import alfred_developer_core as dev
def register_developer_page(app):
bp=Blueprint('alfred_developer_ui',__name__)
@bp.get('/maintenance')
def maintenance_page():
return render_template('alfred_maintenance.html')
@bp.get('/api/control/developer/tasks')
@admin_console._require_admin
def tasks():
return jsonify(ok=True, tasks=dev.list_tasks())
@bp.get('/api/control/developer/tasks/<pid>')
@admin_console._require_admin
def detail(pid):
try:return jsonify(ok=True, task=dev.task_detail(pid))
except (ValueError,OSError,RuntimeError) as e:return jsonify(error=str(e)),400
@bp.post('/api/control/developer/tasks/<pid>/decision')
@admin_console._require_admin
def decide(pid):
data=request.get_json(silent=True) or {}
try:
return jsonify(ok=True, result=dev.approve(pid,data.get('decision'),data.get('diff_sha256')))
except (ValueError,OSError,RuntimeError) as e:
return jsonify(error=str(e)),400
app.register_blueprint(bp)
+365
View File
@@ -0,0 +1,365 @@
# ALFRED_DUPLEX_CONVERSATION_V2
"""Dona duplex V2: short stop commands, interrupted follow-ups and conversation window.
No acoustic echo cancellation is provided. A shared, echo-cancelled input device is
recommended. This code only stops Piper audio, never a Hermes task.
"""
from __future__ import annotations
import json
import os
import queue
import re
import threading
import time
import unicodedata
from collections import deque
def normalize(text):
text = unicodedata.normalize('NFKD', str(text or '').casefold())
return ' '.join(re.sub(r'[^a-z0-9 ]+', ' ', ''.join(c for c in text if not unicodedata.combining(c))).split())
STOP_PHRASES = ('stop', 'stoppe', 'arrete', 'arretes', 'silence', 'tais toi', 'taisez vous', 'chut')
LEAD_WORDS = ('attends', 'attendez', 'une seconde', 'minute')
def without_wake(text, wake):
text = normalize(text)
wake = normalize(wake)
if wake and (text == wake or text.startswith(wake + ' ')):
text = text[len(wake):].strip()
return text
def stop_only(text, wake=''):
cleaned = without_wake(text, wake)
return cleaned in STOP_PHRASES or cleaned in LEAD_WORDS
def clean_question(text, wake):
# Keep original spelling and technical names (Wazuh, Proxmox, LXC).
text = str(text or '').strip(' ,.!?\t\n')
if wake:
prefix = re.match(r'^' + re.escape(wake) + r'\b[\s,.:;!?-]*', text, re.I)
if prefix:
text = text[prefix.end():].strip()
for lead in STOP_PHRASES + LEAD_WORDS:
prefix = re.match(r'^' + re.escape(lead) + r'\b[\s,.:;!?-]+', text, re.I)
if prefix:
text = text[prefix.end():].strip()
break
return text if len(text) >= 3 and not stop_only(text, wake) else ''
def looks_like_echo(candidate, spoken):
candidate = normalize(candidate)
spoken = normalize(spoken)
if not candidate or not spoken:
return False
if candidate in spoken:
return True
cw, sw = candidate.split(), set(spoken.split())
return len(cw) >= 3 and sum(w in sw for w in cw) / len(cw) >= 0.85
def _bool(name, default):
return os.getenv(name, str(default)).strip().lower() in ('1', 'yes', 'true', 'on', 'oui')
def config():
mode = os.getenv('DUPLEX_MODE', 'wake').strip().lower()
if mode not in {'off', 'wake', 'natural'}:
mode = 'off'
try:
threshold = float(os.getenv('DUPLEX_MIC_THRESHOLD', '0.025'))
except (TypeError, ValueError):
threshold = 0.025
try:
seconds = int(os.getenv('DUPLEX_CONVERSATION_SECONDS', '6'))
except (TypeError, ValueError):
seconds = 6
return {'mode': mode, 'threshold': min(0.30, max(0.008, threshold)),
'wake_word': os.getenv('WAKE_WORD', 'alfred').strip().lower(),
'stop_commands': _bool('DUPLEX_STOP_COMMANDS', True),
'handoff': _bool('DUPLEX_HANDOFF', True),
'conversation': _bool('DUPLEX_CONVERSATION', True),
'conversation_seconds': min(15, max(2, seconds)),
'aec': 'not_provided',
'limitations': 'Pas d AEC: risque de faux positifs par le haut-parleur. A utiliser avec une source audio AEC pour le mode naturel.'}
def _rms(block):
import numpy as np
samples = np.frombuffer(block, dtype=np.int16).astype(np.float32)
return float(np.sqrt(np.mean(samples * samples)) / 32768.0) if samples.size else 0.0
def _stream(rate, device):
import sounddevice as sd
frames = max(320, rate // 10)
q = queue.Queue(maxsize=64)
def cb(indata, _frames, _time_info, _status):
try:
q.put_nowait(bytes(indata))
except queue.Full:
pass
return sd.RawInputStream(samplerate=rate, blocksize=frames,
dtype='int16', channels=1, device=device, callback=cb), q
def _vosk_recognizer(rate, *, wake_only=False, wake_word=''):
from vosk import KaldiRecognizer
from services.audio import get_vosk_model
model_path = os.getenv('WAKE_VOSK_MODEL_PATH') if wake_only else os.getenv('VOSK_MODEL_PATH')
if not model_path or not os.path.isfile(model_path):
raise RuntimeError('Modele Vosk requis introuvable : ' + str(model_path))
if wake_only:
words = [wake_word, *STOP_PHRASES, *LEAD_WORDS, '[unk]']
return KaldiRecognizer(get_vosk_model(model_path), rate,
json.dumps(list(dict.fromkeys(words)), ensure_ascii=False))
return KaldiRecognizer(get_vosk_model(model_path), rate)
def transcribe_capture(pcm, rate, *, fallback=''):
"""Uses Whisper for final text when enabled, and local Vosk otherwise."""
if not pcm:
return normalize(fallback)
if os.getenv('STT_ENGINE', 'vosk').strip().lower() in ('whisper', 'wyoming', 'wyoming_whisper'):
try:
from services.wyoming_stt import transcribe_pcm
result = transcribe_pcm(pcm,
host=os.getenv('WYOMING_WHISPER_HOST', '192.168.1.13'),
port=int(os.getenv('WYOMING_WHISPER_PORT', '10300')),
language=os.getenv('WYOMING_WHISPER_LANGUAGE', 'fr'),
sample_rate=rate, connect_timeout=3.0,
response_timeout=float(os.getenv('WYOMING_WHISPER_TIMEOUT', '20')))
if result:
return result.strip()
except Exception as exc:
print('[ALFRED DUPLEX V2] Whisper en secours Vosk :', exc, flush=True)
try:
rec = _vosk_recognizer(rate)
rec.AcceptWaveform(pcm)
result = json.loads(rec.FinalResult() or '{}').get('text') or ''
return result.strip() or str(fallback).strip()
except Exception as exc:
print('[ALFRED DUPLEX V2] Transcription Vosk indisponible :', exc, flush=True)
return str(fallback).strip()
class DuplexMonitor:
def __init__(self, *, muted, on_interrupt, on_status=None):
self.muted = muted
self.on_interrupt = on_interrupt
self.on_status = on_status or (lambda data: None)
self._stop = threading.Event()
self._done = threading.Event()
self._thread = None
self._lock = threading.RLock()
self._interrupted = False
self._last_error = None
self._followup = ''
self._reason = None
self._spoken = ''
def start(self, spoken_text):
cfg = config()
if cfg['mode'] == 'off' or self.muted():
return False
with self._lock:
if self._thread and self._thread.is_alive():
return False
self._stop.clear()
self._done.clear()
self._interrupted = False
self._followup = ''
self._reason = None
self._last_error = None
self._spoken = spoken_text
self._thread = threading.Thread(target=self._listen,
args=(spoken_text, cfg), daemon=True, name='dona-duplex-v2')
self._thread.start()
return True
def stop(self):
self._stop.set()
thread = self._thread
if thread and thread is not threading.current_thread():
thread.join(timeout=1.3)
def status(self):
with self._lock:
return {'active': bool(self._thread and self._thread.is_alive() and not self._stop.is_set()),
'interrupted': self._interrupted, 'reason': self._reason,
'error': self._last_error, **config()}
def interrupted(self):
with self._lock:
return self._interrupted
def wait_for_followup(self, timeout=9.0):
self._done.wait(timeout=timeout)
with self._lock:
return self._followup
def _signal(self, data):
try:
self.on_status(data)
except Exception:
pass
def _fire(self, reason):
with self._lock:
if self._interrupted or self._stop.is_set() or self.muted():
return False
self._interrupted = True
self._reason = reason
print('[ALFRED DUPLEX V2] Interruption :', reason, flush=True)
try:
self.on_interrupt()
except Exception as exc:
self._last_error = str(exc)
self._signal({'mode': reason, 'interrupted': True})
return True
def _read_after_interruption(self, q, rate, cfg, seed, hint):
"""Same microphone stream: don't lose a follow-up immediately after the wake word."""
if not cfg['handoff']:
return ''
# Capture up to 8 seconds, including a short preroll from before interruption.
data = bytearray(seed)
start = time.monotonic()
last_voice = start
voiced = bool(clean_question(hint, cfg['wake_word']))
while time.monotonic() - start < 8 and not self._stop.is_set() and not self.muted():
try:
block = q.get(timeout=0.12)
except queue.Empty:
if voiced and time.monotonic() - last_voice > 1.1:
break
continue
data.extend(block)
if len(data) > rate * 2 * 12:
break
if _rms(block) >= cfg['threshold']:
voiced = True
last_voice = time.monotonic()
if voiced and time.monotonic() - last_voice >= 1.05:
break
if not voiced and time.monotonic() - start > 2.3:
return ''
text = clean_question(transcribe_capture(bytes(data), rate, fallback=hint), cfg['wake_word'])
if not text or looks_like_echo(text, self._spoken) or stop_only(text, cfg['wake_word']):
return ''
return text
def _listen(self, spoken, cfg):
try:
rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000'))
wake_only = cfg['mode'] == 'wake'
rec = _vosk_recognizer(rate, wake_only=wake_only, wake_word=cfg['wake_word'])
device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None
stream, q = _stream(rate, device)
preroll = deque(maxlen=18)
stable = ''
stable_at = 0.0
started = time.monotonic()
with stream:
while not self._stop.is_set() and not self.muted():
try:
block = q.get(timeout=0.12)
except queue.Empty:
continue
preroll.append(block)
if time.monotonic() - started < 0.65 or _rms(block) < cfg['threshold']:
continue
final = rec.AcceptWaveform(block)
payload = json.loads(rec.Result() if final else rec.PartialResult())
heard = normalize(payload.get('text' if final else 'partial') or '')
if not heard or looks_like_echo(heard, spoken):
stable = ''
continue
stop = cfg['stop_commands'] and stop_only(heard, cfg['wake_word'])
woke = cfg['wake_word'] in heard.split()
# Natural mode: a distinct 2+-word utterance, confirmed across
# multiple chunks. The speaker itself may still cause false positives.
natural = (cfg['mode'] == 'natural' and len(heard.split()) >= 2)
candidate = stop or woke or natural
if not candidate:
stable = ''
continue
now = time.monotonic()
if heard != stable:
stable, stable_at = heard, now
continue
if not final and now - stable_at < (0.20 if stop else 0.43):
continue
if not self._fire('stop' if stop else 'wake' if woke else 'natural'):
continue
self._signal({'mode': 'handoff', 'listening': True})
result = self._read_after_interruption(q, rate, cfg,
b''.join(preroll), heard)
with self._lock:
self._followup = result
if result:
print('[ALFRED DUPLEX V2] Nouvelle question :', repr(result), flush=True)
return
except Exception as exc:
self._last_error = str(exc)
print('[ALFRED DUPLEX V2] Capture indisponible :', exc, flush=True)
finally:
self._done.set()
def listen_conversation(self):
"""Short wake-free listening period AFTER Piper has finished speaking."""
cfg = config()
if not cfg['conversation'] or cfg['mode'] == 'off' or self.muted():
return ''
self.stop()
rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000'))
device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None
try:
stream, q = _stream(rate, device)
seconds = cfg['conversation_seconds']
self._signal({'mode': 'conversation', 'listening': True, 'timeout': seconds})
pcm = bytearray()
started = time.monotonic()
last_voice = None
voice_started = False
consecutive_voice = 0
with stream:
while time.monotonic() - started < seconds and not self.muted():
try:
block = q.get(timeout=0.12)
except queue.Empty:
if last_voice and time.monotonic() - last_voice > 1.15:
break
continue
# Ignore the output audio tail immediately after Piper.
if time.monotonic() - started < 0.4:
continue
pcm.extend(block)
if len(pcm) > rate * 2 * 12:
break
if _rms(block) >= cfg['threshold']:
consecutive_voice += 1
if consecutive_voice >= 3: # reject isolated clicks (< ~0.3 s)
voice_started = True
last_voice = time.monotonic()
else:
consecutive_voice = 0
if last_voice and time.monotonic() - last_voice > 1.15:
break
if not voice_started:
return ''
transcript = transcribe_capture(bytes(pcm), rate)
if stop_only(transcript, cfg['wake_word']) or looks_like_echo(transcript, self._spoken):
return ''
return clean_question(transcript, cfg['wake_word'])
except Exception as exc:
self._last_error = str(exc)
print('[ALFRED DUPLEX V2] Conversation indisponible :', exc, flush=True)
return ''
finally:
self._signal({'mode': 'conversation', 'listening': False})
@@ -0,0 +1,89 @@
"""Reset only the Hermes conversation ID used by Alfred.
Admin-protected, CSRF-protected, and concurrency-safe with ask_hermes().
Does NOT delete the actual Hermes conversation history or change MCP/skills.
"""
from __future__ import annotations
import fcntl
import os
from pathlib import Path
import secrets
from datetime import datetime, timezone
from flask import Blueprint, jsonify
from services import admin_console, hermes_client
class HermesSessionBusy(RuntimeError):
"""Hermes is currently processing a message."""
def archive_current_session() -> bool:
"""Return True when an existing session was archived.
The exact same thread lock and filesystem lock are used by ask_hermes.
Both locks are non-blocking here to keep HTTP responsive.
"""
if os.getenv('HERMES_SESSION_MODE', 'persistent').strip().lower() == 'legacy':
raise ValueError('Hermes est en mode legacy : les conversations sont déjà indépendantes.')
request_lock = hermes_client._REQUEST_LOCK
if not request_lock.acquire(blocking=False):
raise HermesSessionBusy('Hermes traite une demande. Réessaie lorsqu’il a terminé.')
try:
path = hermes_client._session_file()
if path.is_symlink():
raise ValueError('Fichier de session symbolique non autorisé.')
path.parent.mkdir(parents=True, exist_ok=True)
lock_path = path.with_name(path.name + '.lock')
flags = os.O_CREAT | os.O_RDWR
if hasattr(os, 'O_NOFOLLOW'):
flags |= os.O_NOFOLLOW
fd = os.open(lock_path, flags, 0o600)
with os.fdopen(fd, 'r+') as lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise HermesSessionBusy('Une conversation Hermes est en cours. Réessaie plus tard.') from exc
try:
if not path.exists():
return False
if not path.is_file():
raise ValueError('État de session Hermes inattendu.')
# Archive the file even if its JSON is invalid: this action is also a recovery path.
archive_dir = path.parent / 'hermes_session_archives'
if archive_dir.is_symlink():
raise ValueError('Répertoire d’archives symbolique non autorisé.')
archive_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
stem = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
name = f'{path.stem}-{stem}-{secrets.token_hex(4)}.json'
os.replace(path, archive_dir / name)
print('[ALFRED] Nouvelle session Hermes demandée depuis l’interface administrateur.', flush=True)
return True
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
finally:
request_lock.release()
def register_hermes_session_page(app):
bp = Blueprint('alfred_hermes_session_button_v1', __name__)
@bp.post('/api/control/hermes/new-session')
@admin_console._require_admin
def new_session():
try:
archived = archive_current_session()
except HermesSessionBusy as exc:
return jsonify(ok=False, error=str(exc)), 409
except (OSError, ValueError, RuntimeError) as exc:
app.logger.warning('Nouvelle session Hermes impossible : %s', exc)
return jsonify(ok=False, error='Impossible de réinitialiser la session : '+str(exc)), 503
response = jsonify(ok=True, archived=archived,
message=('Session précédente sauvegardée. La prochaine demande ouvrira une nouvelle session Hermes.'
if archived else 'Aucune session précédente : Hermes en créera une à la prochaine demande.'))
response.headers['Cache-Control'] = 'no-store, private'
return response
app.register_blueprint(bp)
+48 -5
View File
@@ -3,6 +3,7 @@ import os
import subprocess
import tempfile
import time
import threading # ALFRED_DUPLEX_BARGE_IN_V1
import wave
from wyoming.audio import AudioChunk, AudioStart, AudioStop
@@ -71,10 +72,31 @@ async def synthesize(text, wav_path):
await client.disconnect()
# ALFRED_DUPLEX_BARGE_IN_V1: control only this module's aplay process.
_playback_lock = threading.RLock()
_playback_cancel = threading.Event()
_playback_proc = None
def stop_playback():
"""Interrupts Piper playback or marks pending synth for cancellation."""
global _playback_proc
_playback_cancel.set()
with _playback_lock:
process = _playback_proc
if process is not None and process.poll() is None:
try:
process.terminate()
except OSError:
pass
def speak(text):
if not text or not text.strip():
return
_playback_cancel.clear() # ALFRED_DUPLEX_BARGE_IN_V1
fd, wav_path = tempfile.mkstemp(
prefix="alfred_piper_",
suffix=".wav"
@@ -90,11 +112,32 @@ def speak(text):
print("[ALFRED PIPER] Lecture...", flush=True)
subprocess.run(
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
check=True,
timeout=120
)
# ALFRED_DUPLEX_BARGE_IN_V1: allow a concurrent microphone detector
# to terminate playback, without killing Hermes or the entire service.
global _playback_proc
if _playback_cancel.is_set():
print("[ALFRED PIPER] Annulé avant lecture", flush=True)
return
proc = subprocess.Popen(["aplay", "-q", "-D", AUDIO_DEVICE, wav_path])
with _playback_lock:
_playback_proc = proc
if _playback_cancel.is_set() and proc.poll() is None:
proc.terminate()
try:
result = proc.wait(timeout=120)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
raise
finally:
with _playback_lock:
if _playback_proc is proc:
_playback_proc = None
if _playback_cancel.is_set():
print("[ALFRED PIPER] Lecture interrompue par la voix", flush=True)
return
if result:
raise subprocess.CalledProcessError(result, proc.args)
print(
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",