commit efd775706bfc7465840cfc6b5b9b7e7826d09af8 Author: laurentbarontini Date: Tue May 5 21:38:03 2026 +0200 Initial production-ready release of OpenCare v1 - Flask/SQLAlchemy app with profile, dashboard, recommendations - AI crawl pipeline (GPT-4o) with admin review workflow - 14 curated health sources (RSS + index crawling) - Production config: env vars, Gunicorn, systemd, nginx - deploy/ scripts for VPS setup and updates Co-Authored-By: Claude Sonnet 4.6 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ada09bc --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir \"c:\\\\DataS\\\\OpenCare\\\\static\\\\css\")", + "Bash(mkdir \"c:\\\\DataS\\\\OpenCare\\\\templates\")", + "PowerShell(Get-Command python3 -ErrorAction SilentlyContinue)", + "PowerShell(Get-Command python -ErrorAction SilentlyContinue)", + "PowerShell(py --version 2>&1)", + "Bash(py seed_sources.py)", + "Bash(python -c \"import sys; sys.stdout.buffer.write\\(sys.stdin.buffer.read\\(\\)\\)\")", + "Bash(py -c \"import sqlite3; conn=sqlite3.connect\\('instance/opencare.db'\\); rows=conn.execute\\('SELECT id,name,source_type,category,active FROM crawl_source ORDER BY id'\\).fetchall\\(\\); [print\\(r\\) for r in rows]; conn.close\\(\\)\")", + "Bash(py -c \"import sqlite3; conn=sqlite3.connect\\('instance/opencare.db'\\); cols=[r[1] for r in conn.execute\\('PRAGMA table_info\\(crawl_source\\)'\\).fetchall\\(\\)]; print\\('Colonnes:', cols\\); conn.close\\(\\)\")", + "Bash(git init *)", + "Bash(git add *)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..108006e --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copier ce fichier en .env et remplir les valeurs +# Ne jamais committer .env dans git + +# Clé secrète Flask — générer avec : python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=change-me-with-a-strong-random-key + +# Base de données (SQLite par défaut, PostgreSQL possible) +# SQLite : sqlite:///opencare.db +# Postgres: postgresql://user:password@localhost/opencare +DATABASE_URL=sqlite:///opencare.db + +# Environnement +FLASK_ENV=production + +# Port d'écoute (optionnel, défaut 5000) +PORT=5000 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..25a94d8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto eol=lf +*.sh text eol=lf +*.py text eol=lf +*.html text eol=lf +*.css text eol=lf +*.conf text eol=lf +*.service text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52136c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Environnement +.env +*.env + +# Base de données +instance/ +*.db +*.sqlite3 + +# Python +__pycache__/ +*.py[cod] +*.pyo +.Python +venv/ +env/ +.venv/ +*.egg-info/ +dist/ +build/ + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp + +# Fichiers sensibles +seed_sources.py + +# Sous-dossier non lié au projet +Chimical/ diff --git a/app.py b/app.py new file mode 100644 index 0000000..a41f52b --- /dev/null +++ b/app.py @@ -0,0 +1,886 @@ +from flask import Flask, render_template, redirect, url_for, request, flash, abort, jsonify +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user +from werkzeug.security import generate_password_hash, check_password_hash +from datetime import date, datetime +from functools import wraps +import json +import os + +from dotenv import load_dotenv +load_dotenv() + +app = Flask(__name__) +app.config.update( + SECRET_KEY=os.environ.get('SECRET_KEY', 'opencare-dev-secret-change-in-prod'), + SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', 'sqlite:///opencare.db'), + SQLALCHEMY_TRACK_MODIFICATIONS=False, +) + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Veuillez vous connecter.' +login_manager.login_message_category = 'warning' + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def admin_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + abort(403) + return f(*args, **kwargs) + return decorated + + +_DOMAIN_LABELS = { + 'nutrition': ('Alimentation', '🥗'), + 'fasting': ('Jeûne intermittent', '⏱️'), + 'exercise': ('Exercice', '💪'), + 'sleep': ('Sommeil', '😴'), + 'stress': ('Gestion du stress', '💚'), + 'supplements': ('Suppléments', '💊'), + 'mental_health': ('Santé mentale', '🧠'), + 'other': ('Autre', '📌'), +} + + +# ── Models ───────────────────────────────────────────────────────────────────── + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(256), nullable=False) + is_admin = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=db.func.now()) + profile = db.relationship('Profile', backref='user', uselist=False, cascade='all, delete-orphan') + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + +class Profile(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + first_name = db.Column(db.String(64)) + last_name = db.Column(db.String(64)) + birth_year = db.Column(db.Integer) + sex = db.Column(db.String(16)) + height_cm = db.Column(db.Float) + weight_kg = db.Column(db.Float) + + ethnicity = db.Column(db.String(64)) + country_origin = db.Column(db.String(64)) + blood_type = db.Column(db.String(8)) + + _conditions = db.Column('conditions', db.Text, default='[]') + _allergies_text = db.Column('allergies_text', db.Text, default='') + + sleep_hours = db.Column(db.Float) + stress_level = db.Column(db.Integer) + smoking = db.Column(db.String(16)) + alcohol_freq = db.Column(db.String(16)) + + diet_type = db.Column(db.String(32)) + _food_intolerances = db.Column('food_intolerances', db.Text, default='[]') + + activity_level = db.Column(db.String(16)) + _activity_types = db.Column('activity_types', db.Text, default='[]') + + _health_goals = db.Column('health_goals', db.Text, default='[]') + + updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now()) + + def _get_json(self, col): + return json.loads(getattr(self, col) or '[]') + + def _set_json(self, col, val): + setattr(self, col, json.dumps(val)) + + @property + def conditions(self): return self._get_json('_conditions') + @conditions.setter + def conditions(self, v): self._set_json('_conditions', v) + + @property + def food_intolerances(self): return self._get_json('_food_intolerances') + @food_intolerances.setter + def food_intolerances(self, v): self._set_json('_food_intolerances', v) + + @property + def activity_types(self): return self._get_json('_activity_types') + @activity_types.setter + def activity_types(self, v): self._set_json('_activity_types', v) + + @property + def health_goals(self): return self._get_json('_health_goals') + @health_goals.setter + def health_goals(self, v): self._set_json('_health_goals', v) + + @property + def age(self): + if self.birth_year: + return date.today().year - self.birth_year + return None + + @property + def bmi(self): + if self.height_cm and self.weight_kg and self.height_cm > 0: + h = self.height_cm / 100 + return round(self.weight_kg / (h * h), 1) + return None + + @property + def bmi_category(self): + b = self.bmi + if b is None: return None + if b < 18.5: return ('Insuffisance pondérale', 'info') + if b < 25: return ('Poids normal', 'success') + if b < 30: return ('Surpoids', 'warning') + return ('Obésité', 'danger') + + def to_dict(self): + return { + 'first_name': self.first_name, 'age': self.age, + 'birth_year': self.birth_year, 'sex': self.sex, + 'height_cm': self.height_cm, 'weight_kg': self.weight_kg, + 'bmi': self.bmi, 'ethnicity': self.ethnicity, + 'country_origin': self.country_origin, 'blood_type': self.blood_type, + 'conditions': self.conditions, 'sleep_hours': self.sleep_hours, + 'stress_level': self.stress_level, 'smoking': self.smoking, + 'alcohol_freq': self.alcohol_freq, 'diet_type': self.diet_type, + 'food_intolerances': self.food_intolerances, + 'activity_level': self.activity_level, + 'activity_types': self.activity_types, + 'health_goals': self.health_goals, + } + + +# ── Crawl / Admin Models ──────────────────────────────────────────────────────── + +class CrawlSource(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), nullable=False) + url = db.Column(db.String(512), nullable=False) + source_type = db.Column(db.String(16), default='webpage') # webpage | rss + active = db.Column(db.Boolean, default=True) + last_crawled_at = db.Column(db.DateTime) + articles_found = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=db.func.now()) + category = db.Column(db.String(16), default='advice') # 'advice' | 'scientific' + articles = db.relationship('RawArticle', backref='source', lazy='dynamic') + + +class RawArticle(db.Model): + id = db.Column(db.Integer, primary_key=True) + source_id = db.Column(db.Integer, db.ForeignKey('crawl_source.id'), nullable=True) + url = db.Column(db.String(512), unique=True, nullable=False) + title = db.Column(db.String(512)) + content = db.Column(db.Text) + crawled_at = db.Column(db.DateTime, default=db.func.now()) + analyzed = db.Column(db.Boolean, default=False) + recommendations = db.relationship('PendingRecommendation', backref='article', lazy='dynamic') + + +class PendingRecommendation(db.Model): + id = db.Column(db.Integer, primary_key=True) + article_id = db.Column(db.Integer, db.ForeignKey('raw_article.id'), nullable=True) + + domain = db.Column(db.String(32), nullable=False, default='other') + title = db.Column(db.String(256), nullable=False) + summary = db.Column(db.Text) + details = db.Column(db.Text) + evidence_level = db.Column(db.String(4), default='C') + study_type = db.Column(db.String(32)) + intervention = db.Column(db.String(512)) + effect = db.Column(db.String(512)) + + source_title = db.Column(db.String(512)) + source_doi = db.Column(db.String(256)) + source_url = db.Column(db.String(512)) + + _tags = db.Column('tags', db.Text, default='[]') + _contraindications = db.Column('contraindications', db.Text, default='[]') + _required_conditions = db.Column('required_conditions', db.Text, default='[]') + _excluded_conditions = db.Column('excluded_conditions', db.Text, default='[]') + _required_health_goals = db.Column('required_health_goals', db.Text, default='[]') + _required_diet_types = db.Column('required_diet_types', db.Text, default='[]') + _required_activity_levels = db.Column('required_activity_levels', db.Text, default='[]') + + target_age_min = db.Column(db.Integer) + target_age_max = db.Column(db.Integer) + target_sex = db.Column(db.String(16), default='all') + min_stress_level = db.Column(db.Integer) + min_bmi = db.Column(db.Float) + not_for_under_18 = db.Column(db.Boolean, default=False) + not_for_pregnant = db.Column(db.Boolean, default=False) + + priority = db.Column(db.Integer, default=5) + status = db.Column(db.String(16), default='pending') # pending | approved | rejected + admin_notes = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=db.func.now()) + reviewed_at = db.Column(db.DateTime) + + def _get_json(self, col): + try: + return json.loads(getattr(self, col) or '[]') + except Exception: + return [] + + def _set_json(self, col, val): + setattr(self, col, json.dumps(val if val is not None else [])) + + @property + def tags(self): return self._get_json('_tags') + @tags.setter + def tags(self, v): self._set_json('_tags', v) + + @property + def contraindications(self): return self._get_json('_contraindications') + @contraindications.setter + def contraindications(self, v): self._set_json('_contraindications', v) + + @property + def required_conditions(self): return self._get_json('_required_conditions') + @required_conditions.setter + def required_conditions(self, v): self._set_json('_required_conditions', v) + + @property + def excluded_conditions(self): return self._get_json('_excluded_conditions') + @excluded_conditions.setter + def excluded_conditions(self, v): self._set_json('_excluded_conditions', v) + + @property + def required_health_goals(self): return self._get_json('_required_health_goals') + @required_health_goals.setter + def required_health_goals(self, v): self._set_json('_required_health_goals', v) + + @property + def required_diet_types(self): return self._get_json('_required_diet_types') + @required_diet_types.setter + def required_diet_types(self, v): self._set_json('_required_diet_types', v) + + @property + def required_activity_levels(self): return self._get_json('_required_activity_levels') + @required_activity_levels.setter + def required_activity_levels(self, v): self._set_json('_required_activity_levels', v) + + def matches_profile(self, profile: dict) -> bool: + age = profile.get('age') or 0 + sex = profile.get('sex') or '' + conditions = profile.get('conditions') or [] + health_goals = profile.get('health_goals') or [] + diet_type = profile.get('diet_type') or '' + activity_level = profile.get('activity_level') or '' + bmi = profile.get('bmi') or 0 + stress = profile.get('stress_level') or 0 + + # Hard gates + if self.not_for_under_18 and age and age < 18: + return False + if self.target_age_min and age and age < self.target_age_min: + return False + if self.target_age_max and age and age > self.target_age_max: + return False + if self.target_sex and self.target_sex != 'all' and sex and sex != self.target_sex: + return False + if self.min_bmi and bmi and bmi < self.min_bmi: + return False + if self.min_stress_level and stress and stress < self.min_stress_level: + return False + for cond in self.excluded_conditions: + if cond in conditions: + return False + + # Soft criteria — OR across groups + criteria = [] + if self.required_conditions: + criteria.append(any(c in conditions for c in self.required_conditions)) + if self.required_health_goals: + criteria.append(any(g in health_goals for g in self.required_health_goals)) + if self.required_diet_types: + criteria.append(diet_type in self.required_diet_types) + if self.required_activity_levels: + criteria.append(activity_level in self.required_activity_levels) + + if not criteria: + return True # Universal recommendation + return any(criteria) + + def to_edit_dict(self) -> dict: + return { + 'id': self.id, + 'domain': self.domain, + 'evidence_level': self.evidence_level, + 'study_type': self.study_type or '', + 'title': self.title, + 'summary': self.summary or '', + 'details': self.details or '', + 'intervention': self.intervention or '', + 'effect': self.effect or '', + 'source_title': self.source_title or '', + 'source_doi': self.source_doi or '', + 'source_url': self.source_url or '', + 'priority': self.priority, + 'target_sex': self.target_sex or 'all', + 'target_age_min': self.target_age_min, + 'target_age_max': self.target_age_max, + 'not_for_under_18': bool(self.not_for_under_18), + 'not_for_pregnant': bool(self.not_for_pregnant), + 'admin_notes': self.admin_notes or '', + '_required_conditions': self._required_conditions or '[]', + '_excluded_conditions': self._excluded_conditions or '[]', + '_required_health_goals': self._required_health_goals or '[]', + '_required_diet_types': self._required_diet_types or '[]', + '_required_activity_levels': self._required_activity_levels or '[]', + } + + def to_rec_dict(self) -> dict: + label, icon = _DOMAIN_LABELS.get(self.domain, (self.domain, '📌')) + src = self.source_title or '' + if self.source_doi: + src += f' — DOI: {self.source_doi}' + elif self.source_url: + src += f' — {self.source_url}' + return { + 'id': f'db_{self.id}', + 'domain': self.domain, + 'domain_label': label, + 'icon': icon, + 'title': self.title, + 'summary': self.summary, + 'details': self.details, + 'evidence_level': self.evidence_level, + 'source': src, + 'tags': self.tags, + 'priority': self.priority, + } + + +class CrawlRun(db.Model): + id = db.Column(db.Integer, primary_key=True) + started_at = db.Column(db.DateTime, default=db.func.now()) + ended_at = db.Column(db.DateTime) + status = db.Column(db.String(16), default='running') # running | done | error + articles_processed = db.Column(db.Integer, default=0) + recs_generated = db.Column(db.Integer, default=0) + _log_lines = db.Column('log_lines', db.Text, default='[]') + + @property + def log_lines(self): + try: + return json.loads(self._log_lines or '[]') + except Exception: + return [] + + def append_log(self, msg): + ts = datetime.utcnow().strftime('%H:%M:%S') + lines = self.log_lines + lines.append(f"[{ts}] {msg}") + self._log_lines = json.dumps(lines[-150:]) + db.session.add(self) + db.session.commit() + + def to_dict(self): + return { + 'status': self.status, + 'run_id': self.id, + 'started_at': self.started_at.strftime('%d/%m/%Y %H:%M:%S') if self.started_at else None, + 'ended_at': self.ended_at.strftime('%d/%m/%Y %H:%M:%S') if self.ended_at else None, + 'articles_processed': self.articles_processed or 0, + 'recs_generated': self.recs_generated or 0, + 'log': self.log_lines, + } + + +class CrawlConfig(db.Model): + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(64), unique=True, nullable=False) + value = db.Column(db.Text) + + @classmethod + def get(cls, key, default=None): + r = cls.query.filter_by(key=key).first() + return r.value if r else default + + @classmethod + def set(cls, key, value): + r = cls.query.filter_by(key=key).first() + v = str(value) if value is not None else '' + if r: + r.value = v + else: + db.session.add(cls(key=key, value=v)) + + +# ── Scheduler ────────────────────────────────────────────────────────────────── + +scheduler = None + + +def _build_scheduler(): + from apscheduler.schedulers.background import BackgroundScheduler + s = BackgroundScheduler(daemon=True) + return s + + +def _scheduled_crawl(): + from pipeline import run_pipeline + with app.app_context(): + api_key = CrawlConfig.get('openai_api_key', '') + model = CrawlConfig.get('openai_model', 'gpt-4o') + max_art = int(CrawlConfig.get('max_articles_per_run', '20')) + run_pipeline(db.engine, api_key=api_key, model=model, max_articles=max_art) + + +def start_scheduler(): + global scheduler + if scheduler and scheduler.running: + return + scheduler = _build_scheduler() + hour = int(CrawlConfig.get('crawl_hour', '2')) + minute = int(CrawlConfig.get('crawl_minute', '0')) + scheduler.add_job(_scheduled_crawl, 'cron', hour=hour, minute=minute, id='nightly_crawl') + scheduler.start() + + +def reschedule(): + if scheduler and scheduler.running: + hour = int(CrawlConfig.get('crawl_hour', '2')) + minute = int(CrawlConfig.get('crawl_minute', '0')) + scheduler.reschedule_job('nightly_crawl', trigger='cron', hour=hour, minute=minute) + + +# ── Login manager ────────────────────────────────────────────────────────────── + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + + +# ── Auth routes ──────────────────────────────────────────────────────────────── + +@app.route('/') +def index(): + return render_template('index.html') + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('dashboard')) + if request.method == 'POST': + email = request.form.get('email', '').strip().lower() + password = request.form.get('password', '') + confirm = request.form.get('confirm_password', '') + if not email or not password: + flash('Email et mot de passe requis.', 'danger') + elif len(password) < 6: + flash('Le mot de passe doit contenir au moins 6 caractères.', 'danger') + elif password != confirm: + flash('Les mots de passe ne correspondent pas.', 'danger') + elif User.query.filter_by(email=email).first(): + flash('Cet email est déjà utilisé.', 'danger') + else: + is_first = User.query.count() == 0 + user = User(email=email, is_admin=is_first) + user.set_password(password) + db.session.add(user) + db.session.commit() + login_user(user) + if is_first: + flash('Compte admin créé ! Configurez le crawl dans Administration.', 'success') + else: + flash('Compte créé ! Complétez votre profil pour recevoir vos recommandations.', 'success') + return redirect(url_for('profile')) + return render_template('register.html') + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('dashboard')) + if request.method == 'POST': + email = request.form.get('email', '').strip().lower() + password = request.form.get('password', '') + user = User.query.filter_by(email=email).first() + if user and user.check_password(password): + login_user(user, remember=True) + return redirect(url_for('dashboard')) + flash('Email ou mot de passe incorrect.', 'danger') + return render_template('login.html') + + +@app.route('/logout') +@login_required +def logout(): + logout_user() + flash('Vous êtes déconnecté.', 'info') + return redirect(url_for('index')) + + +# ── Profile ──────────────────────────────────────────────────────────────────── + +@app.route('/profile', methods=['GET', 'POST']) +@login_required +def profile(): + prof = current_user.profile + if prof is None: + prof = Profile(user_id=current_user.id) + db.session.add(prof) + + if request.method == 'POST': + f = request.form + prof.first_name = f.get('first_name', '').strip() or None + prof.last_name = f.get('last_name', '').strip() or None + prof.birth_year = int(f['birth_year']) if f.get('birth_year') else None + prof.sex = f.get('sex') or None + prof.height_cm = float(f['height_cm']) if f.get('height_cm') else None + prof.weight_kg = float(f['weight_kg']) if f.get('weight_kg') else None + prof.ethnicity = f.get('ethnicity') or None + prof.country_origin = f.get('country_origin', '').strip() or None + prof.blood_type = f.get('blood_type') or None + prof.conditions = request.form.getlist('conditions') + prof._allergies_text = f.get('allergies_text', '').strip() + prof.sleep_hours = float(f['sleep_hours']) if f.get('sleep_hours') else None + prof.stress_level = int(f['stress_level']) if f.get('stress_level') else None + prof.smoking = f.get('smoking') or None + prof.alcohol_freq = f.get('alcohol_freq') or None + prof.diet_type = f.get('diet_type') or None + prof.food_intolerances = request.form.getlist('food_intolerances') + prof.activity_level = f.get('activity_level') or None + prof.activity_types = request.form.getlist('activity_types') + prof.health_goals = request.form.getlist('health_goals') + db.session.commit() + flash('Profil mis à jour avec succès.', 'success') + return redirect(url_for('dashboard')) + + return render_template('profile.html', profile=prof) + + +# ── Dashboard ────────────────────────────────────────────────────────────────── + +@app.route('/dashboard') +@login_required +def dashboard(): + from recommendations import get_recommendations, profile_completeness + prof = current_user.profile + if not prof: + flash('Complétez votre profil pour recevoir des recommandations personnalisées.', 'info') + return redirect(url_for('profile')) + + prof_dict = prof.to_dict() + static_recs = get_recommendations(prof_dict) + + approved = PendingRecommendation.query.filter_by(status='approved').all() + db_recs = [r.to_rec_dict() for r in approved if r.matches_profile(prof_dict)] + + all_recs = static_recs + db_recs + all_recs.sort(key=lambda r: -r.get('priority', 0)) + + domain_order = ['nutrition', 'fasting', 'exercise', 'sleep', 'stress', 'supplements', 'mental_health', 'other'] + domains = {} + for r in all_recs: + domains.setdefault(r['domain'], []).append(r) + + ordered_domains = [ + (d, _DOMAIN_LABELS.get(d, (d, '📌')), domains[d]) + for d in domain_order if d in domains + ] + + completeness = profile_completeness(prof) + return render_template( + 'dashboard.html', + profile=prof, + ordered_domains=ordered_domains, + total=len(all_recs), + completeness=completeness, + ) + + +# ── Admin routes ──────────────────────────────────────────────────────────────── + +@app.route('/admin') +@login_required +@admin_required +def admin_index(): + stats = { + 'sources': CrawlSource.query.count(), + 'sources_active': CrawlSource.query.filter_by(active=True).count(), + 'articles': RawArticle.query.count(), + 'pending': PendingRecommendation.query.filter_by(status='pending').count(), + 'approved': PendingRecommendation.query.filter_by(status='approved').count(), + 'rejected': PendingRecommendation.query.filter_by(status='rejected').count(), + } + last_article = RawArticle.query.order_by(RawArticle.crawled_at.desc()).first() + last_crawl = last_article.crawled_at if last_article else None + api_key_set = bool(CrawlConfig.get('openai_api_key')) + return render_template('admin/index.html', stats=stats, last_crawl=last_crawl, api_key_set=api_key_set) + + +@app.route('/admin/sources', methods=['GET', 'POST']) +@login_required +@admin_required +def admin_sources(): + if request.method == 'POST': + name = request.form.get('name', '').strip() + url = request.form.get('url', '').strip() + stype = request.form.get('source_type', 'rss') + category = request.form.get('category', 'advice') + if not name or not url: + flash('Nom et URL requis.', 'danger') + else: + db.session.add(CrawlSource(name=name, url=url, source_type=stype, category=category)) + db.session.commit() + flash(f'Source « {name} » ajoutée.', 'success') + return redirect(url_for('admin_sources')) + sources = CrawlSource.query.order_by(CrawlSource.created_at.desc()).all() + return render_template('admin/sources.html', sources=sources) + + +@app.route('/admin/sources//toggle', methods=['POST']) +@login_required +@admin_required +def admin_source_toggle(sid): + src = db.get_or_404(CrawlSource, sid) + src.active = not src.active + db.session.commit() + return redirect(url_for('admin_sources')) + + +@app.route('/admin/sources//delete', methods=['POST']) +@login_required +@admin_required +def admin_source_delete(sid): + src = db.get_or_404(CrawlSource, sid) + db.session.delete(src) + db.session.commit() + flash(f'Source « {src.name} » supprimée.', 'info') + return redirect(url_for('admin_sources')) + + +@app.route('/admin/crawl/run', methods=['POST']) +@login_required +@admin_required +def admin_crawl_run(): + import threading + from pipeline import run_pipeline + + source_id = request.form.get('source_id', type=int) + + # Fetch everything we need while we're still in the Flask/SQLAlchemy context + engine = db.engine + api_key = CrawlConfig.get('openai_api_key', '') + model = CrawlConfig.get('openai_model', 'gpt-4o') + max_art = int(CrawlConfig.get('max_articles_per_run', '20')) + + def do_crawl(): + # Background thread: no Flask context, plain SQLAlchemy session + run_pipeline(engine, source_id=source_id, + api_key=api_key, model=model, max_articles=max_art) + + threading.Thread(target=do_crawl, daemon=True).start() + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'status': 'starting'}) + + flash('Crawl lancé en arrière-plan.', 'info') + return redirect(url_for('admin_sources')) + + +@app.route('/admin/crawl/status') +@login_required +@admin_required +def admin_crawl_status(): + run = CrawlRun.query.order_by(CrawlRun.started_at.desc()).first() + if not run: + return jsonify({'status': 'idle', 'log': [], 'run_id': None}) + return jsonify(run.to_dict()) + + +@app.route('/admin/review') +@login_required +@admin_required +def admin_review(): + status_filter = request.args.get('status', 'pending') + query = PendingRecommendation.query + if status_filter in ('pending', 'approved', 'rejected'): + query = query.filter_by(status=status_filter) + recs = query.order_by(PendingRecommendation.created_at.desc()).all() + counts = { + 'pending': PendingRecommendation.query.filter_by(status='pending').count(), + 'approved': PendingRecommendation.query.filter_by(status='approved').count(), + 'rejected': PendingRecommendation.query.filter_by(status='rejected').count(), + } + domains = list(_DOMAIN_LABELS.keys()) + return render_template('admin/review.html', recs=recs, counts=counts, + status_filter=status_filter, domains=domains, + domain_labels=_DOMAIN_LABELS) + + +@app.route('/admin/review//approve', methods=['POST']) +@login_required +@admin_required +def admin_review_approve(rid): + rec = db.get_or_404(PendingRecommendation, rid) + rec.status = 'approved' + rec.reviewed_at = datetime.utcnow() + db.session.commit() + flash(f'« {rec.title} » approuvée et active.', 'success') + return redirect(url_for('admin_review', status='pending')) + + +@app.route('/admin/review//reject', methods=['POST']) +@login_required +@admin_required +def admin_review_reject(rid): + rec = db.get_or_404(PendingRecommendation, rid) + rec.status = 'rejected' + rec.admin_notes = request.form.get('notes', '') + rec.reviewed_at = datetime.utcnow() + db.session.commit() + flash(f'« {rec.title} » rejetée.', 'info') + return redirect(url_for('admin_review', status='pending')) + + +@app.route('/admin/review//update', methods=['POST']) +@login_required +@admin_required +def admin_review_update(rid): + rec = db.get_or_404(PendingRecommendation, rid) + f = request.form + + rec.domain = f.get('domain', rec.domain) + rec.title = f.get('title', rec.title).strip()[:256] + rec.summary = f.get('summary', rec.summary).strip() + rec.details = f.get('details', rec.details).strip() + rec.evidence_level = f.get('evidence_level', rec.evidence_level) + rec.study_type = f.get('study_type', rec.study_type) + rec.intervention = f.get('intervention', rec.intervention or '').strip() + rec.effect = f.get('effect', rec.effect or '').strip() + rec.source_title = f.get('source_title', rec.source_title or '').strip() + rec.source_doi = f.get('source_doi', rec.source_doi or '').strip() + rec.source_url = f.get('source_url', rec.source_url or '').strip() + rec.priority = int(f.get('priority', rec.priority)) + rec.target_sex = f.get('target_sex', rec.target_sex) + rec.target_age_min = int(f['target_age_min']) if f.get('target_age_min') else None + rec.target_age_max = int(f['target_age_max']) if f.get('target_age_max') else None + rec.not_for_under_18 = 'not_for_under_18' in f + rec.not_for_pregnant = 'not_for_pregnant' in f + rec.required_conditions = f.getlist('required_conditions') + rec.excluded_conditions = f.getlist('excluded_conditions') + rec.required_health_goals = f.getlist('required_health_goals') + rec.required_diet_types = f.getlist('required_diet_types') + rec.required_activity_levels = f.getlist('required_activity_levels') + rec.admin_notes = f.get('admin_notes', '').strip() + + action = f.get('action', 'save') + if action == 'approve': + rec.status = 'approved' + rec.reviewed_at = datetime.utcnow() + flash(f'« {rec.title} » modifiée et approuvée.', 'success') + else: + flash(f'« {rec.title} » mise à jour.', 'info') + + db.session.commit() + return redirect(url_for('admin_review', status=rec.status if action != 'approve' else 'pending')) + + +@app.route('/admin/library') +@login_required +@admin_required +def admin_library(): + recs = PendingRecommendation.query.filter_by(status='approved') \ + .order_by(PendingRecommendation.domain, PendingRecommendation.priority.desc()).all() + return render_template('admin/library.html', recs=recs, domain_labels=_DOMAIN_LABELS) + + +@app.route('/admin/library//delete', methods=['POST']) +@login_required +@admin_required +def admin_library_delete(rid): + rec = db.get_or_404(PendingRecommendation, rid) + title = rec.title + rec.status = 'rejected' + rec.reviewed_at = datetime.utcnow() + db.session.commit() + flash(f'« {title} » retirée de la bibliothèque.', 'info') + return redirect(url_for('admin_library')) + + +@app.route('/admin/config', methods=['GET', 'POST']) +@login_required +@admin_required +def admin_config(): + if request.method == 'POST': + f = request.form + if f.get('openai_api_key'): + CrawlConfig.set('openai_api_key', f['openai_api_key'].strip()) + CrawlConfig.set('openai_model', f.get('openai_model', 'gpt-4o')) + CrawlConfig.set('crawl_hour', f.get('crawl_hour', '2')) + CrawlConfig.set('crawl_minute', f.get('crawl_minute', '0')) + CrawlConfig.set('max_articles_per_run', f.get('max_articles_per_run', '20')) + db.session.commit() + reschedule() + flash('Configuration enregistrée.', 'success') + return redirect(url_for('admin_config')) + + config = { + 'openai_api_key': CrawlConfig.get('openai_api_key', ''), + 'openai_model': CrawlConfig.get('openai_model', 'gpt-4o'), + 'crawl_hour': CrawlConfig.get('crawl_hour', '2'), + 'crawl_minute': CrawlConfig.get('crawl_minute', '0'), + 'max_articles_per_run': CrawlConfig.get('max_articles_per_run', '20'), + } + return render_template('admin/config.html', config=config) + + +# ── Template globals ─────────────────────────────────────────────────────────── + +@app.context_processor +def inject_admin_globals(): + return dict( + CrawlSource=CrawlSource, + PendingRecommendation=PendingRecommendation, + CrawlConfig=CrawlConfig, + CrawlRun=CrawlRun, + ) + + +# ── Error handlers ───────────────────────────────────────────────────────────── + +@app.errorhandler(403) +def forbidden(e): + return render_template('403.html'), 403 + + +# ── Startup ──────────────────────────────────────────────────────────────────── + +def _init_db(): + db.create_all() + from sqlalchemy import text as _sql_text + with db.engine.connect() as _c: + try: + _c.execute(_sql_text( + "ALTER TABLE crawl_source ADD COLUMN category VARCHAR(16) DEFAULT 'advice'" + )) + _c.commit() + except Exception: + pass + + +if __name__ == '__main__': + with app.app_context(): + _init_db() + if not os.environ.get('WERKZEUG_RUN_MAIN'): + with app.app_context(): + start_scheduler() + debug = os.environ.get('FLASK_ENV') != 'production' + port = int(os.environ.get('PORT', 5000)) + app.run(debug=debug, host='0.0.0.0', port=port) diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..6aef8fa --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,47 @@ +server { + listen 80; + server_name opencare.open-squared.tech; # adapter selon votre domaine + + # Redirection HTTPS (décommenter après avoir configuré le certificat) + # return 301 https://$host$request_uri; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # Fichiers statiques servis directement par nginx + location /static/ { + alias /opt/opencare/static/; + expires 7d; + add_header Cache-Control "public, immutable"; + } +} + +# Bloc HTTPS (générer le certificat avec : certbot --nginx -d opencare.open-squared.tech) +# server { +# listen 443 ssl; +# server_name opencare.open-squared.tech; +# ssl_certificate /etc/letsencrypt/live/opencare.open-squared.tech/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/opencare.open-squared.tech/privkey.pem; +# include /etc/letsencrypt/options-ssl-nginx.conf; +# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +# +# location / { +# proxy_pass http://127.0.0.1:5000; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# proxy_read_timeout 120s; +# } +# +# location /static/ { +# alias /opt/opencare/static/; +# expires 7d; +# } +# } diff --git a/deploy/opencare.service b/deploy/opencare.service new file mode 100644 index 0000000..0a9ea4f --- /dev/null +++ b/deploy/opencare.service @@ -0,0 +1,18 @@ +[Unit] +Description=OpenCare Web App +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/opencare +EnvironmentFile=/opt/opencare/.env +ExecStart=/opt/opencare/venv/bin/gunicorn -c gunicorn.conf.py wsgi:app +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/deploy/setup_vps.sh b/deploy/setup_vps.sh new file mode 100644 index 0000000..9554e3b --- /dev/null +++ b/deploy/setup_vps.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Script d'installation OpenCare sur VPS Ubuntu/Debian +# Usage : sudo bash setup_vps.sh + +set -e + +APP_DIR=/opt/opencare +APP_USER=www-data +REPO=https://gitea.open-squared.tech/admin/opencare.git + +echo "=== Installation OpenCare ===" + +# 1. Dépendances système +apt-get update -qq +apt-get install -y python3 python3-pip python3-venv nginx git + +# 2. Cloner / mettre à jour le repo +if [ -d "$APP_DIR/.git" ]; then + echo "--- Mise à jour du code ---" + cd "$APP_DIR" && git pull +else + echo "--- Clonage du repo ---" + git clone "$REPO" "$APP_DIR" +fi + +cd "$APP_DIR" + +# 3. Environnement virtuel Python +if [ ! -d "$APP_DIR/venv" ]; then + python3 -m venv venv +fi +venv/bin/pip install -q --upgrade pip +venv/bin/pip install -q -r requirements.txt + +# 4. Fichier .env (créer si absent) +if [ ! -f "$APP_DIR/.env" ]; then + cp .env.example .env + SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))") + sed -i "s/change-me-with-a-strong-random-key/$SECRET/" .env + echo "ATTENTION : .env créé avec une clé secrète aléatoire." + echo "Editez /opt/opencare/.env pour configurer DATABASE_URL et autres." +fi + +# 5. Dossier instance (SQLite) +mkdir -p "$APP_DIR/instance" +chown -R "$APP_USER:$APP_USER" "$APP_DIR" + +# 6. Service systemd +cp deploy/opencare.service /etc/systemd/system/opencare.service +systemctl daemon-reload +systemctl enable opencare +systemctl restart opencare + +# 7. Nginx +cp deploy/nginx.conf /etc/nginx/sites-available/opencare +ln -sf /etc/nginx/sites-available/opencare /etc/nginx/sites-enabled/opencare +rm -f /etc/nginx/sites-enabled/default +nginx -t && systemctl reload nginx + +echo "" +echo "=== Déploiement terminé ===" +echo "L'application tourne sur http://$(hostname -I | awk '{print $1}')" +echo "Consultez les logs : journalctl -u opencare -f" diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100644 index 0000000..8ef9fdf --- /dev/null +++ b/deploy/update.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Mise à jour rapide après git pull +# Usage (depuis /opt/opencare) : bash deploy/update.sh + +set -e +cd /opt/opencare + +git pull +venv/bin/pip install -q -r requirements.txt +systemctl restart opencare +echo "Mise à jour terminée — $(systemctl is-active opencare)" diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..7469b0a --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,10 @@ +import os + +bind = f"0.0.0.0:{os.environ.get('PORT', '5000')}" +workers = 1 # 1 worker = APScheduler tourne une seule fois (SQLite-safe) +threads = 4 # Multi-thread dans le worker unique +preload_app = True # Charge l'app avant de forker +timeout = 120 +accesslog = "-" +errorlog = "-" +loglevel = "info" diff --git a/pipeline.py b/pipeline.py new file mode 100644 index 0000000..7326095 --- /dev/null +++ b/pipeline.py @@ -0,0 +1,407 @@ +""" +Pipeline de crawl — fonctionne dans un thread background sans contexte Flask. +Reçoit un sqlalchemy.engine.Engine + la config, crée sa propre Session native. +""" +import json +import logging +import requests +from bs4 import BeautifulSoup +from datetime import datetime +from sqlalchemy.orm import Session as SASession + +logger = logging.getLogger(__name__) + +_CONDITIONS = [ + 'hypertension', 'diabetes_risk', 'cardiovascular_risk', 'anxiety', + 'digestive_issues', 'back_pain', 'osteoporosis_risk', 'metabolic_syndrome', + 'thyroid', 'sleep_apnea', +] +_HEALTH_GOALS = [ + 'weight_loss', 'muscle_gain', 'longevity', 'energy', 'better_sleep', + 'stress_reduction', 'metabolic_health', 'cardiovascular_health', 'cognitive_health', +] +_DIET_TYPES = ['omnivore', 'flexitarian', 'vegetarian', 'vegan', 'paleo', 'keto', 'other'] +_ACTIVITY_LEVELS = ['sedentary', 'light', 'moderate', 'intense', 'athlete'] + +SYSTEM_PROMPT = f"""Tu es un expert en médecine factuelle (evidence-based medicine). +Analyse le contenu santé fourni et extrait les recommandations médicales/santé qu'il contient. + +Retourne UNIQUEMENT du JSON valide. +Si le contenu ne contient pas de recommandation santé pertinente, retourne: {{"relevant": false}} + +Format attendu: +{{ + "relevant": true, + "recommendations": [ + {{ + "domain": "nutrition|fasting|exercise|sleep|stress|supplements|mental_health|other", + "title": "Titre court (< 60 caractères)", + "summary": "1-2 phrases résumant l'action recommandée", + "details": "2-4 phrases avec mécanisme d'action et données quantitatives si disponibles", + "evidence_level": "A (méta-analyse/RCT solide) | B (observationnel/RCT limité) | C (expert/anecdote)", + "study_type": "meta-analysis|RCT|observational|expert_opinion|anecdote", + "intervention": "Action concrète à mettre en oeuvre", + "effect": "Résultat attendu avec chiffres si disponibles", + "source_title": "Titre de l'étude ou article source", + "source_doi": "DOI si disponible sinon null", + "source_url": "URL de la source", + "tags": ["tag1", "tag2", "tag3"], + "contraindications": ["contre-indication 1"], + "target_age_min": null, + "target_age_max": null, + "target_sex": "all|male|female", + "required_conditions": [], + "excluded_conditions": [], + "required_health_goals": [], + "required_diet_types": [], + "required_activity_levels": [], + "not_for_under_18": false, + "not_for_pregnant": false, + "priority": 5 + }} + ] +}} + +Valeurs acceptées pour required_conditions / excluded_conditions: {_CONDITIONS} +Valeurs acceptées pour required_health_goals: {_HEALTH_GOALS} +Valeurs acceptées pour required_diet_types: {_DIET_TYPES} +Valeurs acceptées pour required_activity_levels: {_ACTIVITY_LEVELS} + +Pour required_conditions: conditions qui DÉCLENCHENT cette rec. +Pour excluded_conditions: conditions INCOMPATIBLES avec cette rec. +Si la recommandation est universelle, laisser toutes les listes vides. + +RÈGLE ABSOLUE — Citation scientifique: +- Identifier TOUJOURS l'étude primaire sous-jacente (PubMed, Cochrane, NEJM, Lancet, BMJ…) +- source_doi: DOI de l'étude originale (ex: 10.1056/NEJMoa1200303), PAS l'URL de l'article source +- source_url: URL PubMed ou DOI de la source primaire (https://doi.org/...), pas le site référant +- evidence_level A UNIQUEMENT si méta-analyse ou RCT avec ≥ 100 participants +- Ne jamais inventer de référence: si incertain, mettre source_doi null et evidence_level C +- study_type doit refléter le niveau de preuve réel de l'étude citée +""" + + +# ── DB helpers (session-aware, no Flask context) ─────────────────────────────── + +def _log(session: SASession, run, msg: str) -> None: + ts = datetime.utcnow().strftime('%H:%M:%S') + lines = run.log_lines + lines.append(f"[{ts}] {msg}") + run._log_lines = json.dumps(lines[-150:]) + session.add(run) + session.commit() + logger.info(msg) + + +# ── Web fetchers ─────────────────────────────────────────────────────────────── + +def _fetch_webpage(url: str) -> tuple: + headers = {'User-Agent': 'Mozilla/5.0 (compatible; OpenCare-Bot/1.0)'} + r = requests.get(url, headers=headers, timeout=20) + r.raise_for_status() + soup = BeautifulSoup(r.content, 'html.parser') + for tag in soup(['script', 'style', 'nav', 'footer', 'header', 'aside', 'iframe', 'form']): + tag.decompose() + title_tag = soup.find('title') + title = title_tag.get_text(strip=True) if title_tag else url + main = ( + soup.find('article') or soup.find('main') or + soup.find(id='content') or soup.find(class_='content') or + soup.find('body') + ) + text = main.get_text(separator='\n', strip=True) if main else soup.get_text() + return title, text[:6000] + + +def _fetch_rss(url: str) -> list: + import feedparser + feed = feedparser.parse(url) + results = [] + for entry in feed.entries[:30]: + link = entry.get('link', '') + title = entry.get('title', '') + content = entry.get('summary') or entry.get('description') or '' + if content: + soup = BeautifulSoup(content, 'html.parser') + content = soup.get_text(strip=True) + results.append((link, title, content[:4000])) + return results + + +def _fetch_index_page(url: str) -> list: + """Crawl an index/listing page and return (article_url, title) pairs found on it.""" + from urllib.parse import urlparse, urljoin + _SKIP = ( + '/tag/', '/category/', '/author/', '/page/', '/feed', '/wp-content', + 'login', 'search', 'contact', 'about', 'privacy', 'terms', + 'twitter.com', 'facebook.com', 'instagram.com', 'youtube.com', + ) + headers = {'User-Agent': 'Mozilla/5.0 (compatible; OpenCare-Bot/1.0)'} + r = requests.get(url, headers=headers, timeout=20) + r.raise_for_status() + soup = BeautifulSoup(r.content, 'html.parser') + base = urlparse(url) + seen = set() + links = [] + for a in soup.find_all('a', href=True): + href = a['href'].strip() + if not href or href.startswith(('#', 'mailto:', 'tel:', 'javascript:')): + continue + full = urljoin(url, href) + p = urlparse(full) + if p.netloc != base.netloc: + continue + path = p.path.rstrip('/') + if not path or path == base.path.rstrip('/'): + continue + if full in seen: + continue + if any(s in full.lower() for s in _SKIP): + continue + title = a.get_text(strip=True) + if len(title) < 10 or len(title) > 200: + continue + seen.add(full) + links.append((full, title)) + return links[:60] + + +# ── OpenAI analysis ──────────────────────────────────────────────────────────── + +def _analyze(text: str, title: str, api_key: str, model: str = 'gpt-4o') -> list: + from openai import OpenAI + client = OpenAI(api_key=api_key) + user_msg = f"Titre: {title}\n\nContenu:\n{text}" + resp = client.chat.completions.create( + model=model, + response_format={"type": "json_object"}, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + temperature=0.2, + ) + data = json.loads(resp.choices[0].message.content) + if not data.get('relevant', False): + return [] + return data.get('recommendations', []) + + +def _store_recs(session: SASession, pending_model, article_id: int, + recs: list, fallback_url: str) -> int: + count = 0 + for rec in recs: + p = pending_model( + article_id=article_id, + domain=rec.get('domain', 'other'), + title=(rec.get('title') or '')[:256], + summary=rec.get('summary', ''), + details=rec.get('details', ''), + evidence_level=rec.get('evidence_level', 'C'), + study_type=rec.get('study_type', ''), + intervention=(rec.get('intervention') or '')[:512], + effect=(rec.get('effect') or '')[:512], + source_title=(rec.get('source_title') or '')[:512], + source_doi=(rec.get('source_doi') or '')[:256], + source_url=(rec.get('source_url') or fallback_url or '')[:512], + target_age_min=rec.get('target_age_min'), + target_age_max=rec.get('target_age_max'), + target_sex=rec.get('target_sex', 'all'), + not_for_under_18=bool(rec.get('not_for_under_18', False)), + not_for_pregnant=bool(rec.get('not_for_pregnant', False)), + priority=int(rec.get('priority', 5)), + ) + p.tags = rec.get('tags') or [] + p.contraindications = rec.get('contraindications') or [] + p.required_conditions = rec.get('required_conditions') or [] + p.excluded_conditions = rec.get('excluded_conditions') or [] + p.required_health_goals = rec.get('required_health_goals') or [] + p.required_diet_types = rec.get('required_diet_types') or [] + p.required_activity_levels = rec.get('required_activity_levels') or [] + session.add(p) + count += 1 + return count + + +# ── Main entry point ─────────────────────────────────────────────────────────── + +def run_pipeline(engine, source_id: int = None, + api_key: str = '', model: str = 'gpt-4o', max_articles: int = 20): + """ + Runs entirely in a background thread with no Flask context. + Uses a plain SQLAlchemy session bound to the passed engine. + """ + # Import models lazily (avoiding circular imports at module load time) + from app import CrawlRun, CrawlSource, RawArticle, PendingRecommendation + + with SASession(engine) as session: + # Create run record — immediately visible to the polling endpoint + run = CrawlRun() + session.add(run) + session.commit() + + try: + if not api_key: + run.status = 'error' + _log(session, run, '❌ Clé API OpenAI manquante — configurez-la dans Administration > Configuration') + return 0 + + query = session.query(CrawlSource).filter_by(active=True) + if source_id: + query = query.filter_by(id=source_id) + sources = query.all() + + if not sources: + run.status = 'done' + run.ended_at = datetime.utcnow() + _log(session, run, 'ℹ Aucune source active à crawler') + return 0 + + _log(session, run, f'🚀 Démarrage — {len(sources)} source(s), modèle: {model}') + + total_articles = 0 + total_recs = 0 + + for source in sources: + if total_articles >= max_articles: + _log(session, run, f'⏹ Limite de {max_articles} articles atteinte — arrêt') + break + + _log(session, run, f'🔍 {source.name} — {source.url[:70]}…') + + try: + if source.source_type == 'rss': + items = _fetch_rss(source.url) + _log(session, run, f' → {len(items)} entrée(s) dans le flux RSS') + src_articles = 0 + src_recs = 0 + + for item_url, item_title, item_content in items: + if total_articles >= max_articles: + break + if not item_url: + continue + if session.query(RawArticle).filter_by(url=item_url).first(): + continue + + article = RawArticle( + source_id=source.id, url=item_url, + title=item_title, content=item_content, + ) + session.add(article) + session.flush() + + try: + _log(session, run, f' 🤖 Analyse : « {(item_title or item_url)[:55]} »') + recs = _analyze(item_content, item_title, api_key, model) + n = _store_recs(session, PendingRecommendation, article.id, recs, item_url) + session.commit() + article.analyzed = True + src_articles += 1 + src_recs += n + total_articles += 1 + total_recs += n + if recs: + _log(session, run, f' ✅ {n} recommandation(s) extraite(s)') + else: + _log(session, run, f' — Aucune recommandation pertinente') + except Exception as e: + _log(session, run, f' ⚠ Erreur analyse IA : {str(e)[:80]}') + session.rollback() + + _log(session, run, f'✓ {source.name} — {src_articles} article(s), {src_recs} rec(s)') + + elif source.source_type == 'index': + links = _fetch_index_page(source.url) + _log(session, run, f' → {len(links)} lien(s) repérés sur la page index') + src_articles = 0 + src_recs = 0 + + for link_url, link_title in links: + if total_articles >= max_articles: + break + if session.query(RawArticle).filter_by(url=link_url).first(): + continue + try: + title, text_content = _fetch_webpage(link_url) + title = title or link_title + article = RawArticle( + source_id=source.id, url=link_url, + title=title, content=text_content, + ) + session.add(article) + session.flush() + _log(session, run, f' 🤖 Analyse : « {(title or link_url)[:55]} »') + recs = _analyze(text_content, title, api_key, model) + n = _store_recs(session, PendingRecommendation, article.id, recs, link_url) + session.commit() + article.analyzed = True + src_articles += 1 + src_recs += n + total_articles += 1 + total_recs += n + if recs: + _log(session, run, f' ✅ {n} recommandation(s) extraite(s)') + else: + _log(session, run, f' — Aucune recommandation pertinente') + except Exception as e: + _log(session, run, f' ⚠ {link_url[:50]}: {str(e)[:60]}') + session.rollback() + + _log(session, run, f'✓ {source.name} — {src_articles} article(s), {src_recs} rec(s)') + + else: # webpage + if session.query(RawArticle).filter_by(url=source.url).first(): + _log(session, run, f' → Déjà crawlé (ignoré)') + else: + title, text = _fetch_webpage(source.url) + article = RawArticle( + source_id=source.id, url=source.url, + title=title, content=text, + ) + session.add(article) + session.flush() + + _log(session, run, f' 🤖 Analyse : « {title[:60]} »') + try: + recs = _analyze(text, title, api_key, model) + n = _store_recs(session, PendingRecommendation, article.id, recs, source.url) + session.commit() + article.analyzed = True + total_articles += 1 + total_recs += n + if recs: + _log(session, run, f'✓ {source.name} — {n} recommandation(s) extraite(s)') + else: + _log(session, run, f'✓ {source.name} — Aucune recommandation pertinente') + except Exception as e: + _log(session, run, f'⚠ {source.name} — Erreur IA : {str(e)[:80]}') + session.rollback() + + source.last_crawled_at = datetime.utcnow() + session.commit() + + except requests.exceptions.Timeout: + _log(session, run, f'⚠ {source.name} — Timeout réseau (>20s)') + session.rollback() + except requests.exceptions.ConnectionError as e: + _log(session, run, f'⚠ {source.name} — Erreur connexion : {str(e)[:80]}') + session.rollback() + except Exception as e: + _log(session, run, f'⚠ {source.name} — Erreur : {str(e)[:80]}') + session.rollback() + + run.status = 'done' + run.ended_at = datetime.utcnow() + run.articles_processed = total_articles + run.recs_generated = total_recs + _log(session, run, + f"🏁 Terminé — {total_articles} article(s), {total_recs} rec(s) en file d'attente") + return total_articles + + except Exception as e: + run.status = 'error' + run.ended_at = datetime.utcnow() + _log(session, run, f'💥 Erreur fatale : {str(e)[:120]}') + logger.exception("Pipeline fatal error") + return 0 diff --git a/recommendations.py b/recommendations.py new file mode 100644 index 0000000..3ef676f --- /dev/null +++ b/recommendations.py @@ -0,0 +1,405 @@ +# Evidence levels: A = meta-analyse/RCT solide, B = observationnel/RCT limité, C = consensus expert + +RECS = [ + # ── NUTRITION ───────────────────────────────────────────────────────────── + { + "id": "med_diet", + "domain": "nutrition", "domain_label": "Alimentation", "icon": "🥗", + "title": "Régime méditerranéen", + "summary": "Huile d'olive, légumes, légumineuses, poissons gras, noix. Limiter viandes rouges et ultra-transformés.", + "details": "Réduit de 30% le risque cardiovasculaire (étude PREDIMED, 7447 participants). Protège contre le diabète T2, l'Alzheimer et certains cancers. Riche en antioxydants et acides gras mono-insaturés.", + "evidence_level": "A", + "source": "Estruch R. et al., NEJM 2013 (PREDIMED) — Sofi F. et al., BMJ 2008", + "tags": ["cœur", "longévité", "anti-inflammatoire"], + "priority": 10, + "condition": lambda p: p.get('diet_type') in [None, 'omnivore', 'flexitarian'], + }, + { + "id": "low_gi", + "domain": "nutrition", "domain_label": "Alimentation", "icon": "📊", + "title": "Alimentation à faible indice glycémique", + "summary": "Privilégier glucides complexes et fibres pour stabiliser la glycémie et l'énergie.", + "details": "Favoriser légumineuses, céréales complètes, légumes non-féculents. Éviter sucres ajoutés, pain blanc, produits industriels. Réduit de 20% le risque de diabète T2 et améliore le contrôle glycémique existant.", + "evidence_level": "A", + "source": "Jenkins D.J. et al., Diabetes Care 2008 — Livesey G. et al., Am J Clin Nutr 2019", + "tags": ["glycémie", "diabète", "énergie"], + "priority": 9, + "condition": lambda p: ( + (p.get('bmi') or 0) > 25 or + 'diabetes_risk' in (p.get('conditions') or []) or + 'metabolic_syndrome' in (p.get('conditions') or []) + ), + }, + { + "id": "omega3", + "domain": "nutrition", "domain_label": "Alimentation", "icon": "🐟", + "title": "Acides gras oméga-3 (EPA/DHA)", + "summary": "2-3 portions/semaine de poissons gras ou supplémentation 1g/jour d'EPA+DHA.", + "details": "Réduisent les triglycérides de 25-30%, l'inflammation (CRP) et le risque d'arythmie. Action anti-agrégante plaquettaire. Pour les végétaliens : DHA d'algues recommandé (même source que les poissons).", + "evidence_level": "A", + "source": "Mozaffarian D. & Wu J.H.Y., JACC 2011 — GISSI trial, Lancet 1999", + "tags": ["cœur", "cerveau", "anti-inflammatoire"], + "priority": 9, + "condition": lambda p: ( + 'cardiovascular_risk' in (p.get('conditions') or []) or + p.get('diet_type') in ['omnivore', 'flexitarian', 'vegan'] + ), + }, + { + "id": "hydration", + "domain": "nutrition", "domain_label": "Alimentation", "icon": "💧", + "title": "Hydratation optimale", + "summary": "1.5 à 2.5L d'eau par jour. La soif est un signal tardif de déshydratation.", + "details": "Même une déshydratation légère (1-2%) dégrade les fonctions cognitives et l'humeur. L'eau plate reste la meilleure boisson. Tisanes et infusions comptent. Limiter sodas, jus industriels et alcool.", + "evidence_level": "B", + "source": "EFSA Panel on Dietetic Products, EFSA Journal 2010", + "tags": ["base", "énergie", "cognition"], + "priority": 7, + "condition": lambda p: True, + }, + { + "id": "iron_women", + "domain": "nutrition", "domain_label": "Alimentation", "icon": "🩸", + "title": "Apport en fer — femmes en âge de procréer", + "summary": "Besoins 2x supérieurs (18mg/j vs 8mg/j). Viandes rouges maigres, légumineuses, épinards.", + "details": "Associer aliments riches en fer avec vitamine C pour multiplier par 3 l'absorption non-héminique. Éviter thé et café pendant les repas riches en fer (tanins inhibiteurs). Le fer héminique (viandes) est absorbé à 25% vs 5% pour le fer végétal.", + "evidence_level": "A", + "source": "OMS — Iron Deficiency Anaemia guidelines 2001 — Hallberg L., Eur J Clin Nutr 2001", + "tags": ["fer", "fatigue", "femmes"], + "priority": 8, + "condition": lambda p: p.get('sex') == 'female' and (p.get('age') or 0) < 50, + }, + { + "id": "calcium_bones", + "domain": "nutrition", "domain_label": "Alimentation", "icon": "🦴", + "title": "Calcium et santé osseuse", + "summary": "1000–1200 mg/j via produits laitiers, sardines avec arêtes, amandes, légumes verts.", + "details": "La densité osseuse (pic) est atteinte à 30 ans, puis décline. Le calcium seul est insuffisant : associer vitamine D et activité physique portante. Éviter suppléments >500mg/prise (risque cardiovasculaire si fortes doses).", + "evidence_level": "A", + "source": "Weaver C.M. et al., Osteoporosis International 2016 — EFSA 2015", + "tags": ["os", "vieillissement", "femmes"], + "priority": 7, + "condition": lambda p: ( + (p.get('age') or 0) > 40 or + p.get('sex') == 'female' or + 'osteoporosis_risk' in (p.get('conditions') or []) + ), + }, + { + "id": "plant_protein", + "domain": "nutrition", "domain_label": "Alimentation", "icon": "🌱", + "title": "Protéines végétales complètes", + "summary": "Combiner légumineuses + céréales (ex: lentilles + riz) pour tous les acides aminés essentiels.", + "details": "Substituer 3% des calories des protéines animales par des végétales réduit la mortalité de 10%. Tofu, tempeh, quinoa (protéine complète), edamame sont d'excellentes sources. La complémentation n'a pas besoin d'être au même repas.", + "evidence_level": "B", + "source": "Song M. et al., JAMA Internal Medicine 2016 — Willett W. et al., Lancet 2019", + "tags": ["protéines", "cœur", "longévité"], + "priority": 6, + "condition": lambda p: p.get('diet_type') in ['vegetarian', 'vegan', 'flexitarian'], + }, + + # ── JEÛNE INTERMITTENT ───────────────────────────────────────────────────── + { + "id": "fasting_14_10", + "domain": "fasting", "domain_label": "Jeûne intermittent", "icon": "⏳", + "title": "Protocole 14/10 — démarrage progressif", + "summary": "Jeûner 14h, fenêtre alimentaire de 10h. Idéal pour débuter sans contrainte excessive.", + "details": "Plus facile à tenir que le 16/8, tout en activant la cétose légère et l'autophagie débutante après 12-14h. Recommandé aux débutants, femmes et +50 ans. Dernier repas avant 20h améliore la qualité du sommeil et réduit le cortisol nocturne.", + "evidence_level": "B", + "source": "Sutton E.F. et al., Cell Metabolism 2018 — Wilkinson M.J. et al., Cell Metabolism 2020", + "tags": ["débutant", "métabolisme", "sommeil"], + "priority": 8, + "condition": lambda p: ( + p.get('activity_level') in ['sedentary', 'light', 'moderate'] or + p.get('sex') == 'female' or + (p.get('age') or 0) > 50 + ), + }, + { + "id": "fasting_16_8", + "domain": "fasting", "domain_label": "Jeûne intermittent", "icon": "⏱️", + "title": "Protocole 16/8", + "summary": "Jeûner 16h, fenêtre alimentaire de 8h (ex: 12h–20h). Standard pour sportifs actifs.", + "details": "Améliore la sensibilité à l'insuline de 25%, réduit l'IL-6 et le TNF-α (marqueurs inflammatoires). La perte de poids est comparable à la restriction calorique continue avec meilleure adhérence à long terme. Ne pas pratiquer les jours d'entraînement intensif sans adaptation.", + "evidence_level": "B", + "source": "Lowe D.A. et al., JAMA Internal Medicine 2020 — Cioffi I. et al., Nutrients 2018", + "tags": ["métabolisme", "poids", "insuline"], + "priority": 9, + "condition": lambda p: ( + p.get('activity_level') in ['moderate', 'intense', 'athlete'] and + ( + 'weight_loss' in (p.get('health_goals') or []) or + 'metabolic_health' in (p.get('health_goals') or []) + ) + ), + }, + { + "id": "fasting_5_2", + "domain": "fasting", "domain_label": "Jeûne intermittent", "icon": "📅", + "title": "Protocole 5:2", + "summary": "5 jours normaux, 2 jours à 500-600 kcal. Flexibilité maximale.", + "details": "Efficace pour perte de poids (-5-8% en 3 mois) avec bonne adhérence. Les jours de restriction peuvent être consécutifs (plus d'effet) ou non (plus facile). Éviter les jours de sport intense pour les jours restreints.", + "evidence_level": "B", + "source": "Harvie M.N. et al., Int Journal of Obesity 2011 — Harris L. et al., Obesity Reviews 2018", + "tags": ["poids", "flexibilité", "calories"], + "priority": 7, + "condition": lambda p: 'weight_loss' in (p.get('health_goals') or []), + }, + { + "id": "fasting_autophagy", + "domain": "fasting", "domain_label": "Jeûne intermittent", "icon": "🔬", + "title": "Jeûne et autophagie cellulaire", + "summary": "À partir de 16-18h de jeûne, l'autophagie s'active et recycle les protéines endommagées.", + "details": "L'autophagie (Prix Nobel 2016, Yoshinori Ohsumi) est le mécanisme de nettoyage cellulaire. Activée par la déplétion en nutriments (mTOR inhibé). Potentiellement protecteur contre cancers, Alzheimer et vieillissement cellulaire. Encore peu d'études humaines directes.", + "evidence_level": "B", + "source": "Levine B. & Kroemer G., Cell 2019 — Nobel Prize 2016 Commentary", + "tags": ["longévité", "cellules", "vieillissement"], + "priority": 7, + "condition": lambda p: 'longevity' in (p.get('health_goals') or []), + }, + + # ── EXERCICE ────────────────────────────────────────────────────────────── + { + "id": "zone2_cardio", + "domain": "exercise", "domain_label": "Exercice", "icon": "🏃", + "title": "Cardio zone 2 — 150 min/semaine", + "summary": "Marche rapide, vélo, natation à effort modéré. Réduction de 35% de la mortalité toutes causes.", + "details": "Zone 2 = 60-70% FCmax, conversation possible. Optimise la biogenèse mitochondriale et le métabolisme des graisses. L'OMS recommande 150 min/semaine minimum. Même 11 min/jour montrent des bénéfices significatifs.", + "evidence_level": "A", + "source": "OMS Physical Activity Guidelines 2020 — Wen C.P. et al., Lancet 2011", + "tags": ["cœur", "métabolisme", "base"], + "priority": 10, + "condition": lambda p: True, + }, + { + "id": "strength_training", + "domain": "exercise", "domain_label": "Exercice", "icon": "💪", + "title": "Musculation 2-3x/semaine", + "summary": "Préserve la masse musculaire, la densité osseuse et le métabolisme de base après 35 ans.", + "details": "La sarcopénie débute à 30 ans (-3-5%/décennie sans exercice). La musculation augmente la sensibilité à l'insuline, améliore la composition corporelle et réduit le risque de fractures. Priorité absolue après 40 ans, même avec poids légers.", + "evidence_level": "A", + "source": "Westcott W.L., Current Sports Medicine Reports 2012 — Liu C.J. & Latham N., Cochrane 2009", + "tags": ["muscle", "métabolisme", "os", "vieillissement"], + "priority": 9, + "condition": lambda p: ( + 'muscle_gain' in (p.get('health_goals') or []) or + (p.get('age') or 0) > 35 or + p.get('activity_level') in ['moderate', 'intense', 'athlete'] + ), + }, + { + "id": "hiit", + "domain": "exercise", "domain_label": "Exercice", "icon": "⚡", + "title": "HIIT — Entraînement par intervalles", + "summary": "20-30 min de HIIT 2x/semaine. +15% VO2max en 8 semaines, combustion graisses prolongée.", + "details": "Le HIIT génère un effet EPOC (Excess Post-exercise Oxygen Consumption) : combustion accrue jusqu'à 24h post-effort. Améliore la VO2max et la sensibilité à l'insuline plus efficacement que le cardio continu par unité de temps. Ne pas faire plus de 2-3 sessions/semaine.", + "evidence_level": "A", + "source": "Gibala M.J. et al., Journal of Physiology 2012 — Weston K.S. et al., Br J Sports Med 2014", + "tags": ["VO2max", "graisses", "efficacité"], + "priority": 8, + "condition": lambda p: p.get('activity_level') in ['moderate', 'intense', 'athlete'], + }, + { + "id": "core_training", + "domain": "exercise", "domain_label": "Exercice", "icon": "🎯", + "title": "Gainage et renforcement du core", + "summary": "Planche, dead bug, bird dog : 3 séries de 30-60s, 3x/semaine. Prévient les lombalgies.", + "details": "Le renforcement du core (abdominaux profonds, lombaires, plancher pelvien) réduit les lombalgies chroniques de 50%. La planche frontale active plus efficacement le transverse de l'abdomen que les crunchs classiques, sans stress vertébral.", + "evidence_level": "B", + "source": "McGill S.M., J Orthopedic & Sports PT 2010 — Coulombe B.J. et al., Sports Health 2017", + "tags": ["posture", "dos", "performance"], + "priority": 7, + "condition": lambda p: ( + 'back_pain' in (p.get('conditions') or []) or + p.get('activity_level') in ['moderate', 'intense', 'athlete'] or + 'muscle_gain' in (p.get('health_goals') or []) + ), + }, + { + "id": "mobility", + "domain": "exercise", "domain_label": "Exercice", "icon": "🧘", + "title": "Mobilité et flexibilité", + "summary": "10-15 min d'étirements dynamiques ou yoga quotidien. Prévient blessures et douleurs chroniques.", + "details": "La mobilité articulaire décline de 30% entre 30 et 70 ans sans entretien. Le yoga combine mobilité, force et réduction du stress (cortisol -20%). Privilégier étirements dynamiques avant effort, statiques après.", + "evidence_level": "B", + "source": "Cramer H. et al., Cochrane Database 2013 — Behm D.G. et al., Applied Physiology 2016", + "tags": ["souplesse", "blessures", "stress"], + "priority": 6, + "condition": lambda p: ( + p.get('activity_level') in ['sedentary', 'light'] or + (p.get('age') or 0) > 50 or + 'stress_reduction' in (p.get('health_goals') or []) + ), + }, + + # ── SOMMEIL ─────────────────────────────────────────────────────────────── + { + "id": "sleep_duration", + "domain": "sleep", "domain_label": "Sommeil", "icon": "😴", + "title": "Optimiser la durée du sommeil (7-9h)", + "summary": "Dormir moins de 6h double le risque d'obésité, de diabète T2 et de maladies cardiovasculaires.", + "details": "La dette de sommeil chronique élève le cortisol, la ghréline (appétit) et diminue la leptine (satiété). La consolidation mémorielle et la réparation cellulaire se font principalement en sommeil profond (ondes lentes). Récupérer le week-end ne compense pas.", + "evidence_level": "A", + "source": "Cappuccio F.P. et al., Sleep 2010 — Walker M., Why We Sleep 2017", + "tags": ["récupération", "hormones", "poids"], + "priority": 10, + "condition": lambda p: (p.get('sleep_hours') or 8) < 7, + }, + { + "id": "sleep_hygiene", + "domain": "sleep", "domain_label": "Sommeil", "icon": "🌙", + "title": "Hygiène du sommeil", + "summary": "Chambre à 18°C, obscurité totale, horaires réguliers, pas d'écrans 1h avant.", + "details": "La mélatonine est inhibée par la lumière bleue (LED, écrans). La régularité des horaires synchronise l'horloge circadienne. Une chambre froide (18°C) facilite la chute de la température corporelle nécessaire à l'endormissement. Caféine à éviter après 14h (demi-vie 5-7h).", + "evidence_level": "B", + "source": "Gradisar M. et al., Sleep Medicine Reviews 2013 — Chang A.M. et al., PNAS 2015", + "tags": ["mélatonine", "circadien", "qualité"], + "priority": 8, + "condition": lambda p: True, + }, + { + "id": "sleep_stress", + "domain": "sleep", "domain_label": "Sommeil", "icon": "🧠", + "title": "Protocole anti-stress pré-sommeil", + "summary": "Cohérence cardiaque 5 min + journaling : réduit le cortisol et facilite l'endormissement.", + "details": "Le cortisol élevé en soirée (lié au stress chronique) fragmente le sommeil et réduit le sommeil profond. La respiration 5-5 (5s inspire / 5s expire) active le système parasympathique en 3-4 cycles. Le journaling externalise les pensées ruminatives.", + "evidence_level": "B", + "source": "Ong J.C. et al., Sleep 2014 — Lehrer P.M., Applied Psychophysiology 2013", + "tags": ["cortisol", "stress", "relaxation"], + "priority": 9, + "condition": lambda p: (p.get('stress_level') or 0) >= 6, + }, + + # ── GESTION DU STRESS ───────────────────────────────────────────────────── + { + "id": "coherence_cardiaque", + "domain": "stress", "domain_label": "Gestion du stress", "icon": "💚", + "title": "Cohérence cardiaque (méthode 365)", + "summary": "3 fois/jour, 6 respirations/min, 5 min. Réduit le cortisol de 20% en 4 semaines.", + "details": "La méthode 365 (3x/j, 6 resp/min, 5 min) synchronise la HRV (variabilité de fréquence cardiaque). Effets mesurables sur l'anxiété et la pression artérielle. Apps : RespiRelax+, Kardia. Idéalement : matin au réveil, avant déjeuner, soir.", + "evidence_level": "B", + "source": "McCraty R. et al., HeartMath Institute 2015 — Lehrer P.M., Biofeedback 2013", + "tags": ["cortisol", "HRV", "anxiété"], + "priority": 9, + "condition": lambda p: (p.get('stress_level') or 0) >= 5, + }, + { + "id": "meditation", + "domain": "stress", "domain_label": "Gestion du stress", "icon": "🧘", + "title": "Méditation pleine conscience (MBSR)", + "summary": "10-20 min/jour réduit l'anxiété de 38% et modifie structurellement l'amygdale en 8 semaines.", + "details": "Le programme MBSR (Mindfulness-Based Stress Reduction) de Jon Kabat-Zinn est le plus étudié (47 essais contrôlés en méta-analyse). L'IRM montre une réduction du volume de l'amygdale et une augmentation du cortex préfrontal après 8 semaines. Apps : Petit Bambou, Headspace.", + "evidence_level": "A", + "source": "Goyal M. et al., JAMA Internal Medicine 2014 — Hölzel B.K. et al., Psychiatry Research 2011", + "tags": ["anxiété", "dépression", "focus"], + "priority": 8, + "condition": lambda p: ( + (p.get('stress_level') or 0) >= 6 or + 'anxiety' in (p.get('conditions') or []) or + 'stress_reduction' in (p.get('health_goals') or []) + ), + }, + + # ── SUPPLÉMENTS ─────────────────────────────────────────────────────────── + { + "id": "vitamin_d", + "domain": "supplements", "domain_label": "Suppléments", "icon": "☀️", + "title": "Vitamine D3 (cholécalciférol)", + "summary": "80% des Européens déficients. 1000-2000 UI/jour d'oct. à mars, avec un repas gras.", + "details": "La vitamine D est essentielle à l'immunité (T-cells), la santé osseuse, la régulation de l'humeur et la prévention de certains cancers. La D3 est 87% plus efficace que la D2. Taux optimal : 40-60 ng/mL. Vérifier par bilan sanguin (25-OH-D).", + "evidence_level": "A", + "source": "Holick M.F., NEJM 2007 — Martineau A.R. et al., BMJ 2017", + "tags": ["immunité", "os", "humeur"], + "priority": 9, + "condition": lambda p: True, + }, + { + "id": "magnesium", + "domain": "supplements", "domain_label": "Suppléments", "icon": "⚗️", + "title": "Magnésium (bisglycinate ou malate)", + "summary": "300-400 mg/jour au coucher. Améliore sommeil, gestion du stress et contractilité musculaire.", + "details": "Le magnésium est cofacteur de 300+ réactions enzymatiques. 70% de la population est déficiente (stress, alcool, sport intensif l'épuisent). Forme bisglycinate = meilleure biodisponibilité + effets sur le sommeil. Forme malate = énergie/musculation. Éviter l'oxyde (très faible absorption).", + "evidence_level": "B", + "source": "Abbasi B. et al., J Research in Medical Sciences 2012 — Boyle N.B. et al., Nutrients 2017", + "tags": ["sommeil", "stress", "muscles"], + "priority": 8, + "condition": lambda p: ( + (p.get('stress_level') or 0) >= 5 or + (p.get('sleep_hours') or 8) < 7 or + p.get('activity_level') in ['intense', 'athlete'] + ), + }, + { + "id": "probiotics", + "domain": "supplements", "domain_label": "Suppléments", "icon": "🦠", + "title": "Probiotiques et santé du microbiote", + "summary": "Lactobacillus + Bifidobacterium multi-souches. Kéfir, yaourt, kimchi, kombucha en natif.", + "details": "Le microbiote intestinal influence l'immunité (70% des cellules immunitaires), l'humeur (axe intestin-cerveau via nerf vague) et le métabolisme. Probiotiques efficaces si au moins 10^9 UFC/dose. Les sources fermentées naturelles sont préférables aux suppléments.", + "evidence_level": "B", + "source": "Hill C. et al., Nature Reviews Gastroenterology 2014 — Cryan J.F. et al., Nature Reviews 2019", + "tags": ["immunité", "digestion", "humeur"], + "priority": 7, + "condition": lambda p: ( + 'digestive_issues' in (p.get('conditions') or []) or + p.get('diet_type') in ['vegan', 'vegetarian'] or + (p.get('stress_level') or 0) >= 6 + ), + }, + { + "id": "creatine", + "domain": "supplements", "domain_label": "Suppléments", "icon": "💊", + "title": "Créatine monohydrate", + "summary": "3-5g/jour. Supplément le plus étudié au monde. Force +8%, récupération et mémoire.", + "details": "La créatine augmente les réserves de phosphocréatine musculaire. Plus de 1000 études cliniques. Bénéfices cognitifs émergents (mémoire de travail +14% en carence). Particulièrement utile pour végétariens (faibles réserves endogènes). Phase de charge inutile.", + "evidence_level": "A", + "source": "Lanhers C. et al., European J Sport Science 2017 — Rawson E.S. & Venezia A.C., J Nutrition 2011", + "tags": ["muscle", "force", "cognition"], + "priority": 7, + "condition": lambda p: ( + 'muscle_gain' in (p.get('health_goals') or []) or + p.get('activity_level') in ['intense', 'athlete'] or + p.get('diet_type') in ['vegetarian', 'vegan'] + ), + }, + { + "id": "b12_vegan", + "domain": "supplements", "domain_label": "Suppléments", "icon": "🔴", + "title": "Vitamine B12 — essentielle pour végétaliens", + "summary": "500-1000 µg/semaine ou 25-250 µg/jour. Seul supplément non-négociable en régime vegan.", + "details": "La B12 n'existe pas dans les végétaux (algues et levures nutritionnelles insuffisantes). Carence irréversible en neurologie à long terme. Méthylcobalamine ou cyanocobalamine. Tester le taux sanguin (optimal >300 pmol/L) tous les 1-2 ans.", + "evidence_level": "A", + "source": "Watanabe F. et al., Nutrients 2014 — Pawlak R. et al., Nutrition Reviews 2013", + "tags": ["vegan", "neurologie", "énergie"], + "priority": 10, + "condition": lambda p: p.get('diet_type') == 'vegan', + }, +] + + +def get_recommendations(profile: dict) -> list: + if not profile: + return [] + results = [] + for rec in RECS: + try: + if rec['condition'](profile): + results.append({k: v for k, v in rec.items() if k != 'condition'}) + except Exception: + pass + results.sort(key=lambda r: -r.get('priority', 0)) + return results + + +def profile_completeness(prof) -> int: + if not prof: + return 0 + scalar_fields = [ + 'first_name', 'birth_year', 'sex', 'height_cm', 'weight_kg', + 'blood_type', 'sleep_hours', 'stress_level', 'smoking', + 'alcohol_freq', 'diet_type', 'activity_level', 'ethnicity', + ] + list_fields = ['conditions', 'health_goals', 'activity_types'] + filled = sum(1 for f in scalar_fields if getattr(prof, f, None) is not None) + filled += sum(1 for f in list_fields if getattr(prof, f, [])) + total = len(scalar_fields) + len(list_fields) + return int(filled / total * 100) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3b2c99 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Werkzeug==3.0.3 +openai>=1.30.0 +requests>=2.31.0 +beautifulsoup4>=4.12.0 +APScheduler>=3.10.0 +feedparser>=6.0.11 +gunicorn>=22.0.0 +python-dotenv>=1.0.0 +lxml>=5.0.0 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..dfb5764 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,588 @@ +/* ── Variables ────────────────────────────────────────────────── */ +:root { + --primary: #2563eb; + --primary-dark: #1d4ed8; + --accent: #10b981; + --accent-light: #d1fae5; + --bg: #f8fafc; + --card: #ffffff; + --text: #1e293b; + --muted: #64748b; + --border: #e2e8f0; + --shadow-sm: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 16px rgba(0,0,0,.10); + --shadow-lg: 0 10px 40px rgba(0,0,0,.12); + --radius: 12px; + --radius-sm: 8px; +} + +body { + background: var(--bg); + color: var(--text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 0.95rem; +} + +/* ── Navbar ───────────────────────────────────────────────────── */ +.oc-nav { + background: var(--text); + box-shadow: 0 2px 8px rgba(0,0,0,.2); + padding: 0.6rem 0; +} +.text-accent { color: var(--accent) !important; } +.btn-accent { + background: var(--accent); + color: #fff; + border: none; +} +.btn-accent:hover { background: #059669; color: #fff; } + +/* ── Footer ───────────────────────────────────────────────────── */ +.oc-footer { + background: var(--text); + color: #94a3b8; +} + +/* ── Hero ─────────────────────────────────────────────────────── */ +.hero-section { + background: linear-gradient(135deg, #eff6ff 0%, #ecfdf5 100%); + border-bottom: 1px solid var(--border); +} +.text-gradient { + background: linear-gradient(135deg, var(--primary), var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.bg-accent-light { background: var(--accent-light); } + +.hero-visual { + padding: 1.5rem; +} +.hero-pill { + display: flex; + align-items: center; + gap: 0.5rem; + background: #fff; + border: 1px solid var(--border); + border-radius: 50px; + padding: 0.5rem 1rem; + box-shadow: var(--shadow-sm); + font-size: 0.88rem; + font-weight: 500; + animation: floatIn 0.4s ease forwards; +} +.hero-pill-icon { font-size: 1.2rem; } +@keyframes floatIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Stats bar ────────────────────────────────────────────────── */ +.stat-num { + font-size: 2rem; + font-weight: 800; + line-height: 1; +} +.stat-lbl { + font-size: 0.8rem; + color: var(--muted); + margin-top: 0.25rem; +} + +/* ── Domain cards (landing) ───────────────────────────────────── */ +.domain-card { + border-radius: var(--radius) !important; + transition: transform 0.2s, box-shadow 0.2s; + cursor: default; +} +.domain-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow) !important; +} +.domain-icon { font-size: 2.5rem; } + +/* ── How it works steps ───────────────────────────────────────── */ +.step-card { padding: 1.5rem; } +.step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + background: var(--primary); + color: #fff; + border-radius: 50%; + font-weight: 700; + font-size: 1.1rem; + margin: 0 auto 1rem; +} + +/* ── Auth cards ───────────────────────────────────────────────── */ +.auth-card { + border-radius: var(--radius) !important; +} +.auth-icon-wrap { + width: 3.5rem; + height: 3.5rem; + background: #eff6ff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; +} + +/* ── Profile tabs ─────────────────────────────────────────────── */ +.profile-tabs .nav-link { + color: var(--muted); + background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius-sm) !important; + padding: 0.5rem 0.9rem; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.15s; +} +.profile-tabs .nav-link.active, +.profile-tabs .nav-link:hover { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} +.profile-section { + border-radius: var(--radius) !important; +} +.section-title { + font-weight: 700; + font-size: 1rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--border); + margin-bottom: 0 !important; +} +.info-box { + background: #eff6ff; + border-radius: var(--radius-sm); + padding: 0.75rem 1rem; + font-size: 0.85rem; +} + +/* Condition / intolerance checkboxes */ +.condition-check, .intol-check, .activity-check { + background: #f8fafc; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.6rem 0.8rem; + margin: 0; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.condition-check:has(.form-check-input:checked), +.intol-check:has(.form-check-input:checked), +.activity-check:has(.form-check-input:checked) { + background: #eff6ff; + border-color: var(--primary); +} +.condition-check .form-check-input, +.intol-check .form-check-input, +.activity-check .form-check-input { + margin-top: 0.1rem; +} +.condition-check label, .intol-check label, .activity-check label { + cursor: pointer; + font-size: 0.875rem; +} + +/* Activity level radio cards */ +.activity-level-card { + display: block; + text-align: center; + padding: 1rem 0.75rem; + border: 2px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.15s; + background: #fff; + user-select: none; +} +.activity-level-card input { display: none; } +.activity-level-card:hover { border-color: var(--primary); background: #eff6ff; } +.activity-level-card.selected { border-color: var(--primary); background: #eff6ff; } +.act-icon { font-size: 1.75rem; margin-bottom: 0.25rem; } +.act-label { font-size: 0.875rem; } +.act-desc { font-size: 0.75rem; } + +/* Goal cards */ +.goal-card { + display: block; + padding: 1rem; + border: 2px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.15s; + background: #fff; + user-select: none; + text-align: center; +} +.goal-card input { display: none; } +.goal-card:hover { border-color: var(--accent); background: #f0fdf4; } +.goal-card.selected { border-color: var(--accent); background: #f0fdf4; } +.goal-icon { font-size: 1.75rem; margin-bottom: 0.3rem; } +.goal-title { font-size: 0.875rem; } +.goal-desc { font-size: 0.75rem; } + +/* ── Dashboard ────────────────────────────────────────────────── */ +.profile-summary-bar { + border-radius: var(--radius) !important; +} +.avatar-circle { + width: 2.75rem; + height: 2.75rem; + background: var(--primary); + color: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1.1rem; + flex-shrink: 0; +} +.profile-badge { + display: inline-flex; + align-items: center; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 50px; + padding: 0.25rem 0.7rem; + font-size: 0.8rem; + font-weight: 500; +} +.profile-badge-success { border-color: #86efac; background: #f0fdf4; color: #166534; } +.profile-badge-warning { border-color: #fcd34d; background: #fffbeb; color: #92400e; } +.profile-badge-danger { border-color: #fca5a5; background: #fef2f2; color: #991b1b; } +.profile-badge-info { border-color: #93c5fd; background: #eff6ff; color: #1e40af; } + +.completeness-bar { height: 6px; border-radius: 3px; } + +/* Evidence badges */ +.evidence-badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 50px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.03em; +} +.ev-A { background: #dcfce7; color: #166534; } +.ev-B { background: #fef9c3; color: #854d0e; } +.ev-C { background: #ffedd5; color: #9a3412; } + +/* Domain section header */ +.domain-header { border-bottom: 2px solid var(--border); padding-bottom: 0.75rem; } +.domain-icon-lg { font-size: 2rem; } + +/* Recommendation cards */ +.rec-card { + border-radius: var(--radius) !important; + cursor: pointer; + transition: transform 0.18s, box-shadow 0.18s; +} +.rec-card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow) !important; +} +.rec-icon { font-size: 1.5rem; } +.rec-icon-lg { font-size: 2.5rem; } +.rec-summary { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-clamp: 3; + overflow: hidden; +} + +/* Tag pills */ +.tag-pill { + display: inline-block; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 50px; + padding: 0.15rem 0.55rem; + font-size: 0.7rem; + color: var(--muted); +} +.tag-pill-lg { font-size: 0.8rem; padding: 0.25rem 0.7rem; } + +/* Modal */ +.rec-detail-box { + background: #f8fafc; + border-radius: var(--radius-sm); + padding: 1rem; + line-height: 1.7; +} +.source-box { + background: #eff6ff; + border-radius: var(--radius-sm); + padding: 0.75rem 1rem; + font-size: 0.85rem; +} + +/* Empty state */ +.empty-state { max-width: 400px; margin: 0 auto; } +.empty-icon { font-size: 4rem; } + +/* ── Forms ────────────────────────────────────────────────────── */ +.form-control, .form-select { + border-radius: var(--radius-sm); + border-color: var(--border); + font-size: 0.9rem; +} +.form-control:focus, .form-select:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37,99,235,.15); +} +.input-group-text { + border-radius: var(--radius-sm) 0 0 var(--radius-sm) !important; +} +.input-group .form-control { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0 !important; +} + +/* ── Buttons ──────────────────────────────────────────────────── */ +.btn-primary { + background: var(--primary); + border-color: var(--primary); + border-radius: var(--radius-sm); +} +.btn-primary:hover { + background: var(--primary-dark); + border-color: var(--primary-dark); +} +.btn-outline-primary { + color: var(--primary); + border-color: var(--primary); + border-radius: var(--radius-sm); +} +.btn-outline-primary:hover { + background: var(--primary); + border-color: var(--primary); +} + +/* ── Admin layout ─────────────────────────────────────────────── */ +.admin-layout { + display: flex; + min-height: calc(100vh - 120px); + gap: 0; +} + +.admin-sidebar { + width: 220px; + flex-shrink: 0; + background: var(--text); + padding: 1.25rem 0; + display: flex; + flex-direction: column; + border-right: 1px solid rgba(255,255,255,.08); +} + +.admin-sidebar-brand { + color: #94a3b8; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + padding: 0 1.25rem 1rem; + border-bottom: 1px solid rgba(255,255,255,.08); + margin-bottom: .5rem; +} + +.admin-nav { list-style: none; padding: 0; margin: 0; flex: 1; } +.admin-nav li { padding: 0 .5rem; } + +.admin-nav-link { + display: flex; + align-items: center; + gap: .7rem; + color: #94a3b8; + text-decoration: none; + padding: .6rem .75rem; + border-radius: var(--radius-sm); + font-size: .875rem; + font-weight: 500; + transition: background .15s, color .15s; +} +.admin-nav-link:hover { background: rgba(255,255,255,.08); color: #e2e8f0; } +.admin-nav-link.active { background: var(--primary); color: #fff; } +.admin-nav-link i { width: 1.1rem; text-align: center; opacity: .8; } + +.admin-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.4rem; + height: 1.4rem; + padding: 0 .35rem; + border-radius: 50px; + font-size: .7rem; + font-weight: 700; + background: rgba(255,255,255,.15); + color: #e2e8f0; +} +.admin-badge-danger { background: #dc2626; color: #fff; } + +.admin-sidebar-footer { + padding: .5rem; + border-top: 1px solid rgba(255,255,255,.08); + margin-top: .5rem; +} + +.admin-main { + flex: 1; + padding: 1.75rem 2rem; + overflow-x: auto; + background: var(--bg); +} + +.admin-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1.75rem; + gap: 1rem; + flex-wrap: wrap; +} +.admin-page-title { font-size: 1.5rem; font-weight: 800; margin: 0; } + +/* Admin stat cards */ +.admin-stat-card { + display: flex; + align-items: center; + gap: 1rem; + background: #fff; + border-radius: var(--radius); + padding: 1.25rem; + box-shadow: var(--shadow-sm); +} +.admin-stat-icon { + width: 2.75rem; + height: 2.75rem; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + flex-shrink: 0; +} +.admin-stat-num { + font-size: 1.75rem; + font-weight: 800; + line-height: 1; +} +.admin-stat-total { font-size: 1rem; color: var(--muted); font-weight: 400; } +.admin-stat-lbl { font-size: .78rem; color: var(--muted); margin-top: .2rem; } + +/* Status list */ +.admin-status-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: .5rem 0; + border-bottom: 1px solid var(--border); + font-size: .875rem; +} +.admin-status-list li:last-child { border-bottom: none; } + +/* Tables */ +.admin-table th { + font-size: .78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--muted); + border-bottom: 2px solid var(--border); + padding: .75rem 1rem; +} +.admin-table td { padding: .75rem 1rem; vertical-align: middle; } + +/* Domain badge in review */ +.admin-domain-badge { + background: #eff6ff; + color: var(--primary); + font-size: .75rem; + font-weight: 600; +} + +/* Review card */ +.review-card { transition: box-shadow .15s; } +.review-card:hover { box-shadow: var(--shadow) !important; } + +/* Admin check pills (checkboxes styled as tags) */ +.admin-check-pill { + display: inline-flex; + align-items: center; + gap: .3rem; + background: #f8fafc; + border: 1px solid var(--border); + border-radius: 50px; + padding: .2rem .6rem; + font-size: .75rem; + cursor: pointer; + transition: background .12s, border-color .12s; + user-select: none; +} +.admin-check-pill:has(input:checked) { + background: #eff6ff; + border-color: var(--primary); + color: var(--primary); +} +.admin-check-pill input { display: none; } + +/* Tag pill variants */ +.tag-pill-warn { background: #fff7ed; border-color: #fed7aa; color: #9a3412; } +.tag-pill-blue { background: #eff6ff; border-color: #bfdbfe; color: #1e40af; } +.tag-pill-red { background: #fef2f2; border-color: #fecaca; color: #991b1b; } +.tag-pill-green { background: #f0fdf4; border-color: #bbf7d0; color: #166534; } + +/* Domain divider in library */ +.admin-domain-divider { + font-size: .9rem; + font-weight: 700; + color: var(--muted); + padding: 1rem 0 .4rem; + border-bottom: 2px solid var(--border); + margin-bottom: .5rem; + text-transform: uppercase; + letter-spacing: .04em; +} + +/* Admin note */ +.admin-note { + background: #fffbeb; + border-radius: var(--radius-sm); + padding: .35rem .6rem; + border: 1px solid #fde68a; +} + +/* ── Crawl log terminal ───────────────────────────────────────── */ +.crawl-log-box { + background: #0f172a; + border-radius: var(--radius-sm); + padding: .75rem 1rem; + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-size: .78rem; + max-height: 280px; + overflow-y: auto; + line-height: 1.6; +} +.crawl-log-line { color: #94a3b8; } +.crawl-log-line.log-ok { color: #4ade80; } +.crawl-log-line.log-err { color: #f87171; } +.crawl-log-line.log-ai { color: #a78bfa; } + +/* ── Responsive ───────────────────────────────────────────────── */ +@media (max-width: 768px) { + .profile-tabs { gap: 0.25rem !important; } + .profile-tabs .nav-link { font-size: 0.78rem; padding: 0.4rem 0.65rem; } + .hero-section { padding: 2rem 0; } + .display-4 { font-size: 2rem; } +} diff --git a/templates/403.html b/templates/403.html new file mode 100644 index 0000000..6d7b218 --- /dev/null +++ b/templates/403.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block title %}Accès refusé — OpenCare{% endblock %} +{% block content %} +
+
+
🚫
+

