v2 - new interface, many new page for fine tuning
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
"""Alfred Listes V1 — courses et tâches locales, SQLite, commandes vocales sûres.
|
||||
|
||||
Aucune dépendance externe. Le serveur MCP n'attend jamais et ne lance jamais de shell.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import unicodedata
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
DB_PATH = Path(os.getenv('ALFRED_LISTS_DB', str(Path(__file__).resolve().parents[1] / 'data' / 'alfred_lists.sqlite3')))
|
||||
LOCK = threading.RLock()
|
||||
KINDS = {'courses': 'courses', 'course': 'courses', 'taches': 'taches', 'tache': 'taches',
|
||||
'tasks': 'taches', 'task': 'taches', 'todo': 'taches', 'shopping': 'courses'}
|
||||
MAX_ITEMS = 500
|
||||
MAX_LEN = 160
|
||||
|
||||
|
||||
def fold(s):
|
||||
return ''.join(c for c in unicodedata.normalize('NFKD', s.casefold()) if not unicodedata.combining(c))
|
||||
|
||||
|
||||
def normalize_kind(kind):
|
||||
if not isinstance(kind, str) or fold(kind.strip()) not in KINDS:
|
||||
raise ValueError('Liste inconnue : choisir courses ou taches.')
|
||||
return KINDS[fold(kind.strip())]
|
||||
|
||||
|
||||
def clean_label(label, kind=None):
|
||||
if not isinstance(label, str):
|
||||
raise ValueError('Le nom de l’élément doit être un texte.')
|
||||
label = ' '.join(label.split()).strip(' .!?;,')
|
||||
if kind == 'courses':
|
||||
label = re.sub(r"^(?:du\s+|de\s+la\s+|des\s+|de\s+l['’])", '', label, flags=re.I).strip()
|
||||
if not label or len(label) > MAX_LEN or any(ord(x) < 32 for x in label):
|
||||
raise ValueError('Nom attendu : de 1 à 160 caractères.')
|
||||
return label
|
||||
|
||||
|
||||
def _conn():
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=6)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute('PRAGMA busy_timeout=6000')
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
conn.execute('''CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('courses','taches')),
|
||||
label TEXT NOT NULL,
|
||||
normalized TEXT NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(kind, normalized)
|
||||
)''')
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _db():
|
||||
with LOCK:
|
||||
conn = _conn()
|
||||
try:
|
||||
with conn:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row(row):
|
||||
return {'id': row['id'], 'kind': row['kind'], 'label': row['label'],
|
||||
'done': bool(row['done']), 'created_at': row['created_at']}
|
||||
|
||||
|
||||
def list_items(kind=None):
|
||||
if kind is not None: kind = normalize_kind(kind)
|
||||
with _db() as conn:
|
||||
q = ('SELECT id, kind, label, done, created_at FROM items '
|
||||
+ ('WHERE kind=? ' if kind else '')
|
||||
+ 'ORDER BY done ASC, id DESC LIMIT 1000')
|
||||
rows = conn.execute(q, (kind,) if kind else ()).fetchall()
|
||||
items = [_row(r) for r in rows]
|
||||
result = {'courses': [], 'taches': []}
|
||||
for item in items: result[item['kind']].append(item)
|
||||
return {'ok': True, 'lists': result,
|
||||
'counts': {k: {'active': sum(not r['done'] for r in v), 'total': len(v)}
|
||||
for k, v in result.items()}}
|
||||
|
||||
|
||||
def add_item(kind, label):
|
||||
kind = normalize_kind(kind)
|
||||
label = clean_label(label, kind)
|
||||
norm = fold(label)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with _db() as conn:
|
||||
row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, norm)).fetchone()
|
||||
if row:
|
||||
if row['done']:
|
||||
conn.execute('UPDATE items SET done=0, label=?, updated_at=? WHERE id=?', (label, now, row['id']))
|
||||
existing = False
|
||||
else: existing = True
|
||||
item_id = row['id']
|
||||
else:
|
||||
n = conn.execute('SELECT COUNT(*) FROM items WHERE kind=?', (kind,)).fetchone()[0]
|
||||
if n >= MAX_ITEMS: raise ValueError('Liste pleine (500 éléments maximum).')
|
||||
item_id = conn.execute('INSERT INTO items (kind,label,normalized,done,created_at,updated_at) VALUES (?,?,?,?,?,?)',
|
||||
(kind, label, norm, 0, now, now)).lastrowid
|
||||
existing = False
|
||||
row = conn.execute('SELECT * FROM items WHERE id=?', (item_id,)).fetchone()
|
||||
return {'ok': True, 'item': _row(row), 'already_present': existing}
|
||||
|
||||
|
||||
def _resolve(conn, kind, item_id=None, label=None):
|
||||
kind = normalize_kind(kind)
|
||||
if item_id is not None:
|
||||
try: n = int(item_id)
|
||||
except (ValueError, TypeError): raise ValueError('Identifiant invalide.')
|
||||
row = conn.execute('SELECT * FROM items WHERE id=? AND kind=?', (n, kind)).fetchone()
|
||||
elif label is not None:
|
||||
label = clean_label(label,kind)
|
||||
row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, fold(label))).fetchone()
|
||||
else:
|
||||
raise ValueError('Indique l’identifiant ou le nom exact de l’élément.')
|
||||
if row is None: raise ValueError('Élément introuvable dans la liste.')
|
||||
return row
|
||||
|
||||
|
||||
def change_item(action, kind, item_id=None, label=None):
|
||||
if action not in ('complete_item', 'reopen_item', 'delete_item'):
|
||||
raise ValueError('Action inconnue.')
|
||||
with _db() as conn:
|
||||
row = _resolve(conn, kind, item_id, label)
|
||||
if action == 'delete_item':
|
||||
conn.execute('DELETE FROM items WHERE id=?', (row['id'],))
|
||||
else:
|
||||
conn.execute('UPDATE items SET done=?, updated_at=? WHERE id=?',
|
||||
(int(action == 'complete_item'), datetime.now(timezone.utc).isoformat(), row['id']))
|
||||
return {'ok': True, 'item': _row(row), 'action': action}
|
||||
|
||||
|
||||
def clear_completed(kind):
|
||||
kind = normalize_kind(kind)
|
||||
with _db() as conn:
|
||||
count = conn.execute('DELETE FROM items WHERE kind=? AND done=1', (kind,)).rowcount
|
||||
return {'ok': True, 'deleted': count}
|
||||
|
||||
|
||||
def execute(action, data):
|
||||
if action == 'list_items': return list_items(data.get('kind'))
|
||||
if action == 'add_item': return add_item(data.get('kind'), data.get('label'))
|
||||
if action in ('complete_item', 'reopen_item', 'delete_item'):
|
||||
return change_item(action, data.get('kind'), data.get('id'), data.get('label'))
|
||||
if action == 'clear_completed': return clear_completed(data.get('kind'))
|
||||
raise ValueError('Action inconnue.')
|
||||
|
||||
|
||||
# Routes vocales directes pour les cas courants : les LLM ne peuvent pas inventer le succès.
|
||||
_ADD_RE = re.compile(
|
||||
r"^\s*(?:ajoute|rajoute|mets|met|note|inscris|ajouter)\s+(?P<label>.+?)\s+"
|
||||
r"(?:a|à|sur|dans)\s+(?:ma|la|mes|notre)\s+liste\s+(?:de\s+)?"
|
||||
r"(?P<kind>courses?|taches?|tâches?|todo)\s*[.!?]*\s*$", re.I)
|
||||
_VIEW_RE = re.compile(
|
||||
r"^\s*(?:(?:affiche|montre|lis|donne|consulte)\s+)?"
|
||||
r"(?:(?:moi|nous)\s+)?(?:(?:qu['’]est.ce\s+qu['’]il\s+y\s+a\s+(?:dans|sur)\s+)|(?:quelle\s+est\s+))?"
|
||||
r"(?:ma|la|notre)\s+liste\s+(?:de\s+)?(?P<kind>courses?|taches?|tâches?|todo)\s*[.!?]*\s*$", re.I)
|
||||
|
||||
|
||||
def parse_voice(text):
|
||||
"""Return (action, kind, label) for unambiguous commands only."""
|
||||
if not isinstance(text, str): return None
|
||||
# Case insensitive + accent insensitive, but keep original item spelling.
|
||||
normalized = fold(text).replace('’', "'")
|
||||
m = _ADD_RE.match(normalized)
|
||||
if m:
|
||||
# Re-extract original label by matching an accent-tolerant prefix/suffix.
|
||||
orig = re.match(r'^\s*(?:ajoute|rajoute|mets|met|note|inscris|ajouter)\s+(.+?)\s+(?:à|a|sur|dans)\s+(?:ma|la|mes|notre)\s+liste\s+(?:de\s+)?(?:courses?|taches?|tâches?|todo)\s*[.!?]*\s*$',text,re.I)
|
||||
label = orig.group(1) if orig else m.group('label')
|
||||
return ('add_item', normalize_kind(m.group('kind')), label)
|
||||
m = _VIEW_RE.match(normalized)
|
||||
if m: return ('list_items', normalize_kind(m.group('kind')), None)
|
||||
return None
|
||||
|
||||
|
||||
def handle_voice(text, notify=None):
|
||||
parsed = parse_voice(text)
|
||||
if not parsed: return None
|
||||
action, kind, label = parsed
|
||||
result = execute(action, {'kind': kind, 'label': label})
|
||||
if action == 'add_item':
|
||||
item = result['item']['label']
|
||||
dest = 'liste de courses' if kind == 'courses' else 'liste de tâches'
|
||||
message = (f'{item} est déjà dans ta {dest}.' if result['already_present']
|
||||
else f"J’ai ajouté {item} à ta {dest}.")
|
||||
if not result['already_present'] and notify: notify()
|
||||
return message
|
||||
active = [x['label'] for x in result['lists'][kind] if not x['done']]
|
||||
if not active: return 'Ta liste de courses est vide.' if kind == 'courses' else 'Ta liste de tâches est vide.'
|
||||
category = 'courses' if kind == 'courses' else 'tâches'
|
||||
readable = ', '.join(active[:12])
|
||||
return f'Voici ta liste de {category} : {readable}' + (f', et {len(active)-12} autres.' if len(active)>12 else '.')
|
||||
|
||||
|
||||
def _authorized_write():
|
||||
if not request.is_json: return False
|
||||
# local MCP path: no remote client may spoof this header
|
||||
if request.remote_addr in ('127.0.0.1','::1') and request.headers.get('X-Alfred-MCP-Local') == 'lists':
|
||||
return True
|
||||
if request.headers.get('X-Alfred-Request') != '1': return False
|
||||
origin = request.headers.get('Origin', '')
|
||||
if origin:
|
||||
u = urlparse(origin)
|
||||
return u.scheme in ('http','https') and u.netloc == request.host
|
||||
return request.remote_addr in ('127.0.0.1','::1')
|
||||
|
||||
|
||||
def register_lists(app, socketio):
|
||||
bp = Blueprint('alfred_lists_v1', __name__)
|
||||
|
||||
@bp.get('/api/alfred/lists')
|
||||
def get_lists():
|
||||
try: return jsonify(list_items())
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
return jsonify(ok=False, error='Stockage listes indisponible.'), 503
|
||||
|
||||
@bp.post('/api/alfred/lists/action')
|
||||
def action_lists():
|
||||
if not _authorized_write(): return jsonify(ok=False, error='Requête non autorisée.'), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict): return jsonify(ok=False, error='Objet JSON attendu.'), 400
|
||||
action = data.get('action')
|
||||
try:
|
||||
result = execute(action, data)
|
||||
except ValueError as exc:
|
||||
return jsonify(ok=False, error=str(exc)), 400
|
||||
except (sqlite3.Error, OSError) as exc:
|
||||
return jsonify(ok=False, error='Stockage listes indisponible.'), 503
|
||||
if action != 'list_items' and not result.get('already_present'):
|
||||
socketio.emit('alfred_lists_changed', {'kind': data.get('kind'), 'action': action})
|
||||
return jsonify(result)
|
||||
|
||||
app.register_blueprint(bp)
|
||||
Reference in New Issue
Block a user