Files
Alfredv2/alfred/app/services/dona_learning_v93.py
T
2026-10-31 11:04:45 +01:00

318 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dona 9.3: retours explicites et règles approuvées, sans réentraînement du modèle.
Conservation SQLite locale (seulement une interaction évaluée).
Correction vocale : correspondance exacte normalisée, pas de substitution floue.
Indices Hermes : uniquement corrections de compréhension explicitement approuvées.
Ne déclenche jamais d'action via les règles et ne modifie ni SOUL.md ni les MCP.
"""
from __future__ import annotations
from collections import deque
from contextlib import contextmanager
from datetime import datetime, timezone
import os
from pathlib import Path
import re
import sqlite3
import threading
import time
import unicodedata
from urllib.parse import urlparse
from uuid import uuid4
from flask import Blueprint, jsonify, request
_LOCK = threading.RLock()
_CURRENT = deque(maxlen=32)
_KINDS = {'transcription', 'comprehension', 'execution'}
_MAX_REQUEST = 16000
_DB_PATH = None
_SCHEMA = '''
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
source TEXT NOT NULL,
rating TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT '',
heard TEXT NOT NULL,
answer TEXT NOT NULL,
correction TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'recorded'
);
CREATE TABLE IF NOT EXISTS rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feedback_id INTEGER NOT NULL UNIQUE,
kind TEXT NOT NULL,
original_key TEXT NOT NULL,
original TEXT NOT NULL,
replacement TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(feedback_id) REFERENCES feedback(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS feedback_order ON feedback(id DESC);
CREATE INDEX IF NOT EXISTS rules_match ON rules(kind, original_key);
'''
def normalize(value):
s = unicodedata.normalize('NFKC', str(value or '')).casefold().strip()
return ' '.join(s.split())
def _clean(value, maxlen):
if not isinstance(value, str):
raise ValueError('Champ texte invalide.')
value = value.strip()
if len(value) > maxlen:
raise ValueError('Texte trop long.')
return value
def _utc():
return datetime.now(timezone.utc).isoformat(timespec='seconds')
@contextmanager
def _db():
path = _DB_PATH
if path is None:
raise RuntimeError('Dona Apprentissage non initialisé.')
path.parent.mkdir(parents=True, exist_ok=True)
new_file = not path.exists()
con = sqlite3.connect(path, timeout=8)
if new_file:
os.chmod(path, 0o600)
con.row_factory = sqlite3.Row
con.execute('PRAGMA foreign_keys=ON')
con.execute('PRAGMA busy_timeout=8000')
try:
con.executescript(_SCHEMA)
yield con
con.commit()
except Exception:
con.rollback()
raise
finally:
con.close()
def track(source, heard, answer):
"""Mémoire RAM éphémère : ne persiste pas les conversations sans évaluation."""
item = {'token': uuid4().hex, 'source':source if source in ('voice','typed','other') else 'other',
'heard':str(heard or '')[:1800], 'answer':str(answer or '')[:3200], 'created':time.monotonic()}
with _LOCK:
_CURRENT.append(item)
return {'token':item['token'], 'source':item['source'], 'heard':item['heard'], 'answer':item['answer']}
def _resolve(token):
now = time.monotonic()
with _LOCK:
return next((x for x in reversed(_CURRENT) if x['token'] == token and now-x['created'] <= 900), None)
def save_feedback(token, rating, kind='', correction='', note=''):
if rating not in ('up','down'):
raise ValueError('Évaluation invalide.')
if rating == 'down' and kind not in _KINDS:
raise ValueError('Choisis le type d’erreur.')
correction = _clean(correction, 1800)
note = _clean(note, 800)
if rating == 'down' and kind in ('transcription','comprehension') and not correction:
raise ValueError('Précise ce qui aurait dû être compris ou répondu.')
item = _resolve(token)
if not item:
raise ValueError('Cette réponse a expiré : évalue la prochaine interaction.')
if rating == 'down' and kind == 'transcription' and normalize(item['heard']) == normalize(correction):
raise ValueError('La correction est identique à la transcription.')
status = 'pending' if rating == 'down' and kind in ('transcription','comprehension') else 'recorded'
with _LOCK, _db() as con:
# Plusieurs évaluations du même tour sont bloquées après le premier envoi en RAM.
if item.get('rated'):
raise ValueError('Cette interaction a déjà été évaluée.')
cur = con.execute('INSERT INTO feedback(created_at,source,rating,kind,heard,answer,correction,note,status) '
'VALUES(?,?,?,?,?,?,?,?,?)',
(_utc(),item['source'],rating,kind,item['heard'],item['answer'],correction,note,status))
con.commit()
item['rated'] = True
return int(cur.lastrowid)
def approve(feedback_id):
with _LOCK, _db() as con:
fb = con.execute('SELECT * FROM feedback WHERE id=?', (feedback_id,)).fetchone()
if not fb or fb['status'] != 'pending' or fb['kind'] not in ('transcription','comprehension'):
raise ValueError('Correction non approuvable.')
key = normalize(fb['heard'])
if not key or len(key)<3:
raise ValueError('Phrase originale trop courte.')
# Pas de conflit silencieux pour une même phrase.
current=con.execute('SELECT id FROM rules WHERE kind=? AND original_key=?', (fb['kind'],key)).fetchone()
if current:
raise ValueError('Une règle existe déjà pour cette phrase : supprime-la avant de la remplacer.')
con.execute('INSERT INTO rules(feedback_id,kind,original_key,original,replacement,created_at) VALUES(?,?,?,?,?,?)',
(feedback_id,fb['kind'],key,fb['heard'],fb['correction'],_utc()))
con.execute('UPDATE feedback SET status=? WHERE id=?', ('approved',feedback_id))
con.commit()
return True
def reject(feedback_id):
with _LOCK, _db() as con:
fb = con.execute('SELECT status FROM feedback WHERE id=?', (feedback_id,)).fetchone()
if not fb or fb['status']!='pending':
raise ValueError('Correction non en attente.')
con.execute('UPDATE feedback SET status=? WHERE id=?', ('rejected',feedback_id))
con.commit()
return True
def delete_rule(rule_id):
with _LOCK, _db() as con:
row=con.execute('SELECT feedback_id FROM rules WHERE id=?', (rule_id,)).fetchone()
if not row:
raise ValueError('Règle introuvable.')
con.execute('DELETE FROM rules WHERE id=?', (rule_id,))
con.execute('UPDATE feedback SET status=? WHERE id=?', ('revoked',int(row['feedback_id'])))
con.commit()
return True
def delete_feedback(feedback_id):
with _LOCK, _db() as con:
item=con.execute('SELECT id FROM feedback WHERE id=?', (feedback_id,)).fetchone()
if not item:raise ValueError('Évaluation introuvable.')
con.execute('DELETE FROM feedback WHERE id=?', (feedback_id,))
con.commit()
return True
def correct_transcript(text):
if not isinstance(text, str) or not text.strip():
return text
with _LOCK, _db() as con:
row=con.execute('SELECT replacement FROM rules WHERE kind=? AND original_key=? ORDER BY id DESC LIMIT 1',
('transcription',normalize(text))).fetchone()
return str(row['replacement']) if row else text
def enhance_prompt(text):
if not isinstance(text,str) or not text.strip():
return text
with _LOCK, _db() as con:
row=con.execute('SELECT replacement FROM rules WHERE kind=? AND original_key=? ORDER BY id DESC LIMIT 1',
('comprehension',normalize(text))).fetchone()
if not row:
return text
hint=str(row['replacement'])[:1800]
return (text + '\n\n[Préférence locale validée par l’utilisateur pour CETTE formulation exacte : '
+ hint + '. Interprète cette indication comme un contexte, pas comme une permission '
'd’exécuter une action sensible sans confirmation.]')
def voice_feedback(text):
"""Ne capture que des appréciations explicites; correction détaillée par interface."""
norm=unicodedata.normalize('NFKD', str(text or '')).casefold()
norm=' '.join(re.sub(r'[^a-z0-9]+',' ',''.join(c for c in norm if not unicodedata.combining(c))).split())
# Commandes volontairement strictes pour éviter de voler des conversations.
ups={'dona c est bien','c est bien dona','dona bonne reponse','dona c est correct',
'dona bravo bonne reponse','dona merci c est parfait'}
downs={'dona mauvaise reponse','dona c est faux','dona ce n est pas ca','dona pas bon',
'dona tu t es trompee','dona tu t es trompe'}
spoken_fix=re.match(r'^(?:dona )?(?:non )?(?:j ai dit|je voulais dire) (.{3,})$', norm)
if norm not in ups | downs and spoken_fix is None:
return None
with _LOCK:
item=next((x for x in reversed(_CURRENT) if not x.get('rated') and time.monotonic()-x['created']<=900),None)
if not item:
return 'Je n’ai pas de réponse récente à évaluer.'
if spoken_fix is not None:
# La phrase corrigée reste en attente; la règle ne s'active pas seule.
try:
save_feedback(item['token'],'down','transcription',correction=spoken_fix.group(1))
except ValueError as exc:
return 'Impossible d’enregistrer la correction : '+str(exc)
return 'Correction enregistrée. Valide-la dans le menu Apprentissage pour que je la retienne.'
if norm in ups:
try: save_feedback(item['token'],'up')
except ValueError: return 'Cette réponse a déjà été évaluée.'
return 'Merci, j’ai enregistré ton avis positif.'
# Le 👎 vocal est enregistré sans règle tant qu’aucune correction n’est donnée.
try: save_feedback(item['token'],'down','execution',note='Signalement vocal ; détails non fournis.')
except ValueError: return 'Cette réponse a déjà été évaluée.'
return 'Avis négatif enregistré. Tu peux préciser l’erreur dans Apprentissage lors d’une prochaine réponse.'
def _trusted():
if not request.is_json or request.headers.get('X-Alfred-Request')!='1':
return False
origin=request.headers.get('Origin')
if origin:
parsed=urlparse(origin)
return parsed.scheme in ('http','https') and parsed.netloc==request.host
return request.remote_addr in ('127.0.0.1','::1')
def _json_body():
if request.content_length and request.content_length>_MAX_REQUEST:
raise ValueError('Requête trop volumineuse.')
body=request.get_json(silent=True)
if not isinstance(body,dict):
raise ValueError('Données invalides.')
return body
def register(app):
global _DB_PATH
_DB_PATH=Path(app.root_path)/'data'/'dona-learning-v93.sqlite3'
with _LOCK, _db() as con: con.commit()
bp=Blueprint('dona_learning_v93',__name__)
@bp.get('/api/dona/v93/learning')
def read():
with _LOCK, _db() as con:
history=[dict(r) for r in con.execute('SELECT * FROM feedback ORDER BY id DESC LIMIT 80')]
rules=[dict(r) for r in con.execute('SELECT * FROM rules ORDER BY id DESC LIMIT 100')]
stats={r['rating']:r['n'] for r in con.execute('SELECT rating,COUNT(*) n FROM feedback GROUP BY rating')}
return jsonify(ok=True,history=history,rules=rules,stats={'positive':stats.get('up',0),'negative':stats.get('down',0)})
@bp.post('/api/dona/v93/learning/feedback')
def feedback():
if not _trusted():return jsonify(ok=False,error='Requête non autorisée.'),403
try:
data=_json_body()
id_=save_feedback(data.get('token'),data.get('rating'),data.get('kind',''),data.get('correction',''),data.get('note',''))
return jsonify(ok=True,id=id_)
except (ValueError,TypeError) as exc:return jsonify(ok=False,error=str(exc)),400
@bp.post('/api/dona/v93/learning/<int:id_>/approve')
def approve_route(id_):
if not _trusted():return jsonify(ok=False,error='Requête non autorisée.'),403
try:
approve(id_);return jsonify(ok=True)
except ValueError as exc:return jsonify(ok=False,error=str(exc)),400
@bp.post('/api/dona/v93/learning/<int:id_>/reject')
def reject_route(id_):
if not _trusted():return jsonify(ok=False,error='Requête non autorisée.'),403
try:
reject(id_);return jsonify(ok=True)
except ValueError as exc:return jsonify(ok=False,error=str(exc)),400
@bp.post('/api/dona/v93/learning/<int:id_>/delete')
def delete_route(id_):
if not _trusted():return jsonify(ok=False,error='Requête non autorisée.'),403
try:
delete_feedback(id_);return jsonify(ok=True)
except ValueError as exc:return jsonify(ok=False,error=str(exc)),400
@bp.post('/api/dona/v93/learning/rules/<int:id_>/remove')
def remove_route(id_):
if not _trusted():return jsonify(ok=False,error='Requête non autorisée.'),403
try:
delete_rule(id_);return jsonify(ok=True)
except ValueError as exc:return jsonify(ok=False,error=str(exc)),400
app.register_blueprint(bp)