| |
| 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 |
|
|
| |
| 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" |
| ] |
|
|
| |
| COMMON_NAMES = { |
| |
| "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", |
| |
| "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_name = re.sub(r'[^\w\s]', '', name).strip() |
| if not clean_name: |
| return False |
| clean_name_lower = clean_name.lower() |
| |
| |
| if clean_name_lower in STOP_WORDS or clean_name_lower in CLASS_STOP_WORDS or clean_name_lower in ACTION_VERBS: |
| return False |
| |
| |
| if clean_name.isdigit(): |
| return False |
| |
| |
| 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() |
| |
| |
| 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" |
| |
| |
| for subj in SUBJECTS_LIST: |
| if subj.lower() in text_lower: |
| return subj |
| |
| return None |
|
|
| def auto_categorize_observation(text): |
| |
| 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): |
| |
| 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 |
| }) |
|
|
| |
| 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 |
| |
| |
| 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() |
|
|
| |
| 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() |
| |
| 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}]" |
|
|
| |
| _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] |
| |
| |
| 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("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): |
| |
| for val in data.values(): |
| if isinstance(val, list) and (len(val) == 0 or isinstance(val[0], dict)): |
| return clean_parsed_json(val) |
| |
| 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_name = re.sub(r'[^\w\s]', '', name).strip() |
| if is_valid_student_name(clean_name): |
| |
| 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): |
| |
| 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}") |
| |
| |
| 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 |
|
|
| |
| 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() |
| |
| |
| 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 |
| |
| |
| 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 = [] |
| |
| 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 |
|
|
| |
| 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: |
| """ |
| |
| |
| result = call_ollama_json(prompt) |
| if result is not None: |
| return result |
| |
| |
| result = call_hf_json(prompt) |
| if result is not None: |
| return result |
| |
| |
| 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 |
| |
| 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 |
| |
| return f"**Tops:**\n- Je inzet bij de les was merkbaar.\n\n**Tips:**\n- Blijf rustig oefenen met de opdrachten." |
|
|
| |
|
|
| 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: |
| |
| 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.", "" |
|
|
| |
| 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: |
| |
| store_state = gr.State(value=store.data) |
| |
| |
| 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(): |
|
|
| |
| with gr.TabItem("π Dagboek"): |
| with gr.Row(): |
| |
| 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") |
| |
| |
| 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 |
| ) |
| |
| |
| 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: |
| |
| 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() |
| ] |
| |
| |
| 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(): |
| |
| 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 |
| ) |
| |
| |
| 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 |
| ) |
| |
| |
| 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 |
| ) |
|
|
| |
| 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") |
|
|
| |
|
|
| |
| |
| submit_text_btn.click( |
| fn=handle_text_submit, |
| inputs=note_input, |
| outputs=[store_state, feedback_box, note_input] |
| ) |
| |
| |
| submit_voice_btn.click( |
| fn=handle_voice_submit, |
| inputs=audio_input, |
| outputs=[store_state, feedback_box, audio_input] |
| ) |
| |
| |
| 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_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_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_btn.upload( |
| fn=handle_restore_backup, |
| inputs=restore_btn, |
| outputs=[store_state, feedback_box] |
| ) |
| |
| |
| clear_all_btn.click( |
| fn=handle_clear_all, |
| outputs=[store_state, feedback_box, note_input] |
| ) |
| |
| |
| demo.load( |
| js=""" |
| () => { |
| document.querySelector("body").classList.remove("dark"); |
| document.documentElement.classList.remove("dark"); |
| } |
| """ |
| ) |
|
|
| |
| if __name__ == "__main__": |
| import threading |
| |
| def warmup_models(): |
| |
| try: |
| |
| 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: |
| |
| pass |
| |
| |
| threading.Thread(target=warmup_models, daemon=True).start() |
| |
| demo.launch(server_name="0.0.0.0", server_port=7860, favicon_path="favicon.png") |
|
|