318 lines
13 KiB
Python
318 lines
13 KiB
Python
"""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)
|