"""
LGCC Dossier Module v1.0 — Fase C PVA "Proactieve Agendabesturing"
===================================================================
AI-voorbereidingsdossier per agenda-item (volgens DOSSIER_DESIGN.md):
- tabel agenda_dossiers (idempotente migratie bij registratie)
- genereer_dossier(): ai_complete-prompt per agendatype + kennisbank-context
(max 3 fragmenten via LIKE-zoekgang op knowledge_docs — géén embeddings, lean)
- statusflow concept → definitief (+ 'pending' bij AI-storing; nooit fake-content)
- eindredactie: textarea op de detailpagina slaat bewerkte inhoud op als inhoud_md
- koppelingen naar bestaande flows (geen duplicaten):
· 'Gebruik in nieuwsbrief' → seed in de bestaande newsletter_content_pool
· 'Social-ready' → JSON-export in /api/social/generate-payload-formaat
Integratie in app.py (zelfde registreerpatroon als agenda_module):
from dossier_module import register_dossier_routes, init_dossier_db
register_dossier_routes(app, page, BASE_PATH, ai_complete, db) # draait ook de migratie
Lokaal testen (repo):
python3 -m pytest tests/test_dossier.py -v
"""
from datetime import date
from html import escape as _esc
from flask import request, jsonify, session, redirect
from urllib.parse import urlencode
import re
MODULE_VERSION = '1.0'
# Statusflow: pending (AI-storing, retry) → concept (AI-levering) → definitief (DG-afvink)
DOSSIER_STATUSES = ('concept', 'definitief', 'pending')
# Handmatig toegestane overgangen (pending wordt alleen door het systeem gezet)
USER_TRANSITIONS = {'concept': {'definitief'}, 'definitief': {'concept'}}
STATUS_BADGE = {
'concept': ('badge-gold', '#F5C518'),
'definitief': ('badge-green', '#28A745'),
'pending': ('badge-red', '#DC3545'),
}
# AI-model-label (ai_complete kiest intern het model; dit is traceerbaarheid)
AI_MODEL_LABEL = 'ai_complete'
# ──────────────────────────────────────────────────────────────────────────────
# DATABASE — idempotente migratie
# ──────────────────────────────────────────────────────────────────────────────
def init_dossier_db(db_module):
"""Maak agenda_dossiers aan indien nog niet bestaand. Veilig herhaaldelijk te draaien."""
conn = db_module.get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS agenda_dossiers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agenda_item_id INTEGER NOT NULL,
gegenereerd_op TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
type TEXT DEFAULT '',
status TEXT DEFAULT 'concept',
inhoud_md TEXT DEFAULT '',
ai_model TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_agenda_dossiers_item ON agenda_dossiers(agenda_item_id);
""")
conn.commit()
conn.close()
# ──────────────────────────────────────────────────────────────────────────────
# Type-normalisatie (agenda-item-type → dossier-template)
# ──────────────────────────────────────────────────────────────────────────────
def norm_type(agenda_type):
"""Koppel het vrije agenda-type aan een template uit DOSSIER_DESIGN.md."""
t = (agenda_type or '').strip().lower()
if any(k in t for k in ('kabinets', 'kabinet', 'gmt', 'glt', 'gst', 'bestuursvergadering')):
return 'kabinetsvergadering'
if 'gouverneursraad' in t:
return 'gouverneursraad'
if 'convent' in t or 'europaforum' in t:
return 'conventie'
if 'zone' in t:
return 'zonebijeenkomst'
if 'nieuwsbrief' in t or 'newsletter' in t:
return 'nieuwsbrief'
if 'campagne' in t:
return 'campagne'
if 'training' in t or 'workshop' in t:
return 'training'
return 'standaard'
# Type-specifieke focus per DOSSIER_DESIGN.md ("Per type")
TYPE_FOCUS = {
'kabinetsvergadering': (
'- Agenda-opbouw van de vergadering (opening, jaarthema-moment, per team)\n'
'- Verwachte bespreekpunten per team (GMT / GLT / GST)\n'
'- Aansluiting op het jaarthema "Samen Verder Bouwen"\n'
'- Actiepunten uit de vorige vergadering (opvolging)'
),
'gouverneursraad': (
'- Beleidsstemmingen die op de agenda kunnen komen\n'
'- Clubstatus: aandachtspunten per club/zone\n'
'- Notulen-vragenlijst: welke punten uit de vorige notulen zijn nog open'
),
'conventie': (
'- Reislogistiek (heen/terug, verblijf, timing)\n'
'- Speech van 5 minuten (opbouw + kernboodschap)\n'
'- Vertegenwoordiging MD110: wie vertegenwoordigt wat\n'
'- Netwerkdoelen: wie moet de DG deze conventie spreken'
),
'zonebijeenkomst': (
'- Zone-info: bijzonderheden per zone\n'
'- Clubprestaties: recente hoogtepunten per club\n'
'- Boodschap per zone (kickoffjaar — inspireer en activeer)'
),
'nieuwsbrief': (
'- Sectie-indeling volgens nieuwsbrief #1 als template\n'
'- Agenda-extract: komende activiteiten die de moeite waard zijn\n'
'- Campagne-status: waar staan de lopende campagnes\n'
'- Call-to-action per team (GMT/GLT/GST-contacten)'
),
'campagne': (
'- KPI-momenten: bereik en NAWE-leads\n'
'- Materialenstatus: wat is beschikbaar, wat ontbreekt\n'
'- Tussenevaluatie-momenten inplannen'
),
'training': (
'- Deelnemerslijst-vragen: wie komt, wie ontbreekt nog\n'
'- Locatie en logistiek\n'
'- Uitnodigingsworkflow (T-20 = uitnodiging versturen!)'
),
'standaard': (
'- Wat is hiervoor concreet nodig (mensen, materialen, tijd)\n'
'- Wat kan misgaan en hoe voorkom je dat'
),
}
# ──────────────────────────────────────────────────────────────────────────────
# Kennisbank-context — lean LIKE-zoekgang (géén embeddings)
# ──────────────────────────────────────────────────────────────────────────────
STOPWOORDEN = {
'de', 'het', 'een', 'en', 'van', 'in', 'op', 'voor', 'met', 'te', 'dat', 'die',
'the', 'and', 'for', 'with', 'vanuit', 'naar', 'bij', 'aan', 'is', 'zijn',
}
def _zoekwoorden(item):
"""Belangrijkste woorden uit titel, type en beschrijving (voor LIKE-zoekgang)."""
tekst = ' '.join(filter(None, [item.get('titel', ''), item.get('type', ''),
item.get('beschrijving', '')])).lower()
woorden = [w for w in re.findall(r'[a-zà-ÿ]{4,}', tekst) if w not in STOPWOORDEN]
out, seen = [], set()
for w in woorden: # uniek, volgorde van belangrijkheid (titel eerst)
if w not in seen:
out.append(w)
seen.add(w)
return out[:6]
def zoek_kennisbank_fragmenten(db_module, item, max_frag=3):
"""
Max `max_frag` relevante fragmenten uit de kennisbank (tabel knowledge_docs).
Lean: LIKE op original_name (titel) én content_text; fragment van ~600 tekens
rond de eerste match. Tabel niet aanwezig of geen treffers → [] (context is
optioneel; generatie draait gewoon door).
"""
woorden = _zoekwoorden(item)
if not woorden:
return []
conn = db_module.get_db()
fragmenten, gezien = [], set()
try:
for w in woorden:
if len(fragmenten) >= max_frag:
break
pat = f'%{w}%'
try:
rows = conn.execute(
"SELECT id, original_name, content_text FROM knowledge_docs "
"WHERE original_name LIKE ? OR content_text LIKE ? LIMIT 3",
(pat, pat)).fetchall()
except Exception:
break # tabel bestaat niet → geen context
for r in rows:
if r['id'] in gezien:
continue
gezien.add(r['id'])
tekst = (r['content_text'] or '').strip()
if not tekst:
continue
pos = tekst.lower().find(w)
start = max(0, pos - 250) if pos >= 0 else 0
fragmenten.append({
'titel': r['original_name'],
'fragment': '…' + tekst[start:start + 600] + '…',
})
if len(fragmenten) >= max_frag:
break
finally:
conn.close()
return fragmenten
# ──────────────────────────────────────────────────────────────────────────────
# GENERATIE — ai_complete met prompt per type (DOSSIER_DESIGN.md)
# ──────────────────────────────────────────────────────────────────────────────
SYSTEM_PROMPT = (
'Je bent de persoonlijke voorbereidingsassistent van de Districtsgouverneur van '
'Lions Clubs International, District 110AN (Nederland), lionsjaar 2026-2027, '
'met jaarthema "Samen Verder Bouwen". Je maakt beknopte, praktische '
'voorbereidingsdossiers in het Nederlands, in Markdown.\n'
'Regels:\n'
'1. Gebruik EXACT deze vijf koppen (##-niveau, in deze volgorde): '
'"Kernkaart", "Context", "Checklist voorbereiding", "Talking points", '
'"Open vragen aan de DG".\n'
'2. Kernkaart: wat, wanneer, waar, wie aanwezig, rol van de DG — als tabel of bullets.\n'
'3. Checklist: concrete acties mét deadline (T-x afleiden uit de datum).\n'
'4. Talking points: 3-5 punten in de warme, verbindende toon van "Samen Verder Bouwen".\n'
'5. Verzin NOOIT feiten. Wat je niet weet uit de meegegeven context komt onder '
'"Open vragen aan de DG". Dat is een kwaliteit, geen tekortkoming.\n'
'6. Output: uitsluitend het Markdown-dossier, geen voor- of nawerk.'
)
def _build_messages(item, kennis_fragmenten):
"""Bouw de [system, user]-messages voor ai_complete volgens DOSSIER_DESIGN.md."""
dtype = norm_type(item.get('type', ''))
datum = item.get('datum', '')
datum_eind = item.get('datum_eind') or ''
dagen_tot = ''
try:
diff = (date.fromisoformat(datum) - date.today()).days
dagen_tot = (f'\nTeller: het evenement is over {diff} dagen (T-{max(diff, 0)}).'
if diff >= 0 else '\nLet op: de datum is al geweest.')
except (ValueError, TypeError):
pass
if kennis_fragmenten:
kb = ('\n\nRELEVANTE KENNISBANK-FRAGMENTEN (Lions 110AN):\n'
+ '\n\n'.join(f'### {f["titel"]}\n{f["fragment"]}' for f in kennis_fragmenten)
+ '\n(Gebruik deze fragmenten als feitelijke context waar relevant; '
'noem de brontitel bij feiten.)')
else:
kb = ('\n\nRELEVANTE KENNISBANK-FRAGMENTEN: geen gevonden — verzin niets, '
'zet onbekende zaken onder "Open vragen aan de DG".')
user = f"""Bereid het volgende agenda-item voor de Districtsgouverneur voor.
AGENDA-ITEM:
- Titel: {item.get('titel', '')}
- Type: {item.get('type', '') or 'onbekend'} (dossiertype: {dtype})
- Datum: {datum}{' t/m ' + datum_eind if datum_eind else ''}{dagen_tot}
- Locatie: {item.get('locatie', '') or 'onbekend'}
- Beschrijving/voorbereidingsbehoeften: {item.get('beschrijving', '') or '(geen)'}
SPECIFIEKE AANDACHTSPUNTEN VOOR DIT TYPE ({dtype}):
{TYPE_FOCUS[dtype]}{kb}
Genereer nu het volledige dossier in Markdown met de vijf vaste koppen."""
return [{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': user}], dtype
def genereer_dossier(agenda_item, ai_complete_fn, db_module):
"""
Genereert de dossierinhoud (Markdown) voor `agenda_item` via ai_complete.
Raist bij AI-storing/leeg antwoord — de caller zet dan status 'pending'.
NOOIT fake-content. Retourneert (inhoud_md, dossiertype, messages).
"""
fragmenten = zoek_kennisbank_fragmenten(db_module, agenda_item)
messages, dtype = _build_messages(agenda_item, fragmenten)
inhoud = ai_complete_fn(messages)
if not inhoud or not str(inhoud).strip():
raise RuntimeError('AI gaf een lege respons terug')
return str(inhoud).strip(), dtype, messages
# ──────────────────────────────────────────────────────────────────────────────
# QUERY-HELPERS
# ──────────────────────────────────────────────────────────────────────────────
def get_dossier(db_module, dossier_id):
"""Dossier + bijbehorend agenda-item (JOIN) als dict, of None."""
conn = db_module.get_db()
row = conn.execute(
"SELECT d.*, a.datum AS item_datum, a.datum_eind AS item_datum_eind, "
"a.titel AS item_titel, a.type AS item_type, a.locatie AS item_locatie, "
"a.beschrijving AS item_beschrijving, a.status AS item_status "
"FROM agenda_dossiers d LEFT JOIN agenda_items a ON a.id = d.agenda_item_id "
"WHERE d.id = ?", (dossier_id,)).fetchone()
conn.close()
return dict(row) if row else None
def get_dossier_voor_item(db_module, item_id):
"""Nieuwste dossier behorend bij een agenda-item (of None)."""
conn = db_module.get_db()
row = conn.execute(
"SELECT * FROM agenda_dossiers WHERE agenda_item_id = ? ORDER BY id DESC LIMIT 1",
(item_id,)).fetchone()
conn.close()
return dict(row) if row else None
def _upsert_dossier(db_module, item_id, dtype, status, inhoud_md, ai_model):
"""
Eén dossier per agenda-item: nieuw → INSERT, bestaand → UPDATE.
inhoud_md=None betekent: bestaande inhoud behouden (bij mislukte regeneratie).
Retourneert dossier-id.
"""
conn = db_module.get_db()
try:
row = conn.execute("SELECT id, inhoud_md FROM agenda_dossiers WHERE agenda_item_id=? "
"ORDER BY id DESC LIMIT 1", (item_id,)).fetchone()
if row:
nieuwe_inhoud = inhoud_md if inhoud_md is not None else (row['inhoud_md'] or '')
conn.execute(
"UPDATE agenda_dossiers SET gegenereerd_op=CURRENT_TIMESTAMP, type=?, "
"status=?, inhoud_md=?, ai_model=? WHERE id=?",
(dtype, status, nieuwe_inhoud, ai_model, row['id']))
dossier_id = row['id']
else:
cur = conn.execute(
"INSERT INTO agenda_dossiers (agenda_item_id, gegenereerd_op, type, status, "
"inhoud_md, ai_model) VALUES (?, CURRENT_TIMESTAMP, ?, ?, ?, ?)",
(item_id, dtype, status, inhoud_md or '', ai_model))
dossier_id = cur.lastrowid
conn.commit()
return dossier_id
finally:
conn.close()
def _extract_section(md, naam):
"""Haal de inhoud van een '## Naam'-sectie uit het Markdown-dossier."""
m = re.search(rf'^##\s*{re.escape(naam)}\s*$(.*?)(?=^##\s|\Z)', md or '',
re.M | re.I | re.S)
return m.group(1).strip()
# ──────────────────────────────────────────────────────────────────────────────
# FLASK-ROUTES — zelfde registreerpatroon als agenda_module
# ──────────────────────────────────────────────────────────────────────────────
def register_dossier_routes(app, page_func, BASE_PATH, ai_complete_fn, db_module,
ai_model_label=AI_MODEL_LABEL):
"""Register alle dossier-routes op de Flask-app. Draait ook de idempotente migratie."""
init_dossier_db(db_module)
def _admin_ok():
# Zelfde vangnet als agenda_module: de productie-app heeft een globale
# before_request login-check; dit is de extra guard op dossier-acties.
return bool(session.get('authenticated') or session.get('user_id'))
def _get_item(item_id):
conn = db_module.get_db()
row = conn.execute("SELECT * FROM agenda_items WHERE id=?", (item_id,)).fetchone()
conn.close()
return dict(row) if row else None
def _flash_redirect(dossier_id, msg, soort='success'):
return redirect(f'{BASE_PATH}/agenda/dossier/{dossier_id}?'
+ urlencode({'flash': msg, 'fsoort': soort}))
def _badge(status):
kleur = STATUS_BADGE.get(status, ('badge-gray', '#868E96'))[1]
return f'{_esc(status)}'
# ── POST /agenda/dossier//genereer — admin-only AI-generatie ──
@app.route('/agenda/dossier//genereer', methods=['POST'])
def dossier_genereer(item_id):
if not _admin_ok():
return jsonify({'error': 'Admin-sessie vereist'}), 403
item = _get_item(item_id)
if not item:
return page_func('
{_badge(status)}
{_esc(d.get('type') or 'standaard')}
{_esc(d.get('item_titel') or '(agenda-item verwijderd)')}
📅 {_esc(datum_str)}{(" t/m " + _esc(d.get('item_datum_eind'))) if d.get('item_datum_eind') else ''}
{(' · 📍 ' + _esc(d['item_locatie'])) if d.get('item_locatie') else ''}
{(' · 🏷️ ' + _esc(d['item_type'])) if d.get('item_type') else ''}
Gegenereerd: {_esc(str(d.get('gegenereerd_op') or '')[:19])}
{(' · model: ' + _esc(d.get('ai_model') or '—'))}
· ← agenda
{status_uitleg}
{acties}
📄 Dossierinhoud
{_esc(inhoud) if inhoud else '(nog geen inhoud — zie pending-melding hierboven)'}
✏️ Eindredactie
Bewerk de inhoud naar eigen inzicht (Markdown). Opslaan
overschrijft inhoud_md; daarna kun je het dossier definitief maken.
'''
return page_func(body, page_title='Dossier', active='agenda')
# ── POST /agenda/dossier//status — statusflow concept↔definitief ──
@app.route('/agenda/dossier//status', methods=['POST'])
def dossier_status(dossier_id):
if not _admin_ok():
return jsonify({'error': 'Admin-sessie vereist'}), 403
d = get_dossier(db_module, dossier_id)
if not d:
return jsonify({'error': 'Dossier niet gevonden'}), 404
nieuw = (request.form.get('status') or '').strip().lower()
huidig = d['status'] or 'concept'
if nieuw not in DOSSIER_STATUSES:
return jsonify({'error': f'Ongeldige status "{nieuw}"' }), 400
if nieuw not in USER_TRANSITIONS.get(huidig, set()):
return jsonify({'error': f'Transitie {huidig} → {nieuw} is niet toegestaan'}), 400
conn = db_module.get_db()
conn.execute("UPDATE agenda_dossiers SET status=? WHERE id=?", (nieuw, dossier_id))
conn.commit()
conn.close()
return _flash_redirect(dossier_id,
'✅ Dossier definitief gemaakt.' if nieuw == 'definitief'
else '↩️ Dossier teruggezet naar concept.')
# ── POST /agenda/dossier//bewerken — eindredactie opslaan ──────────
@app.route('/agenda/dossier//bewerken', methods=['POST'])
def dossier_bewerken(dossier_id):
if not _admin_ok():
return jsonify({'error': 'Admin-sessie vereist'}), 403
d = get_dossier(db_module, dossier_id)
if not d:
return jsonify({'error': 'Dossier niet gevonden'}), 404
inhoud = request.form.get('inhoud_md')
if inhoud is None or not inhoud.strip():
return _flash_redirect(dossier_id, '❌ Lege inhoud wordt niet opgeslagen.', 'error')
conn = db_module.get_db()
conn.execute("UPDATE agenda_dossiers SET inhoud_md=? WHERE id=?",
(inhoud, dossier_id))
conn.commit()
conn.close()
return _flash_redirect(dossier_id, '💾 Eindredactie opgeslagen als inhoud_md.')
# ── POST /agenda/dossier//naar-nieuwsbrief — seed in bestaande pool ─
@app.route('/agenda/dossier//naar-nieuwsbrief', methods=['POST'])
def dossier_naar_nieuwsbrief(dossier_id):
if not _admin_ok():
return jsonify({'error': 'Admin-sessie vereist'}), 403
d = get_dossier(db_module, dossier_id)
if not d or not (d['inhoud_md'] or '').strip():
return _flash_redirect(dossier_id, '❌ Geen dossierinhoud om te delen — genereer eerst een dossier.', 'error')
titel = f"Dossier: {d.get('item_titel') or 'agenda-item'} ({d.get('item_datum') or '?'})"
categorie = 'agenda'
tags = 'agenda,dossier,' + (norm_type(d.get('item_type', '')) or 'standaard')
try:
conn = db_module.get_db()
conn.execute(
"INSERT INTO newsletter_content_pool (title, content, content_type, category, tags) "
"VALUES (?,?,?,?,?)",
(titel, d['inhoud_md'][:50000], 'text', categorie, tags))
conn.commit()
conn.close()
except Exception as e:
return _flash_redirect(dossier_id, f'❌ Kon niet toevoegen aan nieuwsbrief-pool: {str(e)[:80]}', 'error')
return _flash_redirect(dossier_id,
'📰 Als seed toegevoegd aan de nieuwsbrief-content-pool — '
f'selecteer het item in de nieuwsbrief-generator.')
# ── GET /agenda/dossier//social-export — JSON voor Social Generator ─
@app.route('/agenda/dossier//social-export', methods=['GET'])
def dossier_social_export(dossier_id):
d = get_dossier(db_module, dossier_id)
if not d:
return jsonify({'error': 'Dossier niet gevonden'}), 404
tp = _extract_section(d['inhoud_md'] or '', 'Talking points')
if not tp:
tp = (d['inhoud_md'] or '')[:1500]
titel = d.get('item_titel') or 'Lions agenda-item'
omschrijving = (f"Talking points uit voorbereidingsdossier ({d.get('item_datum')}, "
f"{d.get('item_type') or 'activiteit'}):\n{tp}")
payloads = [{'title': titel, 'description': omschrijving, 'platform': p,
'language': 'nl', 'tone': 'inspirerend'}
for p in ('instagram', 'linkedin', 'facebook')]
resp = jsonify({
'dossier_id': dossier_id,
'agenda_item_id': d['agenda_item_id'],
'status': d['status'],
'titel': titel,
'talking_points': tp,
'social_generator': {
'endpoint': f'{BASE_PATH}/api/social/generate',
'methode': 'POST (JSON)',
'uitleg': 'Stuur één payload per platform naar de bestaande Social Generator; '
'die slaat de post zelf op in social_posts.',
'payloads': payloads,
},
})
resp.headers['Content-Disposition'] = f'attachment; filename="dossier-{dossier_id}-social.json"'
return resp
# ──────────────────────────────────────────────────────────────────────────────
# AGENDA-PAGINA-KOPPELING — dossier-knop/link per item zodra T-14 bereikt
# ──────────────────────────────────────────────────────────────────────────────
def render_dossier_kolom(db_module, item, BASE_PATH=''):
"""
HTML voor de Dossier-kolom op /agenda (gebruikt door agenda_module._row()):
- dossier bestaat → link naar detail (met statuskleur)
- geen dossier én startdatum binnen 14 dagen (T-14 bereikt) → genereer-knop
- anders → leeg (lean: geen ruis op de agenda)
"""
d = get_dossier_voor_item(db_module, item['id'])
if d:
kleur = STATUS_BADGE.get(d['status'], ('', '#868E96'))[1]
return (f''
f'📂 dossier ({_esc(d["status"])})')
try:
diff = (date.fromisoformat(item['datum']) - date.today()).days
except (ValueError, TypeError, KeyError):
return ''
if diff <= 14:
return (f'')
return ''