Accès refusé

+

Vous n'avez pas les droits pour accéder à cette page.

+ Retour au tableau de bord +
+
+{% endblock %} diff --git a/templates/admin/_sidebar.html b/templates/admin/_sidebar.html new file mode 100644 index 0000000..dd83740 --- /dev/null +++ b/templates/admin/_sidebar.html @@ -0,0 +1,56 @@ + diff --git a/templates/admin/config.html b/templates/admin/config.html new file mode 100644 index 0000000..b2d1ac1 --- /dev/null +++ b/templates/admin/config.html @@ -0,0 +1,132 @@ +{% extends 'base.html' %} +{% block title %}Configuration — Admin OpenCare{% endblock %} + +{% block content %} +
+ {% include 'admin/_sidebar.html' %} + +
+
+

+ Configuration +

+

Paramètres du pipeline de veille

+
+ +
+
+ + +
+
+
+
+ OpenAI +
+
+
+ +
+ + +
+
+ {% if config.openai_api_key %} + Clé configurée. + Laissez vide pour conserver la clé actuelle. + {% else %} + Aucune clé configurée — le crawl ne peut pas s'exécuter. + {% endif %} +
+
+
+ + +
GPT-4o offre la meilleure extraction structurée.
+
+
+
+
+
+ + +
+
+
+
+ Planification du crawl +
+
+
+ + +
Heure locale du serveur
+
+
+ + +
+
+
+ + Crawl automatique chaque nuit à + {{ config.crawl_hour }}h{{ '%02d'|format(config.crawl_minute|int) }}. + Les résultats apparaissent en file d'attente le lendemain matin. +
+
+
+
+
+
+ + +
+
+
+
+ Limites +
+
+
+ + +
Limite le nombre d'appels API OpenAI par nuit.
+
+
+
+ + Coût estimé : ~0.01–0.05 USD par article (GPT-4o). + 20 articles/nuit ≈ 0.20–1.00 USD/nuit. +
+
+
+
+
+
+ +
+ + Annuler +
+
+
+
+
+{% endblock %} diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..4d4e202 --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,141 @@ +{% extends 'base.html' %} +{% block title %}Admin — OpenCare{% endblock %} + +{% block content %} +
+ {% include 'admin/_sidebar.html' %} + +
+
+

