v2 - new interface, many new page for fine tuning

This commit is contained in:
pporcheret
2026-10-28 21:45:40 +01:00
parent a6169bcfdb
commit a17c3cb614
89 changed files with 8691 additions and 13 deletions
+204
View File
@@ -0,0 +1,204 @@
"""Identity editor and current assistant name, authenticated by Alfred's admin UI.
Identity text stays in config/identity.md. All writes are atomic; dated revisions
are kept separately. Technical names/paths/API identifiers are never renamed.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import threading
from datetime import datetime
from flask import Blueprint, jsonify, render_template, request
from services import admin_console
ROOT = Path(__file__).resolve().parents[1]
CONFIG = ROOT / 'config'
IDENTITY = CONFIG / 'identity.md'
TRACKER = CONFIG / 'identity_name_state.json'
HISTORY = CONFIG / 'identity_history'
LOCK = threading.RLock()
MAX_SIZE = 40_000
ASSISTANT_RE = re.compile(r"(?<![A-Za-z0-9_.-])Alfred(?![A-Za-z0-9_.-])")
def active_name():
name = (os.getenv('ALFRED_NAME') or 'Alfred').strip()
return name if 2 <= len(name) <= 30 else 'Alfred'
def identity_path():
# Avoid an admin Web editor writing outside the identity config directory.
raw = Path(os.getenv('ALFRED_IDENTITY_FILE', str(IDENTITY))).expanduser()
resolved = raw.resolve()
if resolved.parent != CONFIG.resolve() or raw.is_symlink():
raise ValueError('Le fichier ALFRED_IDENTITY_FILE est externe à config/ : édition Web refusée.')
return resolved
def sha(text):
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def _read_text():
path = identity_path()
return path.read_text(encoding='utf-8') if path.exists() else ''
def _save_atomic(path, content, mode=0o600):
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp = tempfile.mkstemp(dir=str(path.parent), prefix='.identity-', suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
fh.write(content)
fh.flush()
os.fsync(fh.fileno())
os.chmod(temp, mode)
os.replace(temp, path)
finally:
if os.path.exists(temp):
os.unlink(temp)
def _revision(text, label):
HISTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
filename = datetime.now().strftime('%Y%m%d_%H%M%S_%f') + '_' + label + '.md'
dest = HISTORY / filename
fd = os.open(dest, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(text)
def _old_name(text):
if TRACKER.exists():
try:
data = json.loads(TRACKER.read_text(encoding='utf-8'))
value = data.get('name', '')
if isinstance(value, str) and 2 <= len(value) <= 30:
return value
except (OSError, ValueError, TypeError):
pass
# Existing identity files tend to contain "Tu t'appelles Alfred".
match = re.search(r"\bTu\s+t['’]appelles\s+([A-Za-zÀ-ÖØ-öø-ÿ-]{2,30})\b", text, flags=re.I)
if match:
return match.group(1)
return 'Alfred'
def _replace_persona_name(text, old, new):
if old == new:
return text
# Replace proper standalone name only; not ALFRED_NAME, alfred.service,
# MCP_alfred_timers, /opt/alfred etc.
pattern = re.compile(r'(?<![A-Za-z0-9_.-])' + re.escape(old) + r'(?![A-Za-z0-9_-]|\.[A-Za-z0-9_-])')
changed = pattern.sub(lambda _m: new, text)
if old != new and old and old[0].lower() in 'aeiouy' and new and new[0].lower() not in 'aeiouyh':
changed = changed.replace("d'" + new, 'de ' + new).replace('d’' + new, 'de ' + new)
if changed == text:
# No existing self-name found: insert an explicit, visible identity rule.
changed = f'# Identité actuelle\nTu t’appelles {new}.\n\n' + text
return changed
def sync_identity_name(name=None):
"""Called on startup and after a /config rename. Never erases custom prose."""
new = name or active_name()
if not isinstance(new, str) or not 2 <= len(new) <= 30 or any(c in new for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
raise ValueError('Nom d’assistant invalide.')
with LOCK:
path = identity_path()
original = _read_text()
if not original.strip():
# No identity installed: don't silently create a new persona.
raise ValueError('Le fichier identity.md est vide ou absent.')
old = _old_name(original)
updated = _replace_persona_name(original, old, new)
if updated != original:
_revision(original, 'avant_nom')
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
_save_atomic(path, updated, mode)
# Keep original metadata if the file text was unchanged; preserve editability.
if not TRACKER.exists() or old != new:
_save_atomic(TRACKER, json.dumps({'name':new}, ensure_ascii=False) + '\n')
return {'changed': updated != original, 'name':new}
def identity_for_hermes(text):
"""Runtime guard: current name wins even if a legacy identity wasn't synced."""
new = active_name()
old = _old_name(text)
content = _replace_persona_name(text, old, new) if text else text
return ('Règle d’identité prioritaire : ton nom actuel est ' + new +
'. Si l’on demande ton nom, réponds « Je m’appelle ' + new + ' ».\n\n' + content).strip()
def register_identity_page(app):
bp = Blueprint('alfred_identity_glass', __name__)
@bp.get('/identity')
@bp.get('/identity/')
def page():
# Never embed identity text or secrets in unauthenticated HTML.
return render_template('alfred_identity_glass.html')
@bp.get('/api/alfred/identity/name')
def name_api():
res = jsonify(name=active_name(), wake_word=os.getenv('WAKE_WORD', 'alfred'))
res.headers['Cache-Control'] = 'no-store'
return res
@bp.get('/api/control/identity')
@admin_console._require_admin
def get_identity():
try:
with LOCK:
content = _read_text()
except (OSError, ValueError) as e:
return jsonify(error=str(e)), 503
res = jsonify(name=active_name(), text=content, revision=sha(content),
file='config/identity.md')
res.headers['Cache-Control'] = 'no-store, private'
return res
@bp.post('/api/control/identity')
@admin_console._require_admin
def save_identity():
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify(error='Données JSON invalides.'), 400
text = data.get('text')
revision = data.get('revision')
if not isinstance(text, str) or not isinstance(revision, str):
return jsonify(error='Texte ou révision manquante.'), 400
if not text.strip() or len(text.encode('utf-8')) > MAX_SIZE or '\x00' in text:
return jsonify(error='L’identité doit être non vide et ne pas dépasser 40 Ko.'), 400
if not re.fullmatch(r'[0-9a-f]{64}', revision):
return jsonify(error='Révision invalide.'), 400
try:
with LOCK:
path = identity_path()
old = _read_text()
if sha(old) != revision:
return jsonify(error='Le fichier a changé depuis ton chargement. Recharge avant d’enregistrer.'), 409
if text != old:
_revision(old, 'avant_edition')
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
_save_atomic(path, text, mode)
res = jsonify(ok=True, revision=sha(text), name=active_name(),
updated=text != old, file='config/identity.md')
res.headers['Cache-Control'] = 'no-store, private'
return res
except (OSError, ValueError) as e:
return jsonify(error='Enregistrement impossible : '+str(e)), 500
app.register_blueprint(bp)
# If a renamed assistant was configured before installation, reconcile on startup.
try:
sync_identity_name()
except (OSError, ValueError) as e:
app.logger.warning('Synchronisation identité différée : %s', e)