v4
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user