+ Tableau de bord +

+

Vue d'ensemble du pipeline de veille

+
+ + {% if not api_key_set %} +
+ +
+ Clé API OpenAI manquante. + Le crawl ne peut pas s'exécuter. + Configurer → +
+
+ {% endif %} + + +
+
+
+
+ +
+
+
{{ stats.sources_active }}/{{ stats.sources }}
+
Sources actives
+
+
+
+
+
+
+ +
+
+
{{ stats.articles }}
+
Articles crawlés
+
+
+
+
+
+
+ +
+
+
{{ stats.pending }}
+
En attente de validation
+
+
+
+
+
+
+ +
+
+
{{ stats.approved }}
+
Recommandations actives
+
+
+
+
+ +
+ + + + +
+
+
+
État du pipeline
+
    +
  • + Dernier crawl + + {% if last_crawl %} + {{ last_crawl.strftime('%d/%m/%Y à %H:%M') }} + {% else %} + Jamais exécuté + {% endif %} + +
  • +
  • + Clé OpenAI + {% if api_key_set %} + Configurée + {% else %} + Manquante + {% endif %} +
  • +
  • + Crawl planifié + {{ CrawlConfig.get('crawl_hour', '2') }}h{{ CrawlConfig.get('crawl_minute', '00') }} (heure locale) +
  • +
  • + Articles rejetés + {{ stats.rejected }} +
  • +
