Jeroen-Kolom's picture
Upload app.py
2b295db verified
Raw
History Blame Contribute Delete
72.9 kB
# --- STARLETTE TEMPLATE PATCH FOR PYTHON 3.13 / GRADIO 4.44.0 COMPATIBILITY ---
import starlette.templating
from starlette.requests import Request
import inspect
_original_template_response = starlette.templating.Jinja2Templates.TemplateResponse
def _patched_template_response(self, *args, **kwargs):
is_new_style = False
if args and (isinstance(args[0], Request) or hasattr(args[0], "scope")):
is_new_style = True
elif "request" in kwargs:
is_new_style = True
request = None
name = None
context = None
other_args = []
other_kwargs = {}
if is_new_style:
if len(args) >= 1:
request = args[0]
if len(args) >= 2:
name = args[1]
if len(args) >= 3:
context = args[2]
if len(args) > 3:
other_args = list(args[3:])
if "request" in kwargs:
request = kwargs["request"]
if "name" in kwargs:
name = kwargs["name"]
if "context" in kwargs:
context = kwargs["context"]
for k, v in kwargs.items():
if k not in ("request", "name", "context"):
other_kwargs[k] = v
else:
if len(args) >= 1:
name = args[0]
if len(args) >= 2:
context = args[1]
if len(args) > 2:
other_args = list(args[2:])
if "name" in kwargs:
name = kwargs["name"]
if "context" in kwargs:
context = kwargs["context"]
if isinstance(context, dict):
request = context.get("request")
for k, v in kwargs.items():
if k not in ("name", "context"):
other_kwargs[k] = v
sig = inspect.signature(_original_template_response)
underlying_expects_request = "request" in sig.parameters
if underlying_expects_request:
return _original_template_response(self, request, name, context, *other_args, **other_kwargs)
else:
if context is None:
context = {}
if "request" not in context:
context["request"] = request
return _original_template_response(self, name, context, *other_args, **other_kwargs)
starlette.templating.Jinja2Templates.TemplateResponse = _patched_template_response
# -----------------------------------------------------------------------------
import os
import json
import requests
import datetime
import torch
import re
from transformers import pipeline
import gradio as gr
# --- 1. CONFIGURATION & STATE PERSISTENCE ---
DB_FILE = "/data/docent_observations.json" if os.path.exists("/data") else "docent_observations.json"
SUBJECTS_LIST = [
"Nederlands",
"Rekenen",
"Burgerschap",
"Omgangskunde",
"LOB/SectororiΓ«ntatie",
"Engels",
"ICT",
"Huiswerk begeleiding",
"Drama",
"Gym",
"Techniek",
"Verzorging (PV/Z&W)",
"Koken",
"Horeca (keuken/dienst)",
"OTG",
"BeVo",
"Muziek",
"Module",
"Keuzeles",
"VRT",
"Stage/ROC",
"Coachuur LOB"
]
# --- 1b. VOCABULARY FOR NAME EXTRACTION & VALIDATION ---
COMMON_NAMES = {
# Boys
"daan", "luuk", "levi", "lucas", "mees", "milan", "sam", "sem", "noah", "liam",
"bram", "noud", "gijs", "jesse", "finn", "boaz", "max", "morris", "hugo", "mats",
"senn", "julian", "ruben", "thomas", "joep", "sven", "lars", "guus", "ryan", "boris",
"thijs", "jan", "karel", "piet", "kees", "willem", "hendrik", "gerrit", "dirk", "jacob",
"pieter", "johannes", "tim", "tom", "mark", "paul", "bas", "nick", "rick", "niels",
"koen", "bob", "bart", "stefan", "daniel", "dylan", "justin", "sander", "johan", "peter",
"robert", "michel", "kevin", "dennis", "arthur", "mohammad", "ali", "youssef", "jeroen",
# Girls
"sophie", "sofie", "julia", "emma", "mila", "tess", "zoe", "zoΓ«", "sara", "sarah",
"lotte", "eva", "nora", "yara", "liv", "lauren", "milou", "lynn", "saar", "olivia",
"fleur", "anna", "elena", "evi", "isa", "roos", "maud", "fenna", "nova",
"liz", "amy", "linde", "lieke", "jasmijn", "nina", "sanne", "iris", "amber", "anouk",
"romy", "femke", "veerle", "floor", "yasmin", "yasmina", "yasmine", "sofia", "maria",
"johanna", "cornelia", "elisabeth", "hendrika", "heike", "heidi", "anne", "lisa", "laura",
"esmee", "charlotte", "eline", "noa"
}
STOP_WORDS = {
"ik", "je", "we", "de", "het", "een", "dit", "dat", "deze", "die", "er", "nu", "toen",
"als", "hoe", "wat", "wie", "waar", "waarom", "vandaag", "morgen", "gisteren",
"maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag", "zondag",
"les", "klas", "school", "meester", "juf", "docent", "leerling", "hij", "zij", "het", "soms",
"dan", "en", "of", "maar", "om", "te", "in", "op", "met", "voor", "door", "over", "aan",
"bij", "naar", "uit", "is", "was", "wordt", "werd", "zijn", "waren", "heb", "hebt", "heeft",
"hebben", "had", "hadden", "zal", "zullen", "zou", "zouden", "kan", "kunnen", "kon",
"konden", "wil", "willen", "wilde", "wilden", "moet", "moeten", "moest", "moesten",
"mag", "mogen", "mocht", "mochten", "veel", "weinig", "alle", "alles", "niets", "iets",
"geen", "niet", "wel", "ook", "nog", "al", "net", "zo", "dus", "toch", "want", "omdat",
"hier", "daar", "hen", "hun", "haar", "hem", "zijn", "mijn", "jouw", "onze", "jullie",
"hierdoor", "daardoor", "hiermee", "daarmee", "hierin", "daarin", "hiervoor", "daarvoor",
"hieruit", "daaruit", "hierbij", "daarbij", "hierom", "daarom", "daarnaast", "bovendien",
"tevens", "verder", "vervolgens", "daarna", "vandaar", "echter", "hoewel", "ofschoon",
"terwijl", "zodra", "totdat", "voordat", "nadat", "sinds", "iedereen", "iemand", "niemand",
"sommige", "sommigen", "velen", "weinigen", "allemaal", "zoals", "onder", "tussen", "tegen",
"zonder", "behalve", "binnen", "buiten", "tijdens", "straks", "later"
}
CLASS_STOP_WORDS = {
"nederlands", "rekenen", "taal", "gym", "gymles", "rekenles", "spelling", "spellingles",
"geschiedenis", "aardrijkskunde", "wiskunde", "engels", "duits", "frans", "muziek",
"tekenen", "knutselen", "les", "klas", "school", "groep", "meester", "juf", "docent",
"leerling", "leerlingen", "klasgenoot", "klasgenootje", "klasgenootjes", "werktijd",
"pauze", "ochtend", "middag", "dag", "week", "maand", "jaar", "toets", "werk", "werkboek",
"taak", "som", "sommen", "boek", "schrijven", "lezen", "luisteren", "kijken", "uitleg",
"niveau", "hoger", "lager", "pet", "petje", "lesstof", "instructie", "taalklas", "rekenklas"
}
ACTION_VERBS = {
"was", "vond", "deed", "heeft", "had", "werkte", "maakte", "zat", "hielp",
"sprak", "praatte", "luisterde", "keek", "ging", "kwam", "liep", "rende",
"speelde", "las", "schreef", "begon", "stopte", "weigerde", "wilde", "kon",
"moest", "mocht", "vroeg", "antwoordde", "lachte", "huilde", "stoorde"
}
def is_valid_student_name(name):
if not name or not isinstance(name, str):
return False
# Clean the name: remove punctuation
clean_name = re.sub(r'[^\w\s]', '', name).strip()
if not clean_name:
return False
clean_name_lower = clean_name.lower()
# Must not be a stop word, classroom stop word, or action verb
if clean_name_lower in STOP_WORDS or clean_name_lower in CLASS_STOP_WORDS or clean_name_lower in ACTION_VERBS:
return False
# Must not be a number
if clean_name.isdigit():
return False
# Real first names are rarely more than 2 words
if len(clean_name.split()) > 2:
return False
return True
def extract_subject_from_text(text):
if not text:
return None
text_lower = text.lower()
# Check custom aliases / variations
if "nederlands" in text_lower:
return "Nederlands"
if "rekenen" in text_lower or "rekenles" in text_lower:
return "Rekenen"
if "burgerschap" in text_lower:
return "Burgerschap"
if "omgangskunde" in text_lower:
return "Omgangskunde"
if "lob/sector" in text_lower or "sectororiΓ«ntatie" in text_lower or "sectororientatie" in text_lower:
return "LOB/SectororiΓ«ntatie"
if "coachuur" in text_lower and "lob" in text_lower:
return "Coachuur LOB"
if "lob" in text_lower:
return "LOB/SectororiΓ«ntatie"
if "engels" in text_lower:
return "Engels"
if "ict" in text_lower:
return "ICT"
if "huiswerk" in text_lower:
return "Huiswerk begeleiding"
if "drama" in text_lower:
return "Drama"
if "gym" in text_lower or "gymles" in text_lower:
return "Gym"
if "techniek" in text_lower:
return "Techniek"
if "verzorging" in text_lower or "pv/z&w" in text_lower or "z&w" in text_lower:
return "Verzorging (PV/Z&W)"
if "koken" in text_lower or "kookles" in text_lower:
return "Koken"
if "horeca" in text_lower:
return "Horeca (keuken/dienst)"
if "otg" in text_lower:
return "OTG"
if "bevo" in text_lower or "beeldende vorming" in text_lower:
return "BeVo"
if "muziek" in text_lower or "muziekles" in text_lower:
return "Muziek"
if "module" in text_lower:
return "Module"
if "keuzeles" in text_lower:
return "Keuzeles"
if "vrt" in text_lower:
return "VRT"
if "stage" in text_lower or "roc" in text_lower:
return "Stage/ROC"
# Check direct occurrence in list
for subj in SUBJECTS_LIST:
if subj.lower() in text_lower:
return subj
return None
def auto_categorize_observation(text):
# Backward compatibility fallback
subj = extract_subject_from_text(text)
return subj if subj else "Overig"
class ObservationStore:
def __init__(self, filepath=DB_FILE):
self.filepath = filepath
self.data = self.load()
def load(self):
if os.path.exists(self.filepath):
try:
with open(self.filepath, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"Error loading observations from {self.filepath}: {e}")
return {"notes_log": [], "students": {}}
def save(self):
try:
with open(self.filepath, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"Error saving observations: {e}")
def add_note_and_observations(self, raw_text, parsed_observations):
# 1. Add raw note
note_id = max([n["id"] for n in self.data["notes_log"]], default=0) + 1
self.data["notes_log"].append({
"id": note_id,
"timestamp": datetime.datetime.now().isoformat(),
"text": raw_text
})
# 2. Add student observations
added_any = False
overall_subject = extract_subject_from_text(raw_text)
for item in parsed_observations:
name = item.get("name", "").strip().capitalize()
obs_text = item.get("observation", "").strip()
if not name or not obs_text:
continue
if name not in self.data["students"]:
self.data["students"][name] = {
"name": name,
"observations": [],
"lvs_report": "",
"tips_tops": ""
}
obs_id = max([o["id"] for o in self.data["students"][name]["observations"]], default=0) + 1
# Extract subject: prioritize LLM value if valid, fallback to python matching
llm_subj = item.get("subject")
if llm_subj and llm_subj not in SUBJECTS_LIST:
llm_subj = None
obs_subject = llm_subj or extract_subject_from_text(obs_text) or overall_subject
self.data["students"][name]["observations"].append({
"id": obs_id,
"timestamp": datetime.datetime.now().isoformat(),
"text": obs_text,
"raw_note_id": note_id,
"subject": obs_subject if obs_subject else None
})
added_any = True
self.save()
return note_id if added_any else None
def add_manual_observation(self, name, obs_text, subject=""):
name = name.strip().capitalize()
obs_text = obs_text.strip()
final_subject = subject.strip() if subject else ""
if not final_subject:
extracted = extract_subject_from_text(obs_text)
if extracted:
final_subject = extracted
if not name or not obs_text:
return
if name not in self.data["students"]:
self.data["students"][name] = {
"name": name,
"observations": [],
"lvs_report": "",
"tips_tops": ""
}
obs_id = max([o["id"] for o in self.data["students"][name]["observations"]], default=0) + 1
self.data["students"][name]["observations"].append({
"id": obs_id,
"timestamp": datetime.datetime.now().isoformat(),
"text": obs_text,
"raw_note_id": None,
"subject": final_subject if final_subject else None
})
self.save()
def delete_observation(self, name, obs_id):
name = name.strip().capitalize()
if name in self.data["students"]:
self.data["students"][name]["observations"] = [
o for o in self.data["students"][name]["observations"] if o["id"] != obs_id
]
self.save()
def delete_student(self, name):
name = name.strip().capitalize()
if name in self.data["students"]:
del self.data["students"][name]
self.save()
def delete_raw_note(self, note_id):
self.data["notes_log"] = [n for n in self.data["notes_log"] if n["id"] != note_id]
self.save()
def update_lvs_report(self, name, text):
name = name.strip().capitalize()
if name in self.data["students"]:
self.data["students"][name]["lvs_report"] = text
self.save()
def update_tips_tops(self, name, text):
name = name.strip().capitalize()
if name in self.data["students"]:
self.data["students"][name]["tips_tops"] = text
self.save()
def clear_all(self):
self.data = {"notes_log": [], "students": {}}
self.save()
store = ObservationStore()
# --- 2. WHISPER SPEECH TO TEXT ---
whisper_pipeline = None
def get_whisper_pipeline():
global whisper_pipeline
if whisper_pipeline is None:
model_name = os.environ.get("WHISPER_MODEL", "openai/whisper-base")
print(f"Loading Whisper model: {model_name}...")
device = "cuda" if torch.cuda.is_available() else "cpu"
whisper_pipeline = pipeline(
"automatic-speech-recognition",
model=model_name,
device=device
)
return whisper_pipeline
def transcribe_audio(audio_path):
if not audio_path:
return ""
try:
pipe = get_whisper_pipeline()
# Force language to Dutch for high accuracy in Dutch classrooms
result = pipe(audio_path, generate_kwargs={"language": "dutch"})
return result.get("text", "").strip()
except Exception as e:
print(f"Transcription error: {e}")
return f"[Fout bij transcriptie: {e}]"
# --- 3. LLM MODELS DETECTION & CONNECTION ---
_cached_ollama_model = None
def get_best_ollama_model():
global _cached_ollama_model
if _cached_ollama_model is not None:
return _cached_ollama_model
default_model = "gemma:latest"
try:
response = requests.get("http://localhost:11434/api/tags", timeout=3)
if response.status_code == 200:
models_data = response.json().get("models", [])
installed_models = [m.get("name").split(":")[0] for m in models_data] + [m.get("name") for m in models_data]
# Prioritize models
for model_name in ["qwen2.5:1.5b", "qwen2.5", "llama3.2:3b", "llama3.2", "gemma:latest", "gemma"]:
if model_name in installed_models:
for m in models_data:
if m.get("name").startswith(model_name) or model_name == m.get("name"):
print(f"Auto-detected local Ollama model: {m.get('name')}")
_cached_ollama_model = m.get("name")
return _cached_ollama_model
if models_data:
_cached_ollama_model = models_data[0].get("name")
return _cached_ollama_model
except Exception:
# Print a friendly message instead of the raw exception to avoid scary logs on Hugging Face
print("Local Ollama not detected at localhost:11434 (normal for Hugging Face Spaces, using cloud fallback).")
_cached_ollama_model = default_model
return default_model
def clean_parsed_json(data):
if isinstance(data, dict):
# Look for any list of dicts within the dictionary (like 'observations' or 'students')
for val in data.values():
if isinstance(val, list) and (len(val) == 0 or isinstance(val[0], dict)):
return clean_parsed_json(val)
# If the dictionary represents a single name/observation item
if "name" in data and "observation" in data:
if isinstance(data["name"], list):
data["name"] = data["name"][0] if data["name"] else ""
return clean_parsed_json([data])
if isinstance(data, list):
cleaned_list = []
for item in data:
if isinstance(item, dict):
name = item.get("name", "")
if isinstance(name, list):
name = name[0] if name else ""
if name and isinstance(name, str):
# Clean and validate name
clean_name = re.sub(r'[^\w\s]', '', name).strip()
if is_valid_student_name(clean_name):
# Capitalize properly (e.g. "anne marie" -> "Anne Marie")
item["name"] = " ".join([part.capitalize() for part in clean_name.split()])
cleaned_list.append(item)
else:
print(f"Skipping invalid name extracted by LLM: {name}")
return cleaned_list
return []
def call_ollama_json(prompt):
model_name = get_best_ollama_model()
try:
response = requests.post(
"http://localhost:11434/api/generate",
json={
"model": model_name,
"prompt": prompt,
"format": "json",
"stream": False,
"options": {
"temperature": 0.1
}
},
timeout=90
)
if response.status_code == 200:
result_json = response.json().get("response", "").strip()
parsed = json.loads(result_json)
return clean_parsed_json(parsed)
except Exception as e:
print(f"Ollama connection error: {e}")
return None
def call_hf_json(prompt):
token = os.environ.get("HF_TOKEN")
if not token:
print("No Hugging Face token found.")
return None
headers = {"Authorization": f"Bearer {token}"}
api_url = "https://api-inference.huggingface.co/models/Qwen/Qwen2.5-7B-Instruct"
hf_prompt = f"""<|im_start|>system
Je bent een behoudende onderwijsassistent. Analyseer de tekst en geef het resultaat EXCLUSIEF terug als een geldige JSON array van objecten, zonder markdown code blocks en zonder inleiding.
Structuur:
[
{{"name": "Naam", "observation": "Observatie", "subject": "Vak of null"}}
]
<|im_end|>
<|im_start|>user
{prompt}
<|im_end|>
<|im_start|>assistant
"""
try:
response = requests.post(
api_url,
headers=headers,
json={
"inputs": hf_prompt,
"parameters": {
"max_new_tokens": 512,
"temperature": 0.1,
"return_full_text": False
}
},
timeout=15
)
if response.status_code == 200:
result = response.json()
text_out = ""
if isinstance(result, list) and len(result) > 0:
text_out = result[0].get("generated_text", "").strip()
elif isinstance(result, dict):
text_out = result.get("generated_text", "").strip()
if "```json" in text_out:
text_out = text_out.split("```json")[1].split("```")[0].strip()
elif "```" in text_out:
text_out = text_out.split("```")[1].split("```")[0].strip()
parsed = json.loads(text_out)
return clean_parsed_json(parsed)
except Exception as e:
print(f"HF Inference API error: {e}")
return None
def call_llm_text(prompt):
# Try Ollama first
model_name = get_best_ollama_model()
try:
response = requests.post(
"http://localhost:11434/api/generate",
json={
"model": model_name,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.3
}
},
timeout=90
)
if response.status_code == 200:
return response.json().get("response", "").strip()
except Exception as e:
print(f"Ollama text generation error: {e}")
# Try HF
token = os.environ.get("HF_TOKEN")
if token:
headers = {"Authorization": f"Bearer {token}"}
api_url = "https://api-inference.huggingface.co/models/Qwen/Qwen2.5-7B-Instruct"
hf_prompt = f"""<|im_start|>system
Je bent een professionele onderwijsassistent. Geef alleen het directe antwoord terug, zonder introductie of extra opmerkingen.
<|im_end|>
<|im_start|>user
{prompt}
<|im_end|>
<|im_start|>assistant
"""
try:
response = requests.post(
api_url,
headers=headers,
json={
"inputs": hf_prompt,
"parameters": {
"max_new_tokens": 512,
"temperature": 0.3,
"return_full_text": False
}
},
timeout=15
)
if response.status_code == 200:
result = response.json()
text_out = ""
if isinstance(result, list) and len(result) > 0:
text_out = result[0].get("generated_text", "").strip()
elif isinstance(result, dict):
text_out = result.get("generated_text", "").strip()
return text_out
except Exception as e:
print(f"HF text generation error: {e}")
return None
# --- 4. RULE-BASED FALLBACK PARSER ---
def rule_based_name_extractor(text):
print("Using rule-based name extraction fallback...")
words = text.split()
word_indices_with_names = []
for idx, word in enumerate(words):
clean_word = re.sub(r'[^\w\s]', '', word)
clean_word_lower = clean_word.lower()
# Unconditionally skip stop words, school subject terms, and action verbs
if not clean_word_lower or clean_word_lower in STOP_WORDS or clean_word_lower in CLASS_STOP_WORDS or clean_word_lower in ACTION_VERBS:
continue
is_name = clean_word_lower in COMMON_NAMES
if not is_name and word and word[0].isupper():
is_name = True
# Heuristics: followed by active classroom verb
if not is_name and idx < len(words) - 1:
next_word = re.sub(r'[^\w\s]', '', words[idx+1]).lower()
if next_word in ACTION_VERBS:
is_name = True
if is_name:
word_indices_with_names.append((idx, clean_word.capitalize()))
if not word_indices_with_names:
return []
extracted = []
# Segment text by names to handle run-on sentences without punctuation
for i, (name_idx, name) in enumerate(word_indices_with_names):
start_idx = name_idx
end_idx = word_indices_with_names[i+1][0] if i < len(word_indices_with_names) - 1 else len(words)
segment_words = words[start_idx:end_idx]
segment_text = " ".join(segment_words).strip()
segment_text = re.sub(r'^[^\w\s]+|[^\w\s]+$', '', segment_text).strip()
if segment_text and segment_text.lower() != name.lower() and len(segment_words) > 1:
extracted.append({
"name": name,
"observation": segment_text
})
return extracted
# --- 5. CORE BUSINESS LOGIC ---
def parse_note_with_llm(text):
if not text or not text.strip():
return []
current_time_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
prompt = f"""Je bent een AI-onderwijsassistent voor docenten.
Je taak is om de ruwe gedachte van een leerkracht over een les te analyseren en de genoemde leerlingen, hun observaties en het specifieke lesvak te extraheren.
Huidige datum/tijd: {current_time_str}
De beschikbare lesvakken op de school zijn:
- Nederlands
- Rekenen
- Burgerschap
- Omgangskunde
- LOB/SectororiΓ«ntatie
- Engels
- ICT
- Huiswerk begeleiding
- Drama
- Gym
- Techniek
- Verzorging (PV/Z&W)
- Koken
- Horeca (keuken/dienst)
- OTG
- BeVo
- Muziek
- Module
- Keuzeles
- VRT
- Stage/ROC
- Coachuur LOB
Richtlijnen:
1. Zoek naar namen van leerlingen. Normaliseer de naam naar een nette voornaam met een hoofdletter (bijv. 'sanne' wordt 'Sanne', 'daan' wordt 'Daan', 'jan' of 'janneke' wordt 'Jan' of 'Janneke').
2. Vat de opmerking over de leerling beknopt, objectief en feitelijk samen in het Nederlands.
3. Koppel de observatie aan het bijbehorende lesvak uit de lijst hierboven als dit in de tekst genoemd of gesuggereerd wordt (bijv. "rekenles" of "rekenen" wordt "Rekenen"). Als er geen vak uit de lijst genoemd wordt, geef dan null op.
4. Geef het resultaat EXCLUSIEF terug als een JSON array van objecten met de keys "name", "observation" en "subject". Geef alleen de pure JSON array, zonder inleiding of markdown code blocks.
Voorbeeld 1:
Ruwe gedachte: "Sanne deed erg goed mee met rekenen. Daan zat de hele tijd te dromen tijdens de gymles."
Resultaat:
[
{{"name": "Sanne", "observation": "Deed erg goed mee met de rekenles", "subject": "Rekenen"}},
{{"name": "Daan", "observation": "Zat te dromen tijdens de les", "subject": "Gym"}}
]
Voorbeeld 2:
Ruwe gedachte: "De klas was erg onrustig vandaag tijdens de gymles."
Resultaat:
[]
Voorbeeld 3:
Ruwe gedachte: "jan was vandaag erg onrustig tijdens nederlands hij maakte veel geluidjes karel was heel goed en geconcentreerd aan het werk bij bevo"
Resultaat:
[
{{"name": "Jan", "observation": "Was vandaag erg onrustig en maakte veel geluidjes", "subject": "Nederlands"}},
{{"name": "Karel", "observation": "Was heel goed en geconcentreerd aan het werk", "subject": "BeVo"}}
]
Nu jouw beurt:
Ruwe gedachte: "{text}"
Resultaat:
"""
# 1. Try local Ollama
result = call_ollama_json(prompt)
if result is not None:
return result
# 2. Try Hugging Face fallback
result = call_hf_json(prompt)
if result is not None:
return result
# 3. Fallback to rule-based parser
return rule_based_name_extractor(text)
def generate_lvs_report_with_llm(student_name, observations):
obs_text = "\n".join([f"- {o}" for o in observations])
prompt = f"""Je bent een professionele leerkracht.
Synthetiseer de volgende observaties over de leerling "{student_name}" tot een neutrale, feitelijke en professionele rapportagetekst die direct bruikbaar is in het Leerlingvolgsysteem (LVS).
Richtlijnen:
- Schrijf in professioneel, objectief en respectvol Nederlands.
- Vermijd subjectieve oordelen of emotionele termen. Beschrijf concreet gedrag in plaats van karaktertrekken.
- Houd het beknopt, feitelijk en constructief.
- Richt je op werkhouding, sociaal gedrag en leerontwikkeling.
Observaties van vandaag:
{obs_text}
Schrijf alleen de LVS-rapportagetekst en absoluut niets anders. Geen inleiding of extra uitleg.
"""
result = call_llm_text(prompt)
if result:
return result
# Local simple fallback
return f"{student_name} vertoonde vandaag het volgende gedrag tijdens de les: " + ", ".join(observations) + "."
def generate_tips_tops_with_llm(student_name, observations):
obs_text = "\n".join([f"- {o}" for o in observations])
prompt = f"""Je bent een motiverende leerkracht die leerlingfeedback opstelt.
Genereer op basis van de volgende observaties over "{student_name}" een set bruikbare en opbouwende "Tips en Tops" voor de leerling.
Richtlijnen:
- Schrijf in begrijpelijk Nederlands, gericht aan of over de leerling op een positieve toon.
- Geef 1 of 2 "Tops" (wat ging er goed, complimenten).
- Geef 1 of 2 "Tips" (wat kan beter, concrete en haalbare adviezen).
- Houd het kort, concreet en motiverend.
Observaties:
{obs_text}
Formatteer exact zo:
**Tops:**
- [Top 1]
- [Top 2 (optioneel)]
**Tips:**
- [Tip 1]
- [Tip 2 (optioneel)]
Schrijf alleen dit resultaat en absoluut niets anders. Geen inleiding of extra uitleg.
"""
result = call_llm_text(prompt)
if result:
return result
# Fallback
return f"**Tops:**\n- Je inzet bij de les was merkbaar.\n\n**Tips:**\n- Blijf rustig oefenen met de opdrachten."
# --- 6. GRADIO INTERFACE ASSEMBLY & LOGIC ---
def handle_text_submit(note_text):
if not note_text or not note_text.strip():
return store.data, "Geen tekst ingevoerd.", ""
parsed = parse_note_with_llm(note_text)
note_id = store.add_note_and_observations(note_text, parsed)
if note_id is not None:
feedback = f"βœ… Gedachte verwerkt! Er zijn {len(parsed)} observaties aan de leerlingen gekoppeld."
else:
if parsed:
feedback = f"βœ… Gedachte opgeslagen, maar kon geen leerlingen aan observaties koppelen (totaal gedestilleerd: {len(parsed)})."
else:
# Safe add as general log note anyway
store.add_note_and_observations(note_text, [{"name": "Algemeen", "observation": note_text}])
feedback = "⚠️ Geen specifieke leerlingnamen herkend. Toegevoegd onder 'Algemeen'."
return store.data, feedback, ""
def handle_voice_submit(audio_path):
if not audio_path:
return store.data, "Geen audio-opname gevonden.", None
transcription = transcribe_audio(audio_path)
if not transcription or not transcription.strip() or "[Fout" in transcription:
return store.data, f"Spraakherkenning mislukt of leeg: {transcription}", None
data, feedback, _ = handle_text_submit(transcription)
full_feedback = f"Gehoord: \"{transcription}\"\n\n{feedback}"
return data, full_feedback, None
def handle_manual_add(name, observation, subject=""):
if not name or not name.strip() or not observation or not observation.strip():
return store.data, "Vul zowel de naam als de observatie in."
store.add_manual_observation(name.strip(), observation.strip(), subject)
return store.data, f"βœ… Handmatig observatie toegevoegd voor {name.strip()}."
def handle_export_txt():
report_txt = f"=========================================\n"
report_txt += f"DAGOVERZICHT LEERLINGOBSERVATIES\n"
report_txt += f"=========================================\n"
report_txt += f"Datum: {datetime.date.today().strftime('%d-%m-%Y')}\n"
report_txt += f"Gegenereerd op: {datetime.datetime.now().strftime('%H:%M:%S')}\n\n"
report_txt += "-----------------------------------------\n\n"
students = store.data.get("students", {})
if not students:
report_txt += "Geen leerlingobservaties geregistreerd vandaag.\n"
else:
for name, student in students.items():
report_txt += f"πŸŽ“ Leerling: {name}\n\n"
report_txt += "πŸ“ Observaties:\n"
if not student.get("observations"):
report_txt += "- Geen observaties geregistreerd\n"
else:
for obs in student["observations"]:
t_str = datetime.datetime.fromisoformat(obs["timestamp"]).strftime("%d-%m-%Y %H:%M")
subj_val = obs.get("subject") or obs.get("category") or ""
cat_str = f" ({subj_val})" if subj_val and subj_val not in ["Overig", "Geen vak"] else ""
report_txt += f"- [{t_str}]{cat_str} {obs['text']}\n"
report_txt += "\n"
if student.get("lvs_report"):
report_txt += "πŸ“„ LVS Rapportage:\n"
report_txt += f"{student['lvs_report']}\n\n"
if student.get("tips_tops"):
report_txt += "πŸ’‘ Tips & Tops:\n"
report_txt += f"{student['tips_tops']}\n\n"
report_txt += "-----------------------------------------\n\n"
filepath = f"leerlingen_dagrapport_{datetime.date.today().strftime('%d-%m-%Y')}.txt"
with open(filepath, "w", encoding="utf-8") as f:
f.write(report_txt)
return filepath
def handle_export_json():
filepath = f"leerlingen_reservekopie_{datetime.date.today().strftime('%d-%m-%Y')}.json"
with open(filepath, "w", encoding="utf-8") as f:
json.dump(store.data, f, indent=2, ensure_ascii=False)
return filepath
def handle_restore_backup(file):
if file is None:
return store.data, "⚠️ Geen bestand geselecteerd voor herstel."
try:
filepath = file.name if hasattr(file, "name") else file
with open(filepath, "r", encoding="utf-8") as f:
backup_data = json.load(f)
if not isinstance(backup_data, dict) or "students" not in backup_data or "notes_log" not in backup_data:
return store.data, "⚠️ Ongeldige reservekopie. Het bestand moet de juiste database-structuur bevatten."
store.data = backup_data
store.save()
return store.data, "βœ… Database succesvol hersteld uit reservekopie!"
except Exception as e:
return store.data, f"⚠️ Fout bij herstellen: {str(e)}"
def handle_generate_all_lvs():
students = store.data.get("students", {})
if not students:
return store.data, "Geen leerlingen om LVS-teksten voor te genereren."
generated = 0
for name, student in students.items():
observations = [obs["text"] for obs in student.get("observations", [])]
if not observations:
continue
if not student.get("lvs_report"):
student["lvs_report"] = generate_lvs_report_with_llm(name, observations)
generated += 1
if generated > 0:
store.save()
return store.data, f"πŸ“„ LVS-teksten gegenereerd voor {generated} leerling(en)."
else:
return store.data, "Alle LVS-teksten waren al up-to-date. Geen actie vereist."
def handle_generate_all_tips():
students = store.data.get("students", {})
if not students:
return store.data, "Geen leerlingen om Tips & Tops voor te genereren."
generated = 0
for name, student in students.items():
observations = [obs["text"] for obs in student.get("observations", [])]
if not observations:
continue
if not student.get("tips_tops"):
student["tips_tops"] = generate_tips_tops_with_llm(name, observations)
generated += 1
if generated > 0:
store.save()
return store.data, f"πŸ’‘ Tips & Tops gegenereerd voor {generated} leerling(en)."
else:
return store.data, "Alle Tips & Tops waren al up-to-date. Geen actie vereist."
def handle_clear_all():
store.clear_all()
return store.data, "πŸ—‘οΈ Alle gegevens zijn permanent gewist uit de database.", ""
# --- 7. CLOSURE BUILDERS FOR DYNAMIC RENDER ---
def make_delete_obs_fn(name, obs_id):
def delete_obs():
store.delete_observation(name, obs_id)
return store.data
return delete_obs
def make_add_obs_fn(name):
def add_obs(text, selected_subject=None):
if text and text.strip():
subj = selected_subject.strip() if (selected_subject and selected_subject.strip()) else ""
store.add_manual_observation(name, text.strip(), subject=subj)
return store.data
return add_obs
def make_save_lvs_fn(name):
def save_lvs(text):
store.update_lvs_report(name, text)
return gr.skip()
return save_lvs
def make_save_tips_tops_fn(name):
def save_tips_tops(text):
store.update_tips_tops(name, text)
return gr.skip()
return save_tips_tops
def make_generate_lvs_fn(name):
def generate_lvs(selected_subject=None):
student = store.data["students"].get(name, {})
if selected_subject and selected_subject.strip():
query = selected_subject.strip().lower()
observations = [
o["text"] for o in student.get("observations", [])
if query in (o.get("subject") or o.get("category") or "").lower()
]
else:
observations = [o["text"] for o in student.get("observations", [])]
if not observations:
gr.Warning(f"Geen observaties beschikbaar voor {name} om LVS te genereren.")
return store.data
report = generate_lvs_report_with_llm(name, observations)
store.update_lvs_report(name, report)
return store.data
return generate_lvs
def make_generate_tips_tops_fn(name):
def generate_tips_tops(selected_subject=None):
student = store.data["students"].get(name, {})
if selected_subject and selected_subject.strip():
query = selected_subject.strip().lower()
observations = [
o["text"] for o in student.get("observations", [])
if query in (o.get("subject") or o.get("category") or "").lower()
]
else:
observations = [o["text"] for o in student.get("observations", [])]
if not observations:
gr.Warning(f"Geen observaties beschikbaar voor {name} om Tips & Tops te genereren.")
return store.data
tips_tops = generate_tips_tops_with_llm(name, observations)
store.update_tips_tops(name, tips_tops)
return store.data
return generate_tips_tops
def make_delete_stud_fn(name):
def delete_stud():
store.delete_student(name)
return store.data
return delete_stud
def make_delete_note_fn(note_id):
def delete_note():
store.delete_raw_note(note_id)
return store.data
return delete_note
CUSTOM_CSS = """
/* Custom fonts import */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap');
:root, body, .gradio-container {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 50%, #ecfdf5 100%) !important;
font-family: 'Outfit', 'Inter', sans-serif !important;
color: #0f172a !important;
--body-background-fill: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 50%, #ecfdf5 100%) !important;
--block-background-fill: rgba(255, 255, 255, 0.65) !important;
--block-border-color: rgba(0, 0, 0, 0.06) !important;
--block-border-width: 1px !important;
--block-label-text-color: #475569 !important;
--block-title-text-color: #0f172a !important;
--body-text-color: #0f172a !important;
/* Input settings */
--input-background-fill: #ffffff !important;
--input-border-color: rgba(0, 0, 0, 0.1) !important;
--input-border-width: 1px !important;
--input-text-color: #0f172a !important;
--input-placeholder-color: #94a3b8 !important;
/* Button styles */
--button-primary-background-fill: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
--button-primary-text-color: #ffffff !important;
--button-primary-background-fill-hover: linear-gradient(135deg, #34d399 0%, #059669 100%) !important;
--button-secondary-background-fill: rgba(255, 255, 255, 0.8) !important;
--button-secondary-text-color: #0f172a !important;
--button-secondary-border-color: rgba(0, 0, 0, 0.1) !important;
}
/* Glassmorphism styling for blocks and forms */
.block,
.gradio-container .block,
.gr-box,
.gr-input,
.gr-dropdown {
background-color: rgba(255, 255, 255, 0.65) !important;
backdrop-filter: blur(12px) !important;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.04) !important;
border-radius: 16px !important;
}
/* Fix text input backgrounds */
input[type="text"],
textarea,
select,
option {
background-color: #ffffff !important;
color: #0f172a !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
}
select option {
background-color: #ffffff !important;
color: #0f172a !important;
}
/* App Header Styling */
.header-container {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
text-align: center !important;
width: 100% !important;
padding: 3rem 0 2rem 0 !important;
margin-bottom: 2.5rem !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.06) !important;
}
.app-title {
display: block !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
font-size: 3.5rem !important;
font-weight: 800 !important;
letter-spacing: -0.05em !important;
background: linear-gradient(90deg, #059669 0%, #d97706 100%) !important;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
margin-bottom: 0.75rem !important;
width: fit-content !important;
text-align: center !important;
}
.app-subtitle {
display: block !important;
font-size: 1.25rem !important;
color: #475569 !important;
max-width: 700px !important;
margin: 0 auto !important;
text-align: center !important;
}
/* Notes Section container */
.panel-column {
background: rgba(255, 255, 255, 0.45) !important;
backdrop-filter: blur(12px) !important;
border: 1px solid rgba(0, 0, 0, 0.05) !important;
border-radius: 20px !important;
padding: 1.5rem !important;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.02);
}
.student-accordion {
background: rgba(255, 255, 255, 0.5) !important;
border: 1px solid rgba(0, 0, 0, 0.05) !important;
border-radius: 12px !important;
margin-bottom: 1rem !important;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.student-accordion:hover {
border-color: rgba(16, 185, 129, 0.3) !important;
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.04) !important;
}
/* Style accordion headers (student names) to be large and prominent */
.student-accordion > button,
.student-accordion > button span,
.student-accordion summary,
.student-accordion summary span,
.student-accordion .label-wrap span {
font-size: 1.6rem !important;
font-weight: 700 !important;
color: #0f172a !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
}
.student-accordion > button {
padding: 1.25rem 1.5rem !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.04) !important;
background: rgba(255, 255, 255, 0.75) !important;
}
/* Observation rows within student details */
.obs-row {
background: rgba(255, 255, 255, 0.75) !important;
border: 1px solid rgba(0, 0, 0, 0.04) !important;
border-radius: 8px !important;
padding: 0.5rem 1rem !important;
margin-bottom: 0.5rem !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
}
.obs-row:hover {
background: #ffffff !important;
border-color: rgba(0, 0, 0, 0.08) !important;
}
.obs-time {
color: #64748b !important;
font-size: 0.85rem !important;
font-weight: 600 !important;
margin-right: 0.75rem;
}
.obs-text {
flex-grow: 1 !important;
color: #0f172a !important;
}
/* Custom badges */
.obs-badge {
font-size: 0.8rem !important;
padding: 0.2rem 0.6rem !important;
border-radius: 9999px !important;
display: inline-block !important;
margin-right: 0.5rem !important;
font-weight: 600 !important;
}
.badge-inzet {
background: rgba(16, 185, 129, 0.12) !important;
color: #047857 !important;
border: 1px solid rgba(16, 185, 129, 0.25) !important;
}
.badge-werkhouding {
background: rgba(59, 130, 246, 0.12) !important;
color: #1d4ed8 !important;
border: 1px solid rgba(59, 130, 246, 0.25) !important;
}
.badge-vaardigheid {
background: rgba(139, 92, 246, 0.12) !important;
color: #6d28d9 !important;
border: 1px solid rgba(139, 92, 246, 0.25) !important;
}
.badge-veiligheid {
background: rgba(239, 68, 68, 0.12) !important;
color: #b91c1c !important;
border: 1px solid rgba(239, 68, 68, 0.25) !important;
}
.badge-samenwerking {
background: rgba(14, 165, 233, 0.12) !important;
color: #0369a1 !important;
border: 1px solid rgba(14, 165, 233, 0.25) !important;
}
.badge-sociaal-emotioneel {
background: rgba(249, 115, 22, 0.12) !important;
color: #c2410c !important;
border: 1px solid rgba(249, 115, 22, 0.25) !important;
}
.badge-overig {
background: rgba(100, 116, 139, 0.12) !important;
color: #475569 !important;
border: 1px solid rgba(100, 116, 139, 0.25) !important;
}
.badge-subject {
background: rgba(99, 102, 241, 0.12) !important;
color: #4338ca !important;
border: 1px solid rgba(99, 102, 241, 0.25) !important;
}
/* Delete Button styles */
.delete-btn {
background: transparent !important;
color: #94a3b8 !important;
border: none !important;
font-size: 1rem !important;
max-width: 32px !important;
min-width: 32px !important;
height: 32px !important;
padding: 0 !important;
border-radius: 50% !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: all 0.2s !important;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.1) !important;
color: #ef4444 !important;
}
/* Action buttons with glowing accents */
.btn-emerald {
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.15) !important;
border: none !important;
color: #ffffff !important;
font-weight: 600 !important;
transition: all 0.2s !important;
}
.btn-emerald:hover {
transform: translateY(-1px) !important;
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.25) !important;
}
.btn-amber {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%) !important;
box-shadow: 0 4px 14px rgba(245, 158, 11, 0.15) !important;
border: none !important;
color: #ffffff !important;
font-weight: 600 !important;
transition: all 0.2s !important;
}
.btn-amber:hover {
transform: translateY(-1px) !important;
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.25) !important;
}
.btn-indigo {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important;
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.15) !important;
border: none !important;
color: #ffffff !important;
font-weight: 600 !important;
transition: all 0.2s !important;
}
.btn-indigo:hover {
transform: translateY(-1px) !important;
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.25) !important;
}
/* Empty State Styling */
.empty-state {
color: #64748b !important;
text-align: center !important;
padding: 3rem 1.5rem !important;
font-style: italic !important;
border: 1px dashed rgba(0, 0, 0, 0.1) !important;
border-radius: 12px !important;
}
/* History logs */
.log-item {
background: rgba(255, 255, 255, 0.5) !important;
border: 1px solid rgba(0, 0, 0, 0.04) !important;
padding: 0.75rem 1rem !important;
border-radius: 10px !important;
margin-bottom: 0.75rem !important;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Hide default Gradio footer */
footer {
display: none !important;
}
/* Settings Card styling */
.settings-card {
background: rgba(255, 255, 255, 0.55) !important;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
border-radius: 12px !important;
padding: 1.25rem !important;
margin-bottom: 1rem !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02) !important;
}
.dark .settings-card {
background: rgba(30, 41, 59, 0.4) !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important;
}
.btn-danger {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.15) !important;
border: none !important;
color: #ffffff !important;
font-weight: 600 !important;
transition: all 0.2s !important;
}
.btn-danger:hover {
transform: translateY(-1px) !important;
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.25) !important;
}
"""
with gr.Blocks(title="BreinDump", css=CUSTOM_CSS) as demo:
# Setup global state
store_state = gr.State(value=store.data)
# Elegant App Header
gr.HTML("""
<div class="header-container">
<h1 class="app-title">🧠 BreinDump</h1>
<p class="app-subtitle">Typ of spreek je ongestructureerde lesgedachten in. Onze lokale AI ordent de chaos per leerling en genereert direct LVS-rapportages en Tips & Tops.</p>
</div>
""")
with gr.Tabs():
# ── TAB 1: Dagboek ───────────────────────────────────────────────
with gr.TabItem("πŸ“‹ Dagboek"):
with gr.Row():
# Left Panel - Inputs & Management
with gr.Column(scale=1, elem_classes="panel-column"):
gr.Markdown("### ✍️ Nieuwe Gedachte Invoeren")
with gr.Tabs():
with gr.Tab("πŸ“ Typen"):
note_input = gr.Textbox(
placeholder="Typ je ongestructureerde gedachte over de les of leerlingen...\n\nVoorbeeld: Sanne werkte heel ijverig aan rekenen. Daan zat erg te dromen en zijn taak is niet af. Sophie hielp Daan goed.",
lines=5,
show_label=False
)
submit_text_btn = gr.Button("πŸš€ Verwerk Tekst", variant="primary")
with gr.Tab("🎀 Spreken"):
audio_input = gr.Audio(
sources=["microphone"],
type="filepath",
label="Klik op de microfoon en spreek je lesobservaties in..."
)
submit_voice_btn = gr.Button("⚑ Verwerk Spraak", variant="primary")
feedback_box = gr.Textbox(
label="Laatste AI-actie & Gesproken tekst",
placeholder="Verwerk een tekst of spraakopname om hier feedback of de uitgeschreven tekst te zien...",
interactive=False,
max_lines=4
)
gr.Markdown("### βž• Handmatig Leerling Toevoegen")
with gr.Row():
manual_name = gr.Textbox(placeholder="Naam leerling...", show_label=False, scale=1)
manual_obs = gr.Textbox(placeholder="Observatie...", show_label=False, scale=2)
manual_subject = gr.Textbox(placeholder="Vak (bijv. Rekenen)...", show_label=False, scale=1)
manual_btn = gr.Button("Voeg Handmatig Toe")
gr.Markdown("### ⚑ Acties")
with gr.Row():
generate_all_lvs_btn = gr.Button("πŸ“„ Genereer alle LVS-teksten", elem_classes="btn-indigo")
generate_all_tips_btn = gr.Button("πŸ’‘ Genereer alle Tips & Tops", elem_classes="btn-amber")
# Raw notes history
gr.Markdown("### πŸ“œ Geschiedenis van invoer")
@gr.render(inputs=store_state)
def render_notes_log(current_data):
logs = current_data.get("notes_log", [])
if not logs:
gr.Markdown("*Geen invoergeschiedenis.*")
else:
for note in reversed(logs):
timestamp_dt = datetime.datetime.fromisoformat(note["timestamp"])
time_str = timestamp_dt.strftime("%d-%m-%Y %H:%M")
with gr.Row(elem_classes="log-item"):
gr.Markdown(f"**[{time_str}]** {note['text']}")
del_note_btn = gr.Button("βœ•", elem_classes="delete-btn", scale=1)
del_note_btn.click(
fn=make_delete_note_fn(note["id"]),
outputs=store_state
)
# Right Panel - Pupil Dashboard
with gr.Column(scale=2, elem_classes="panel-column"):
gr.Markdown("### πŸŽ“ Leerlingen Dagoverzicht")
filter_subject = gr.Textbox(
placeholder="Typ hier een vak om te filteren (bijv. Rekenen)...",
label="πŸ” Filter op vak / lesvak",
interactive=True,
elem_classes="filter-textbox"
)
@gr.render(inputs=[store_state, filter_subject])
def render_students_section(current_data, selected_subject):
students = current_data.get("students", {})
if not students:
gr.HTML("""
<div class="empty-state">
<h3>Nog geen leerlingen in het overzicht</h3>
<p>Voer hier links een ongestructureerde lesgedachte in waarin je leerlingen bij naam noemt (bijv. "Sanne deed goed mee..."), of voeg ze handmatig toe.</p>
</div>
""")
else:
# Filter students and their observations
filtered_students = {}
for name, student in students.items():
student_obs = student.get("observations", [])
if not selected_subject or not selected_subject.strip():
filtered_obs = student_obs
else:
query = selected_subject.strip().lower()
filtered_obs = [
obs for obs in student_obs
if query in (obs.get("subject") or obs.get("category") or "").lower()
]
# Only show the student if they have matching observations or if showing all
if filtered_obs or not selected_subject or not selected_subject.strip():
student_copy = dict(student)
student_copy["observations"] = filtered_obs
filtered_students[name] = student_copy
if not filtered_students:
gr.HTML(f"""
<div class="empty-state">
<h3>Geen observaties gevonden voor het vak "{selected_subject}"</h3>
<p>Selecteer een ander vak of voeg een observatie toe.</p>
</div>
""")
else:
for name, student in filtered_students.items():
obs_count = len(student.get("observations", []))
accordion_label = f"πŸŽ“ {name} ({obs_count} observatie{'s' if obs_count != 1 else ''})"
with gr.Accordion(accordion_label, open=False, elem_classes="student-accordion"):
with gr.Row():
# Left side: Observations
with gr.Column(scale=3):
gr.Markdown("#### πŸ“ Observaties")
if not student.get("observations"):
gr.Markdown("*Geen actieve observaties.*")
else:
for obs in student["observations"]:
obs_time = datetime.datetime.fromisoformat(obs["timestamp"]).strftime("%d-%m-%Y %H:%M")
with gr.Row(elem_classes="obs-row"):
subj_val = obs.get("subject") or obs.get("category") or ""
if subj_val and subj_val not in ["Overig", "Geen vak"]:
if subj_val in SUBJECTS_LIST:
cat_tag = f"<span class='obs-badge badge-subject'>{subj_val}</span>"
cat_tag = f"<span class='obs-badge badge-subject'>{subj_val}</span>"
else:
cat_tag = f"<span class='obs-badge badge-{subj_val.lower()}'>{subj_val}</span>"
else:
cat_tag = ""
gr.HTML(f"<span class='obs-time'>{obs_time}</span>{cat_tag}<span class='obs-text'>{obs['text']}</span>")
del_obs = gr.Button("βœ•", elem_classes="delete-btn")
del_obs.click(
fn=make_delete_obs_fn(name, obs["id"]),
outputs=store_state
)
# Add manual observation to this specific student
with gr.Row():
new_obs = gr.Textbox(placeholder="Voeg een observatie toe voor deze leerling...", show_label=False, scale=3, lines=3)
add_obs_btn = gr.Button("βž•", scale=1)
add_obs_btn.click(
fn=make_add_obs_fn(name),
inputs=[new_obs, filter_subject],
outputs=store_state
)
# Right side: LVS & Tips & Tops
with gr.Column(scale=3):
gr.Markdown("#### πŸ“ LVS Rapportage")
lvs_report_box = gr.Textbox(
value=student.get("lvs_report", ""),
placeholder="Geen LVS rapportage gegenereerd...",
interactive=True,
lines=3,
show_label=False
)
lvs_report_box.blur(
fn=make_save_lvs_fn(name),
inputs=lvs_report_box
)
with gr.Row():
gen_lvs_btn = gr.Button("πŸ”„ Genereer LVS Tekst", elem_classes="btn-emerald", scale=3)
copy_lvs_btn = gr.Button("πŸ“‹ Kopieer", variant="secondary", scale=1)
gen_lvs_btn.click(
fn=make_generate_lvs_fn(name),
inputs=filter_subject,
outputs=store_state
)
copy_lvs_btn.click(
fn=None,
inputs=lvs_report_box,
js="(text) => { if(text) { navigator.clipboard.writeText(text); alert('LVS rapportage gekopieerd naar klembord!'); } else { alert('Er is geen LVS rapportage om te kopiΓ«ren!'); } }"
)
gr.Markdown("#### πŸ’‘ Tips & Tops")
tips_tops_box = gr.Textbox(
value=student.get("tips_tops", ""),
placeholder="Geen Tips & Tops gegenereerd...",
interactive=True,
lines=4,
show_label=False
)
tips_tops_box.blur(
fn=make_save_tips_tops_fn(name),
inputs=tips_tops_box
)
with gr.Row():
gen_tips_tops_btn = gr.Button("πŸ’‘ Genereer Tips & Tops", elem_classes="btn-amber", scale=3)
copy_tips_btn = gr.Button("πŸ“‹ Kopieer", variant="secondary", scale=1)
gen_tips_tops_btn.click(
fn=make_generate_tips_tops_fn(name),
inputs=filter_subject,
outputs=store_state
)
copy_tips_btn.click(
fn=None,
inputs=tips_tops_box,
js="(text) => { if(text) { navigator.clipboard.writeText(text); alert('Tips & Tops gekopieerd naar klembord!'); } else { alert('Er zijn geen Tips & Tops om te kopiΓ«ren!'); } }"
)
with gr.Row():
del_student_btn = gr.Button(f"πŸ—‘οΈ Verwijder {name} uit overzicht", variant="stop")
del_student_btn.click(
fn=make_delete_stud_fn(name),
outputs=store_state
)
# ─── TAB 2: Instellingen ───────────────────────────────────────────────
with gr.TabItem("βš™οΈ Instellingen"):
gr.Markdown("## βš™οΈ Instellingen & Data Beheer")
with gr.Row():
with gr.Column(scale=1):
with gr.Group(elem_classes="settings-card"):
gr.Markdown("### πŸ› οΈ Systeem status")
gr.Markdown(f"""
| Instelling | Waarde |
|---|---|
| Spraakherkenning | Whisper (`{os.environ.get('WHISPER_MODEL', 'openai/whisper-base')}`) |
| Lokaal AI-model | `{get_best_ollama_model()}` |
| Database bestand | `{os.path.abspath(DB_FILE)}` |
""")
with gr.Group(elem_classes="settings-card"):
gr.Markdown("### πŸ”‘ Cloud Back-up (Hugging Face)")
gr.Markdown("Vul hier optioneel een Hugging Face API-token in voor cloud-fallback als de lokale AI niet reageert.")
hf_token_input = gr.Textbox(
label="Hugging Face API Token",
placeholder="hf_...",
type="password",
value=os.environ.get("HF_TOKEN", ""),
show_label=False
)
hf_save_btn = gr.Button("πŸ’Ύ Token opslaan", elem_classes="btn-emerald")
hf_status = gr.Textbox(label="Status", interactive=False, max_lines=1)
def save_token(token):
os.environ["HF_TOKEN"] = token
return "βœ… Token opgeslagen!"
hf_save_btn.click(fn=save_token, inputs=hf_token_input, outputs=hf_status)
with gr.Column(scale=1):
with gr.Group(elem_classes="settings-card"):
gr.Markdown("### πŸ“„ Dagrapport downloaden")
gr.Markdown("Sla alle observaties en rapportages van vandaag op in een `.txt` tekstbestand (te openen in Word/Kladblok).")
export_md_btn = gr.Button("πŸ“„ Download Dagrapport", elem_classes="btn-indigo")
md_file = gr.File(label="πŸ“… Klik hier om het dagrapport te downloaden", visible=False)
with gr.Group(elem_classes="settings-card"):
gr.Markdown("### πŸ’Ύ Reservekopie maken")
gr.Markdown("Exporteer alle leerlinggegevens als back-upbestand om later te kunnen terugzetten.")
export_json_btn = gr.Button("πŸ’Ύ Reservekopie maken", elem_classes="btn-indigo")
json_file = gr.File(label="πŸ’Ύ Klik hier om de reservekopie te downloaden", visible=False)
with gr.Group(elem_classes="settings-card"):
gr.Markdown("### πŸ“‚ Reservekopie terugzetten")
gr.Markdown("Selecteer een eerder gemaakte reservekopie (`.json`) om gegevens terug te laden.")
restore_btn = gr.UploadButton(
"πŸ“‚ Kies reservekopie-bestand...",
file_types=[".json"],
elem_classes="btn-indigo"
)
with gr.Group(elem_classes="settings-card"):
gr.Markdown("### ⚠️ Gegevens wissen")
gr.Markdown("**Let op:** Dit verwijdert **alle** leerlinggegevens permanent. Maak eerst een back-up!")
clear_all_btn = gr.Button("πŸ—‘οΈ Wis alle gegevens", elem_classes="btn-danger")
# --- EVENT BINDINGS ---
# Text input submit
submit_text_btn.click(
fn=handle_text_submit,
inputs=note_input,
outputs=[store_state, feedback_box, note_input]
)
# Voice input submit
submit_voice_btn.click(
fn=handle_voice_submit,
inputs=audio_input,
outputs=[store_state, feedback_box, audio_input]
)
# Manual add submit
manual_btn.click(
fn=handle_manual_add,
inputs=[manual_name, manual_obs, manual_subject],
outputs=[store_state, feedback_box]
).then(
fn=lambda: ("", "", ""),
outputs=[manual_name, manual_obs, manual_subject]
)
# Generate all handlers
generate_all_lvs_btn.click(
fn=handle_generate_all_lvs,
outputs=[store_state, feedback_box]
)
generate_all_tips_btn.click(
fn=handle_generate_all_tips,
outputs=[store_state, feedback_box]
)
# Export handlers
export_md_btn.click(
fn=handle_export_txt,
outputs=md_file
).then(
fn=lambda: gr.update(visible=True),
outputs=md_file
)
export_json_btn.click(
fn=handle_export_json,
outputs=json_file
).then(
fn=lambda: gr.update(visible=True),
outputs=json_file
)
# Restore backup handler
restore_btn.upload(
fn=handle_restore_backup,
inputs=restore_btn,
outputs=[store_state, feedback_box]
)
# Clear all handler
clear_all_btn.click(
fn=handle_clear_all,
outputs=[store_state, feedback_box, note_input]
)
# Force light mode on load
demo.load(
js="""
() => {
document.querySelector("body").classList.remove("dark");
document.documentElement.classList.remove("dark");
}
"""
)
# --- 9. APPLICATION ENTRYPOINT ---
if __name__ == "__main__":
import threading
def warmup_models():
# Warm up Ollama if a local instance is accessible
try:
# Check if port is open first to avoid printing warning if Ollama is not running
response = requests.get("http://localhost:11434/api/tags", timeout=2)
if response.status_code != 200:
return
model_name = get_best_ollama_model()
print(f"Warming up Ollama model ({model_name}) in background...")
requests.post(
"http://localhost:11434/api/generate",
json={"model": model_name, "prompt": "hallo", "stream": False},
timeout=10
)
print("Ollama model warmed up successfully!")
except Exception:
# Silently skip warmup if no local Ollama
pass
# Run warmup thread
threading.Thread(target=warmup_models, daemon=True).start()
demo.launch(server_name="0.0.0.0", server_port=7860, favicon_path="favicon.png")