Files
Alfredv2/alfred/app/services/alfred_lists.py
T

250 lines
10 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.
"""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)