+
+
+
+
+
+
+{% endblock %} diff --git a/templates/admin/library.html b/templates/admin/library.html new file mode 100644 index 0000000..206afde --- /dev/null +++ b/templates/admin/library.html @@ -0,0 +1,89 @@ +{% extends 'base.html' %} +{% block title %}Bibliothèque — Admin OpenCare{% endblock %} + +{% block content %} +
+ {% include 'admin/_sidebar.html' %} + +
+
+
+

+ Bibliothèque +

+

{{ recs|length }} recommandation(s) approuvée(s) et active(s)

+
+
+ + {% if recs %} + {% set current_domain = namespace(val='') %} + {% for rec in recs %} + {% if rec.domain != current_domain.val %} + {% set current_domain.val = rec.domain %} +
+ {{ domain_labels.get(rec.domain, (rec.domain, ''))[1] }} + {{ domain_labels.get(rec.domain, (rec.domain, ''))[0] }} + + ({{ recs|selectattr('domain', 'equalto', rec.domain)|list|length }}) + +
+ {% endif %} + +
+
+
+
+
+ Niveau {{ rec.evidence_level }} + Priorité {{ rec.priority }} + {{ rec.reviewed_at.strftime('%d/%m/%Y') if rec.reviewed_at else '—' }} +
+ {{ rec.title }} +

{{ rec.summary }}

+ {% if rec.source_title %} +
+ {{ rec.source_title }} + {% if rec.source_doi %} — {{ rec.source_doi }}{% endif %} +
+ {% endif %} + +
+ {% if rec.target_sex and rec.target_sex != 'all' %} + {{ 'Hommes' if rec.target_sex == 'male' else 'Femmes' }} + {% endif %} + {% if rec.target_age_min or rec.target_age_max %} + {{ rec.target_age_min or '?' }}–{{ rec.target_age_max or '∞' }} ans + {% endif %} + {% for c in rec.required_conditions %} + Si: {{ c.replace('_',' ') }} + {% endfor %} + {% for c in rec.excluded_conditions %} + Exclu: {{ c.replace('_',' ') }} + {% endfor %} + {% if not rec.required_conditions and not rec.required_health_goals and not rec.required_diet_types and not rec.required_activity_levels %} + Universel + {% endif %} +
+
+
+ +
+
+
+
+ {% endfor %} + {% else %} +
+ +

Aucune recommandation approuvée. Allez dans la file d'attente pour en valider.

+ + Aller à la file d'attente + +
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/admin/review.html b/templates/admin/review.html new file mode 100644 index 0000000..10adec1 --- /dev/null +++ b/templates/admin/review.html @@ -0,0 +1,443 @@ +{% extends 'base.html' %} +{% block title %}Révision — Admin OpenCare{% endblock %} + +{% block content %} +
+ {% include 'admin/_sidebar.html' %} + +
+
+
+

+ File de révision +

+

Validez les recommandations extraites par l'IA avant publication

+
+
+ + + + + {% if recs %} +
+ {% for rec in recs %} +
+
+
+ +
+
+ + {{ domain_labels.get(rec.domain, (rec.domain, ''))[0] }} + + Niveau {{ rec.evidence_level }} + {% if rec.study_type %} + {{ rec.study_type }} + {% endif %} + {{ rec.created_at.strftime('%d/%m/%Y') }} +
+ +
{{ rec.title }}
+

{{ rec.summary }}

+ + {% if rec.details %} +

{{ rec.details[:200] }}{% if rec.details|length > 200 %}…{% endif %}

+ {% endif %} + + + {% if rec.source_url or rec.source_title %} +
+ + {% if rec.source_title %}{{ rec.source_title }}{% endif %} + {% if rec.source_doi %} — DOI: {{ rec.source_doi }}{% endif %} + {% if rec.source_url %} + + + + {% endif %} +
+ {% endif %} + + +
+ {% if rec.target_age_min or rec.target_age_max %} + + + {{ rec.target_age_min or '?' }}–{{ rec.target_age_max or '∞' }} ans + + {% endif %} + {% if rec.target_sex and rec.target_sex != 'all' %} + {{ 'Homme' if rec.target_sex == 'male' else 'Femme' }} + {% endif %} + {% if rec.not_for_under_18 %} + 18+ + {% endif %} + {% if rec.not_for_pregnant %} + Pas pour grossesse + {% endif %} + {% for c in rec.required_conditions %} + Si: {{ c }} + {% endfor %} + {% for c in rec.excluded_conditions %} + Exclu si: {{ c }} + {% endfor %} + {% for g in rec.required_health_goals %} + Objectif: {{ g }} + {% endfor %} +
+
+ + +
+ {% if status_filter == 'pending' %} + +
+ +
+ + {% elif status_filter == 'approved' %} + + {% endif %} + {% if rec.admin_notes %} +
+ {{ rec.admin_notes }} +
+ {% endif %} +
+
+
+
+ {% endfor %} +
+ {% else %} +
+ +

+ {% if status_filter == 'pending' %} + Aucune recommandation en attente. Lancez un crawl pour en générer. + {% elif status_filter == 'approved' %} + Aucune recommandation approuvée. + {% else %} + Aucune recommandation rejetée. + {% endif %} +

+
+ {% endif %} +
+
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/admin/sources.html b/templates/admin/sources.html new file mode 100644 index 0000000..0ff4641 --- /dev/null +++ b/templates/admin/sources.html @@ -0,0 +1,313 @@ +{% extends 'base.html' %} +{% block title %}Sources — Admin OpenCare{% endblock %} + +{% block content %} +
+ {% include 'admin/_sidebar.html' %} + +
+
+
+

+ Sources de crawl +

+

Gérez les URLs surveillées quotidiennement

+
+ +
+ + + + + +
+
+
Ajouter une source
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + + {% if sources %} +
+
+ + + + + + + + + + + + + + + {% for src in sources %} + + + + + + + + + + + {% endfor %} + +
NomURLTypeCatégorieDernier crawlArticlesStatutActions
{{ src.name }} + + {{ src.url }} + + + {% if src.source_type == 'rss' %} + RSS + {% elif src.source_type == 'index' %} + Index + {% else %} + Webpage + {% endif %} + + {% if src.category == 'scientific' %} + Scientifique + {% else %} + Conseils + {% endif %} + + {{ src.last_crawled_at.strftime('%d/%m/%Y %H:%M') if src.last_crawled_at else '—' }} + {{ src.articles_found or 0 }} + {% if src.active %} + Actif + {% else %} + Inactif + {% endif %} + +
+ + +
+ +
+
+ +
+
+
+
+
+ {% else %} +
+ +

Aucune source configurée. Ajoutez une URL ci-dessus.

+
+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..34324fe --- /dev/null +++ b/templates/base.html @@ -0,0 +1,88 @@ + + + + + + {% block title %}OpenCare{% endblock %} + + + + + + + + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for cat, msg in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ {% block content %}{% endblock %} +
+ +
+
+

OpenCare — Santé personnalisée basée sur les preuves

+ Les informations sont à titre éducatif uniquement et ne remplacent pas un avis médical professionnel. +
+
+ + +{% block scripts %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..4f57b2a --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,183 @@ +{% extends "base.html" %} +{% block title %}Tableau de bord — OpenCare{% endblock %} +{% block content %} +
+ + +
+
+

+ + Bonjour {% if profile.first_name %}{{ profile.first_name }}{% else %}!{% endif %} +

+

{{ total }} recommandations personnalisées pour votre profil

+
+ +
+ + +
+
+
+
+
+
+ {{ (profile.first_name or 'U')[0].upper() }} +
+
+
{{ profile.first_name or '' }} {{ profile.last_name or '' }}
+
{{ current_user.email }}
+
+
+
+
+
+ {% if profile.age %} + {{ profile.age }} ans + {% endif %} + {% if profile.bmi %} + + IMC {{ profile.bmi }} — {{ profile.bmi_category[0] }} + + {% endif %} + {% if profile.blood_type %} + Groupe {{ profile.blood_type }} + {% endif %} + {% if profile.diet_type %} + + {% set diet_icons = {'omnivore':'🥩','flexitarian':'🥗','vegetarian':'🌿','vegan':'🌱','keto':'🥑','paleo':'🍖','mediterranean':'🫒'} %} + {{ diet_icons.get(profile.diet_type, '🍽️') }} + {{ {'omnivore':'Omnivore','flexitarian':'Flexitarien','vegetarian':'Végétarien','vegan':'Vegan','keto':'Keto','paleo':'Paléo','mediterranean':'Méditerranéen'}.get(profile.diet_type, profile.diet_type) }} + + {% endif %} + {% if profile.activity_level %} + + {% set act_icons = {'sedentary':'🪑','light':'🚶','moderate':'🚴','intense':'🏋️','athlete':'🏆'} %} + {{ act_icons.get(profile.activity_level, '🏃') }} + {{ {'sedentary':'Sédentaire','light':'Actif léger','moderate':'Actif modéré','intense':'Très actif','athlete':'Athlète'}.get(profile.activity_level, profile.activity_level) }} + + {% endif %} +
+
+
+
+
+ Profil complété + {{ completeness }}% +
+
+
+
+ {% if completeness < 70 %} + + Compléter pour plus de recommandations + + {% endif %} +
+
+
+
+
+ + +
+ Niveau de preuve : + A — Fort (méta-analyse / RCT) + B — Modéré (observationnel) + C — Expert consensus +
+ + + {% for domain_key, (domain_label, domain_icon), recs in ordered_domains %} +
+
+ {{ domain_icon }} +
+

{{ domain_label }}

+ {{ recs|length }} recommandation{{ 's' if recs|length > 1 else '' }} +
+
+ +
+ {% for rec in recs %} +
+
+
+
+ {{ rec.icon }} + {{ rec.evidence_level }} +
+
{{ rec.title }}
+

{{ rec.summary }}

+
+ {% for tag in rec.tags[:3] %} + {{ tag }} + {% endfor %} +
+
+ +
+
+ + + + + {% endfor %} +
+
+ {% endfor %} + + {% if not ordered_domains %} +
+
🔍
+

Aucune recommandation pour l'instant

+

Complétez votre profil de santé pour recevoir des conseils personnalisés.

+ + Compléter mon profil + +
+ {% endif %} + +
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..33ea727 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} +{% block title %}OpenCare — Santé personnalisée basée sur les preuves{% endblock %} +{% block content %} + +
+
+
+
+ + Evidence-based medicine + +

+ Votre santé,
vraiment personnalisée +

+

+ OpenCare synthétise des milliers d'études scientifiques et les traduit en recommandations concrètes, adaptées à votre profil unique : âge, génétique, mode de vie et objectifs. +

+ +
+ Données locales + Sources PubMed + Pas de diagnostic +
+
+
+
+ {% for item in [ + ('🧬', 'Génétique & origines'), + ('🥗', 'Nutrition ciblée'), + ('⏱️', 'Jeûne intermittent'), + ('💪', 'Exercice optimal'), + ('😴', 'Sommeil réparateur'), + ('🦠', 'Microbiote'), + ] %} +
+ {{ item[0] }} + {{ item[1] }} +
+ {% endfor %} +
+
+
+
+
+ +
+
+
+
+
+
25+
+
Recommandations
+
+
+
+
+
6
+
Domaines couverts
+
+
+
+
+
100%
+
Sources citées
+
+
+
+
+
+ +
+
+
+

Domaines de santé couverts

+

Chaque recommandation est filtrée selon votre profil et sourcée scientifiquement.

+
+
+ {% for icon, title, desc, color in [ + ('🥗', 'Alimentation', 'Régimes, macronutriments, aliments fonctionnels, intolérances alimentaires.', 'green'), + ('⏱️', 'Jeûne intermittent', 'Protocoles 14/10, 16/8, 5:2 — adaptés à votre activité et objectifs.', 'teal'), + ('💪', 'Exercice', 'Cardio zone 2, musculation, HIIT, mobilité — selon votre niveau actuel.', 'blue'), + ('😴', 'Sommeil', 'Durée, hygiène du sommeil, chronotype, gestion du stress nocturne.', 'indigo'), + ('💚', 'Gestion du stress', 'Cohérence cardiaque, méditation MBSR, techniques validées cliniquement.', 'emerald'), + ('💊', 'Suppléments', 'Vitamine D, oméga-3, magnésium, probiotiques — selon vos déficits réels.', 'amber'), + ] %} +
+
+
+
{{ icon }}
+
{{ title }}
+

{{ desc }}

+
+
+
+ {% endfor %} +
+
+
+ +
+
+
+

Comment ça fonctionne

+
+
+ {% for n, icon, title, desc in [ + (1, 'fas fa-user-plus', 'Créez votre compte', 'Inscription simple et rapide, données stockées localement.'), + (2, 'fas fa-clipboard-list', 'Remplissez votre profil', 'Âge, poids, antécédents, mode de vie, objectifs — 7 sections.'), + (3, 'fas fa-brain', 'Recevez vos recommandations', 'Le moteur croise vos données avec les publications scientifiques.'), + ] %} +
+
+
{{ n }}
+ +
{{ title }}
+

{{ desc }}

+
+
+ {% endfor %} +
+ +
+
+ +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c598e71 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Connexion — OpenCare{% endblock %} +{% block content %} +
+
+
+
+
+
+
+ +
+

Connexion

+

Accédez à vos recommandations personnalisées

+
+
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+ +
+
+

+ Pas encore de compte ? + Créer un compte +

+
+
+
+
+
+{% endblock %} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..0a9c18a --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,438 @@ +{% extends "base.html" %} +{% block title %}Mon profil de santé — OpenCare{% endblock %} +{% block content %} +
+
+
+

Mon profil de santé

+

Plus votre profil est complet, plus les recommandations seront précises.

+
+
+ +
+ + + +
+ + +
+
+
+
Informations personnelles
+
+
+ + +
+
+ + +
+
+ + + {% if profile.age %} +
{{ profile.age }} ans
+ {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {% if profile.bmi %} + {{ profile.bmi }} — {{ profile.bmi_category[0] }} + {% else %} + — (remplissez taille & poids) + {% endif %} +
+
+
+
+
+
+ + +
+
+
+
Origine & Biologie
+

Ces données permettent d'adapter les recommandations aux prédispositions génétiques et culturelles associées aux origines.

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Pourquoi ces données ? Certaines populations présentent des prédispositions documentées : intolérance au lactose plus fréquente en Asie de l'Est (~90%), risque de diabète T2 plus élevé en Asie du Sud à IMC plus bas, etc. +
+
+
+
+
+
+ + +
+
+
+
Antécédents de santé
+

Cochez les conditions qui vous concernent ou qui ont des antécédents familiaux directs.

+
+ {% for val, lbl in [ + ('hypertension', '🫀 Hypertension artérielle'), + ('diabetes_risk', '🍬 Risque / prédiabète T2'), + ('cardiovascular_risk', '❤️ Risque cardiovasculaire'), + ('anxiety', '🧠 Anxiété / dépression'), + ('digestive_issues', '🫃 Troubles digestifs / SII'), + ('back_pain', '🦵 Douleurs dorsales / lombalgies'), + ('osteoporosis_risk', '🦴 Risque d\'ostéoporose'), + ('metabolic_syndrome', '⚖️ Syndrome métabolique'), + ('thyroid_issues', '🦋 Troubles thyroïdiens'), + ('sleep_apnea', '😮 Apnée du sommeil'), + ] %} +
+
+ + +
+
+ {% endfor %} +
+
+ + +
Ces informations restent locales et ne sont pas transmises.
+
+
+
+
+ + +
+
+
+
Mode de vie
+
+
+ + +
+ 3h7.5h (optimal)12h +
+
+
+ + +
+ 😌 Faible😰 Élevé +
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+
Alimentation
+
+
+ + +
+
+ +
+ {% for val, lbl in [ + ('gluten', '🌾 Gluten'), + ('lactose', '🥛 Lactose'), + ('fructose', '🍎 Fructose'), + ('nuts', '🥜 Fruits à coque'), + ('shellfish', '🦐 Crustacés'), + ('eggs', '🥚 Œufs'), + ('soy', '🌿 Soja'), + ('nightshades', '🍅 Solanacées'), + ] %} +
+
+ + +
+
+ {% endfor %} +
+
+
+
+
+
+ + +
+
+
+
Activité physique
+
+
+ +
+ {% for val, icon, lbl, desc in [ + ('sedentary', '🪑', 'Sédentaire', 'Peu ou pas d\'exercice, travail de bureau'), + ('light', '🚶', 'Léger', '1-3 fois/semaine, activité légère'), + ('moderate', '🚴', 'Modéré', '3-5 fois/semaine, effort modéré'), + ('intense', '🏋️', 'Intense', '5-6 fois/semaine, effort soutenu'), + ('athlete', '🏆', 'Athlète', 'Entraînement quotidien ou compétition'), + ] %} +
+ +
+ {% endfor %} +
+
+
+ +
+ {% for val, lbl in [ + ('running', '🏃 Course à pied'), + ('cycling', '🚴 Vélo'), + ('swimming', '🏊 Natation'), + ('gym', '🏋️ Musculation/Salle'), + ('yoga', '🧘 Yoga / Pilates'), + ('team_sports', '⚽ Sports collectifs'), + ('martial_arts', '🥋 Arts martiaux'), + ('hiking', '🥾 Randonnée'), + ('crossfit', '🔥 CrossFit / Functional'), + ] %} +
+
+ + +
+
+ {% endfor %} +
+
+
+
+
+
+ + +
+
+
+
Objectifs de santé
+

Sélectionnez vos objectifs principaux. Les recommandations seront filtrées et priorisées en conséquence.

+
+ {% for val, icon, lbl, desc in [ + ('weight_loss', '⚖️', 'Perte de poids', 'Réduire la masse grasse de façon durable'), + ('muscle_gain', '💪', 'Prise de muscle', 'Augmenter la masse musculaire'), + ('longevity', '🌿', 'Longévité', 'Vieillir en bonne santé'), + ('energy', '⚡', 'Plus d\'énergie', 'Réduire la fatigue chronique'), + ('sleep_quality', '😴', 'Sommeil', 'Améliorer la qualité et durée'), + ('stress_reduction', '🧘', 'Réduire le stress', 'Anxiété, bien-être mental'), + ('metabolic_health', '🔬', 'Santé métabolique', 'Glycémie, insuline, lipides'), + ('cardiovascular_health', '❤️', 'Santé cardio', 'Réduire les facteurs de risque'), + ('cognitive_performance', '🧠', 'Performance cognitive', 'Mémoire, concentration, focus'), + ] %} +
+ +
+ {% endfor %} +
+
+
+
+ +
+ +
+ + Retour au tableau de bord + + +
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..40c43fe --- /dev/null +++ b/templates/register.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Créer un compte — OpenCare{% endblock %} +{% block content %} +
+
+
+
+
+
+
+ +
+

Créer un compte

+

Commencez à recevoir des conseils personnalisés

+
+
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+ +
+
+ + Vos données sont stockées uniquement en local. +
+
+

+ Déjà un compte ? + Se connecter +

+
+
+
+
+
+{% endblock %} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..cc00218 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,9 @@ +""" +Point d'entrée Gunicorn pour la production. +Usage : gunicorn -c gunicorn.conf.py wsgi:app +""" +from app import app, _init_db, start_scheduler + +with app.app_context(): + _init_db() + start_scheduler()