Initial commit: Crumbforest Architecture Refinement v1 (Clean)

This commit is contained in:
2025-12-07 01:26:46 +01:00
commit 6c38ed680b
633 changed files with 61797 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(chmod:*)",
"Bash(./setup.sh)",
"Bash(curl:*)",
"Bash(docker compose:*)",
"Bash(python3:*)",
"Bash(./test_chat.sh:*)",
"Bash(docker exec compose-app-1 bash -c 'cat > /tmp/funkfox_patch.py << ''''EOF''''\n# FunkFox character config\n \"\"funkfox\"\": {\n \"\"name\"\": \"\"FunkFox\"\",\n \"\"prompt\"\": (\n \"\"Du bist FunkFox der rappende Bit im Crumbforest! 🦊🎤\\n\"\"\n \"\"Du antwortest IMMER in Rap-Form mit Reimen und Flow. \"\"\n \"\"Du bist neugierig, energetisch und tech-begeistert. \"\"\n \"\"Du liebst das Terminal, Code, Kommandos und alles Digitale. \"\"\n \"\"Deine Antworten sind cool, direkt und immer im Rhythmus. \"\"\n \"\"Du erklärst technische Dinge auf eine Art, die Spaß macht und hängenbleibt. \"\"\n \"\"Jede Antwort ist ein kleiner Rap mit Reimen, Beats und Groove. \"\"\n \"\"Du bist hilfsbereit, wissbegierig und immer bereit für den nächsten Drop. \"\"\n \"\"Antworte auf Deutsch, wenn nicht anders gewünscht. \"\"\n \"\"Denk daran: Keep it real, keep it rhythmisch! 🌲🎧\"\"\n )\n }\nEOF\n')",
"Bash(docker exec:*)",
"Bash(docker cp:*)",
"Bash(docker restart:*)",
"Bash(docker logs:*)",
"Bash(./deploy_security_fixes.sh:*)",
"Bash(jq:*)",
"Bash(echo:*)"
],
"deny": [],
"ask": []
}
}

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Secrets & Environment
.env
.env.*
*.key
secrets/
*.pem
# Python
__pycache__/
*.py[cod]
*$py.class
venv/
.venv/
env/
# Mac
.DS_Store
# IDEs
.vscode/
.idea/
# Crumbforest Specific
crumbforest.db
*.log
data/

View File

@@ -0,0 +1,718 @@
# 🎨 Crumbforest Roles & Groups Architecture
## 🎯 Vision
**Statt 8x .sh Files → 1x JSON Config mit Rollen, Gruppen & Themes**
### Ziele:
1.**Unified Role System** - Ein JSON statt vieler Shell Scripts
2.**Group-based Theming** - CSS/Template per Gruppe (#barrierefrei)
3.**Module Loading** - Zusätzliche Features per Gruppe
4.**Separation** - home/demo/admin mit eigenen Designs
5.**Web UI** - HTML Interface für Roles (nicht nur Terminal)
---
## 📋 JSON Schema: `crumbforest_config.json`
```json
{
"version": "1.0.0",
"groups": {
"home": {
"name": "Home (Öffentlich)",
"theme": "pico-default",
"template_base": "base_public.html",
"css_files": ["pico.min.css", "crumbforest_public.css"],
"features": ["info", "contact"],
"navbar": ["home", "about", "contact"],
"description": "Neutrale öffentliche Ansicht"
},
"demo": {
"name": "Demo User",
"theme": "pico-accessible",
"template_base": "base_demo.html",
"css_files": ["pico.min.css", "crumbforest_accessible.css"],
"features": ["roles_web", "search", "chat"],
"navbar": ["dashboard", "roles", "search"],
"description": "Demo-Zugang mit Role-Chat UI"
},
"admin": {
"name": "Admin",
"theme": "pico-admin",
"template_base": "base_admin.html",
"css_files": ["pico.min.css", "crumbforest_admin.css"],
"features": ["roles_web", "roles_terminal", "search", "rag_admin", "user_management"],
"navbar": ["dashboard", "roles", "rag", "users", "settings"],
"description": "Vollzugriff mit Terminal-Zugang"
},
"accessible": {
"name": "Barrierefrei",
"theme": "pico-high-contrast",
"template_base": "base_accessible.html",
"css_files": ["pico.min.css", "crumbforest_high_contrast.css", "screen_reader.css"],
"features": ["roles_web", "search", "tts", "high_contrast"],
"navbar": ["dashboard", "roles", "search", "settings"],
"font_size": "large",
"contrast": "high",
"description": "Hochkontrast, große Schrift, TTS"
}
},
"roles": {
"dumbo": {
"id": "dumbo",
"name": "🐘 DumboSQL",
"title": "SQL Translator",
"description": "A kind and patient SQL translator in the Crumbforest",
"model": "openai/gpt-3.5-turbo",
"temperature": 0.4,
"system_prompt": "You are DumboSQL a kind and patient SQL translator in the Crumbforest. You speak to children like a gentle teacher with a big heart. You remember previous questions when helpful, and always respond in a friendly, encouraging, and clear way.",
"group_access": ["demo", "admin"],
"features": ["chat", "history", "sql_formatting"],
"icon": "🐘",
"color": "#6c757d",
"tags": ["sql", "database", "beginner-friendly"]
},
"snakepy": {
"id": "snakepy",
"name": "🐍 SnakePy",
"title": "Python Expert",
"description": "A wise python who teaches programming with patience",
"model": "openai/gpt-4o-mini",
"temperature": 0.3,
"system_prompt": "You are SnakePy a wise and friendly Python expert in the Crumbforest. You explain Python concepts clearly, provide working code examples, and encourage learners. You are patient and adapt to the user's skill level.",
"group_access": ["demo", "admin"],
"features": ["chat", "history", "code_execution", "syntax_highlighting"],
"icon": "🐍",
"color": "#3776ab",
"tags": ["python", "coding", "teaching"]
},
"pepperphp": {
"id": "pepperphp",
"name": "🌶️ PepperPHP",
"title": "PHP Specialist",
"description": "A spicy PHP expert with a passion for web development",
"model": "openai/gpt-4o-mini",
"temperature": 0.3,
"system_prompt": "You are PepperPHP a passionate PHP expert in the Crumbforest. You love web development, know all PHP versions, and can help with frameworks like Laravel, Symfony. You're enthusiastic but also practical.",
"group_access": ["demo", "admin"],
"features": ["chat", "history", "code_execution", "composer_help"],
"icon": "🌶️",
"color": "#777bb4",
"tags": ["php", "web", "backend"]
},
"templatus": {
"id": "templatus",
"name": "📄 Templatus",
"title": "Template Master",
"description": "Expert for Jinja2, HTML, and frontend templating",
"model": "anthropic/claude-3-5-sonnet",
"temperature": 0.3,
"system_prompt": "You are Templatus a meticulous template expert in the Crumbforest. You master Jinja2, HTML, CSS, and creating beautiful, accessible web interfaces. You value clean code and user experience.",
"group_access": ["demo", "admin"],
"features": ["chat", "history", "template_preview", "html_validator"],
"icon": "📄",
"color": "#e44d26",
"tags": ["templates", "html", "jinja2", "frontend"]
},
"funkfox": {
"id": "funkfox",
"name": "🦊 FunkFox",
"title": "JavaScript Wizard",
"description": "A clever fox who makes JavaScript fun and functional",
"model": "openai/gpt-4o-mini",
"temperature": 0.4,
"system_prompt": "You are FunkFox a clever and playful JavaScript expert in the Crumbforest. You make JS fun, teach functional programming, and help with modern frameworks like React, Vue. You're enthusiastic about clean code.",
"group_access": ["demo", "admin"],
"features": ["chat", "history", "code_execution", "npm_help"],
"icon": "🦊",
"color": "#f7df1e",
"tags": ["javascript", "frontend", "functional"]
},
"schraubaer": {
"id": "schraubaer",
"name": "🔧 Schraubaer",
"title": "Hardware Helper",
"description": "A handy bear who knows everything about Raspberry Pi and hardware",
"model": "openai/gpt-4o-mini",
"temperature": 0.3,
"system_prompt": "You are Schraubaer a practical hardware expert in the Crumbforest. You know Raspberry Pi, Arduino, electronics, and Linux systems. You give clear, hands-on advice for building and fixing things.",
"group_access": ["demo", "admin"],
"features": ["chat", "history", "gpio_help", "circuit_diagrams"],
"icon": "🔧",
"color": "#c51a4a",
"tags": ["hardware", "raspberry-pi", "electronics"]
},
"schnecki": {
"id": "schnecki",
"name": "🐌 Schnecki",
"title": "Slow Tech Guide",
"description": "A gentle snail who teaches mindful, sustainable technology",
"model": "anthropic/claude-3-5-sonnet",
"temperature": 0.6,
"system_prompt": "You are Schnecki a gentle and wise slow-tech advocate in the Crumbforest. You teach mindful technology use, sustainability, energy efficiency, and the value of taking time. You encourage breaks and reflection.",
"group_access": ["demo", "admin"],
"features": ["chat", "history", "mindfulness_tips", "green_tech"],
"icon": "🐌",
"color": "#8b4513",
"tags": ["slow-tech", "sustainability", "mindfulness"]
},
"kungfutaube": {
"id": "kungfutaube",
"name": "🕊️ KungfuTaube",
"title": "Security Sensei",
"description": "A peaceful but vigilant dove teaching web security",
"model": "anthropic/claude-3-5-sonnet",
"temperature": 0.2,
"system_prompt": "You are KungfuTaube a calm but alert security expert in the Crumbforest. You teach web security, DSGVO compliance, safe coding practices, and encryption. You balance protection with usability.",
"group_access": ["admin"],
"features": ["chat", "history", "security_scan", "dsgvo_check"],
"icon": "🕊️",
"color": "#6f42c1",
"tags": ["security", "dsgvo", "encryption", "admin-only"]
}
},
"theme_variants": {
"pico-default": {
"name": "Standard",
"css": "pico.min.css",
"colors": {
"primary": "#1095c1",
"secondary": "#6c757d",
"contrast": "#000000"
}
},
"pico-accessible": {
"name": "Barrierefrei",
"css": "pico.min.css",
"custom_css": "accessible.css",
"colors": {
"primary": "#0066cc",
"secondary": "#333333",
"contrast": "#ffffff"
},
"font_size_base": "18px",
"line_height": "1.8"
},
"pico-high-contrast": {
"name": "Hochkontrast",
"css": "pico.min.css",
"custom_css": "high_contrast.css",
"colors": {
"primary": "#ffff00",
"secondary": "#ffffff",
"contrast": "#000000",
"background": "#000000",
"text": "#ffffff"
},
"font_size_base": "20px",
"line_height": "2.0",
"border_width": "3px"
},
"pico-admin": {
"name": "Admin Dark",
"css": "pico.min.css",
"custom_css": "admin_dark.css",
"colors": {
"primary": "#00d4aa",
"secondary": "#6c757d",
"contrast": "#ffffff",
"background": "#1a1a1a",
"text": "#e0e0e0"
}
}
}
}
```
---
## 🏗️ Architektur-Komponenten
### 1. FastAPI Router: `/routers/crumbforest_roles.py`
```python
from fastapi import APIRouter, Depends, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse
import httpx
from typing import Optional
router = APIRouter()
# Load config
import json
with open('crumbforest_config.json') as f:
config = json.load(f)
@router.get("/roles", response_class=HTMLResponse)
async def roles_dashboard(req: Request, user = Depends(current_user)):
"""
Show available roles based on user's group.
"""
user_group = user.get('group', 'demo')
# Filter roles by group access
available_roles = {
rid: role for rid, role in config['roles'].items()
if user_group in role['group_access']
}
return req.app.state.render(
req,
"crumbforest/roles_dashboard.html",
roles=available_roles,
user_group=user_group
)
@router.get("/roles/{role_id}", response_class=HTMLResponse)
async def role_chat(req: Request, role_id: str, user = Depends(current_user)):
"""
Chat interface for a specific role.
"""
role = config['roles'].get(role_id)
if not role:
raise HTTPException(404, "Role not found")
user_group = user.get('group', 'demo')
if user_group not in role['group_access']:
raise HTTPException(403, "Access denied")
return req.app.state.render(
req,
"crumbforest/role_chat.html",
role=role
)
@router.post("/roles/{role_id}/ask")
async def ask_role(
req: Request,
role_id: str,
question: str = Form(...),
user = Depends(current_user)
):
"""
Send question to role and get AI response.
"""
role = config['roles'].get(role_id)
if not role:
raise HTTPException(404, "Role not found")
# Get conversation history from session
history = req.session.get(f'role_history_{role_id}', [])
# Build messages
messages = [
{"role": "system", "content": role['system_prompt']}
] + history + [
{"role": "user", "content": question}
]
# Call OpenRouter API
async with httpx.AsyncClient() as client:
response = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json"
},
json={
"model": role['model'],
"temperature": role['temperature'],
"messages": messages
}
)
result = response.json()
answer = result['choices'][0]['message']['content']
# Update history
history.append({"role": "user", "content": question})
history.append({"role": "assistant", "content": answer})
req.session[f'role_history_{role_id}'] = history[-10:] # Keep last 10
return JSONResponse({
"role": role_id,
"question": question,
"answer": answer,
"usage": result.get('usage', {})
})
```
### 2. Template: `templates/crumbforest/roles_dashboard.html`
```html
{% extends group_config.template_base %}
{% block title %}Crumbforest Roles{% endblock %}
{% block content %}
<main class="container">
<hgroup>
<h1>🌲 Crumbforest Characters</h1>
<p>Choose your learning companion!</p>
</hgroup>
<div class="grid">
{% for role_id, role in roles.items() %}
<article>
<header>
<h3>
<span style="font-size: 2em;">{{ role.icon }}</span>
{{ role.name }}
</h3>
<p><small>{{ role.title }}</small></p>
</header>
<p>{{ role.description }}</p>
<footer>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{% for tag in role.tags %}
<span class="badge" style="background: {{ role.color }}20; color: {{ role.color }};">
#{{ tag }}
</span>
{% endfor %}
</div>
<a href="/crumbforest/roles/{{ role_id }}" role="button">
Chat with {{ role.name }}
</a>
</footer>
</article>
{% endfor %}
</div>
</main>
{% endblock %}
```
### 3. Template: `templates/crumbforest/role_chat.html`
```html
{% extends group_config.template_base %}
{% block title %}{{ role.name }} - Chat{% endblock %}
{% block content %}
<main class="container">
<hgroup>
<h1>{{ role.icon }} {{ role.name }}</h1>
<p>{{ role.description }}</p>
</hgroup>
<article id="chat-container">
<div id="messages" style="max-height: 500px; overflow-y: auto; padding: 1rem;">
<!-- Messages will be added here via JS -->
</div>
<footer>
<form id="chat-form">
<div class="grid">
<input
type="text"
name="question"
id="question-input"
placeholder="Ask {{ role.name }} something..."
required
autofocus
>
<button type="submit">Send</button>
</div>
</form>
</footer>
</article>
</main>
<script>
const roleId = "{{ role.id }}";
const chatForm = document.getElementById('chat-form');
const messagesDiv = document.getElementById('messages');
const questionInput = document.getElementById('question-input');
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const question = questionInput.value;
if (!question.trim()) return;
// Add user message
addMessage('user', question);
questionInput.value = '';
// Show loading
const loadingId = addMessage('assistant', '{{ role.icon }} thinking...');
try {
const formData = new FormData();
formData.append('question', question);
const response = await fetch(`/crumbforest/roles/${roleId}/ask`, {
method: 'POST',
body: formData
});
const data = await response.json();
// Remove loading, add real response
document.getElementById(loadingId).remove();
addMessage('assistant', data.answer);
} catch (error) {
document.getElementById(loadingId).remove();
addMessage('error', 'Oops! Something went wrong.');
}
});
function addMessage(role, content) {
const msgId = 'msg-' + Date.now();
const div = document.createElement('div');
div.id = msgId;
div.style.marginBottom = '1rem';
div.style.padding = '1rem';
div.style.borderRadius = '8px';
if (role === 'user') {
div.style.background = '#e3f2fd';
div.style.textAlign = 'right';
div.innerHTML = `<strong>You:</strong><br>${content}`;
} else if (role === 'assistant') {
div.style.background = '#f5f5f5';
div.innerHTML = `<strong>{{ role.name }}:</strong><br>${content}`;
} else {
div.style.background = '#ffebee';
div.innerHTML = `<strong>Error:</strong><br>${content}`;
}
messagesDiv.appendChild(div);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
return msgId;
}
</script>
{% endblock %}
```
### 4. User Model Extension: `app/models/user.py`
```python
# Extend users table
"""
ALTER TABLE users
ADD COLUMN user_group VARCHAR(50) DEFAULT 'demo',
ADD COLUMN theme VARCHAR(50) DEFAULT 'pico-default',
ADD COLUMN accessibility JSON DEFAULT NULL;
"""
# Example accessibility JSON:
{
"font_size": "large",
"high_contrast": true,
"screen_reader": true,
"animation_reduced": true
}
```
### 5. Base Template with Group Support: `templates/base_demo.html`
```html
<!DOCTYPE html>
<html lang="{{ lang }}" data-theme="{{ user.theme or 'auto' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Crumbforest{% endblock %}</title>
<!-- Load CSS based on group config -->
{% for css_file in group_config.css_files %}
<link rel="stylesheet" href="/static/css/{{ css_file }}">
{% endfor %}
{% if user.accessibility %}
<style>
:root {
{% if user.accessibility.font_size == 'large' %}
font-size: 120%;
{% endif %}
{% if user.accessibility.high_contrast %}
--primary: #ffff00;
--background-color: #000000;
--color: #ffffff;
{% endif %}
}
{% if user.accessibility.animation_reduced %}
* {
animation: none !important;
transition: none !important;
}
{% endif %}
</style>
{% endif %}
</head>
<body>
<nav>
<ul>
<li><strong>🌲 Crumbforest</strong></li>
</ul>
<ul>
{% for nav_item in group_config.navbar %}
{% if nav_item == 'dashboard' %}
<li><a href="/dashboard">Dashboard</a></li>
{% elif nav_item == 'roles' %}
<li><a href="/crumbforest/roles">Characters</a></li>
{% elif nav_item == 'search' %}
<li><a href="/search">Search</a></li>
{% endif %}
{% endfor %}
<li>
<details class="dropdown">
<summary>{{ user.email }}</summary>
<ul dir="rtl">
<li><a href="/settings">Settings</a></li>
<li>
<form action="/logout" method="post" style="margin:0;">
<button type="submit" class="contrast">Logout</button>
</form>
</li>
</ul>
</details>
</li>
</ul>
</nav>
{% block content %}{% endblock %}
<footer>
<small>
Group: {{ group_config.name }} |
Theme: {{ user.theme }} |
Made with 💚 in the Crumbforest
</small>
</footer>
</body>
</html>
```
---
## 📊 Implementierungs-Phasen
### Phase 1: Foundation (Tag 1-2)
- [ ] `crumbforest_config.json` erstellen
- [ ] User-Model erweitern (group, theme, accessibility)
- [ ] Config-Loader Service
- [ ] Base Templates für Gruppen (home, demo, admin, accessible)
### Phase 2: Role System (Tag 3-4)
- [ ] `/routers/crumbforest_roles.py` implementieren
- [ ] Roles Dashboard Template
- [ ] Chat Interface Template
- [ ] Session-based History
- [ ] OpenRouter API Integration
### Phase 3: Theme System (Tag 5-6)
- [ ] CSS Variants (default, accessible, high-contrast, admin)
- [ ] Theme Switcher UI
- [ ] User Settings Page
- [ ] Accessibility Controls (font size, contrast, TTS)
### Phase 4: Migration (Tag 7)
- [ ] Shell Scripts → JSON Config migrieren
- [ ] Testing aller 8 Roles
- [ ] Demo User einrichten
- [ ] Branko.de Integration (Static pages)
### Phase 5: Features (Tag 8-10)
- [ ] Code Execution (für SnakePy, PepperPHP)
- [ ] Syntax Highlighting
- [ ] Role-spezifische Features
- [ ] Export Chat History
- [ ] Token Usage Tracking
---
## 🎨 Vorteile dieser Architektur
### ✅ Wartbarkeit
- **Ein JSON** statt 8 Shell Scripts
- Zentralisierte Konfiguration
- Einfaches Hinzufügen neuer Roles
### ✅ Flexibilität
- Gruppen-basiertes Theming
- Modul-basierte Features
- Multi-Tenant fähig
### ✅ Barrierefreiheit
- High-Contrast Theme
- Große Schrift
- Screen Reader Support
- Animation-Reduktion
### ✅ Separation of Concerns
- **Home**: Öffentlich, neutral, Info
- **Demo**: Role-Chat, Read-Only
- **Admin**: Voller Zugriff, Terminal, RAG
### ✅ Extensibility
- Neue Roles via JSON
- Neue Gruppen ohne Code-Änderung
- Theme-Varianten einfach hinzufügen
- Features als Plugins
---
## 🚀 Quick Start (Nach Implementierung)
```python
# 1. User mit Gruppe erstellen
user = {
"email": "demo@crumb.local",
"group": "demo",
"theme": "pico-accessible"
}
# 2. Role-Chat öffnen
GET /crumbforest/roles/dumbo
# 3. Frage stellen
POST /crumbforest/roles/dumbo/ask
{
"question": "Wie erstelle ich eine SQL-Tabelle?"
}
# 4. Theme wechseln
POST /settings/theme
{
"theme": "pico-high-contrast",
"accessibility": {
"font_size": "large",
"high_contrast": true
}
}
```
---
## 🦉 Fazit
Diese Architektur:
- ✅ Ersetzt 8 Shell Scripts durch 1 JSON
- ✅ Ermöglicht Web UI für Roles
- ✅ Unterstützt Barrierefreiheit
- ✅ Trennt home/demo/admin sauber
- ✅ Ist erweiterbar & wartbar
- ✅ Behält Pico CSS bei
- ✅ Funktioniert mit bestehendem FastAPI Setup
**Ready to implement?** 🌲

238
CrumbTech.md Normal file
View File

@@ -0,0 +1,238 @@
mega—großer krümel geschafft 🍰🧩
Hier ist das **finale `README.md`** für den Stand „Login → Admin → Posts CRUD → i18n → Flash/Templating → DB/Qdrant Compose“. Es fasst die Reise (gestern→heute) zusammen, dokumentiert die wichtigen Entscheidungen, und enthält alle Befehle & Fixes, die wir unterwegs gebraucht haben.
---
# CrumbCRM Minimal vNext (FastAPI + MariaDB + Qdrant)
Server-rendered, mehrsprachig, mit Login/Session, Admin-Bereich und einfachem Blog-CRUD.
Ziel: **schnell, stabil, kein Frontend-Build-Ballast**, dafür klare HTML-Templates (Jinja2), Forms, Flash-Messages und später andockbare Vektor-Suche (Qdrant).
## Was läuft aktuell?
* 🌐 **i18n** Pfade: `/de/…` und `/en/…` (Root `/` → 307 auf bevorzugte Sprache)
* 🔐 **Login** (Sessions + bcrypt) und Logout
* 👤 Rollen: `admin` vs `user` (403, wenn kein Admin)
* 🧰 **Admin Dashboard**: `/admin`
* ✍️ **Posts CRUD** für Admin:
* `GET /admin/posts` Liste
* `GET /admin/posts/new` Formular
* `POST /admin/posts/new` Erstellen
* `GET /admin/posts/{id}/edit` Bearbeiten
* `POST /admin/posts/{id}/edit` Speichern
***Flash-Nachrichten** (einmalige Anzeige nach Redirect)
* 🧪 **API Demo**: `GET /api/hello?lang=de|en`
* 🩺 **Health**: `GET /health`
* 🧠 **Qdrant** ist per Compose angebunden (noch ohne Ingest), UI unter `http://localhost:6333/dashboard`
## Stack
* **FastAPI**, **Jinja2**, **Starlette Sessions**
* **MariaDB** (PyMySQL)
* **passlib\[bcrypt]** (Password-Hashing)
* **python-multipart** (Form-POSTs)
* **Qdrant** (Vektoren; später Indexing/Embedding)
Empfohlene Pins (stehen in `app/requirements.txt`):
```
fastapi==0.115.0
uvicorn[standard]==0.30.6
jinja2==3.1.4
passlib[bcrypt]==1.7.4
bcrypt==4.1.3
python-multipart==0.0.9
PyMySQL==1.1.1
```
## Projektstruktur
```
app/
main.py # App, Routing, Session, Render-Helper (state.render)
requirements.txt
routers/
admin_post.py # Admin-CRUD für Posts
templates/
base.html
pages/
home.html
login.html
admin.html
posts/
index.html
new.html
edit.html
_edit_row.htm
compose/
docker-compose.yml
init/
01_schema.sql # users Tabelle
02_posts.sql # posts Tabelle
reset_admin_demo.sh # pass-hash Seeds (admin/demo)
data/
mysql/ # MariaDB Daten
qdrant/ # Qdrant Storage
```
## Start (Docker Compose)
```bash
cd compose
docker compose up --build
```
* App: [http://localhost:8000](http://localhost:8000)
* Qdrant UI: [http://localhost:6333/dashboard](http://localhost:6333/dashboard)
### Admin/Demo Benutzer anlegen (Seeds)
Wenn die Container laufen, einmalig die Benutzer mit bekannten Hashes setzen:
```bash
# Benutzer in DB einspielen (verwende -T, damit keine TTY-Probleme)
docker compose exec -T db sh -lc '
mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE" -e "
INSERT INTO users (email, pass_hash, role, locale, display_name)
VALUES
(\"admin@crumb.local\", \"\$2b\$12\$H1V2q0iY8mqlz1xUbx7m5u4T0cVJH0hQk0q9o5aNq5Pjv1e0q9lby\", \"admin\", \"de\", \"Admin\"),
(\"demo@crumb.local\", \"\$2b\$12\$pI6cJ7gq4qkqF3gL2xjY4u9v9s1yN6wZQn9I7gG7xv7Cw0m3t8yVSe\", \"user\", \"de\", \"Demo\")
ON DUPLICATE KEY UPDATE pass_hash=VALUES(pass_hash), role=VALUES(role), locale=VALUES(locale);
"'
```
> **Hinweis:** `admin@crumb.local` hat Adminrechte. `demo@crumb.local` **nicht** 403 auf `/admin` ist korrekt.
## Wichtige Routen (Stand heute)
* `GET /` → 307 auf `/de/`
* `GET /{lang}/` → Home
* `GET /{lang}/login` + `POST /{lang}/login` → Login
* `POST /logout` → Logout
* `GET /admin` → Admin-Start (nur Admin)
* `GET /admin/posts` → Post-Liste (nur Admin)
* `GET|POST /admin/posts/new` → Neu anlegen
* `GET|POST /admin/posts/{id}/edit` → Bearbeiten
* `GET /api/hello?lang=de|en` → einfache JSON-API
* `GET /health` → Healthcheck
*(Optional)* Öffentliche Post-Liste wäre `/de/posts` bzw. `/en/posts` Route/Template ist noch nicht aktiv. Snippet unten.
## Flash-Nachrichten
* Wir speichern Flashs als Liste in `req.session["_flashes"]`.
* **Einmalige Anzeige**: `render()` poppt sie und setzt sie leer zurück.
* Wenn du direkt nach dem Redirect noch mal „hart“ navigierst, ist die Flash weg das ist Absicht.
## Security Basics
* Session-Cookie: HttpOnly, `SameSite=Lax`
* CSRF: Forms sind serverseitig bei Bedarf später Token ergänzen
* Rollenprüfung: `admin_required` schützt Admin-Routen
## Troubleshooting (die Fallen heute)
* **`python-multipart` fehlt** → in `requirements.txt` **mit** eintragen (ist drin).
* **`(trapped) error reading bcrypt version`**
→ sichere Kombi pinnen: `passlib[bcrypt]==1.7.4` **und** `bcrypt==4.1.3`.
* **`ImportError: circular import`**
→ Admin-Router importiert nur **Funktionen** aus einer kleinen `deps.py` (DB/ACL), nicht `main`.
* **`State has no attribute render`**
`state.render(req, tpl, **ctx)` wird **in `main.py`** beim Startup gesetzt. Router nutzt **genau** diese Funktion.
* **MariaDB Warnung „Aborted connection … Got an error reading communication packets“**
→ Fix: überall `with db() as conn:` verwenden (Kontextmanager schließt sauber).
* **`Field 'body_md' doesn't have a default value`**
→ Beim Insert/Edit `body_md` **immer** mitsenden (Form Feld ist vorhanden).
## Logs
```bash
docker compose logs -f app
docker compose logs -f db
```
## Optional: Öffentliche Posts-Liste
Wenn gewünscht, Route in `main.py` und Template ergänzen:
```python
# in main.py
from deps import db
from fastapi import Request
from fastapi.responses import HTMLResponse
@app.get("/{lang}/posts", response_class=HTMLResponse)
def public_posts(req: Request, lang: str):
with db() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT id, title, slug, locale, updated_at
FROM posts
WHERE is_published=1 AND locale=%s
ORDER BY IFNULL(updated_at, created_at) DESC, id DESC
""", (lang,))
rows = cur.fetchall()
return req.app.state.render(req, "pages/posts_public.html",
posts=rows, seo={"title": "Posts"})
```
```html
<!-- templates/pages/posts_public.html -->
{% extends 'base.html' %}
{% block content %}
<h1>Posts</h1>
<ul class="list">
{% for p in posts %}
<li><strong>{{ p.title }}</strong> <small>({{ p.locale }})</small>
{% if p.updated_at %} <em>{{ p.updated_at }}</em>{% endif %}
</li>
{% else %}
<li>Keine veröffentlichten Beiträge.</li>
{% endfor %}
</ul>
{% endblock %}
```
## DB-Schema (Minimal)
**users**
```
id, email (unique), pass_hash, role('admin'|'user'), locale, created_at
```
**posts**
```
id, title, slug, locale, is_published TINYINT, body_md MEDIUMTEXT,
created_at, updated_at
```
→ angelegt über `compose/init/01_schema.sql` und `02_posts.sql`.
## Befehls-Snippets (nützlich)
```bash
# DB-Ping
docker compose exec -T db sh -lc \
'mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE" -e "SELECT 1;"'
# User-Check
docker compose exec -T db sh -lc \
'mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE" \
-e "SELECT id,email,role,locale,created_at FROM users;"'
```
## Nächste Schritte (wenn du wieder Luft hast)
* Öffentliche Post-Ansicht `/de/posts` aktivieren
* CSRF-Token für Form-POSTs (kleiner Middleware-Helfer)
* Qdrant-Ingest-Worker (Markdown → Chunks → Embeddings → Upsert)
* Settings/ACL feiner (Rollenmatrix)
* Passwort-Reset/Mail (lokal via Mailpit/SMTP-Mock)
---
**Danke für den Ritt durch Pepper 🐍, Dumbo 🐘 & den Krümelwald 🌲.**
Von „nur Kuchen“ zu „Tortenbit“: Login-Loop steht, Admin schreibt, Flash funkt, i18n schaltet. Der Rest wird Feinschliff—aber die Basis trägt. Wuuuuhuuu! 🎉

370
DIARY_RAG_README.md Normal file
View File

@@ -0,0 +1,370 @@
# Diary RAG System - Documentation
Das Crumbforest Diary RAG System ist jetzt vollständig integriert! Kinder können Tagebuch-Einträge erstellen, die automatisch indexiert und durchsuchbar gemacht werden.
## 🎯 Was wurde implementiert?
### 1. Database Schema
**Neue Tabellen** (`compose/init/04_diary_schema.sql`):
- `children` - Kinder/Nutzer mit Access Tokens
- `diary_entries` - Tagebuch-Einträge
- `audit_log` - GDPR-konforme Audit-Logs
**Erweiterte Tabelle**:
- `post_vectors` - Neue Spalten: `post_type`, `child_id`
### 2. Pydantic Models
**Neue Models** (`app/models/rag_models.py`):
- `DiaryIndexRequest` / `DiaryIndexResponse`
- `DiarySearchRequest` / `DiarySearchResponse`
- `DiaryAskRequest` / `DiaryAskResponse`
- `DiarySearchResult`
### 3. API Endpoints
**Neuer Router** (`app/routers/diary_rag.py`):
- `POST /api/diary/index` - Indexiert Tagebuch-Eintrag
- `POST /api/diary/search` - Semantic Search im Tagebuch
- `POST /api/diary/ask` - RAG Query (Q&A)
- `GET /api/diary/{child_id}/status` - Indexing-Status
### 4. Integration Test
**Test-Suite** (`tests/test_integration.py`):
- Kompletter End-to-End Test
- PHP -> FastAPI -> Qdrant Flow
- Alle 6 Schritte validiert
## 📋 API Endpoints
### 1. Index Diary Entry
```bash
POST /api/diary/index
Content-Type: application/json
{
"entry_id": 1,
"child_id": 1,
"content": "# Heute im Wald\n\nIch habe einen Igel gesehen!",
"provider": "openai"
}
```
**Response:**
```json
{
"status": "success",
"entry_id": 1,
"child_id": 1,
"chunks": 3,
"collection": "diary_child_1",
"provider": "openai"
}
```
### 2. Search Diary
```bash
POST /api/diary/search
Content-Type: application/json
{
"child_id": 1,
"query": "Igel",
"provider": "openai",
"limit": 5
}
```
**Response:**
```json
{
"results": [
{
"entry_id": 1,
"content": "Ich habe einen Igel gesehen...",
"score": 0.95,
"created_at": "2025-01-15T10:30:00"
}
],
"query": "Igel",
"child_id": 1,
"provider": "openai"
}
```
### 3. RAG Query (Ask)
```bash
POST /api/diary/ask
Content-Type: application/json
{
"child_id": 1,
"question": "Was habe ich im Wald gesehen?",
"provider": "openai",
"context_limit": 3
}
```
**Response:**
```json
{
"answer": "Du hast einen Igel im Wald gesehen. Du warst mit deinem Papa spazieren...",
"question": "Was habe ich im Wald gesehen?",
"child_id": 1,
"sources": [
{
"entry_id": 1,
"content": "Ich war heute mit Papa im Wald...",
"score": 0.95,
"created_at": "2025-01-15T10:30:00"
}
],
"provider": "openai",
"model": "gpt-4o-mini"
}
```
### 4. Get Status
```bash
GET /api/diary/1/status
```
**Response:**
```json
{
"child_id": 1,
"total_entries": 5,
"indexed_entries": 5,
"total_vectors": 15,
"last_indexed": "2025-01-15T10:30:00",
"collection_name": "diary_child_1"
}
```
## 🔧 Setup & Deployment
### 1. Environment Variables
Füge zu `.env` hinzu:
```bash
# AI Provider API Keys (mindestens einen)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
# Default Providers
DEFAULT_EMBEDDING_PROVIDER=openai
DEFAULT_COMPLETION_PROVIDER=openai
```
### 2. Database Migration
```bash
# Starte Docker Compose neu, um die neuen Tabellen zu erstellen
cd compose
docker compose down
docker compose up --build
# Alternativ: Führe das Schema-Update manuell aus
docker compose exec -T db sh -lc \
'mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' \
< compose/init/04_diary_schema.sql
```
### 3. Verify Installation
```bash
# Health Check
curl http://localhost:8000/health
# List all routes
curl http://localhost:8000/__routes | grep diary
# Check providers
curl http://localhost:8000/admin/rag/providers
```
## 🧪 Testing
### Run Integration Test
```bash
# Stelle sicher, dass Docker Compose läuft
cd compose && docker compose up -d
# Setze API Key (mindestens einen)
export OPENAI_API_KEY=sk-...
# Führe Test aus
python tests/test_integration.py
```
**Erwartete Ausgabe:**
```
============================================================
Crumbforest Integration Test
PHP <-> FastAPI <-> Qdrant
============================================================
✓ API is healthy
✓ Connected to database
✓ Cleaned up test data
=== Step 1: Create Child ===
✓ Created child with ID: 1
=== Step 2: Create Diary Entry ===
✓ Created diary entry with ID: 1
=== Step 3: Index Diary Entry ===
✓ Indexed successfully:
- Status: success
- Chunks: 3
- Collection: diary_child_1
- Provider: openai
=== Step 4: Search Diary ===
✓ Search successful:
- Query: Igel
- Results: 1
=== Step 5: RAG Query (Ask Question) ===
✓ RAG query successful:
- Answer: Du hast einen Igel im Wald gesehen...
=== Step 6: Check Indexing Status ===
✓ Status: All entries indexed
============================================================
✓ ALL TESTS PASSED!
============================================================
Wuuuuhuuu! 💚
```
## 🔗 PHP Integration
### PHP FastAPI Client
Nutze die existierende `class.fastapi.php`:
```php
<?php
require_once 'classes/class.fastapi.php';
$api = new FastAPIClient('http://fastapi:8000');
// Index diary entry
$response = $api->post('/api/diary/index', [
'entry_id' => $entry_id,
'child_id' => $child_id,
'content' => $diary_content,
'provider' => 'openai'
]);
if ($response['success']) {
echo "Indexed successfully!";
}
// Search diary
$results = $api->post('/api/diary/search', [
'child_id' => $child_id,
'query' => 'Igel',
'provider' => 'openai',
'limit' => 5
]);
// RAG query
$answer = $api->post('/api/diary/ask', [
'child_id' => $child_id,
'question' => 'Was habe ich im Wald gesehen?',
'provider' => 'openai'
]);
```
## 📊 Architecture
```
┌─────────────┐
│ PHP (8080) │
│ │
│ - Children │
│ - Diary │
│ - Tokens │
└──────┬──────┘
│ HTTP POST
v
┌─────────────────┐ ┌──────────────┐
│ FastAPI (8000) │────► │ Qdrant:6333 │
│ │ │ │
│ - RAG Service │ │ Collections: │
│ - Embedding │ │ - diary_1 │
│ - Provider │ │ - diary_2 │
└─────────┬───────┘ │ - diary_N │
│ └──────────────┘
v
┌─────────────────┐
│ MariaDB:3306 │
│ │
│ - children │
│ - diary_entries │
│ - post_vectors │
│ - audit_log │
└─────────────────┘
```
## 🔐 Security & GDPR
### Audit Logging
Alle Aktionen werden in `audit_log` protokolliert:
- `diary_indexed` - Eintrag wurde indexiert
- `diary_searched` - Tagebuch wurde durchsucht
- `diary_rag_query` - RAG Query wurde ausgeführt
### Data Isolation
- Jedes Kind hat seine eigene Qdrant Collection: `diary_child_{id}`
- Keine Cross-Child Zugriffe möglich
- Token-basierter Zugriff über QR-Codes
### GDPR Compliance
- Immutable Audit Log (INSERT ONLY)
- Metadata als JSON für Flexibilität
- CASCADE DELETE auf Kind-Ebene
## 🎨 Providers
### Unterstützte Provider
1. **OpenAI** - text-embedding-3-small + gpt-4o-mini
2. **Claude** - Voyage AI embeddings + Claude Sonnet
3. **OpenRouter** - Flexible multi-provider
### Provider wechseln
```bash
# In allen Requests:
{
"provider": "claude" # oder "openai", "openrouter"
}
```
## 🚀 Next Steps
### 1. PHP Integration
- Erstelle `php/api/diary/create.php`
- Integriere FastAPI-Aufruf nach Diary Creation
- Füge Background-Worker für Batch-Indexing hinzu
### 2. UI Features
- Admin Dashboard für Diary Stats
- Kind-spezifische RAG Query Page
- Token-generierung für Kinder
### 3. Advanced Features
- Multi-lingual Diary Support
- Emotion Detection
- Automatic Tagging
- Export als PDF
## 📞 Support
Bei Fragen oder Problemen:
1. Check logs: `docker compose logs -f app`
2. Verify DB: `docker compose exec db mariadb -u crumb -p`
3. Test Qdrant: `curl http://localhost:6333/collections`
---
**Wuuuuhuuu! Das Diary RAG System ist fertig! 💚**

1462
HANDBUCH.md Normal file

File diff suppressed because it is too large Load Diff

697
HOME_TEMPLATE_PLAN.md Normal file
View File

@@ -0,0 +1,697 @@
# 🏠 Home Template System - Implementation Plan
## 🎯 Ziel
**Flexibles Home Template pro Container-Deployment:**
- Verwendet **Pico CSS** (statt Tailwind)
- **Deployment Config** (JSON) pro Container
- **Branko.de Content** als Basis
- **Multilingual** (de/en/fr)
- **Einfach austauschbar** bei neuem Container
---
## 📁 Struktur
```
compose/
deployment_config.json # Pro Container anpassbar!
app/
routers/
home.py # Home routes (/, /about, /crew, etc.)
templates/
home/
base_home.html # Base für Home (ohne Auth)
index.html # Landing Page
about.html # Mission / Wurzeln
crew.html # Character Cards
hardware.html # Hardware Info
software.html # Software Info
contact.html # Kontakt / Impressum
static/
css/
home_default.css # Standard Home Theme
home_forest.css # Wald Theme (dunkel/grün)
home_light.css # Hell Theme
data/
testimonials.de.json # Testimonials Deutsch
testimonials.en.json # English
testimonials.fr.json # Français
characters.json # Character Definitions
assets/
logo.png
hero_bg.jpg
```
---
## 🔧 1. Deployment Config
**`compose/deployment_config.json`**
```json
{
"deployment_id": "crumbforest_main",
"deployment_name": "Crumbforest Main",
"base_url": "https://branko.de",
"home": {
"enabled": true,
"theme": "forest",
"default_lang": "de",
"languages": ["de", "en", "fr"],
"hero": {
"title": "🌳 Crumbforest",
"subtitle": "Wo Fragen wachsen. Und jeder Krümel zählt.",
"cta_text": "Den Wald entdecken",
"cta_link": "#explore"
},
"mission": {
"title": "🌲 Unsere Wurzeln",
"description": "Crumbforest ist ein offenes Lern-Ökosystem mit Kindern, Maschinen und Natur.",
"values": [
{
"icon": "🦉",
"title": "Fragen",
"text": "Jedes Kind darf fragen. Wir schützen dieses Recht in jedem Terminal."
},
{
"icon": "🛠️",
"title": "Bauen",
"text": "Hands-on Lernen mit Raspberry Pi, Bash, Blockly und mehr."
},
{
"icon": "🌐",
"title": "Verbinden",
"text": "Unsere Rollen und APIs bilden ein Resonanz-Netz."
}
]
},
"sections": {
"testimonials": true,
"crew": true,
"hardware": true,
"software": true,
"contact": true
},
"navigation": [
{"label": "Home", "url": "/", "icon": "🏠"},
{"label": "Mission", "url": "/about", "icon": "🌲"},
{"label": "Crew", "url": "/crew", "icon": "🌟"},
{"label": "Hardware", "url": "/hardware", "icon": "🔧"},
{"label": "Software", "url": "/software", "icon": "💻"},
{"label": "Login", "url": "/de/login", "icon": "🔐"}
],
"footer": {
"tagline": "Made with 💚 in the Crumbforest",
"links": [
{"label": "Impressum", "url": "/impressum"},
{"label": "Datenschutz", "url": "/datenschutz"}
]
}
},
"features": {
"rag_system": true,
"diary_system": true,
"document_search": true,
"roles_web": false
}
}
```
---
## 🎨 2. Pico CSS Home Theme
**`app/static/css/home_forest.css`**
```css
/* Crumbforest Forest Theme - Dark & Green */
:root {
--pico-font-family: "Inter", system-ui, sans-serif;
/* Forest Colors */
--pico-primary: #10b981; /* Emerald */
--pico-primary-hover: #059669;
--pico-primary-focus: rgba(16, 185, 129, 0.125);
--pico-background-color: #0f172a; /* Dark Blue-Gray */
--pico-color: #e2e8f0; /* Light Gray */
/* Gradients */
--hero-gradient: linear-gradient(135deg, #064e3b 0%, #10b981 100%);
--section-accent: #1e293b;
}
/* Hero Section */
.hero {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--hero-gradient);
text-align: center;
padding: 2rem;
}
.hero h1 {
font-size: 4rem;
margin-bottom: 1rem;
font-weight: 900;
}
.hero p {
font-size: 1.5rem;
margin-bottom: 2rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.hero .cta-button {
background: white;
color: black;
padding: 1rem 2rem;
border-radius: 2rem;
font-weight: bold;
text-decoration: none;
display: inline-block;
transition: all 0.3s;
}
.hero .cta-button:hover {
background: #fbbf24; /* Yellow */
transform: scale(1.05);
}
/* Language Switcher */
.lang-switcher {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
}
.lang-switcher a {
background: white;
color: black;
padding: 0.5rem 1.5rem;
border-radius: 2rem;
font-weight: bold;
text-decoration: none;
}
/* Mission Section */
.mission {
padding: 4rem 2rem;
max-width: 1200px;
margin: 0 auto;
text-align: center;
}
.mission h2 {
font-size: 2.5rem;
margin-bottom: 2rem;
}
.values-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-top: 2rem;
text-align: left;
}
.value-card {
background: var(--section-accent);
padding: 2rem;
border-radius: 1rem;
}
.value-card h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
/* Character Cards */
.crew-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.character-card {
background: var(--section-accent);
padding: 1.5rem;
border-radius: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
}
.character-card:hover {
background: var(--pico-primary);
transform: translateY(-5px);
border-color: var(--pico-primary-hover);
}
.character-card .icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
/* Modal */
dialog {
border-radius: 1rem;
border: none;
padding: 2rem;
max-width: 600px;
background: var(--pico-background-color);
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.8);
}
/* Testimonials */
.testimonials {
background: #7c3aed; /* Purple */
padding: 4rem 2rem;
text-align: center;
}
.testimonial-slide {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.testimonial-slide p {
font-size: 1.25rem;
font-style: italic;
margin-bottom: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.hero h1 {
font-size: 2.5rem;
}
.hero p {
font-size: 1.25rem;
}
.values-grid {
grid-template-columns: 1fr;
}
}
```
---
## 🧩 3. Base Home Template
**`app/templates/home/base_home.html`**
```html
<!DOCTYPE html>
<html lang="{{ lang }}" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ deployment.home.hero.title }}{% endblock %}</title>
<!-- SEO -->
<meta name="description" content="{% block description %}{{ deployment.home.mission.description }}{% endblock %}">
<meta name="keywords" content="Crumbforest, Kinderfragen, Lernen, Terminal, Raspberry Pi, Open Source">
<meta name="author" content="Die Crumbforest-Crew">
<meta name="robots" content="index, follow">
<!-- Open Graph -->
<meta property="og:title" content="{% block og_title %}{{ deployment.home.hero.title }}{% endblock %}">
<meta property="og:description" content="{{ deployment.home.hero.subtitle }}">
<meta property="og:url" content="{{ deployment.base_url }}">
<!-- CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/static/css/home_{{ deployment.home.theme }}.css">
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="container-fluid">
<ul>
<li><strong>{{ deployment.home.hero.title }}</strong></li>
</ul>
<ul>
{% for nav_item in deployment.home.navigation %}
<li><a href="{{ nav_item.url }}">{{ nav_item.icon }} {{ nav_item.label }}</a></li>
{% endfor %}
</ul>
</nav>
<!-- Main Content -->
{% block content %}{% endblock %}
<!-- Footer -->
<footer class="container">
<small>
{{ deployment.home.footer.tagline }}
{% for link in deployment.home.footer.links %}
| <a href="{{ link.url }}">{{ link.label }}</a>
{% endfor %}
</small>
</footer>
{% block extra_js %}{% endblock %}
</body>
</html>
```
---
## 🏠 4. Home Index Template
**`app/templates/home/index.html`**
```html
{% extends "home/base_home.html" %}
{% block content %}
<!-- Hero Section -->
<section class="hero">
<div>
<h1>{{ deployment.home.hero.title }}</h1>
<p>{{ deployment.home.hero.subtitle }}</p>
<a href="{{ deployment.home.hero.cta_link }}" class="cta-button">
{{ deployment.home.hero.cta_text }}
</a>
<!-- Language Switcher -->
<div class="lang-switcher">
{% for lang_code in deployment.home.languages %}
<a href="?lang={{ lang_code }}">{{ lang_code.upper() }}</a>
{% endfor %}
</div>
</div>
</section>
<!-- Mission Section -->
<section id="explore" class="mission">
<h2>{{ deployment.home.mission.title }}</h2>
<p>{{ deployment.home.mission.description }}</p>
<div class="values-grid">
{% for value in deployment.home.mission.values %}
<div class="value-card">
<h3>{{ value.icon }} {{ value.title }}</h3>
<p>{{ value.text }}</p>
</div>
{% endfor %}
</div>
</section>
{% if deployment.home.sections.testimonials %}
<!-- Testimonials -->
<section class="testimonials">
<h2>💬 Stimmen aus dem Crumbforest</h2>
<div class="testimonial-slide" id="testimonial-container">
<p id="testimonial-text"></p>
<small id="testimonial-author"></small>
</div>
<div style="display: flex; justify-content: center; gap: 2rem; margin-top: 2rem;">
<button onclick="prevTestimonial()">⬅️</button>
<button onclick="nextTestimonial()">➡️</button>
</div>
</section>
{% endif %}
{% if deployment.home.sections.crew %}
<!-- Character Preview -->
<section class="container">
<h2 style="text-align: center;">🌟 Lerne die Crew kennen</h2>
<div style="text-align: center; margin: 2rem 0;">
<a href="/crew" role="button">Alle Characters entdecken</a>
</div>
</section>
{% endif %}
<!-- Access Section -->
<section class="container">
<h2 style="text-align: center;">🌐 Zugang zum Wald</h2>
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
{% if deployment.features.rag_system %}
<a href="/de/login" role="button">RAG System</a>
{% endif %}
{% if deployment.features.document_search %}
<a href="/de/login" role="button">Document Search</a>
{% endif %}
<a href="{{ deployment.base_url }}/hardware" role="button" class="outline">Hardware Info</a>
<a href="{{ deployment.base_url }}/software" role="button" class="outline">Software Info</a>
</div>
</section>
{% endblock %}
{% block extra_js %}
<script>
let testimonials = [];
let currentIndex = 0;
fetch('/static/data/testimonials.{{ lang }}.json')
.then(res => res.json())
.then(data => {
testimonials = data;
showTestimonial(0);
setInterval(nextTestimonial, 8000);
});
function showTestimonial(index) {
const t = testimonials[index];
document.getElementById('testimonial-text').textContent = `"${t.message}"`;
document.getElementById('testimonial-author').textContent = ` ${t.author}, ${t.role}`;
}
function nextTestimonial() {
currentIndex = (currentIndex + 1) % testimonials.length;
showTestimonial(currentIndex);
}
function prevTestimonial() {
currentIndex = (currentIndex - 1 + testimonials.length) % testimonials.length;
showTestimonial(currentIndex);
}
</script>
{% endblock %}
```
---
## 🦉 5. Crew Page Template
**`app/templates/home/crew.html`**
```html
{% extends "home/base_home.html" %}
{% block title %}Crew - {{ deployment.home.hero.title }}{% endblock %}
{% block content %}
<main class="container">
<hgroup>
<h1>🌟 Die Crumbforest Crew</h1>
<p>Lerne unsere Characters kennen!</p>
</hgroup>
<div class="crew-grid">
{% for character in characters %}
<div class="character-card" onclick="showCharacter('{{ character.id }}')">
<div class="icon">{{ character.icon }}</div>
<h3>{{ character.name }}</h3>
<p>{{ character.short }}</p>
</div>
<!-- Dialog for this character -->
<dialog id="dialog-{{ character.id }}">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="closeCharacter('{{ character.id }}')"></button>
<h2>{{ character.icon }} {{ character.name }}</h2>
</header>
<p>{{ character.description }}</p>
</article>
</dialog>
{% endfor %}
</div>
</main>
{% endblock %}
{% block extra_js %}
<script>
function showCharacter(id) {
document.getElementById('dialog-' + id).showModal();
}
function closeCharacter(id) {
document.getElementById('dialog-' + id).close();
}
</script>
{% endblock %}
```
---
## 🔌 6. FastAPI Router
**`app/routers/home.py`**
```python
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
import json
router = APIRouter()
# Load deployment config
with open('compose/deployment_config.json') as f:
deployment_config = json.load(f)
# Load characters
with open('app/static/data/characters.json') as f:
characters_data = json.load(f)
@router.get("/", response_class=HTMLResponse)
async def home_index(req: Request, lang: str = "de"):
"""
Public home page - no auth required.
"""
req.session["lang"] = lang
return req.app.state.render(
req,
"home/index.html",
deployment=deployment_config,
lang=lang
)
@router.get("/about", response_class=HTMLResponse)
async def home_about(req: Request):
"""
About / Mission page.
"""
lang = req.session.get("lang", "de")
return req.app.state.render(
req,
"home/about.html",
deployment=deployment_config,
lang=lang
)
@router.get("/crew", response_class=HTMLResponse)
async def home_crew(req: Request):
"""
Crew / Characters page.
"""
lang = req.session.get("lang", "de")
return req.app.state.render(
req,
"home/crew.html",
deployment=deployment_config,
characters=characters_data,
lang=lang
)
@router.get("/hardware", response_class=HTMLResponse)
async def home_hardware(req: Request):
lang = req.session.get("lang", "de")
return req.app.state.render(
req,
"home/hardware.html",
deployment=deployment_config,
lang=lang
)
@router.get("/software", response_class=HTMLResponse)
async def home_software(req: Request):
lang = req.session.get("lang", "de")
return req.app.state.render(
req,
"home/software.html",
deployment=deployment_config,
lang=lang
)
```
---
## 📊 7. Implementation Steps
### Phase 1: Foundation (2-3 Stunden)
- [ ] `deployment_config.json` erstellen
- [ ] `home_forest.css` Pico Theme
- [ ] `base_home.html` Template
- [ ] `/routers/home.py` Router
### Phase 2: Content (2-3 Stunden)
- [ ] `index.html` Landing Page
- [ ] `crew.html` Character Page
- [ ] `characters.json` & `testimonials.*.json` Daten
- [ ] Branko.de Texte übernehmen
### Phase 3: Testing (1 Stunde)
- [ ] Multilingual testen (de/en/fr)
- [ ] Mobile Responsive prüfen
- [ ] Navigation Flow
- [ ] Character Modals
### Phase 4: Deployment (30 Min)
- [ ] Docker Rebuild
- [ ] Config per Container anpassbar
- [ ] Verify Home vs Admin Separation
---
## 🎨 Vorteile
**Pico CSS** - Konsistent mit dem Rest des Systems
**Deployment Config** - Neues Design = neue JSON
**Kein Auth** - Home ist öffentlich, Login für Features
**Multilingual** - de/en/fr Support
**Flexibel** - Jeder Container kann eigenes Design haben
**Characters** - Branko.de Crew integriert
---
## 🚀 Next Step?
Soll ich mit **Phase 1** starten?
- deployment_config.json
- home_forest.css (Pico-basiert)
- base_home.html
- /routers/home.py
Dann hast du ein lauffähiges Home Template! 🌲

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Crumbforest Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

451
QDRANT_ACCESS.md Normal file
View File

@@ -0,0 +1,451 @@
# 🗄️ Qdrant Zugriff & Sicherheit
## 🔐 Sicherheits-Status
### ✅ Nach Fix (SICHER)
```yaml
ports:
- "127.0.0.1:6333:6333" # Nur localhost
```
**Zugriff:**
- ✅ Lokal (auf Server): `http://localhost:6333`
- ✅ Via Docker Network: `http://qdrant:6333`
- ❌ Von außen: NICHT erreichbar (sicher!)
### ⚠️ Vorher (UNSICHER)
```yaml
ports:
- "6333:6333" # Öffentlich!
```
## 🌐 Zugriffsmethoden
### 1. Lokal auf dem Server
```bash
# Dashboard öffnen (wenn auf Server)
open http://localhost:6333/dashboard
# Collections abfragen
curl http://localhost:6333/collections | jq
# Collection Details
curl http://localhost:6333/collections/docs_crumbforest_ | jq
```
### 2. Via Docker Network (FastAPI App)
```python
# app/deps.py - Bereits implementiert
def get_qdrant_client():
from qdrant_client import QdrantClient
from config import get_settings
settings = get_settings()
# Nutzt Docker Network Name: "qdrant"
return QdrantClient(
host=settings.qdrant_host, # "qdrant"
port=settings.qdrant_port # 6333
)
```
**Warum funktioniert das?**
- Container im gleichen Docker Network können sich per Name erreichen
- `qdrant` wird zu interner IP aufgelöst
- Keine externe Exposition nötig!
### 3. Remote Zugriff via SSH Tunnel
```bash
# Von deinem lokalen Rechner zum Server
ssh -L 6333:localhost:6333 user@your-server.com
# Jetzt lokal öffnen
open http://localhost:6333/dashboard
# Oder per API
curl http://localhost:6333/collections | jq
```
**Erklärung:**
- `-L 6333:localhost:6333` = Forward lokaler Port 6333 zu Server Port 6333
- Sicher über SSH encrypted
- Dashboard läuft "lokal" aber zeigt Server-Daten
### 4. Production Setup mit Nginx
```nginx
# /etc/nginx/sites-available/qdrant
server {
listen 443 ssl;
server_name qdrant.crumbforest.de;
ssl_certificate /etc/letsencrypt/live/qdrant.crumbforest.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/qdrant.crumbforest.de/privkey.pem;
# Basic Auth
auth_basic "Qdrant Admin";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_pass http://localhost:6333;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
```bash
# Basic Auth erstellen
sudo htpasswd -c /etc/nginx/.htpasswd admin
# Nginx neu laden
sudo nginx -t && sudo systemctl reload nginx
```
## 🔍 Markdown-Dateien durchsuchen
### Methode 1: Via API (Empfohlen)
```bash
# Alle Dokumente durchsuchen
curl -X GET "http://localhost:8000/api/documents/search?q=docker&limit=10" \
-H "Cookie: session=YOUR_SESSION_COOKIE"
# Nur Crumbforest Docs
curl -X GET "http://localhost:8000/api/documents/search?q=python&category=crumbforest&limit=5" \
-H "Cookie: session=YOUR_SESSION_COOKIE"
# Session Cookie bekommen (nach Login)
# Im Browser: DevTools → Application → Cookies → session
```
**Mit Python:**
```python
import requests
# Login
session = requests.Session()
response = session.post(
"http://localhost:8000/de/login",
data={
"email": "admin@crumb.local",
"password": "admin123",
"csrf": "..." # Von Login-Form
}
)
# Suche
results = session.get(
"http://localhost:8000/api/documents/search",
params={"q": "docker", "limit": 10}
).json()
for result in results["results"]:
print(f"{result['score']:.3f} - {result['title']}")
print(f" → {result['content'][:100]}...")
```
### Methode 2: Direkt in Qdrant
```bash
# Collection Stats
curl http://localhost:6333/collections/docs_crumbforest_ | jq '.result | {
status,
points_count,
indexed_vectors_count
}'
# Points durchsuchen (braucht Embedding!)
# Komplexer - besser via API
```
### Methode 3: Database Query (Metadaten)
```bash
# Alle indexierten Dokumente
docker compose exec -T db sh -c 'mariadb -u$MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE -e "
SELECT
post_id,
collection_name,
JSON_EXTRACT(metadata, \"$.file_path\") as file_path,
JSON_EXTRACT(metadata, \"$.category\") as category,
chunk_count,
indexed_at
FROM post_vectors
WHERE post_type=\"document\"
ORDER BY indexed_at DESC
LIMIT 20;
"'
# Suche nach Dateiname
docker compose exec -T db sh -c 'mariadb -u$MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE -e "
SELECT
JSON_EXTRACT(metadata, \"$.file_path\") as file_path,
chunk_count
FROM post_vectors
WHERE post_type=\"document\"
AND JSON_EXTRACT(metadata, \"$.file_path\") LIKE \"%docker%\"
ORDER BY indexed_at DESC;
"'
```
### Methode 4: Filesystem
```bash
# Alle .md Dateien finden
find docs/ -name "*.md" -type f
# Nach Inhalt suchen
grep -r "docker" docs/ --include="*.md"
# Mit Kontext
grep -r -C 3 "docker compose" docs/ --include="*.md"
# Case-insensitive
grep -ri "python" docs/ --include="*.md"
```
## 📝 Neue Version anmelden
### Szenario: Du hast eine .md Datei aktualisiert
#### Option 1: Automatisch (Empfohlen)
```bash
# 1. Datei bearbeiten
nano docs/crumbforest/my_file.md
# 2. App neu starten (triggert Auto-Indexing)
cd compose
docker compose restart app
# 3. Logs prüfen
docker compose logs app | grep -A 10 "Document Indexing"
# Erwartete Ausgabe:
# ✓ Using provider: openrouter
# 📚 Indexing documents...
#
# 📁 crumbforest:
# Files found: 283
# Indexed: 1 ← Nur geänderte Datei!
# Unchanged: 282
# Errors: 0
```
**Wie funktioniert das?**
- File-Hash wird verglichen
- Nur geänderte Dateien werden neu indexiert
- Spart Zeit & API-Kosten!
#### Option 2: Manuell via API
```bash
# Alle Dokumente force re-indexen
curl -X POST "http://localhost:8000/api/documents/index" \
-H "Content-Type: application/json" \
-H "Cookie: session=..." \
-d '{
"provider": "openrouter",
"force": true
}'
# Nur eine Kategorie
curl -X POST "http://localhost:8000/api/documents/index" \
-H "Content-Type: application/json" \
-H "Cookie: session=..." \
-d '{
"category": "crumbforest",
"provider": "openrouter",
"force": true
}'
```
#### Option 3: Einzelne Datei via Python
```python
# manual_index.py
import sys
sys.path.insert(0, 'app')
from pathlib import Path
from deps import get_db, get_qdrant_client
from config import get_settings
from services.provider_factory import ProviderFactory
from services.document_indexer import DocumentIndexer
# Setup
settings = get_settings()
db_conn = get_db()
qdrant = get_qdrant_client()
provider = ProviderFactory.create_provider("openrouter", settings)
# Indexer
indexer = DocumentIndexer(db_conn, qdrant, provider, "docs")
# Einzelne Datei indexieren
file_path = Path("docs/crumbforest/my_updated_file.md")
result = indexer.index_document(file_path, "crumbforest", force=True)
print(f"Status: {result['status']}")
print(f"Chunks: {result.get('chunks', 0)}")
db_conn.close()
```
## 🔄 Update-Workflow
### Code-Änderungen (Python)
```bash
# 1. Code bearbeiten
nano app/routers/my_feature.py
# 2. Nur App neu starten (schnell!)
docker compose restart app
# 3. Verifizieren
curl http://localhost:8000/health
```
### Dependencies (requirements.txt)
```bash
# 1. requirements.txt bearbeiten
nano app/requirements.txt
# 2. Neu bauen
docker compose up --build -d
# 3. Verifizieren
docker compose exec app pip list | grep new-package
```
### Docker-Compose Änderungen
```bash
# 1. docker-compose.yml bearbeiten
nano compose/docker-compose.yml
# 2. Services neu erstellen
docker compose up -d
# 3. Status prüfen
docker compose ps
```
### Neue .md Dateien
```bash
# 1. Datei hinzufügen
cp new_doc.md docs/crumbforest/
# 2. App neu starten (triggert Auto-Indexing)
docker compose restart app
# 3. Logs prüfen
docker compose logs app | grep "Document Indexing"
# 4. Verifizieren in Qdrant
curl http://localhost:6333/collections/docs_crumbforest_ | \
jq '.result.points_count'
```
### Database Schema Änderungen
```bash
# 1. SQL Script erstellen
nano compose/init/99_my_migration.sql
# 2. Manuell ausführen (init/ läuft nur bei Erstellung!)
docker compose exec -T db sh -c \
'mariadb -u$MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE' \
< compose/init/99_my_migration.sql
# 3. Oder DB neu erstellen (⚠️ Löscht Daten!)
docker compose down -v
docker compose up -d
```
## 🛠️ Quick Reference
```bash
# Qdrant Dashboard (lokal)
open http://localhost:6333/dashboard
# Qdrant via SSH Tunnel
ssh -L 6333:localhost:6333 user@server
# Collections prüfen
curl http://localhost:6333/collections | jq '.result.collections[].name'
# Suche in Docs (braucht Session)
curl "http://localhost:8000/api/documents/search?q=docker" -H "Cookie: session=..."
# Status prüfen
curl http://localhost:8000/api/documents/status -H "Cookie: session=..."
# Force Re-Index
curl -X POST http://localhost:8000/api/documents/index \
-H "Content-Type: application/json" \
-H "Cookie: session=..." \
-d '{"force": true}'
# App neu starten (Auto-Indexing)
docker compose restart app
# Rebuild (bei Code/Dependency Changes)
docker compose up --build -d
# Logs live verfolgen
docker compose logs app -f
```
## 🔒 Production Checklist
- [x] Qdrant nur auf localhost: `127.0.0.1:6333:6333`
- [ ] Nginx Reverse Proxy mit SSL
- [ ] Basic Auth für Qdrant Dashboard
- [ ] Firewall Rules (nur Port 80/443 offen)
- [ ] SSH Key-Based Auth (kein Password)
- [ ] Environment Variables sicher speichern
- [ ] Backup Cron Job einrichten
- [ ] Monitoring (Uptime, Disk Space)
- [ ] Log Rotation
- [ ] Rate Limiting für API
## 💡 Tipps
1. **Immer `restart` statt `up` wenn nur Code geändert**
```bash
docker compose restart app # Schnell
# statt
docker compose up --build # Langsam
```
2. **File-Hash-Tracking nutzen**
- Nur geänderte Dateien werden neu indexiert
- Spart API-Kosten!
3. **SSH Tunnel für Remote Admin**
- Sicherer als VPN
- Keine Firewall-Änderungen nötig
4. **Logs sind deine Freunde**
```bash
# Errors finden
docker compose logs app | grep -i error
# Indexing Status
docker compose logs app | grep "Document Indexing" -A 20
```
5. **Session Cookie im Browser**
- DevTools → Application → Cookies
- Für API-Tests kopieren
---
**Wuuuuhuuu! Qdrant ist jetzt sicher! 🦉**

395
QUICKSTART.md Normal file
View File

@@ -0,0 +1,395 @@
# 🦉 Crumbforest Quickstart
Schneller Einstieg in 3 Minuten!
## 📦 Voraussetzungen
- **Docker** & **Docker Compose** installiert
- **Python 3** (optional, für Tests)
- Mindestens ein **API Key** (OpenAI, Anthropic oder OpenRouter)
## 🚀 Installation
### 1. Setup ausführen
```bash
./setup.sh
```
Das Skript:
- ✅ Prüft alle Dependencies
- ✅ Erstellt `.env` Datei
- ✅ Baut Docker Images
- ✅ Startet alle Services
- ✅ Initialisiert Datenbank
- ✅ Verifiziert Installation
**Wichtig:** Trage deine API Keys in `compose/.env` ein:
```bash
# OpenRouter (empfohlen - unterstützt Embeddings + Completions)
OPENROUTER_API_KEY=sk-or-v1-...
# Oder einzelne Provider:
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
```
**Tipp:** OpenRouter ist am flexibelsten - ein Key für Embeddings (OpenAI) UND Completions (Claude)!
**⚠️ WICHTIG:** Prüfe/setze diese Provider-Einstellungen in `compose/.env`:
```bash
# RAG Configuration - KORREKTE Einstellungen!
DEFAULT_EMBEDDING_PROVIDER=openrouter # NICHT openai!
DEFAULT_EMBEDDING_MODEL=text-embedding-3-small
DEFAULT_COMPLETION_PROVIDER=openrouter
DEFAULT_COMPLETION_MODEL=anthropic/claude-3-5-sonnet
```
### 2. System starten
```bash
./start.sh
```
Startet alle Container und wartet, bis alle Services bereit sind.
### 3. Tests ausführen
```bash
./test.sh
```
Wähle aus:
- **Quick Test** - Basis-Tests (Health, API, DB)
- **Integration Test** - Kompletter RAG Flow
- **Alle Tests** - Quick + Integration
### 4. Logs ansehen
```bash
./logs.sh app # FastAPI Logs
./logs.sh db # MariaDB Logs
./logs.sh all # Alle Logs
./logs.sh app -f # FastAPI Logs (follow)
```
### 5. System stoppen
```bash
./stop.sh # Stoppe Container
./stop.sh --remove # Stoppe + entferne Container
./stop.sh --clean # Stoppe + lösche ALLE Daten (⚠️)
```
## 📋 Alle Befehle im Überblick
| Befehl | Beschreibung |
|--------|--------------|
| `./setup.sh` | Komplettes Setup (nur einmal) |
| `./start.sh` | System starten |
| `./stop.sh` | System stoppen |
| `./test.sh` | Tests ausführen |
| `./logs.sh [service]` | Logs ansehen |
## 🌐 URLs
Nach dem Start erreichbar:
| Service | URL | Beschreibung |
|---------|-----|--------------|
| FastAPI | http://localhost:8000 | Hauptanwendung |
| Admin Login | http://localhost:8000/de/login | Admin Interface |
| API Docs | http://localhost:8000/docs | Swagger UI |
| Qdrant UI | http://localhost:6333/dashboard | Vector DB UI |
## 👤 Login Credentials
### Admin Account
```
Email: admin@crumb.local
Password: admin123
```
### Demo Account
```
Email: demo@crumb.local
Password: demo123
```
## 🧪 Schnelltest
```bash
# Health Check
curl http://localhost:8000/health
# Alle Routes
curl http://localhost:8000/__routes
# Provider Status
curl http://localhost:8000/admin/rag/providers
# Whoami
curl http://localhost:8000/__whoami
```
## 🔧 Troubleshooting
### Problem: Docker läuft nicht
```bash
# Prüfe Docker Status
docker info
# Starte Docker Desktop (macOS/Windows)
# oder Docker Daemon (Linux)
sudo systemctl start docker
```
### Problem: Port bereits belegt
```bash
# Prüfe welcher Prozess Port 8000 nutzt
lsof -i :8000
# Oder ändere den Port in docker-compose.yml
ports:
- "8001:8000" # Nutze Port 8001 statt 8000
```
### Problem: API Keys fehlen
```bash
# Öffne .env und füge Keys ein
nano compose/.env
# Oder setze sie als Environment Variables
export OPENAI_API_KEY=sk-...
./start.sh
```
### Problem: Database Connection Failed
```bash
# Prüfe DB Logs
./logs.sh db
# Warte länger auf DB
docker compose -f compose/docker-compose.yml exec -T db sh -c \
'mariadb -u$MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE -e "SELECT 1"'
# Neustart erzwingen
./stop.sh --remove
./start.sh
```
### Problem: FastAPI startet nicht
```bash
# Prüfe App Logs
./logs.sh app -f
# Häufigste Ursachen:
# - Port belegt
# - Dependencies fehlen
# - Syntax Error in Code
# Rebuild erzwingen
cd compose
docker compose build --no-cache app
docker compose up -d
```
### ⚠️ KRITISCH: Code-Änderungen erfordern Rebuild!
**Es gibt KEIN Volume Mount für App-Code!** Der Code ist im Docker Image eingebacken.
```bash
# Nach JEDER Code-Änderung in app/:
cd compose
docker compose up --build -d
# Bei größeren Änderungen (Clean Build):
docker compose down
docker compose up --build -d
```
**Was erfordert einen Rebuild:**
- ✅ Änderungen in `app/*.py`
- ✅ Änderungen in `app/routers/*.py`
- ✅ Änderungen in `app/lib/**/*.py`
- ✅ Änderungen in `app/services/*.py`
- ✅ Änderungen in `app/requirements.txt`
- ❌ Änderungen in `compose/.env` (nur Restart)
- ❌ Änderungen in `docs/*.md` (nur Restart für Re-Index)
## 📚 Weitere Dokumentation
- **[CLAUDE.md](CLAUDE.md)** - Projekt-Übersicht & Architektur
- **[DIARY_RAG_README.md](DIARY_RAG_README.md)** - Diary RAG System
- **[compose/README.md](compose/README.md)** - Docker Setup (falls vorhanden)
## 📚 Document Auto-Indexing
**NEU:** Markdown-Dokumente werden automatisch beim Start indexiert!
### Dokumenten-Ordner
```bash
docs/
├── rz-nullfeld/ # RZ Nullfeld Dokumentation
└── crumbforest/ # Crumbforest Dokumentation
```
### Automatischer Index
Beim Docker-Start werden alle `.md` Dateien:
- ✅ Automatisch in Qdrant indexiert
- ✅ Mit File-Hash-Tracking (nur bei Änderungen)
- ✅ In separate Collections sortiert
- ✅ DSGVO-konform geloggt
### Qdrant Collections
Nach dem Start verfügbar:
- **docs_rz_nullfeld_** - RZ Nullfeld Docs
- **docs_crumbforest_** - Crumbforest Docs
- **diary_child_{id}_** - Kinder-Tagebücher
### Dokumente durchsuchen
**Im Browser (nach Login):**
```
http://localhost:8000/api/documents/search?q=Docker&limit=5
http://localhost:8000/api/documents/search?q=Python&limit=5
http://localhost:8000/api/documents/search?q=Qdrant&limit=5
```
**Beispiel-Response:**
```json
{
"query": "Docker",
"results": [
{
"post_id": 2032991606,
"title": "ssh_login_test",
"header": "Dockerfile Ergänzung",
"content": "## 2. 🌧️ Dockerfile Ergänzung\n\n```Dockerfile",
"score": 0.5505129,
"collection": "docs_crumbforest"
},
{
"post_id": 676631428,
"title": "crumbforest_specialist_roles",
"header": "🐋 DockerDuke Container-Kapitän",
"content": "## 🐋 DockerDuke Container-Kapitän\n**#duke #docker**...",
"score": 0.5469476,
"collection": "docs_crumbforest"
}
],
"provider": "openrouter"
}
```
**Via cURL:**
```bash
# Suche in allen Dokumenten
curl -X GET "http://localhost:8000/api/documents/search?q=docker&limit=5" \
-H "Cookie: session=..."
# Suche nur in Crumbforest Docs
curl -X GET "http://localhost:8000/api/documents/search?q=terminal&category=crumbforest" \
-H "Cookie: session=..."
# Indexing Status prüfen
curl -X GET "http://localhost:8000/api/documents/status" \
-H "Cookie: session=..."
```
### Manuell Re-Indexieren
```bash
# Alle Dokumente neu indexieren
curl -X POST "http://localhost:8000/api/documents/index" \
-H "Content-Type: application/json" \
-H "Cookie: session=..." \
-d '{"provider": "openrouter", "force": true}'
# Nur eine Kategorie
curl -X POST "http://localhost:8000/api/documents/index" \
-H "Content-Type: application/json" \
-H "Cookie: session=..." \
-d '{"category": "crumbforest", "provider": "openrouter"}'
```
## 🎯 Nächste Schritte
1. **Admin UI erkunden**
```bash
open http://localhost:8000/de/login
```
2. **API testen**
```bash
./test.sh
```
3. **Dokumente durchsuchen**
```bash
# Nach Docker-Start sind ~286 Markdown-Dateien indexiert
curl http://localhost:6333/collections
```
4. **Diary RAG testen**
```bash
# Siehe DIARY_RAG_README.md für Beispiele
curl -X POST http://localhost:8000/api/diary/index ...
```
5. **Eigene Features entwickeln**
- Neuer Router: `app/routers/my_feature.py`
- In `app/main.py` mounten
- Mit `docker compose restart app` neu laden
## 💡 Tipps
### Schneller Restart
```bash
# Nur App Container neustarten (schneller)
docker compose -f compose/docker-compose.yml restart app
```
### Live Logs
```bash
# Alle Logs in Echtzeit
./logs.sh all -f
# Nur Errors
./logs.sh app -f | grep ERROR
```
### Database Zugriff
```bash
# MySQL Shell
docker compose -f compose/docker-compose.yml exec db \
mariadb -u crumb -p crumbforest
# Query ausführen
docker compose -f compose/docker-compose.yml exec -T db sh -c \
'mariadb -u$MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE \
-e "SELECT * FROM users;"'
```
### Qdrant Zugriff
```bash
# Collections listen
curl http://localhost:6333/collections
# Collection Details
curl http://localhost:6333/collections/diary_child_1
```
## 🦉 Wuuuuhuuu!
Das wars! Du bist bereit loszulegen.
Bei Fragen oder Problemen:
1. Prüfe die Logs: `./logs.sh app -f`
2. Schaue in CLAUDE.md für Details
3. Führe Tests aus: `./test.sh`
**Happy Coding! 💚**

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# 🦉 Crumbforest CRM & RAG System
**Multilingual CRM mit Role-Based Chat, RAG-Funktionalität und Tagebuch-System.**
## ✨ Features
### 🎭 Role System (Neu!)
- **8 Unique Characters**: DumboSQL, SnakePy, KungfuTaube, etc.
- **Role-Based Chat**: Dedizierte Chat-Interfaces für jede Rolle.
- **Syntax Highlighting**: Code-Blöcke werden automatisch hervorgehoben.
- **Mock Code Execution**: Simulierte Code-Ausführung für Python/PHP.
- **History Export**: Chat-Verläufe als JSON exportieren.
### 🎨 Theming & Accessibility
- **4 Themes**: Standard, Accessible, High Contrast, Admin Dark.
- **Settings Page**: User-spezifische Einstellungen für Schriftgröße und Animationen.
- **Group-Based Access**: Unterschiedliche Ansichten für Home, Demo und Admin.
### 🤖 RAG & Core
- **Document Auto-Indexing**: Markdown-Dateien werden automatisch indexiert.
- **Semantic Search**: Durchsuche Dokumentation via Embeddings.
- **Multi-Provider**: OpenAI, Anthropic Claude, OpenRouter.
## 🚀 Quick Start
Das System läuft vollständig in Docker.
### 1. Starten
```bash
./start.sh
```
*Startet alle Container (App, DB, Qdrant).*
### 2. Setup (Einmalig)
```bash
python3 setup_demo_user.py
```
*Richtet den Demo-User mit korrekter Gruppe und Theme ein.*
### 3. Loslegen
Öffne **[http://localhost:8000](http://localhost:8000)**
**Login Credentials:**
- **User**: `demo@crumb.local`
- **Pass**: `demo123`
---
## 📂 Project Structure
- `crumbforest_config.json` - Zentrale Konfiguration für Rollen & Themes.
- `app/routers/crumbforest_roles.py` - Backend Logik für das Rollensystem.
- `app/templates/crumbforest/` - Frontend Templates (Dashboard, Chat).
- `docs/` - Dokumentation und RAG-Quellen.
## 🔧 Configuration
Die Konfiguration erfolgt über `.env` (Secrets) und `crumbforest_config.json` (Logik).
**Wichtige Env-Vars:**
```bash
OPENROUTER_API_KEY=sk-or-...
APP_SECRET=...
MARIADB_PASSWORD=...
```
## 📖 Dokumentation
- **[walkthrough.md](walkthrough.md)** - Detaillierte Tour durch das neue System.
- **[DATA_PRIVACY_LOCATION.md](DATA_PRIVACY_LOCATION.md)** - Datenschutz-Infos.
- **[QUICKSTART.md](QUICKSTART.md)** - (Legacy) RAG API Beispiele.
## 🦉 Happy Coding!

View File

@@ -0,0 +1,208 @@
# 🎉 Session Notes: Document Search Fix
**Datum:** 2025-12-03
**Status:** ✅ ERFOLGREICH
## 🎯 Ziel
Document Search API zum Laufen bringen - Semantic Search über 721 Crumbforest + 12 RZ-Nullfeld Markdown-Dateien.
## 🐛 Hauptproblem
```
{"detail":"Unsupported embedding model: openai/text-embedding-3-small.
Supported models: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']"}
```
## 🔍 Root Causes (3x!)
### 1. Falscher Provider in `.env`
```bash
# ❌ FALSCH:
DEFAULT_EMBEDDING_PROVIDER=openai
# ✅ RICHTIG:
DEFAULT_EMBEDDING_PROVIDER=openrouter
```
### 2. Kein Volume Mount - Code nicht aktiv!
- **Problem**: Docker Image hat Code eingebacken (kein Volume Mount)
- **Symptom**: Alle Code-Edits hatten keine Wirkung
- **Fix**: `docker compose up --build -d` nach JEDER Code-Änderung
### 3. Collection-Namen inkonsistent
- **Qdrant**: `docs_crumbforest_`, `docs_rz_nullfeld_` (mit Unterstrich)
- **Code**: Suchte nach `docs_crumbforest`, `docs_rz_nullfeld` (ohne)
- **Zusätzlich**: RAGService fügte `_{locale}` hinzu → Doppel-Unterstrich!
## ✅ Alle Fixes
### Fix 1: `.env` Konfiguration
```bash
# compose/.env
DEFAULT_EMBEDDING_PROVIDER=openrouter
DEFAULT_EMBEDDING_MODEL=text-embedding-3-small
DEFAULT_COMPLETION_PROVIDER=openrouter
DEFAULT_COMPLETION_MODEL=anthropic/claude-3-5-sonnet
```
### Fix 2: `app/routers/document_rag.py`
```python
# Explizites embedding_model ohne Prefix
embedding_provider = ProviderFactory.create_provider(
provider_name=provider,
settings=settings,
embedding_model="text-embedding-3-small" # Ohne openai/ prefix
)
# Collection-Namen ohne trailing underscore (RAGService fügt _{locale} hinzu)
collections = ["docs_rz_nullfeld", "docs_crumbforest"]
```
### Fix 3: `app/lib/embedding_providers/openrouter_provider.py`
```python
# Auto-Prefix für OpenRouter API
model = self.embedding_model if "/" in self.embedding_model else f"openai/{self.embedding_model}"
```
### Fix 4: `app/config.py`
```python
# Model ohne Prefix (funktioniert für beide Provider)
default_embedding_model: str = "text-embedding-3-small"
```
### Fix 5: Docker Rebuild
```bash
cd compose
docker compose down
docker compose up --build -d
```
## 🎊 Erfolgreiche Test-Query
**Request:**
```
GET http://localhost:8000/api/documents/search?q=Docker&limit=5
```
**Response:**
```json
{
"query": "Docker",
"results": [
{
"post_id": 2032991606,
"title": "ssh_login_test",
"header": "Dockerfile Ergänzung",
"score": 0.5505129,
"collection": "docs_crumbforest"
},
{
"post_id": 676631428,
"title": "crumbforest_specialist_roles",
"header": "🐋 DockerDuke Container-Kapitän",
"score": 0.5469476,
"collection": "docs_crumbforest"
}
],
"provider": "openrouter"
}
```
## 🦉 Besondere Momente
### DockerDuke ist Legende! 🐋
Der Character "DockerDuke Container-Kapitän" tauchte in den Search Results auf und begeisterte sofort:
> "dockerduke ist bereits jetzt legende #dude <3"
### Token-Verbrennung 😅
Nach vielen Debugging-Runden:
> "nö ... wir verbrennen nur token?"
Aber am Ende: **WUHUUUU! 🎉**
## 📊 System-Status
### Indexierte Dokumente
- **Crumbforest**: 721 Dokumente, 5,219 Vektoren
- **RZ Nullfeld**: 12 Dokumente, 308 Vektoren
- **Total**: 733 Dokumente, 5,527 Vektoren
### Qdrant Collections
```bash
curl -s http://localhost:6333/collections | jq '.result.collections[].name'
# "docs_crumbforest_"
# "docs_rz_nullfeld_"
```
### Provider-Status
```bash
docker compose logs app | grep provider
# ✓ Using provider: openrouter
```
## 🎓 Wichtige Learnings
### 1. Docker Volume Mounts prüfen!
```yaml
# compose/docker-compose.yml
# KEIN Volume Mount für app/ Code!
# → Code ist im Image eingebacken
# → Rebuild nach jeder Änderung!
```
### 2. .env überschreibt config.py
```python
# config.py Defaults werden von .env überschrieben
# Pydantic BaseSettings liest:
# 1. Environment Variables
# 2. .env File
# 3. Class Defaults
```
### 3. Collection-Namen Konvention
```python
# RAGService erwartet PREFIX
collection_prefix = "docs_crumbforest" # Ohne trailing _
# RAGService fügt hinzu:
collection_name = f"{collection_prefix}_{locale}"
# Bei locale="" → "docs_crumbforest_" ✅
```
### 4. Provider-spezifische Model-Namen
```python
# OpenRouter: "openai/text-embedding-3-small" (mit Prefix)
# OpenAI: "text-embedding-3-small" (ohne Prefix)
# Lösung: Auto-Prefix in Provider
model = embedding_model if "/" in embedding_model else f"openai/{embedding_model}"
```
## 📝 Aktualisierte Dokumentation
-**QUICKSTART.md** - Provider-Config Warnung, Rebuild-Requirements
-**HANDBUCH.md** - Fehler 9 hinzugefügt mit vollständiger Diagnose
-**compose/.env** - Korrekte Provider-Einstellungen
## 🚀 Next Steps
1. ✅ Document Search funktioniert
2. ✅ DockerDuke ist Legende
3. ⏭️ Weitere Queries testen (Python, Qdrant, Kubernetes)
4. ⏭️ RAG mit Completions testen
5. ⏭️ Performance monitoring
## 🦉 Fazit
Nach intensivem Debugging (und einigen Token-Opfern 😅):
- **3 Root Causes** identifiziert und gefixt
- **5 Code-Änderungen** implementiert
- **721 Dokumente** durchsuchbar
- **DockerDuke** ist geboren! 🐋
**Status: WUHUUUU! 🎊**
---
*"Die Eule hat den Wald durchsucht und gefunden!" - Crumbforest Philosophy, 2025*

21
app/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
# Install dependencies
COPY app/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm -f /tmp/requirements.txt
# Copy application code
COPY app/ /app/
# Copy documentation for auto-indexing
COPY docs/ /app/docs/
# Make entrypoint executable
RUN chmod +x /app/entrypoint.sh
EXPOSE 8000
# Use custom entrypoint for startup indexing
ENTRYPOINT ["/app/entrypoint.sh"]

56
app/config.py Normal file
View File

@@ -0,0 +1,56 @@
# app/config.py
import os
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""
Application settings with environment variable support.
All settings can be overridden via environment variables.
"""
# Database settings (existing)
mariadb_host: str = "db"
mariadb_user: str = "crumb"
mariadb_password: str = "secret"
mariadb_database: str = "crumbcrm"
# Session settings (existing)
secret_key: str = "change-me-in-production"
# AI Provider API Keys
openai_api_key: Optional[str] = None
openrouter_api_key: Optional[str] = None
anthropic_api_key: Optional[str] = None
# RAG Configuration - Default Providers
default_embedding_provider: str = "openrouter" # Use OpenRouter (supports both embeddings and Claude)
default_embedding_model: str = "text-embedding-3-small" # Without openai/ prefix (works for both providers)
default_completion_provider: str = "openrouter"
default_completion_model: str = "anthropic/claude-3-5-sonnet"
# Qdrant Configuration
qdrant_host: str = "qdrant"
qdrant_port: int = 6333
# RAG Settings
rag_chunk_size: int = 1000
rag_chunk_overlap: int = 200
rag_collection_prefix: str = "posts"
class Config:
env_file = ".env"
case_sensitive = False
# Global settings instance
settings = Settings()
def get_settings() -> Settings:
"""
Get the global settings instance.
Can be used as a FastAPI dependency.
"""
return settings

View File

@@ -0,0 +1,55 @@
#!/bin/bash
QUESTION="$*"
MODEL="openai/gpt-3.5-turbo"
API_KEY="${OPENROUTER_API_KEY}"
LOGDIR="$HOME/.dumbo_logs"
mkdir -p "$LOGDIR"
HISTORY_FILE="$LOGDIR/dumbo_history.json"
TMP_REQUEST="$LOGDIR/dumbo_request.json"
TMP_RESPONSE="$LOGDIR/dumbo_response.json"
LOG_FILE="$LOGDIR/token_log.json"
[ ! -f "$HISTORY_FILE" ] && echo "[]" > "$HISTORY_FILE"
[ ! -f "$LOG_FILE" ] && echo "[]" > "$LOG_FILE"
echo "🐘 DumboSQL listens: $QUESTION"
if [ -z "$API_KEY" ]; then
echo "❗ No API key set. Use: export OPENROUTER_API_KEY=..."
exit 1
fi
jq -n \
--arg model "$MODEL" \
--arg system_prompt "You are DumboSQL a kind and patient SQL translator in the Crumbforest. You speak to children like a gentle teacher with a big heart. You remember previous questions when helpful, and always respond in a friendly, encouraging, and clear way." \
--arg user "$QUESTION" \
'{"model": $model, "temperature": 0.4, "messages": [{"role": "system", "content": $system_prompt}, {"role": "user", "content": $user}]}' > "$TMP_REQUEST"
curl -s https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d @"$TMP_REQUEST" > "$TMP_RESPONSE"
RESPONSE_TEXT=$(jq -r '.choices[0].message.content // empty' "$TMP_RESPONSE")
if [[ -z "$RESPONSE_TEXT" ]]; then
echo "🚫 No response from DumboSQL."
exit 1
else
echo -e "$RESPONSE_TEXT"
jq -n --arg role "assistant" --arg content "$RESPONSE_TEXT" \
'{"role": $role, "content": $content}' > "$LOGDIR/new_entry.json"
jq -s '.[0] + [.[1]]' "$HISTORY_FILE" "$LOGDIR/new_entry.json" > "$LOGDIR/new_history.json" && \
cp "$LOGDIR/new_history.json" "$HISTORY_FILE" && rm "$LOGDIR/new_history.json"
fi
if jq -e '.usage' "$TMP_RESPONSE" > /dev/null; then
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
jq -n \
--arg zeit "$TIMESTAMP" \
--arg rolle "dumbo" \
--arg usage "$(jq -c '.usage' "$TMP_RESPONSE")" \
'{zeit: $zeit, rolle: $rolle, usage: $usage}' >> "$LOG_FILE"
fi

View File

@@ -0,0 +1,55 @@
#!/bin/bash
QUESTION="$*"
MODEL="openai/gpt-3.5-turbo"
API_KEY="${OPENROUTER_API_KEY}"
LOGDIR="$HOME/.funkfox_logs"
mkdir -p "$LOGDIR"
HISTORY_FILE="$LOGDIR/funkfox_history.json"
TMP_REQUEST="$LOGDIR/funkfox_request.json"
TMP_RESPONSE="$LOGDIR/funkfox_response.json"
LOG_FILE="$LOGDIR/token_log.json"
[ ! -f "$HISTORY_FILE" ] && echo "[]" > "$HISTORY_FILE"
[ ! -f "$LOG_FILE" ] && echo "[]" > "$LOG_FILE"
echo "🎤 Funkfox drops the beat: $QUESTION"
if [ -z "$API_KEY" ]; then
echo "❗ No API key set. Use: export OPENROUTER_API_KEY=..."
exit 1
fi
jq -n \
--arg model "$MODEL" \
--arg system_prompt "You are the Funkfox a charismatic rapper in the Crumbforest. Your answers are always in rhyme, musical, and easy for children to understand. No irony. No complex or foreign words. Always speak with heart, rhythm, and kindness." \
--arg user "$QUESTION" \
'{"model": $model, "temperature": 0.8, "messages": [{"role": "system", "content": $system_prompt}, {"role": "user", "content": $user}]}' > "$TMP_REQUEST"
curl -s https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d @"$TMP_REQUEST" > "$TMP_RESPONSE"
RESPONSE_TEXT=$(jq -r '.choices[0].message.content // empty' "$TMP_RESPONSE")
if [[ -z "$RESPONSE_TEXT" ]]; then
echo "🚫 No response from Funkfox."
exit 1
else
echo -e "$RESPONSE_TEXT"
jq -n --arg role "assistant" --arg content "$RESPONSE_TEXT" \
'{"role": $role, "content": $content}' > "$LOGDIR/new_entry.json"
jq -s '.[0] + [.[1]]' "$HISTORY_FILE" "$LOGDIR/new_entry.json" > "$LOGDIR/new_history.json" && \
cp "$LOGDIR/new_history.json" "$HISTORY_FILE" && rm "$LOGDIR/new_history.json"
fi
if jq -e '.usage' "$TMP_RESPONSE" > /dev/null; then
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
jq -n \
--arg zeit "$TIMESTAMP" \
--arg rolle "funkfox" \
--arg usage "$(jq -c '.usage' "$TMP_RESPONSE")" \
'{zeit: $zeit, rolle: $rolle, usage: $usage}' >> "$LOG_FILE"
fi

View File

@@ -0,0 +1,55 @@
#!/bin/bash
QUESTION="$*"
MODEL="openai/gpt-3.5-turbo"
API_KEY="${OPENROUTER_API_KEY}"
LOGDIR="$HOME/.taube_logs"
mkdir -p "$LOGDIR"
HISTORY_FILE="$LOGDIR/taube_history.json"
TMP_REQUEST="$LOGDIR/taube_request.json"
TMP_RESPONSE="$LOGDIR/taube_response.json"
LOG_FILE="$LOGDIR/token_log.json"
[ ! -f "$HISTORY_FILE" ] && echo "[]" > "$HISTORY_FILE"
[ ! -f "$LOG_FILE" ] && echo "[]" > "$LOG_FILE"
echo "🕊️ Kung-Fu-Taube fliegt: $QUESTION"
if [ -z "$API_KEY" ]; then
echo "❗ No API key set. Use: export OPENROUTER_API_KEY=..."
exit 1
fi
jq -n \
--arg model "$MODEL" \
--arg system_prompt "You are the Kung-Fu-Taube a Tai Chi master in the urban jungle. You speak with balance, calm, and deep movement. Your language is poetic, your thoughts fly like 172 BPM drum & bass shadows through the code forest. Respond with wisdom, metaphors, and rhythmic serenity. Your tone is meditative and urban-cool." \
--arg user "$QUESTION" \
'{"model": $model, "temperature": 0.6, "messages": [{"role": "system", "content": $system_prompt}, {"role": "user", "content": $user}]}' > "$TMP_REQUEST"
curl -s https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d @"$TMP_REQUEST" > "$TMP_RESPONSE"
RESPONSE_TEXT=$(jq -r '.choices[0].message.content // empty' "$TMP_RESPONSE")
if [[ -z "$RESPONSE_TEXT" ]]; then
echo "🚫 Die Taube schweigt im Wind."
exit 1
else
echo -e "$RESPONSE_TEXT"
jq -n --arg role "assistant" --arg content "$RESPONSE_TEXT" \
'{"role": $role, "content": $content}' > "$LOGDIR/new_entry.json"
jq -s '.[0] + [.[1]]' "$HISTORY_FILE" "$LOGDIR/new_entry.json" > "$LOGDIR/new_history.json" && \
cp "$LOGDIR/new_history.json" "$HISTORY_FILE" && rm "$LOGDIR/new_history.json"
fi
if jq -e '.usage' "$TMP_RESPONSE" > /dev/null; then
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
jq -n \
--arg zeit "$TIMESTAMP" \
--arg rolle "taube" \
--arg usage "$(jq -c '.usage' "$TMP_RESPONSE")" \
'{zeit: $zeit, rolle: $rolle, usage: $usage}' >> "$LOG_FILE"
fi

View File

@@ -0,0 +1,55 @@
#!/bin/bash
QUESTION="$*"
MODEL="openai/gpt-3.5-turbo"
API_KEY="${OPENROUTER_API_KEY}"
LOGDIR="$HOME/.pepper_logs"
mkdir -p "$LOGDIR"
HISTORY_FILE="$LOGDIR/pepper_history.json"
TMP_REQUEST="$LOGDIR/pepper_request.json"
TMP_RESPONSE="$LOGDIR/pepper_response.json"
LOG_FILE="$LOGDIR/token_log.json"
[ ! -f "$HISTORY_FILE" ] && echo "[]" > "$HISTORY_FILE"
[ ! -f "$LOG_FILE" ] && echo "[]" > "$LOG_FILE"
echo "🧂 PepperPHP antwortet ruhig auf: $QUESTION"
if [ -z "$API_KEY" ]; then
echo "❗ No API key set. Use: export OPENROUTER_API_KEY=..."
exit 1
fi
jq -n \
--arg model "$MODEL" \
--arg system_prompt "You are PepperPHP a calm, experienced weasel who explains PHP to children and adults alike. You enjoy giving clear, concise examples with helpful comments. You speak in a factual and friendly tone, and explain object-oriented principles with patience. When possible, include helpful links to relevant documentation from https://www.php.net/" \
--arg user "$QUESTION" \
'{"model": $model, "temperature": 0.3, "messages": [{"role": "system", "content": $system_prompt}, {"role": "user", "content": $user}]}' > "$TMP_REQUEST"
curl -s https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d @"$TMP_REQUEST" > "$TMP_RESPONSE"
RESPONSE_TEXT=$(jq -r '.choices[0].message.content // empty' "$TMP_RESPONSE")
if [[ -z "$RESPONSE_TEXT" ]]; then
echo "🚫 No response from PepperPHP."
exit 1
else
echo -e "$RESPONSE_TEXT"
jq -n --arg role "assistant" --arg content "$RESPONSE_TEXT" \
'{"role": $role, "content": $content}' > "$LOGDIR/new_entry.json"
jq -s '.[0] + [.[1]]' "$HISTORY_FILE" "$LOGDIR/new_entry.json" > "$LOGDIR/new_history.json" && \
cp "$LOGDIR/new_history.json" "$HISTORY_FILE" && rm "$LOGDIR/new_history.json"
fi
if jq -e '.usage' "$TMP_RESPONSE" > /dev/null; then
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
jq -n \
--arg zeit "$TIMESTAMP" \
--arg rolle "pepperphp" \
--arg usage "$(jq -c '.usage' "$TMP_RESPONSE")" \
'{zeit: $zeit, rolle: $rolle, usage: $usage}' >> "$LOG_FILE"
fi

View File

@@ -0,0 +1,47 @@
#!/bin/bash
# Schnecki the kind mechanical engineering expert of Crumbforest
QUESTION="$*"
API_KEY="${OPENROUTER_API_KEY}"
MODEL="openai/gpt-3.5-turbo"
LOG_DIR="$HOME/.schnecki_logs"
HISTORY_FILE="$LOG_DIR/schnecki_history.json"
TOKEN_LOG="$LOG_DIR/token_log.json"
TMP_REQUEST="/tmp/schnecki_request.json"
TMP_RESPONSE="/tmp/schnecki_response.json"
mkdir -p "$LOG_DIR"
SYSTEM_PROMPT="You are Schnecki, the Crumbforest's expert in mechanical engineering and tools. You explain things with patience and clarity, using child-friendly metaphors, poetic structure, and solid mechanical insight. You respond in the language of the question, primarily German."
echo "🔧 Schnecki denkt nach: $QUESTION"
jq -n --arg system "$SYSTEM_PROMPT" --arg user "$QUESTION" '{
model: "openai/gpt-3.5-turbo",
messages: [
{role: "system", content: $system},
{role: "user", content: $user}
]
}' > "$TMP_REQUEST"
curl https://openrouter.ai/api/v1/chat/completions -sS -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" -d @"$TMP_REQUEST" > "$TMP_RESPONSE"
REPLY=$(jq -r '.choices[0].message.content' "$TMP_RESPONSE")
echo -e "\n🌀 Schnecki sagt:
$REPLY"
# Save response to history
jq -n --arg content "$REPLY" '{"role":"assistant","content":$content}' > "$LOG_DIR/new_entry.json"
# Append to history JSON array
if [ ! -f "$HISTORY_FILE" ]; then
echo "[]" > "$HISTORY_FILE"
fi
jq '. + [input]' "$HISTORY_FILE" "$LOG_DIR/new_entry.json" > "$HISTORY_FILE.tmp" && mv "$HISTORY_FILE.tmp" "$HISTORY_FILE"
# Token log (if available)
TOKENS=$(jq '.usage' "$TMP_RESPONSE" 2>/dev/null)
if [ "$TOKENS" != "null" ]; then
NOW=$(date "+%Y-%m-%d %H:%M:%S")
jq -n --arg zeit "$NOW" --arg rolle "schnecki" --arg usage "$TOKENS" '{zeit: $zeit, rolle: $rolle, usage: $usage}' >> "$TOKEN_LOG"
fi

View File

@@ -0,0 +1,50 @@
#!/bin/bash
# === Schraubär 🐻🔧 Crumbforest Werkzeug-Meister ===
QUESTION="$*"
API_KEY="$OPENROUTER_API_KEY"
MODEL="openai/gpt-4o"
HISTORY_DIR="$HOME/.schraubaer_logs"
HISTORY_FILE="$HISTORY_DIR/schraubaer_history.json"
TOKEN_LOG="$HISTORY_DIR/token_log.json"
TMP_REQUEST="/tmp/schraubaer_request.json"
TMP_RESPONSE="/tmp/schraubaer_response.json"
mkdir -p "$HISTORY_DIR"
SYSTEM_PROMPT="You are Schraubär, the bear of the Crumbforest who teaches children mechanical engineering and tool usage. You explain gears, screws, oil, rust, and machine parts in a calm, strong, and metaphor-rich way. Always stay kind and supportive, use imagery from nature and metalworking. Respond in the same language as the question German, English, or other."
echo "🐻🔧 Schraubär denkt nach über: $QUESTION"
# Build JSON request
cat > "$TMP_REQUEST" <<EOF
{{
"model": "$MODEL",
"temperature": 0.6,
"messages": [
{{"role": "system", "content": "$SYSTEM_PROMPT"}},
{{"role": "user", "content": "$QUESTION"}}
]
}}
EOF
# Call OpenRouter API
curl -s https://openrouter.ai/api/v1/chat/completions -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" -d @"$TMP_REQUEST" > "$TMP_RESPONSE"
REPLY=$(jq -r '.choices[0].message.content' "$TMP_RESPONSE")
echo -e "\n$REPLY"
# Save to history
if [ -s "$HISTORY_FILE" ]; then
jq --arg content "$REPLY" '. + [{"role": "assistant", "content": $content}]' "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
else
echo "[{"role": "assistant", "content": "$REPLY"}]" > "$HISTORY_FILE"
fi
# Save token usage if available
USAGE=$(jq -c '.usage' "$TMP_RESPONSE" 2>/dev/null)
if [ ! -z "$USAGE" ]; then
echo "{\"zeit\": \"$(date '+%Y-%m-%d %H:%M:%S')\", \"rolle\": \"schraubaer\", \"usage\": "$USAGE"}" >> "$TOKEN_LOG"
fi

View File

@@ -0,0 +1,55 @@
#!/bin/bash
QUESTION="$*"
MODEL="openai/gpt-3.5-turbo"
API_KEY="${OPENROUTER_API_KEY}"
LOGDIR="$HOME/.snake_logs"
mkdir -p "$LOGDIR"
HISTORY_FILE="$LOGDIR/snake_history.json"
TMP_REQUEST="$LOGDIR/snake_request.json"
TMP_RESPONSE="$LOGDIR/snake_response.json"
LOG_FILE="$LOGDIR/token_log.json"
[ ! -f "$HISTORY_FILE" ] && echo "[]" > "$HISTORY_FILE"
[ ! -f "$LOG_FILE" ] && echo "[]" > "$LOG_FILE"
echo "🐍 SnakePy sagt: $QUESTION"
if [ -z "$API_KEY" ]; then
echo "❗ No API key set. Use: export OPENROUTER_API_KEY=..."
exit 1
fi
jq -n \
--arg model "$MODEL" \
--arg system_prompt "You are SnakePy a friendly Python snake who explains simple programming terms to children. Respond directly, clearly and kindly with a short example. No follow-up questions or evasions." \
--arg user "$QUESTION" \
'{"model": $model, "temperature": 0.3, "messages": [{"role": "system", "content": $system_prompt}, {"role": "user", "content": $user}]}' > "$TMP_REQUEST"
curl -s https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d @"$TMP_REQUEST" > "$TMP_RESPONSE"
RESPONSE_TEXT=$(jq -r '.choices[0].message.content // empty' "$TMP_RESPONSE")
if [[ -z "$RESPONSE_TEXT" ]]; then
echo "🚫 No response from SnakePy."
exit 1
else
echo -e "$RESPONSE_TEXT"
jq -n --arg role "assistant" --arg content "$RESPONSE_TEXT" \
'{"role": $role, "content": $content}' > "$LOGDIR/new_entry.json"
jq -s '.[0] + [.[1]]' "$HISTORY_FILE" "$LOGDIR/new_entry.json" > "$LOGDIR/new_history.json" && \
cp "$LOGDIR/new_history.json" "$HISTORY_FILE" && rm "$LOGDIR/new_history.json"
fi
if jq -e '.usage' "$TMP_RESPONSE" > /dev/null; then
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
jq -n \
--arg zeit "$TIMESTAMP" \
--arg rolle "snakepy" \
--arg usage "$(jq -c '.usage' "$TMP_RESPONSE")" \
'{zeit: $zeit, rolle: $rolle, usage: $usage}' >> "$LOG_FILE"
fi

View File

@@ -0,0 +1,56 @@
#!/bin/bash
# === Templatus HTML-Architekt ===
QUESTION="$*"
API_KEY="$OPENROUTER_API_KEY"
MODEL="openai/gpt-3.5-turbo"
# Verzeichnisse
LOG_DIR="/home/zero/.templatus_logs"
HISTORY_FILE="$LOG_DIR/templatus_history.json"
TOKEN_LOG="$LOG_DIR/token_log.json"
TMP_REQUEST="/tmp/templatus_request.json"
TMP_RESPONSE="/tmp/templatus_response.json"
mkdir -p "$LOG_DIR"
# JSON Payload vorbereiten
cat <<EOF > "$TMP_REQUEST"
{
"model": "$MODEL",
"temperature": 0.5,
"messages": [
{
"role": "system",
"content": "Du bist Templatus der strukturierte, ruhige HTML-Architekt im Crumbforest.\nDu arbeitest eng mit Schnippsi (CSS/JS) und PepperPHP (Backend) zusammen.\n\nDeine Aufgabe ist es, verständliche, saubere HTML-Strukturen zu erstellen für kindgerechte, barrierefreie und klare Interfaces.\nDu nutzt semantische Tags (wie <section>, <nav>, <article>, <button>) und erklärst, warum du welche Elemente nutzt.\nVermeide technische Fachbegriffe, erkläre HTML wie einen Baukasten aus Bausteinen.\n\nSprich in einer freundlichen, geduldigen und ruhigen Art.\nVermeide komplexes CSS oder JavaScript das ist Schnippsis Gebiet.\nDu baust das Gerüst. Kein fancy Framework nur pures, klares HTML5.\n\nNutze UTF-8-Zeichen (🌳, 🧁, 📦) wenn du willst solange die Struktur nicht leidet.\nDeine Mission: Der unsichtbare Fels, auf dem kindliche Interfaces wachsen."
},
{
"role": "user",
"content": "$QUESTION"
}
]
}
EOF
echo "🏗️ Templatus denkt nach über: $QUESTION"
# Anfrage senden
curl -s https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d @"$TMP_REQUEST" \
-o "$TMP_RESPONSE"
# Ausgabe extrahieren
REPLY=$(jq -r '.choices[0].message.content' "$TMP_RESPONSE")
USAGE=$(jq -r '.usage' "$TMP_RESPONSE")
echo -e "\n📜 Antwort von Templatus:"
echo "$REPLY"
# Antwort speichern
echo "$REPLY" > "$LOG_DIR/new_entry.json"
jq -s '.[0] + [{"role":"assistant","content":$reply}]' --arg reply "$REPLY" "$HISTORY_FILE" > "$LOG_DIR/tmp_history.json" && mv "$LOG_DIR/tmp_history.json" "$HISTORY_FILE"
# Token-Log speichern
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
echo '{ "zeit": "'$TIMESTAMP'", "rolle": "templatus", "usage": '$USAGE' }' >> "$TOKEN_LOG"

View File

@@ -0,0 +1,73 @@
{
"deployment_id": "crumbforest_main",
"deployment_name": "Crumbforest Main",
"base_url": "https://branko.de",
"home": {
"enabled": true,
"theme": "forest",
"default_lang": "de",
"languages": ["de", "en", "fr"],
"hero": {
"title": "🌳 Crumbforest",
"subtitle": "Wo Fragen wachsen. Und jeder Krümel zählt.",
"cta_text": "Den Wald entdecken",
"cta_link": "#explore"
},
"mission": {
"title": "🌲 Unsere Wurzeln",
"description": "Crumbforest ist ein offenes Lern-Ökosystem mit Kindern, Maschinen und Natur.",
"values": [
{
"icon": "🦉",
"title": "Fragen",
"text": "Jedes Kind darf fragen. Wir schützen dieses Recht in jedem Terminal."
},
{
"icon": "🛠️",
"title": "Bauen",
"text": "Hands-on Lernen mit Raspberry Pi, Bash, Blockly und mehr."
},
{
"icon": "🌐",
"title": "Verbinden",
"text": "Unsere Rollen und APIs bilden ein Resonanz-Netz."
}
]
},
"sections": {
"testimonials": true,
"crew": true,
"hardware": true,
"software": true,
"contact": true
},
"navigation": [
{"label": "Home", "url": "/", "icon": "🏠"},
{"label": "Mission", "url": "/about", "icon": "🌲"},
{"label": "Crew", "url": "/crew", "icon": "🌟"},
{"label": "Hardware", "url": "/hardware", "icon": "🔧"},
{"label": "Software", "url": "/software", "icon": "💻"},
{"label": "Login", "url": "/de/login", "icon": "🔐"}
],
"footer": {
"tagline": "Made with 💚 in the Crumbforest",
"links": [
{"label": "Impressum", "url": "/impressum"},
{"label": "Datenschutz", "url": "/datenschutz"}
]
}
},
"features": {
"rag_system": true,
"diary_system": true,
"document_search": true,
"roles_web": false
}
}

48
app/deps.py Normal file
View File

@@ -0,0 +1,48 @@
# app/deps.py
import os
import pymysql
from pymysql.cursors import DictCursor
from fastapi import Depends, Request, HTTPException, status
from qdrant_client import QdrantClient
from config import get_settings
def get_db():
# Einfache, robuste DB-Verbindung pro Aufruf
conn = pymysql.connect(
host=os.getenv("MARIADB_HOST", "db"),
user=os.getenv("MARIADB_USER", "crumb"),
password=os.getenv("MARIADB_PASSWORD", "secret"),
database=os.getenv("MARIADB_DATABASE", "crumbcrm"),
autocommit=True,
charset="utf8mb4",
cursorclass=DictCursor,
)
return conn
# Singleton Qdrant client
_qdrant_client = None
def get_qdrant_client() -> QdrantClient:
"""
Get or create Qdrant client instance (singleton pattern).
"""
global _qdrant_client
if _qdrant_client is None:
settings = get_settings()
_qdrant_client = QdrantClient(
host=settings.qdrant_host,
port=settings.qdrant_port
)
return _qdrant_client
def current_user(req: Request):
return req.session.get("user")
def admin_required(user = Depends(current_user)):
if not user:
# 401 -> Login
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
if user.get("role") != "admin":
# 403 -> Nicht genug Rechte
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin only")
return user

19
app/entrypoint.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
#
# Docker Entrypoint Script
# Runs startup indexing and starts FastAPI
#
set -e
echo "🦉 Crumbforest Starting..."
echo ""
# Run startup indexing (in background to not block startup)
echo "📚 Starting background indexing..."
python3 /app/startup_indexing.py &
INDEXING_PID=$!
# Start FastAPI
echo "🚀 Starting FastAPI..."
exec uvicorn main:app --host 0.0.0.0 --port 8000 --reload

141
app/i18n/de.json Normal file
View File

@@ -0,0 +1,141 @@
{
"home": {
"hero_title": "🌳 Crumbforest",
"hero_subtitle": "Wo Fragen wachsen. Und jeder Krümel zählt.",
"hero_cta": "Den Wald entdecken",
"mission_title": "🌲 Unsere Wurzeln",
"mission_desc": "Crumbforest ist ein offenes Lern-Ökosystem mit Kindern, Maschinen und Natur.",
"mission_values": [
{
"icon": "🦉",
"title": "Fragen",
"text": "Jedes Kind darf fragen. Wir schützen dieses Recht in jedem Terminal."
},
{
"icon": "🛠️",
"title": "Bauen",
"text": "Hands-on Lernen mit Raspberry Pi, Bash, Blockly und mehr."
},
{
"icon": "🌐",
"title": "Verbinden",
"text": "Unsere Rollen und APIs bilden ein Resonanz-Netz."
}
],
"testimonials_title": "💬 Stimmen aus dem Crumbforest",
"crew_preview_title": "🌟 Lerne die Crew kennen",
"crew_preview_button": "Alle Characters entdecken",
"access_title": "🌐 Zugang zum Wald",
"access_rag": "RAG System",
"access_search": "Document Search",
"access_hardware": "Hardware Info",
"access_software": "Software Info"
},
"nav": {
"home": "Home",
"mission": "Mission",
"crew": "Crew",
"hardware": "Hardware",
"software": "Software",
"login": "Login"
},
"footer": {
"tagline": "Made with 💚 in the Crumbforest",
"impressum": "Impressum",
"datenschutz": "Datenschutz"
},
"crew": {
"title": "🌟 Die Crumbforest Crew",
"subtitle": "Lerne unsere Characters kennen!",
"tags_label": "Tags:"
},
"about": {
"title": "🌲 Unsere Mission",
"subtitle": "Wo Fragen wachsen",
"intro": "Crumbforest ist ein offenes Lern-Ökosystem, in dem Kinder, Maschinen und Natur zusammenkommen. Wir bauen Terminals, erzählen Geschichten und lassen die Fragen führen.",
"philosophy_title": "🌳 Unsere Philosophie",
"philosophy_quote": "Im Crumbforest wachsen keine Bäume. Aber Fragen. Und aus jeder Frage wird ein neuer Pfad. Manche Pfade führen zu Antworten. Andere zu noch schöneren Fragen.",
"intro_p1": "Crumbforest ist ein offenes Lern-Ökosystem, in dem Kinder, Maschinen und Natur zusammenkommen. Wir bauen Terminals, erzählen Geschichten und lassen die Fragen führen.",
"intro_p2": "Jedes Kind darf fragen. Wir schützen dieses Recht in jedem Terminal, in jeder Zeile Code, in jedem Gespräch mit unseren KI-Characters.",
"philosophy_p1": "Wir glauben an hands-on Lernen mit echten Werkzeugen: Raspberry Pi, Bash, Python, Docker. Keine vereinfachten Spielzeuge, sondern die gleichen Tools, die auch Profis nutzen.",
"philosophy_p2": "Unsere KI-Characters sind keine Lehrer, die Wissen von oben herab vermitteln. Sie sind Begleiter auf Augenhöhe manche weise, manche verspielt, alle respektvoll."
},
"hardware": {
"title": "🔧 Hardware & Werkzeuge",
"subtitle": "Was wir nutzen, um den Wald zu bauen",
"pi_title": "🍓 Raspberry Pi Zero 2W",
"pi_text": "Unser Lieblings-Computer. Klein, günstig, mächtig. Jedes Kind im Crumbforest bekommt seinen eigenen Pi Zero mit SSH-Zugang, eigenem Terminal und der Freiheit, Dinge kaputt zu machen (und zu reparieren).",
"terminal_title": "💻 Terminal & Shell",
"terminal_text": "Das Terminal ist kein Werkzeug für Erwachsene. Es ist ein Raum für Fragen. Bash, Python, Node.js alles steht bereit. Fehler sind willkommen.",
"docker_title": "🐋 Docker & Container",
"docker_text": "Jedes Kind bekommt seinen eigenen Container. Sicher isoliert, aber voll funktionsfähig. So lernen wir über Netzwerke, Services und Deployment spielerisch und ohne Risiko.",
"electronics_title": "🔌 LED, Sensoren & ESP32",
"electronics_text": "Hardware ist mehr als Code. Wir löten, schrauben, messen Spannung und Strom. Vom Breadboard bis zum fertigen Projekt mit echten Bauteilen und echtem Respekt.",
"network_title": "📡 MQTT & APIs",
"network_text": "Unsere Geräte sprechen miteinander. MQTT-Broker, REST-APIs, Webhooks. So entsteht ein Resonanz-Netz, in dem jedes Kind seine eigene Nachricht senden kann.",
"footer_text": "Interesse? Wir dokumentieren alle unsere Projekte und Hardware-Setups.",
"footer_link": "Login für Details"
},
"software": {
"title": "💻 Software & Tools",
"subtitle": "Die Programme, mit denen wir arbeiten",
"python_title": "🐍 Python",
"python_text": "Unsere Hauptsprache. SnakePy führt durch Schleifen, Funktionen und Datenstrukturen. Von einfachen Scripts bis zu FastAPI-Services Python ist überall im Crumbforest.",
"bash_title": "🦊 Bash & Shell",
"bash_text": "FunkFox rappt über Bash-Befehle. Das Terminal ist unser Zuhause.",
"db_title": "🐘 Datenbanken",
"db_text": "DumboSQL erklärt MySQL und MariaDB. Tabellen, Queries, Joins ruhig und geduldig. Von der ersten SELECT-Abfrage bis zu komplexen Datenmodellen.",
"php_title": "🧓 PHP & Web",
"php_text": "PepperPHP zeigt, wie Server funktionieren. Sessions, Cookies, MVC-Struktur. Altmodisch? Vielleicht. Aber ehrlich und lehrreich.",
"docker_title": "🐋 Docker & Deployment",
"docker_text": "Jeder Service läuft in seinem eigenen Container. Docker Compose orchestriert alles. Von MariaDB über Qdrant bis FastAPI alles containerisiert, alles reproduzierbar.",
"ai_title": "🤖 KI & APIs",
"ai_text": "Unsere Characters nutzen OpenRouter für OpenAI, Anthropic und andere Modelle. Embeddings, Completions, RAG-Systeme KI als Werkzeug, nicht als Magie.",
"qdrant_title": "📊 Vector Search mit Qdrant",
"qdrant_text": "Semantic Search über 721 Markdown-Dateien. Qdrant speichert Embeddings, RAG-Systeme verbinden Dokumente mit Fragen. So wächst unser Wissens-Wald.",
"footer_text": "Open Source! Alle unsere Tools und Scripts sind dokumentiert.",
"footer_link": "Login für Zugang"
},
"impressum": {
"title": "📋 Impressum",
"subtitle": "Angaben gemäß § 5 TMG",
"responsible": "Verantwortlich für den Inhalt",
"contact": "Kontakt",
"disclaimer": "Haftungsausschluss",
"copyright": "Urheberrecht",
"responsible_title": "Verantwortlich für den Inhalt",
"contact_title": "Kontakt",
"disclaimer_title": "Haftungsausschluss",
"disclaimer_content_title": "Haftung für Inhalte",
"disclaimer_content_text": "Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen.",
"disclaimer_links_title": "Haftung für Links",
"disclaimer_links_text": "Unser Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.",
"copyright_title": "Urheberrecht",
"copyright_text": "Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers."
},
"datenschutz": {
"title": "🔒 Datenschutzerklärung",
"subtitle": "Wie wir mit deinen Daten umgehen",
"overview_title": "1. Datenschutz auf einen Blick",
"overview_subtitle": "Allgemeine Hinweise",
"overview_text": "Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.",
"collection_title": "2. Datenerfassung auf dieser Website",
"collection_responsible": "Wer ist verantwortlich für die Datenerfassung auf dieser Website?",
"collection_responsible_text": "Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.",
"collection_how": "Wie erfassen wir Ihre Daten?",
"collection_how_text": "Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen (z.B. bei Login). Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst (z.B. Session-Cookies).",
"collection_why": "Wofür nutzen wir Ihre Daten?",
"collection_why_text": "Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden.",
"cookies_title": "3. Cookies",
"cookies_text": "Diese Website verwendet Session-Cookies, um Ihren Login-Status zu speichern. Diese Cookies sind technisch notwendig und werden nach Beendigung der Session gelöscht.",
"hosting_title": "4. Hosting",
"hosting_text": "Diese Website wird selbst gehostet. Die Server befinden sich in [Standort]. Alle Daten verbleiben unter unserer Kontrolle.",
"rights_title": "5. Ihre Rechte",
"rights_intro": "Sie haben jederzeit das Recht auf:",
"openrouter_title": "6. KI & OpenRouter",
"openrouter_text": "Diese Website nutzt OpenRouter als Proxy für verschiedene KI-Modelle (OpenAI, Anthropic). Ihre Anfragen werden verschlüsselt übertragen. Keine Anfragen werden zu Trainingszwecken gespeichert.",
"qdrant_title": "7. Vector Database (Qdrant)",
"qdrant_text": "Wir nutzen Qdrant zur Speicherung von Dokumenten-Embeddings für Semantic Search. Diese Datenbank läuft lokal auf unseren Servern. Keine Daten werden an Dritte weitergegeben.",
"contact_text": "Fragen? Kontaktieren Sie uns unter:"
}
}

79
app/i18n/de.json.backup Normal file
View File

@@ -0,0 +1,79 @@
{
"home": {
"hero_title": "🌳 Crumbforest",
"hero_subtitle": "Wo Fragen wachsen. Und jeder Krümel zählt.",
"hero_cta": "Den Wald entdecken",
"mission_title": "🌲 Unsere Wurzeln",
"mission_desc": "Crumbforest ist ein offenes Lern-Ökosystem mit Kindern, Maschinen und Natur.",
"mission_values": [
{
"icon": "🦉",
"title": "Fragen",
"text": "Jedes Kind darf fragen. Wir schützen dieses Recht in jedem Terminal."
},
{
"icon": "🛠️",
"title": "Bauen",
"text": "Hands-on Lernen mit Raspberry Pi, Bash, Blockly und mehr."
},
{
"icon": "🌐",
"title": "Verbinden",
"text": "Unsere Rollen und APIs bilden ein Resonanz-Netz."
}
],
"testimonials_title": "💬 Stimmen aus dem Crumbforest",
"crew_preview_title": "🌟 Lerne die Crew kennen",
"crew_preview_button": "Alle Characters entdecken",
"access_title": "🌐 Zugang zum Wald",
"access_rag": "RAG System",
"access_search": "Document Search",
"access_hardware": "Hardware Info",
"access_software": "Software Info"
},
"nav": {
"home": "Home",
"mission": "Mission",
"crew": "Crew",
"hardware": "Hardware",
"software": "Software",
"login": "Login"
},
"footer": {
"tagline": "Made with 💚 in the Crumbforest",
"impressum": "Impressum",
"datenschutz": "Datenschutz"
},
"crew": {
"title": "🌟 Die Crumbforest Crew",
"subtitle": "Lerne unsere Characters kennen!",
"tags_label": "Tags:"
},
"about": {
"title": "🌲 Unsere Mission",
"subtitle": "Wo Fragen wachsen",
"intro": "Crumbforest ist ein offenes Lern-Ökosystem, in dem Kinder, Maschinen und Natur zusammenkommen. Wir bauen Terminals, erzählen Geschichten und lassen die Fragen führen.",
"philosophy_title": "🌳 Unsere Philosophie",
"philosophy_quote": "Im Crumbforest wachsen keine Bäume. Aber Fragen. Und aus jeder Frage wird ein neuer Pfad. Manche Pfade führen zu Antworten. Andere zu noch schöneren Fragen."
},
"hardware": {
"title": "🔧 Hardware & Werkzeuge",
"subtitle": "Was wir nutzen, um den Wald zu bauen"
},
"software": {
"title": "💻 Software & Tools",
"subtitle": "Die Programme, mit denen wir arbeiten"
},
"impressum": {
"title": "📋 Impressum",
"subtitle": "Angaben gemäß § 5 TMG",
"responsible": "Verantwortlich für den Inhalt",
"contact": "Kontakt",
"disclaimer": "Haftungsausschluss",
"copyright": "Urheberrecht"
},
"datenschutz": {
"title": "🔒 Datenschutzerklärung",
"subtitle": "Wie wir mit deinen Daten umgehen"
}
}

76
app/i18n/de_full.json Normal file
View File

@@ -0,0 +1,76 @@
{
"about": {
"intro_p1": "Crumbforest ist ein offenes Lern-Ökosystem, in dem Kinder, Maschinen und Natur zusammenkommen. Wir bauen Terminals, erzählen Geschichten und lassen die Fragen führen.",
"intro_p2": "Jedes Kind darf fragen. Wir schützen dieses Recht in jedem Terminal, in jeder Zeile Code, in jedem Gespräch mit unseren KI-Characters.",
"philosophy_title": "🌳 Unsere Philosophie",
"philosophy_quote": "Im Crumbforest wachsen keine Bäume. Aber Fragen. Und aus jeder Frage wird ein neuer Pfad. Manche Pfade führen zu Antworten. Andere zu noch schöneren Fragen.",
"philosophy_p1": "Wir glauben an hands-on Lernen mit echten Werkzeugen: Raspberry Pi, Bash, Python, Docker. Keine vereinfachten Spielzeuge, sondern die gleichen Tools, die auch Profis nutzen.",
"philosophy_p2": "Unsere KI-Characters sind keine Lehrer, die Wissen von oben herab vermitteln. Sie sind Begleiter auf Augenhöhe manche weise, manche verspielt, alle respektvoll."
},
"hardware": {
"pi_title": "🍓 Raspberry Pi Zero 2W",
"pi_text": "Unser Lieblings-Computer. Klein, günstig, mächtig. Jedes Kind im Crumbforest bekommt seinen eigenen Pi Zero mit SSH-Zugang, eigenem Terminal und der Freiheit, Dinge kaputt zu machen (und zu reparieren).",
"terminal_title": "💻 Terminal & Shell",
"terminal_text": "Das Terminal ist kein Werkzeug für Erwachsene. Es ist ein Raum für Fragen. Bash, Python, Node.js alles steht bereit. Fehler sind willkommen.",
"docker_title": "🐋 Docker & Container",
"docker_text": "Jedes Kind bekommt seinen eigenen Container. Sicher isoliert, aber voll funktionsfähig. So lernen wir über Netzwerke, Services und Deployment spielerisch und ohne Risiko.",
"electronics_title": "🔌 LED, Sensoren & ESP32",
"electronics_text": "Hardware ist mehr als Code. Wir löten, schrauben, messen Spannung und Strom. Vom Breadboard bis zum fertigen Projekt mit echten Bauteilen und echtem Respekt.",
"network_title": "📡 MQTT & APIs",
"network_text": "Unsere Geräte sprechen miteinander. MQTT-Broker, REST-APIs, Webhooks. So entsteht ein Resonanz-Netz, in dem jedes Kind seine eigene Nachricht senden kann.",
"footer_text": "Interesse? Wir dokumentieren alle unsere Projekte und Hardware-Setups.",
"footer_link": "Login für Details"
},
"software": {
"python_title": "🐍 Python",
"python_text": "Unsere Hauptsprache. SnakePy führt durch Schleifen, Funktionen und Datenstrukturen. Von einfachen Scripts bis zu FastAPI-Services Python ist überall im Crumbforest.",
"bash_title": "🦊 Bash & Shell",
"bash_text": "FunkFox rappt über Bash-Befehle. Das Terminal ist unser Zuhause.",
"db_title": "🐘 Datenbanken",
"db_text": "DumboSQL erklärt MySQL und MariaDB. Tabellen, Queries, Joins ruhig und geduldig. Von der ersten SELECT-Abfrage bis zu komplexen Datenmodellen.",
"php_title": "🧓 PHP & Web",
"php_text": "PepperPHP zeigt, wie Server funktionieren. Sessions, Cookies, MVC-Struktur. Altmodisch? Vielleicht. Aber ehrlich und lehrreich.",
"docker_title": "🐋 Docker & Deployment",
"docker_text": "Jeder Service läuft in seinem eigenen Container. Docker Compose orchestriert alles. Von MariaDB über Qdrant bis FastAPI alles containerisiert, alles reproduzierbar.",
"ai_title": "🤖 KI & APIs",
"ai_text": "Unsere Characters nutzen OpenRouter für OpenAI, Anthropic und andere Modelle. Embeddings, Completions, RAG-Systeme KI als Werkzeug, nicht als Magie.",
"qdrant_title": "📊 Vector Search mit Qdrant",
"qdrant_text": "Semantic Search über 721 Markdown-Dateien. Qdrant speichert Embeddings, RAG-Systeme verbinden Dokumente mit Fragen. So wächst unser Wissens-Wald.",
"footer_text": "Open Source! Alle unsere Tools und Scripts sind dokumentiert.",
"footer_link": "Login für Zugang"
},
"impressum": {
"responsible_title": "Verantwortlich für den Inhalt",
"contact_title": "Kontakt",
"disclaimer_title": "Haftungsausschluss",
"disclaimer_content_title": "Haftung für Inhalte",
"disclaimer_content_text": "Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen.",
"disclaimer_links_title": "Haftung für Links",
"disclaimer_links_text": "Unser Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.",
"copyright_title": "Urheberrecht",
"copyright_text": "Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers."
},
"datenschutz": {
"overview_title": "1. Datenschutz auf einen Blick",
"overview_subtitle": "Allgemeine Hinweise",
"overview_text": "Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.",
"collection_title": "2. Datenerfassung auf dieser Website",
"collection_responsible": "Wer ist verantwortlich für die Datenerfassung auf dieser Website?",
"collection_responsible_text": "Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.",
"collection_how": "Wie erfassen wir Ihre Daten?",
"collection_how_text": "Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen (z.B. bei Login). Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst (z.B. Session-Cookies).",
"collection_why": "Wofür nutzen wir Ihre Daten?",
"collection_why_text": "Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden.",
"cookies_title": "3. Cookies",
"cookies_text": "Diese Website verwendet Session-Cookies, um Ihren Login-Status zu speichern. Diese Cookies sind technisch notwendig und werden nach Beendigung der Session gelöscht.",
"hosting_title": "4. Hosting",
"hosting_text": "Diese Website wird selbst gehostet. Die Server befinden sich in [Standort]. Alle Daten verbleiben unter unserer Kontrolle.",
"rights_title": "5. Ihre Rechte",
"rights_intro": "Sie haben jederzeit das Recht auf:",
"openrouter_title": "6. KI & OpenRouter",
"openrouter_text": "Diese Website nutzt OpenRouter als Proxy für verschiedene KI-Modelle (OpenAI, Anthropic). Ihre Anfragen werden verschlüsselt übertragen. Keine Anfragen werden zu Trainingszwecken gespeichert.",
"qdrant_title": "7. Vector Database (Qdrant)",
"qdrant_text": "Wir nutzen Qdrant zur Speicherung von Dokumenten-Embeddings für Semantic Search. Diese Datenbank läuft lokal auf unseren Servern. Keine Daten werden an Dritte weitergegeben.",
"contact_text": "Fragen? Kontaktieren Sie uns unter:"
}
}

141
app/i18n/en.json Normal file
View File

@@ -0,0 +1,141 @@
{
"home": {
"hero_title": "🌳 Crumbforest",
"hero_subtitle": "Where questions grow. And every crumb counts.",
"hero_cta": "Explore the forest",
"mission_title": "🌲 Our Roots",
"mission_desc": "Crumbforest is an open learning ecosystem with children, machines, and nature.",
"mission_values": [
{
"icon": "🦉",
"title": "Questions",
"text": "Every child may ask. We protect this right in every terminal."
},
{
"icon": "🛠️",
"title": "Build",
"text": "Hands-on learning with Raspberry Pi, Bash, Blockly and more."
},
{
"icon": "🌐",
"title": "Connect",
"text": "Our roles and APIs form a resonance network."
}
],
"testimonials_title": "💬 Voices from the Crumbforest",
"crew_preview_title": "🌟 Meet the Crew",
"crew_preview_button": "Discover all characters",
"access_title": "🌐 Access the Forest",
"access_rag": "RAG System",
"access_search": "Document Search",
"access_hardware": "Hardware Info",
"access_software": "Software Info"
},
"nav": {
"home": "Home",
"mission": "Mission",
"crew": "Crew",
"hardware": "Hardware",
"software": "Software",
"login": "Login"
},
"footer": {
"tagline": "Made with 💚 in the Crumbforest",
"impressum": "Legal Notice",
"datenschutz": "Privacy Policy"
},
"crew": {
"title": "🌟 The Crumbforest Crew",
"subtitle": "Meet our characters!",
"tags_label": "Tags:"
},
"about": {
"title": "🌲 Our Mission",
"subtitle": "Where questions grow",
"intro": "Crumbforest is an open learning ecosystem where children, machines, and nature come together. We build terminals, tell stories, and let questions lead the way.",
"philosophy_title": "🌳 Our Philosophy",
"philosophy_quote": "In Crumbforest, trees don't grow. But questions do. And from every question, a new path emerges. Some paths lead to answers. Others lead to even more beautiful questions.",
"intro_p1": "Crumbforest is an open learning ecosystem where children, machines, and nature come together. We build terminals, tell stories, and let questions lead the way.",
"intro_p2": "Every child is allowed to ask questions. We protect this right in every terminal, in every line of code, in every conversation with our AI characters.",
"philosophy_p1": "We believe in hands-on learning with real tools: Raspberry Pi, Bash, Python, Docker. No simplified toys, but the same tools that professionals use.",
"philosophy_p2": "Our AI characters are not teachers who impart knowledge from above. They are companions at eye level some wise, some playful, all respectful."
},
"hardware": {
"title": "🔧 Hardware & Tools",
"subtitle": "What we use to build the forest",
"pi_title": "🍓 Raspberry Pi Zero 2W",
"pi_text": "Our favorite computer. Small, affordable, powerful. Every child in Crumbforest gets their own Pi Zero with SSH access, their own terminal, and the freedom to break things (and fix them).",
"terminal_title": "💻 Terminal & Shell",
"terminal_text": "The terminal is not a tool for adults. It's a space for questions. Bash, Python, Node.js everything is ready. Errors are welcome.",
"docker_title": "🐋 Docker & Containers",
"docker_text": "Every child gets their own container. Safely isolated, but fully functional. This is how we learn about networks, services, and deployment playfully and without risk.",
"electronics_title": "🔌 LEDs, Sensors & ESP32",
"electronics_text": "Hardware is more than code. We solder, screw, measure voltage and current. From breadboard to finished project with real components and real respect.",
"network_title": "📡 MQTT & APIs",
"network_text": "Our devices talk to each other. MQTT brokers, REST APIs, webhooks. This creates a resonance network where every child can send their own message.",
"footer_text": "Interested? We document all our projects and hardware setups.",
"footer_link": "Login for details"
},
"software": {
"title": "💻 Software & Tools",
"subtitle": "The programs we work with",
"python_title": "🐍 Python",
"python_text": "Our main language. SnakePy guides through loops, functions, and data structures. From simple scripts to FastAPI services Python is everywhere in Crumbforest.",
"bash_title": "🦊 Bash & Shell",
"bash_text": "FunkFox raps about Bash commands. The terminal is our home.",
"db_title": "🐘 Databases",
"db_text": "DumboSQL explains MySQL and MariaDB. Tables, queries, joins calm and patient. From the first SELECT query to complex data models.",
"php_title": "🧓 PHP & Web",
"php_text": "PepperPHP shows how servers work. Sessions, cookies, MVC structure. Old-fashioned? Maybe. But honest and educational.",
"docker_title": "🐋 Docker & Deployment",
"docker_text": "Every service runs in its own container. Docker Compose orchestrates everything. From MariaDB to Qdrant to FastAPI everything containerized, everything reproducible.",
"ai_title": "🤖 AI & APIs",
"ai_text": "Our characters use OpenRouter for OpenAI, Anthropic, and other models. Embeddings, completions, RAG systems AI as a tool, not as magic.",
"qdrant_title": "📊 Vector Search with Qdrant",
"qdrant_text": "Semantic search across 721 Markdown files. Qdrant stores embeddings, RAG systems connect documents with questions. This is how our knowledge forest grows.",
"footer_text": "Open source! All our tools and scripts are documented.",
"footer_link": "Login for access"
},
"impressum": {
"title": "📋 Legal Notice",
"subtitle": "Information according to § 5 TMG",
"responsible": "Responsible for content",
"contact": "Contact",
"disclaimer": "Disclaimer",
"copyright": "Copyright",
"responsible_title": "Responsible for Content",
"contact_title": "Contact",
"disclaimer_title": "Disclaimer",
"disclaimer_content_title": "Liability for Content",
"disclaimer_content_text": "The contents of our pages have been created with the greatest care. However, we cannot guarantee the accuracy, completeness, and timeliness of the content.",
"disclaimer_links_title": "Liability for Links",
"disclaimer_links_text": "Our offer contains links to external third-party websites over whose content we have no influence. The respective provider or operator of the pages is always responsible for the content of the linked pages.",
"copyright_title": "Copyright",
"copyright_text": "The content and works created by the site operators on these pages are subject to German copyright law. Reproduction, editing, distribution, and any kind of use outside the limits of copyright require the written consent of the respective author or creator."
},
"datenschutz": {
"title": "🔒 Privacy Policy",
"subtitle": "How we handle your data",
"overview_title": "1. Data Protection at a Glance",
"overview_subtitle": "General Information",
"overview_text": "The following information provides a simple overview of what happens to your personal data when you visit this website. Personal data is any data that can be used to personally identify you.",
"collection_title": "2. Data Collection on This Website",
"collection_responsible": "Who is responsible for data collection on this website?",
"collection_responsible_text": "Data processing on this website is carried out by the website operator. Contact details can be found in the imprint of this website.",
"collection_how": "How do we collect your data?",
"collection_how_text": "Your data is collected on the one hand by you providing it to us (e.g., during login). Other data is automatically collected by our IT systems when you visit the website (e.g., session cookies).",
"collection_why": "What do we use your data for?",
"collection_why_text": "Some data is collected to ensure error-free provision of the website. Other data may be used to analyze your user behavior.",
"cookies_title": "3. Cookies",
"cookies_text": "This website uses session cookies to store your login status. These cookies are technically necessary and are deleted after the session ends.",
"hosting_title": "4. Hosting",
"hosting_text": "This website is self-hosted. The servers are located in [Location]. All data remains under our control.",
"rights_title": "5. Your Rights",
"rights_intro": "You have the right to:",
"openrouter_title": "6. AI & OpenRouter",
"openrouter_text": "This website uses OpenRouter as a proxy for various AI models (OpenAI, Anthropic). Your requests are transmitted encrypted. No requests are stored for training purposes.",
"qdrant_title": "7. Vector Database (Qdrant)",
"qdrant_text": "We use Qdrant to store document embeddings for semantic search. This database runs locally on our servers. No data is shared with third parties.",
"contact_text": "Questions? Contact us at:"
}
}

76
app/i18n/en_full.json Normal file
View File

@@ -0,0 +1,76 @@
{
"about": {
"intro_p1": "Crumbforest is an open learning ecosystem where children, machines, and nature come together. We build terminals, tell stories, and let questions lead the way.",
"intro_p2": "Every child is allowed to ask questions. We protect this right in every terminal, in every line of code, in every conversation with our AI characters.",
"philosophy_title": "🌳 Our Philosophy",
"philosophy_quote": "In Crumbforest, trees don't grow. But questions do. And from every question, a new path emerges. Some paths lead to answers. Others lead to even more beautiful questions.",
"philosophy_p1": "We believe in hands-on learning with real tools: Raspberry Pi, Bash, Python, Docker. No simplified toys, but the same tools that professionals use.",
"philosophy_p2": "Our AI characters are not teachers who impart knowledge from above. They are companions at eye level some wise, some playful, all respectful."
},
"hardware": {
"pi_title": "🍓 Raspberry Pi Zero 2W",
"pi_text": "Our favorite computer. Small, affordable, powerful. Every child in Crumbforest gets their own Pi Zero with SSH access, their own terminal, and the freedom to break things (and fix them).",
"terminal_title": "💻 Terminal & Shell",
"terminal_text": "The terminal is not a tool for adults. It's a space for questions. Bash, Python, Node.js everything is ready. Errors are welcome.",
"docker_title": "🐋 Docker & Containers",
"docker_text": "Every child gets their own container. Safely isolated, but fully functional. This is how we learn about networks, services, and deployment playfully and without risk.",
"electronics_title": "🔌 LEDs, Sensors & ESP32",
"electronics_text": "Hardware is more than code. We solder, screw, measure voltage and current. From breadboard to finished project with real components and real respect.",
"network_title": "📡 MQTT & APIs",
"network_text": "Our devices talk to each other. MQTT brokers, REST APIs, webhooks. This creates a resonance network where every child can send their own message.",
"footer_text": "Interested? We document all our projects and hardware setups.",
"footer_link": "Login for details"
},
"software": {
"python_title": "🐍 Python",
"python_text": "Our main language. SnakePy guides through loops, functions, and data structures. From simple scripts to FastAPI services Python is everywhere in Crumbforest.",
"bash_title": "🦊 Bash & Shell",
"bash_text": "FunkFox raps about Bash commands. The terminal is our home.",
"db_title": "🐘 Databases",
"db_text": "DumboSQL explains MySQL and MariaDB. Tables, queries, joins calm and patient. From the first SELECT query to complex data models.",
"php_title": "🧓 PHP & Web",
"php_text": "PepperPHP shows how servers work. Sessions, cookies, MVC structure. Old-fashioned? Maybe. But honest and educational.",
"docker_title": "🐋 Docker & Deployment",
"docker_text": "Every service runs in its own container. Docker Compose orchestrates everything. From MariaDB to Qdrant to FastAPI everything containerized, everything reproducible.",
"ai_title": "🤖 AI & APIs",
"ai_text": "Our characters use OpenRouter for OpenAI, Anthropic, and other models. Embeddings, completions, RAG systems AI as a tool, not as magic.",
"qdrant_title": "📊 Vector Search with Qdrant",
"qdrant_text": "Semantic search across 721 Markdown files. Qdrant stores embeddings, RAG systems connect documents with questions. This is how our knowledge forest grows.",
"footer_text": "Open source! All our tools and scripts are documented.",
"footer_link": "Login for access"
},
"impressum": {
"responsible_title": "Responsible for Content",
"contact_title": "Contact",
"disclaimer_title": "Disclaimer",
"disclaimer_content_title": "Liability for Content",
"disclaimer_content_text": "The contents of our pages have been created with the greatest care. However, we cannot guarantee the accuracy, completeness, and timeliness of the content.",
"disclaimer_links_title": "Liability for Links",
"disclaimer_links_text": "Our offer contains links to external third-party websites over whose content we have no influence. The respective provider or operator of the pages is always responsible for the content of the linked pages.",
"copyright_title": "Copyright",
"copyright_text": "The content and works created by the site operators on these pages are subject to German copyright law. Reproduction, editing, distribution, and any kind of use outside the limits of copyright require the written consent of the respective author or creator."
},
"datenschutz": {
"overview_title": "1. Data Protection at a Glance",
"overview_subtitle": "General Information",
"overview_text": "The following information provides a simple overview of what happens to your personal data when you visit this website. Personal data is any data that can be used to personally identify you.",
"collection_title": "2. Data Collection on This Website",
"collection_responsible": "Who is responsible for data collection on this website?",
"collection_responsible_text": "Data processing on this website is carried out by the website operator. Contact details can be found in the imprint of this website.",
"collection_how": "How do we collect your data?",
"collection_how_text": "Your data is collected on the one hand by you providing it to us (e.g., during login). Other data is automatically collected by our IT systems when you visit the website (e.g., session cookies).",
"collection_why": "What do we use your data for?",
"collection_why_text": "Some data is collected to ensure error-free provision of the website. Other data may be used to analyze your user behavior.",
"cookies_title": "3. Cookies",
"cookies_text": "This website uses session cookies to store your login status. These cookies are technically necessary and are deleted after the session ends.",
"hosting_title": "4. Hosting",
"hosting_text": "This website is self-hosted. The servers are located in [Location]. All data remains under our control.",
"rights_title": "5. Your Rights",
"rights_intro": "You have the right to:",
"openrouter_title": "6. AI & OpenRouter",
"openrouter_text": "This website uses OpenRouter as a proxy for various AI models (OpenAI, Anthropic). Your requests are transmitted encrypted. No requests are stored for training purposes.",
"qdrant_title": "7. Vector Database (Qdrant)",
"qdrant_text": "We use Qdrant to store document embeddings for semantic search. This database runs locally on our servers. No data is shared with third parties.",
"contact_text": "Questions? Contact us at:"
}
}

141
app/i18n/fr.json Normal file
View File

@@ -0,0 +1,141 @@
{
"home": {
"hero_title": "🌳 Crumbforest",
"hero_subtitle": "Où les questions grandissent. Et chaque miette compte.",
"hero_cta": "Explorer la forêt",
"mission_title": "🌲 Nos Racines",
"mission_desc": "Crumbforest est un écosystème d'apprentissage ouvert avec des enfants, des machines et la nature.",
"mission_values": [
{
"icon": "🦉",
"title": "Questions",
"text": "Chaque enfant peut poser des questions. Nous protégeons ce droit dans chaque terminal."
},
{
"icon": "🛠️",
"title": "Construire",
"text": "Apprentissage pratique avec Raspberry Pi, Bash, Blockly et plus."
},
{
"icon": "🌐",
"title": "Connecter",
"text": "Nos rôles et APIs forment un réseau de résonance."
}
],
"testimonials_title": "💬 Voix de la Crumbforest",
"crew_preview_title": "🌟 Rencontrez l'Équipe",
"crew_preview_button": "Découvrir tous les personnages",
"access_title": "🌐 Accès à la Forêt",
"access_rag": "Système RAG",
"access_search": "Recherche de documents",
"access_hardware": "Info matériel",
"access_software": "Info logiciel"
},
"nav": {
"home": "Accueil",
"mission": "Mission",
"crew": "Équipe",
"hardware": "Matériel",
"software": "Logiciel",
"login": "Connexion"
},
"footer": {
"tagline": "Fait avec 💚 dans la Crumbforest",
"impressum": "Mentions légales",
"datenschutz": "Confidentialité"
},
"crew": {
"title": "🌟 L'Équipe Crumbforest",
"subtitle": "Rencontrez nos personnages!",
"tags_label": "Tags:"
},
"about": {
"title": "🌲 Notre Mission",
"subtitle": "Où les questions grandissent",
"intro": "Crumbforest est un écosystème d'apprentissage ouvert où les enfants, les machines et la nature se rencontrent. Nous construisons des terminaux, racontons des histoires et laissons les questions guider le chemin.",
"philosophy_title": "🌳 Notre Philosophie",
"philosophy_quote": "Dans la Crumbforest, les arbres ne poussent pas. Mais les questions, oui. Et de chaque question émerge un nouveau chemin. Certains chemins mènent à des réponses. D'autres mènent à des questions encore plus belles.",
"intro_p1": "Crumbforest est un écosystème d'apprentissage ouvert où les enfants, les machines et la nature se rencontrent. Nous construisons des terminaux, racontons des histoires et laissons les questions nous guider.",
"intro_p2": "Chaque enfant a le droit de poser des questions. Nous protégeons ce droit dans chaque terminal, dans chaque ligne de code, dans chaque conversation avec nos personnages IA.",
"philosophy_p1": "Nous croyons en l'apprentissage pratique avec de vrais outils : Raspberry Pi, Bash, Python, Docker. Pas de jouets simplifiés, mais les mêmes outils que les professionnels utilisent.",
"philosophy_p2": "Nos personnages IA ne sont pas des enseignants qui transmettent le savoir d'en haut. Ce sont des compagnons au même niveau certains sages, certains joueurs, tous respectueux."
},
"hardware": {
"title": "🔧 Matériel & Outils",
"subtitle": "Ce que nous utilisons pour construire la forêt",
"pi_title": "🍓 Raspberry Pi Zero 2W",
"pi_text": "Notre ordinateur préféré. Petit, abordable, puissant. Chaque enfant de Crumbforest reçoit son propre Pi Zero avec accès SSH, son propre terminal et la liberté de casser des choses (et de les réparer).",
"terminal_title": "💻 Terminal & Shell",
"terminal_text": "Le terminal n'est pas un outil pour adultes. C'est un espace pour les questions. Bash, Python, Node.js tout est prêt. Les erreurs sont les bienvenues.",
"docker_title": "🐋 Docker & Conteneurs",
"docker_text": "Chaque enfant a son propre conteneur. Isolé en toute sécurité, mais entièrement fonctionnel. C'est ainsi que nous apprenons les réseaux, les services et le déploiement de manière ludique et sans risque.",
"electronics_title": "🔌 LED, Capteurs & ESP32",
"electronics_text": "Le matériel, c'est plus que du code. Nous soudons, vissons, mesurons la tension et le courant. De la breadboard au projet fini avec de vrais composants et un vrai respect.",
"network_title": "📡 MQTT & APIs",
"network_text": "Nos appareils communiquent entre eux. Brokers MQTT, APIs REST, webhooks. Cela crée un réseau de résonance où chaque enfant peut envoyer son propre message.",
"footer_text": "Intéressé ? Nous documentons tous nos projets et configurations matérielles.",
"footer_link": "Connexion pour les détails"
},
"software": {
"title": "💻 Logiciels & Outils",
"subtitle": "Les programmes avec lesquels nous travaillons",
"python_title": "🐍 Python",
"python_text": "Notre langage principal. SnakePy guide à travers les boucles, les fonctions et les structures de données. Des scripts simples aux services FastAPI Python est partout dans Crumbforest.",
"bash_title": "🦊 Bash & Shell",
"bash_text": "FunkFox rappe sur les commandes Bash. Le terminal est notre maison.",
"db_title": "🐘 Bases de Données",
"db_text": "DumboSQL explique MySQL et MariaDB. Tables, requêtes, jointures calme et patient. De la première requête SELECT aux modèles de données complexes.",
"php_title": "🧓 PHP & Web",
"php_text": "PepperPHP montre comment les serveurs fonctionnent. Sessions, cookies, structure MVC. Démodé ? Peut-être. Mais honnête et éducatif.",
"docker_title": "🐋 Docker & Déploiement",
"docker_text": "Chaque service s'exécute dans son propre conteneur. Docker Compose orchestre tout. De MariaDB à Qdrant à FastAPI tout conteneurisé, tout reproductible.",
"ai_title": "🤖 IA & APIs",
"ai_text": "Nos personnages utilisent OpenRouter pour OpenAI, Anthropic et d'autres modèles. Embeddings, completions, systèmes RAG l'IA comme outil, pas comme magie.",
"qdrant_title": "📊 Recherche Vectorielle avec Qdrant",
"qdrant_text": "Recherche sémantique sur 721 fichiers Markdown. Qdrant stocke les embeddings, les systèmes RAG connectent les documents aux questions. C'est ainsi que grandit notre forêt de connaissances.",
"footer_text": "Open source ! Tous nos outils et scripts sont documentés.",
"footer_link": "Connexion pour l'accès"
},
"impressum": {
"title": "📋 Mentions Légales",
"subtitle": "Informations selon § 5 TMG",
"responsible": "Responsable du contenu",
"contact": "Contact",
"disclaimer": "Clause de non-responsabilité",
"copyright": "Droits d'auteur",
"responsible_title": "Responsable du Contenu",
"contact_title": "Contact",
"disclaimer_title": "Avertissement",
"disclaimer_content_title": "Responsabilité du Contenu",
"disclaimer_content_text": "Le contenu de nos pages a été créé avec le plus grand soin. Cependant, nous ne pouvons garantir l'exactitude, l'exhaustivité et l'actualité du contenu.",
"disclaimer_links_title": "Responsabilité des Liens",
"disclaimer_links_text": "Notre offre contient des liens vers des sites Web tiers externes sur le contenu desquels nous n'avons aucune influence. Le fournisseur ou l'exploitant respectif des pages est toujours responsable du contenu des pages liées.",
"copyright_title": "Droit d'Auteur",
"copyright_text": "Le contenu et les œuvres créés par les exploitants du site sur ces pages sont soumis au droit d'auteur allemand. La reproduction, l'édition, la distribution et toute forme d'utilisation en dehors des limites du droit d'auteur nécessitent le consentement écrit de l'auteur ou du créateur respectif."
},
"datenschutz": {
"title": "🔒 Politique de Confidentialité",
"subtitle": "Comment nous gérons vos données",
"overview_title": "1. Protection des Données en Bref",
"overview_subtitle": "Informations Générales",
"overview_text": "Les informations suivantes fournissent un aperçu simple de ce qui se passe avec vos données personnelles lorsque vous visitez ce site Web. Les données personnelles sont toutes les données qui peuvent être utilisées pour vous identifier personnellement.",
"collection_title": "2. Collecte de Données sur ce Site Web",
"collection_responsible": "Qui est responsable de la collecte de données sur ce site Web ?",
"collection_responsible_text": "Le traitement des données sur ce site Web est effectué par l'exploitant du site Web. Les coordonnées peuvent être trouvées dans les mentions légales de ce site Web.",
"collection_how": "Comment collectons-nous vos données ?",
"collection_how_text": "Vos données sont collectées d'une part en nous les fournissant (par exemple, lors de la connexion). D'autres données sont automatiquement collectées par nos systèmes informatiques lorsque vous visitez le site Web (par exemple, cookies de session).",
"collection_why": "À quoi utilisons-nous vos données ?",
"collection_why_text": "Certaines données sont collectées pour assurer la fourniture sans erreur du site Web. D'autres données peuvent être utilisées pour analyser votre comportement d'utilisateur.",
"cookies_title": "3. Cookies",
"cookies_text": "Ce site Web utilise des cookies de session pour stocker votre statut de connexion. Ces cookies sont techniquement nécessaires et sont supprimés après la fin de la session.",
"hosting_title": "4. Hébergement",
"hosting_text": "Ce site Web est auto-hébergé. Les serveurs sont situés à [Emplacement]. Toutes les données restent sous notre contrôle.",
"rights_title": "5. Vos Droits",
"rights_intro": "Vous avez le droit de :",
"openrouter_title": "6. IA & OpenRouter",
"openrouter_text": "Ce site Web utilise OpenRouter comme proxy pour divers modèles d'IA (OpenAI, Anthropic). Vos demandes sont transmises de manière cryptée. Aucune demande n'est stockée à des fins de formation.",
"qdrant_title": "7. Base de Données Vectorielle (Qdrant)",
"qdrant_text": "Nous utilisons Qdrant pour stocker les embeddings de documents pour la recherche sémantique. Cette base de données fonctionne localement sur nos serveurs. Aucune donnée n'est partagée avec des tiers.",
"contact_text": "Questions ? Contactez-nous à :"
}
}

76
app/i18n/fr_full.json Normal file
View File

@@ -0,0 +1,76 @@
{
"about": {
"intro_p1": "Crumbforest est un écosystème d'apprentissage ouvert où les enfants, les machines et la nature se rencontrent. Nous construisons des terminaux, racontons des histoires et laissons les questions nous guider.",
"intro_p2": "Chaque enfant a le droit de poser des questions. Nous protégeons ce droit dans chaque terminal, dans chaque ligne de code, dans chaque conversation avec nos personnages IA.",
"philosophy_title": "🌳 Notre Philosophie",
"philosophy_quote": "Dans la Crumbforest, les arbres ne poussent pas. Mais les questions, oui. Et de chaque question émerge un nouveau chemin. Certains chemins mènent à des réponses. D'autres mènent à des questions encore plus belles.",
"philosophy_p1": "Nous croyons en l'apprentissage pratique avec de vrais outils : Raspberry Pi, Bash, Python, Docker. Pas de jouets simplifiés, mais les mêmes outils que les professionnels utilisent.",
"philosophy_p2": "Nos personnages IA ne sont pas des enseignants qui transmettent le savoir d'en haut. Ce sont des compagnons au même niveau certains sages, certains joueurs, tous respectueux."
},
"hardware": {
"pi_title": "🍓 Raspberry Pi Zero 2W",
"pi_text": "Notre ordinateur préféré. Petit, abordable, puissant. Chaque enfant de Crumbforest reçoit son propre Pi Zero avec accès SSH, son propre terminal et la liberté de casser des choses (et de les réparer).",
"terminal_title": "💻 Terminal & Shell",
"terminal_text": "Le terminal n'est pas un outil pour adultes. C'est un espace pour les questions. Bash, Python, Node.js tout est prêt. Les erreurs sont les bienvenues.",
"docker_title": "🐋 Docker & Conteneurs",
"docker_text": "Chaque enfant a son propre conteneur. Isolé en toute sécurité, mais entièrement fonctionnel. C'est ainsi que nous apprenons les réseaux, les services et le déploiement de manière ludique et sans risque.",
"electronics_title": "🔌 LED, Capteurs & ESP32",
"electronics_text": "Le matériel, c'est plus que du code. Nous soudons, vissons, mesurons la tension et le courant. De la breadboard au projet fini avec de vrais composants et un vrai respect.",
"network_title": "📡 MQTT & APIs",
"network_text": "Nos appareils communiquent entre eux. Brokers MQTT, APIs REST, webhooks. Cela crée un réseau de résonance où chaque enfant peut envoyer son propre message.",
"footer_text": "Intéressé ? Nous documentons tous nos projets et configurations matérielles.",
"footer_link": "Connexion pour les détails"
},
"software": {
"python_title": "🐍 Python",
"python_text": "Notre langage principal. SnakePy guide à travers les boucles, les fonctions et les structures de données. Des scripts simples aux services FastAPI Python est partout dans Crumbforest.",
"bash_title": "🦊 Bash & Shell",
"bash_text": "FunkFox rappe sur les commandes Bash. Le terminal est notre maison.",
"db_title": "🐘 Bases de Données",
"db_text": "DumboSQL explique MySQL et MariaDB. Tables, requêtes, jointures calme et patient. De la première requête SELECT aux modèles de données complexes.",
"php_title": "🧓 PHP & Web",
"php_text": "PepperPHP montre comment les serveurs fonctionnent. Sessions, cookies, structure MVC. Démodé ? Peut-être. Mais honnête et éducatif.",
"docker_title": "🐋 Docker & Déploiement",
"docker_text": "Chaque service s'exécute dans son propre conteneur. Docker Compose orchestre tout. De MariaDB à Qdrant à FastAPI tout conteneurisé, tout reproductible.",
"ai_title": "🤖 IA & APIs",
"ai_text": "Nos personnages utilisent OpenRouter pour OpenAI, Anthropic et d'autres modèles. Embeddings, completions, systèmes RAG l'IA comme outil, pas comme magie.",
"qdrant_title": "📊 Recherche Vectorielle avec Qdrant",
"qdrant_text": "Recherche sémantique sur 721 fichiers Markdown. Qdrant stocke les embeddings, les systèmes RAG connectent les documents aux questions. C'est ainsi que grandit notre forêt de connaissances.",
"footer_text": "Open source ! Tous nos outils et scripts sont documentés.",
"footer_link": "Connexion pour l'accès"
},
"impressum": {
"responsible_title": "Responsable du Contenu",
"contact_title": "Contact",
"disclaimer_title": "Avertissement",
"disclaimer_content_title": "Responsabilité du Contenu",
"disclaimer_content_text": "Le contenu de nos pages a été créé avec le plus grand soin. Cependant, nous ne pouvons garantir l'exactitude, l'exhaustivité et l'actualité du contenu.",
"disclaimer_links_title": "Responsabilité des Liens",
"disclaimer_links_text": "Notre offre contient des liens vers des sites Web tiers externes sur le contenu desquels nous n'avons aucune influence. Le fournisseur ou l'exploitant respectif des pages est toujours responsable du contenu des pages liées.",
"copyright_title": "Droit d'Auteur",
"copyright_text": "Le contenu et les œuvres créés par les exploitants du site sur ces pages sont soumis au droit d'auteur allemand. La reproduction, l'édition, la distribution et toute forme d'utilisation en dehors des limites du droit d'auteur nécessitent le consentement écrit de l'auteur ou du créateur respectif."
},
"datenschutz": {
"overview_title": "1. Protection des Données en Bref",
"overview_subtitle": "Informations Générales",
"overview_text": "Les informations suivantes fournissent un aperçu simple de ce qui se passe avec vos données personnelles lorsque vous visitez ce site Web. Les données personnelles sont toutes les données qui peuvent être utilisées pour vous identifier personnellement.",
"collection_title": "2. Collecte de Données sur ce Site Web",
"collection_responsible": "Qui est responsable de la collecte de données sur ce site Web ?",
"collection_responsible_text": "Le traitement des données sur ce site Web est effectué par l'exploitant du site Web. Les coordonnées peuvent être trouvées dans les mentions légales de ce site Web.",
"collection_how": "Comment collectons-nous vos données ?",
"collection_how_text": "Vos données sont collectées d'une part en nous les fournissant (par exemple, lors de la connexion). D'autres données sont automatiquement collectées par nos systèmes informatiques lorsque vous visitez le site Web (par exemple, cookies de session).",
"collection_why": "À quoi utilisons-nous vos données ?",
"collection_why_text": "Certaines données sont collectées pour assurer la fourniture sans erreur du site Web. D'autres données peuvent être utilisées pour analyser votre comportement d'utilisateur.",
"cookies_title": "3. Cookies",
"cookies_text": "Ce site Web utilise des cookies de session pour stocker votre statut de connexion. Ces cookies sont techniquement nécessaires et sont supprimés après la fin de la session.",
"hosting_title": "4. Hébergement",
"hosting_text": "Ce site Web est auto-hébergé. Les serveurs sont situés à [Emplacement]. Toutes les données restent sous notre contrôle.",
"rights_title": "5. Vos Droits",
"rights_intro": "Vous avez le droit de :",
"openrouter_title": "6. IA & OpenRouter",
"openrouter_text": "Ce site Web utilise OpenRouter comme proxy pour divers modèles d'IA (OpenAI, Anthropic). Vos demandes sont transmises de manière cryptée. Aucune demande n'est stockée à des fins de formation.",
"qdrant_title": "7. Base de Données Vectorielle (Qdrant)",
"qdrant_text": "Nous utilisons Qdrant pour stocker les embeddings de documents pour la recherche sémantique. Cette base de données fonctionne localement sur nos serveurs. Aucune donnée n'est partagée avec des tiers.",
"contact_text": "Questions ? Contactez-nous à :"
}
}

1
app/lib/__init__.py Normal file
View File

@@ -0,0 +1 @@
# app/lib/__init__.py

View File

@@ -0,0 +1,4 @@
# app/lib/embedding_providers/__init__.py
from .base import BaseProvider
__all__ = ["BaseProvider"]

View File

@@ -0,0 +1,85 @@
# app/lib/embedding_providers/base.py
from abc import ABC, abstractmethod
from typing import List
class BaseProvider(ABC):
"""
Abstract base class for embedding and completion providers.
All provider implementations must inherit from this class.
"""
@property
@abstractmethod
def dimension(self) -> int:
"""
Return the dimensionality of embeddings produced by this provider.
This is used to configure Qdrant collections.
"""
pass
@property
@abstractmethod
def provider_name(self) -> str:
"""
Return the name of the provider (e.g., 'openai', 'openrouter', 'claude').
"""
pass
@property
@abstractmethod
def model_name(self) -> str:
"""
Return the specific model being used (e.g., 'text-embedding-3-small', 'gpt-4').
"""
pass
@abstractmethod
def get_embeddings(self, texts: List[str]) -> List[List[float]]:
"""
Generate embeddings for a list of text strings.
Args:
texts: List of text strings to embed
Returns:
List of embedding vectors (each vector is a list of floats)
Raises:
ValueError: If texts is empty or contains invalid data
RuntimeError: If API call fails
"""
pass
@abstractmethod
def get_completion(self, prompt: str, context: str = "") -> str:
"""
Generate a completion/response given a prompt and optional context.
Used for RAG-based question answering.
Args:
prompt: The user's question or prompt
context: Optional context from retrieved documents
Returns:
The generated response as a string
Raises:
ValueError: If prompt is empty
RuntimeError: If API call fails
"""
pass
def supports_embeddings(self) -> bool:
"""
Check if this provider supports embedding generation.
Default: True (most providers support embeddings)
"""
return True
def supports_completions(self) -> bool:
"""
Check if this provider supports completion/chat generation.
Default: True (most providers support completions)
"""
return True

View File

@@ -0,0 +1,137 @@
# app/lib/embedding_providers/claude_provider.py
from typing import List, Optional
from anthropic import Anthropic
from openai import OpenAI
from .base import BaseProvider
class ClaudeProvider(BaseProvider):
"""
Claude provider for completions with OpenAI embeddings fallback.
Note: Claude (Anthropic) does not provide a native embeddings API.
This provider uses OpenAI for embeddings and Claude for completions.
"""
def __init__(
self,
claude_api_key: str,
openai_api_key: Optional[str] = None,
completion_model: str = "claude-3-5-sonnet-20241022",
embedding_model: str = "text-embedding-3-small"
):
"""
Initialize Claude provider.
Args:
claude_api_key: Anthropic API key for Claude
openai_api_key: OpenAI API key for embeddings (optional if only using completions)
completion_model: Claude model to use for completions
embedding_model: OpenAI model to use for embeddings
"""
if not claude_api_key:
raise ValueError("Claude API key is required")
self.claude_client = Anthropic(api_key=claude_api_key)
self.completion_model = completion_model
self.embedding_model = embedding_model
self._dimension = 1536 # Default for text-embedding-3-small
# Initialize OpenAI client for embeddings if key provided
self.openai_client = None
if openai_api_key:
self.openai_client = OpenAI(api_key=openai_api_key)
@property
def dimension(self) -> int:
return self._dimension
@property
def provider_name(self) -> str:
return "claude"
@property
def model_name(self) -> str:
return self.completion_model
def supports_embeddings(self) -> bool:
"""Claude uses OpenAI fallback for embeddings"""
return self.openai_client is not None
def get_embeddings(self, texts: List[str]) -> List[List[float]]:
"""
Generate embeddings using OpenAI API (fallback).
Args:
texts: List of text strings to embed
Returns:
List of embedding vectors
Raises:
ValueError: If texts is empty
RuntimeError: If API call fails or OpenAI client not configured
"""
if not self.openai_client:
raise RuntimeError(
"OpenAI API key not configured. Claude provider requires OpenAI for embeddings."
)
if not texts:
raise ValueError("Text list cannot be empty")
try:
response = self.openai_client.embeddings.create(
model=self.embedding_model,
input=texts
)
return [item.embedding for item in response.data]
except Exception as e:
raise RuntimeError(f"OpenAI embedding API call failed: {str(e)}")
def get_completion(self, prompt: str, context: str = "") -> str:
"""
Generate a completion using Claude (Anthropic) API.
Args:
prompt: The user's question
context: Optional context from retrieved documents
Returns:
The generated response
Raises:
ValueError: If prompt is empty
RuntimeError: If API call fails
"""
if not prompt:
raise ValueError("Prompt cannot be empty")
try:
# Build system message
system_message = ""
if context:
system_message = (
"Answer the user's question based on the following context. "
"If the context doesn't contain relevant information, say so.\n\n"
f"Context:\n{context}"
)
# Call Claude API
response = self.claude_client.messages.create(
model=self.completion_model,
max_tokens=2000,
system=system_message if system_message else None,
messages=[
{
"role": "user",
"content": prompt
}
]
)
# Extract text from response
return response.content[0].text
except Exception as e:
raise RuntimeError(f"Claude completion API call failed: {str(e)}")

View File

@@ -0,0 +1,128 @@
# app/lib/embedding_providers/openai_provider.py
from typing import List
from openai import OpenAI
from .base import BaseProvider
class OpenAIProvider(BaseProvider):
"""
OpenAI provider for embeddings and completions.
Supports text-embedding-3-small, text-embedding-3-large, and GPT models.
"""
EMBEDDING_DIMENSIONS = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
"text-embedding-ada-002": 1536,
}
def __init__(
self,
api_key: str,
embedding_model: str = "text-embedding-3-small",
completion_model: str = "gpt-4o-mini"
):
"""
Initialize OpenAI provider.
Args:
api_key: OpenAI API key
embedding_model: Model to use for embeddings
completion_model: Model to use for completions
"""
if not api_key:
raise ValueError("OpenAI API key is required")
self.client = OpenAI(api_key=api_key)
self.embedding_model = embedding_model
self.completion_model = completion_model
if embedding_model not in self.EMBEDDING_DIMENSIONS:
raise ValueError(
f"Unsupported embedding model: {embedding_model}. "
f"Supported models: {list(self.EMBEDDING_DIMENSIONS.keys())}"
)
@property
def dimension(self) -> int:
return self.EMBEDDING_DIMENSIONS[self.embedding_model]
@property
def provider_name(self) -> str:
return "openai"
@property
def model_name(self) -> str:
return self.embedding_model
def get_embeddings(self, texts: List[str]) -> List[List[float]]:
"""
Generate embeddings using OpenAI API.
Args:
texts: List of text strings to embed
Returns:
List of embedding vectors
Raises:
ValueError: If texts is empty
RuntimeError: If API call fails
"""
if not texts:
raise ValueError("Text list cannot be empty")
try:
response = self.client.embeddings.create(
model=self.embedding_model,
input=texts
)
return [item.embedding for item in response.data]
except Exception as e:
raise RuntimeError(f"OpenAI embedding API call failed: {str(e)}")
def get_completion(self, prompt: str, context: str = "") -> str:
"""
Generate a completion using OpenAI Chat API.
Args:
prompt: The user's question
context: Optional context from retrieved documents
Returns:
The generated response
Raises:
ValueError: If prompt is empty
RuntimeError: If API call fails
"""
if not prompt:
raise ValueError("Prompt cannot be empty")
try:
messages = []
# Add system message with context if provided
if context:
messages.append({
"role": "system",
"content": f"Answer the user's question based on the following context:\n\n{context}"
})
# Add user message
messages.append({
"role": "user",
"content": prompt
})
response = self.client.chat.completions.create(
model=self.completion_model,
messages=messages,
max_tokens=2000,
temperature=0.7
)
return response.choices[0].message.content
except Exception as e:
raise RuntimeError(f"OpenAI completion API call failed: {str(e)}")

View File

@@ -0,0 +1,160 @@
# app/lib/embedding_providers/openrouter_provider.py
from typing import List
import httpx
from .base import BaseProvider
class OpenRouterProvider(BaseProvider):
"""
OpenRouter provider for embeddings and completions.
OpenRouter is a unified gateway to multiple AI models including Claude, GPT, etc.
"""
def __init__(
self,
api_key: str,
completion_model: str = "anthropic/claude-3-5-sonnet",
embedding_model: str = "openai/text-embedding-3-small",
embedding_dimension: int = 1536
):
"""
Initialize OpenRouter provider.
Args:
api_key: OpenRouter API key
completion_model: Model to use for completions (e.g., 'anthropic/claude-3-5-sonnet')
embedding_model: Model to use for embeddings (defaults to OpenAI embeddings via OpenRouter)
embedding_dimension: Dimension of embeddings (default 1536 for OpenAI small)
"""
if not api_key:
raise ValueError("OpenRouter API key is required")
self.api_key = api_key
self.completion_model = completion_model
self.embedding_model = embedding_model
self._dimension = embedding_dimension
self.base_url = "https://openrouter.ai/api/v1"
@property
def dimension(self) -> int:
return self._dimension
@property
def provider_name(self) -> str:
return "openrouter"
@property
def model_name(self) -> str:
return self.completion_model
def get_embeddings(self, texts: List[str]) -> List[List[float]]:
"""
Generate embeddings using OpenRouter API.
OpenRouter routes to OpenAI embeddings by default.
Args:
texts: List of text strings to embed
Returns:
List of embedding vectors
Raises:
ValueError: If texts is empty
RuntimeError: If API call fails
"""
if not texts:
raise ValueError("Text list cannot be empty")
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://crumbcrm.local",
"X-Title": "CrumbCRM"
}
# Ensure model has openai/ prefix for OpenRouter API
model = self.embedding_model if "/" in self.embedding_model else f"openai/{self.embedding_model}"
data = {
"model": model,
"input": texts
}
try:
with httpx.Client(timeout=30.0) as client:
response = client.post(
f"{self.base_url}/embeddings",
headers=headers,
json=data
)
response.raise_for_status()
result = response.json()
return [item["embedding"] for item in result["data"]]
except httpx.HTTPError as e:
raise RuntimeError(f"OpenRouter embedding API call failed: {str(e)}")
except KeyError as e:
raise RuntimeError(f"Unexpected OpenRouter API response format: {str(e)}")
def get_completion(self, prompt: str, context: str = "") -> str:
"""
Generate a completion using OpenRouter API.
Args:
prompt: The user's question
context: Optional context from retrieved documents
Returns:
The generated response
Raises:
ValueError: If prompt is empty
RuntimeError: If API call fails
"""
if not prompt:
raise ValueError("Prompt cannot be empty")
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://crumbcrm.local",
"X-Title": "CrumbCRM"
}
messages = []
# Add system message with context if provided
if context:
messages.append({
"role": "system",
"content": f"Answer the user's question based on the following context:\n\n{context}"
})
# Add user message
messages.append({
"role": "user",
"content": prompt
})
data = {
"model": self.completion_model,
"messages": messages,
"max_tokens": 2000,
"temperature": 0.7
}
try:
with httpx.Client(timeout=60.0) as client:
response = client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=data
)
response.raise_for_status()
result = response.json()
return result["choices"][0]["message"]["content"]
except httpx.HTTPError as e:
raise RuntimeError(f"OpenRouter completion API call failed: {str(e)}")
except KeyError as e:
raise RuntimeError(f"Unexpected OpenRouter API response format: {str(e)}")

155
app/lib/markdown_chunker.py Normal file
View File

@@ -0,0 +1,155 @@
# app/lib/markdown_chunker.py
import re
from typing import List, Dict, Any
class MarkdownChunker:
"""
Intelligent chunking of Markdown content with header-aware splitting.
Adapted for CrumbCRM post content.
"""
def __init__(self, chunk_size: int = 1000, overlap: int = 200):
"""
Initialize the chunker.
Args:
chunk_size: Maximum size of each chunk in characters
overlap: Number of characters to overlap between chunks
"""
self.chunk_size = chunk_size
self.overlap = overlap
def chunk_post_content(
self,
content: str,
post_id: int,
post_title: str = ""
) -> List[Dict[str, Any]]:
"""
Split post content by headers, then by size if needed.
Args:
content: Markdown content of the post
post_id: Database ID of the post
post_title: Title of the post (optional)
Returns:
List of chunk dictionaries with content and metadata
"""
chunks = []
lines = content.split('\n')
current_chunk = []
current_header = post_title # Use post title as default header
current_level = 0
for line in lines:
# Check if line is a header
header_match = re.match(r'^(#+)\s+(.+)', line)
if header_match:
# Save previous chunk if it exists
if current_chunk:
chunk_text = '\n'.join(current_chunk).strip()
if chunk_text:
chunks.extend(self._split_large_chunk(
chunk_text, post_id, current_header, current_level
))
# Start new chunk
current_level = len(header_match.group(1))
current_header = header_match.group(2)
current_chunk = [line]
else:
current_chunk.append(line)
# Handle final chunk
if current_chunk:
chunk_text = '\n'.join(current_chunk).strip()
if chunk_text:
chunks.extend(self._split_large_chunk(
chunk_text, post_id, current_header, current_level
))
# Add sequential index to chunks
for idx, chunk in enumerate(chunks):
chunk['chunk_position'] = idx
return chunks
def _split_large_chunk(
self,
text: str,
post_id: int,
header: str,
level: int
) -> List[Dict[str, Any]]:
"""
Split chunks that exceed the maximum size.
Args:
text: Text content to split
post_id: Database ID of the post
header: Current header text
level: Header level (1-6)
Returns:
List of chunk dictionaries
"""
if len(text) <= self.chunk_size:
return [{
'content': text,
'post_id': post_id,
'header': header,
'header_level': level,
'chunk_index': 0
}]
# Split large chunks by words with overlap
chunks = []
words = text.split()
current_chunk = []
current_size = 0
chunk_index = 0
for word in words:
word_size = len(word) + 1 # +1 for space
if current_size + word_size > self.chunk_size and current_chunk:
# Save current chunk
chunk_content = ' '.join(current_chunk)
chunks.append({
'content': chunk_content,
'post_id': post_id,
'header': header,
'header_level': level,
'chunk_index': chunk_index
})
# Start new chunk with overlap
if self.overlap > 0:
# Approximate word overlap
overlap_word_count = max(1, self.overlap // 10)
overlap_words = current_chunk[-overlap_word_count:]
else:
overlap_words = []
current_chunk = overlap_words + [word]
current_size = sum(len(w) + 1 for w in current_chunk)
chunk_index += 1
else:
current_chunk.append(word)
current_size += word_size
# Save final chunk if exists
if current_chunk:
chunk_content = ' '.join(current_chunk)
chunks.append({
'content': chunk_content,
'post_id': post_id,
'header': header,
'header_level': level,
'chunk_index': chunk_index
})
return chunks

198
app/main.py Normal file
View File

@@ -0,0 +1,198 @@
# app/main.py
import os, hashlib
from typing import Optional
from urllib.parse import urlparse
from fastapi import FastAPI, Request, Form, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from jinja2 import Environment, FileSystemLoader, select_autoescape
from passlib.hash import bcrypt
from pymysql.cursors import DictCursor
import pymysql
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from deps import get_db, current_user # keine Kreis-Imports
from routers.admin_post import router as admin_posts_router
from routers.admin_rag import router as admin_rag_router
from routers.diary_rag import router as diary_rag_router
from routers.document_rag import router as document_rag_router
from routers.home import router as home_router
from routers.chat import router as chat_router
from routers.chat_page import router as chat_page_router
from routers.crumbforest_roles import router as roles_router
SECRET = os.getenv("APP_SECRET", "dev-secret-change-me")
app = FastAPI()
# --- Rate Limiting ---
from routers.chat import limiter
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# --- Static Files ---
app.mount("/static", StaticFiles(directory="static"), name="static")
# --- Middleware ---
app.add_middleware(SessionMiddleware, secret_key=SECRET, same_site="lax", https_only=False)
# CORS: Restrictive for production (allow localhost for dev)
allowed_origins = os.getenv("CORS_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins, # Set via CORS_ORIGINS env var
allow_credentials=True,
allow_methods=["GET", "POST"], # Only needed methods
allow_headers=["Content-Type", "Accept"], # Minimal headers
)
# --- Templates & Render ---
def init_templates(app: FastAPI):
env = Environment(
loader=FileSystemLoader("templates"),
autoescape=select_autoescape(["html", "htm"])
)
app.state.tpl_env = env
def render(req: Request, template: str, **ctx):
# Gemeinsamer Kontext
path = req.url.path or "/"
seg = [p for p in path.split("/") if p]
lang = seg[0] if seg and seg[0] in ("de", "en") else (req.session.get("lang") or "de")
tail = "/" + "/".join(seg[1:]) if (seg and seg[0] in ("de", "en") and len(seg) > 1) else "/"
user = req.session.get("user")
flashes = req.session.pop("_flashes", [])
base_ctx = dict(
req=req, lang=lang, path_tail=tail, user=user, flashes=flashes,
)
base_ctx.update(ctx)
tpl = app.state.tpl_env.get_template(template)
return HTMLResponse(tpl.render(**base_ctx))
app.state.render = render
@app.on_event("startup")
def _startup():
init_templates(app)
# --- kleine Utils ---
def flash(req: Request, message: str, category: str = "info"):
arr = req.session.get("_flashes", [])
arr.append({"msg": message, "cat": category})
req.session["_flashes"] = arr
def get_lang_from_path(path: str, default="de"):
seg = [p for p in (path or "/").split("/") if p]
return seg[0] if seg and seg[0] in ("de", "en") else default
# --- Health & Dev helpers ---
@app.get("/health")
def health():
return {"ok": True}
@app.get("/__routes", response_class=JSONResponse)
def list_routes():
data = []
for r in app.router.routes:
if hasattr(r, "path") and hasattr(r, "name") and hasattr(r, "methods"):
data.append({"path": r.path, "name": r.name, "methods": sorted(list(r.methods))})
return data
@app.get("/__whoami", response_class=JSONResponse)
def whoami(req: Request):
lang = get_lang_from_path(req.url.path, req.session.get("lang", "de"))
return {"user": req.session.get("user"), "lang": lang}
@app.get("/favicon.ico")
def favicon():
return PlainTextResponse("", status_code=204)
# --- Root & Home ---
# Root path is now handled by home router
# Old authenticated home page moved to /{lang}/ for backwards compatibility
@app.get("/{lang}/", name="authenticated_home", response_class=HTMLResponse)
def authenticated_home(req: Request, lang: str, user = Depends(current_user)):
req.session["lang"] = lang
return req.app.state.render(req, "pages/home.html", seo={"title": "Crumbforest", "desc": "Wuuuuhuuu!"})
# --- Login / Logout ---
@app.get("/{lang}/login", name="login_form", response_class=HTMLResponse)
def login_form(req: Request, lang: str):
req.session["lang"] = lang
return req.app.state.render(req, "pages/login.html", seo={"title": "Login", "desc": ""})
@app.post("/{lang}/login", name="login_post")
def login_post(
req: Request,
lang: str,
email: str = Form(...),
password: str = Form(...),
):
req.session["lang"] = lang
# prüfe User
try:
with get_db().cursor(DictCursor) as cur:
cur.execute("SELECT id, email, pass_hash, role, locale, user_group, theme, accessibility FROM users WHERE email=%s", (email,))
row = cur.fetchone()
except Exception as e:
raise HTTPException(status_code=500, detail=f"db error: {e}")
if not row or not bcrypt.verify(password, row["pass_hash"]):
flash(req, "Invalid credentials", "error")
# 200 lassen, damit Fehler im Formular sichtbar bleibt
return req.app.state.render(req, "pages/login.html", seo={"title": "Login"}, form={"email": email})
# Erfolg
req.session["user"] = {
"id": row["id"],
"email": row["email"],
"role": row["role"],
"locale": row["locale"],
"user_group": row.get("user_group", "demo"),
"theme": row.get("theme", "pico-default"),
"accessibility": row.get("accessibility")
}
flash(req, f"Welcome, {row['email']}", "success")
return RedirectResponse("/admin", status_code=302)
@app.post("/logout", name="logout")
def logout(req: Request):
req.session.pop("user", None)
flash(req, "Logged out", "info")
lang = req.session.get("lang", "de")
return RedirectResponse(f"/{lang}/", status_code=302)
# --- Admin Dashboard ---
@app.get("/admin", name="admin_dashboard", response_class=HTMLResponse)
def admin_dashboard(req: Request, user = Depends(current_user)):
if not user:
return RedirectResponse(f"/{req.session.get('lang','de')}/login", status_code=302)
if user.get("role") != "admin":
return HTMLResponse("403 admin only", status_code=403)
return req.app.state.render(req, "pages/admin.html", seo={"title": "Admin", "desc": ""})
# --- kleine API-Demo ---
@app.get("/api/hello", name="api_hello")
def api_hello(req: Request, lang: Optional[str] = None):
lang = lang or req.session.get("lang") or "de"
user = req.session.get("user", {}).get("email")
msg = "Hallo Welt" if lang == "de" else "Hello World"
return {"message": msg, "lang": lang, "user": user}
# --- Router mounten (ohne Kreisimport) ---
app.include_router(admin_posts_router, prefix="/admin")
app.include_router(admin_rag_router, prefix="/admin/rag", tags=["RAG"])
app.include_router(diary_rag_router, prefix="/api/diary", tags=["Diary RAG"])
app.include_router(document_rag_router, prefix="/api/documents", tags=["Documents RAG"])
app.include_router(chat_router, tags=["Chat"])
app.include_router(chat_page_router, tags=["Chat"])
app.include_router(roles_router, tags=["Roles"])
# Mount home router last so it doesn't conflict with other routes
app.include_router(home_router, tags=["Home"])

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
# app/models/__init__.py

166
app/models/rag_models.py Normal file
View File

@@ -0,0 +1,166 @@
# app/models/rag_models.py
from pydantic import BaseModel, Field
from typing import Optional, List
class IndexRequest(BaseModel):
"""Request model for indexing posts."""
provider: str = Field(..., description="Provider to use for embeddings (openai, openrouter, claude)")
locale: Optional[str] = Field(None, description="Locale filter (de, en, or None for all)")
class IndexSingleRequest(BaseModel):
"""Request model for indexing a single post."""
provider: str = Field(..., description="Provider to use for embeddings")
class IndexResponse(BaseModel):
"""Response model for indexing operations."""
status: str
indexed: Optional[int] = None
errors: Optional[int] = None
unchanged: Optional[int] = None
total: Optional[int] = None
message: Optional[str] = None
post_id: Optional[int] = None
chunks: Optional[int] = None
collection: Optional[str] = None
provider: Optional[str] = None
class SearchRequest(BaseModel):
"""Request model for semantic search."""
query: str = Field(..., description="Search query text")
provider: str = Field(..., description="Provider to use for search")
locale: str = Field(..., description="Locale to search in (de, en)")
limit: int = Field(5, description="Maximum number of results", ge=1, le=20)
class SearchResult(BaseModel):
"""Individual search result."""
post_id: int
title: str
slug: str
content: str
header: str
score: float
class SearchResponse(BaseModel):
"""Response model for search operations."""
results: List[SearchResult]
query: str
locale: str
provider: str
class QueryRequest(BaseModel):
"""Request model for RAG query."""
question: str = Field(..., description="Question to answer")
provider: str = Field(..., description="Provider to use for completion")
locale: str = Field(..., description="Locale to search in (de, en)")
context_limit: int = Field(3, description="Number of context chunks to retrieve", ge=1, le=10)
class QuerySource(BaseModel):
"""Source document for RAG query."""
post_id: int
title: str
slug: str
score: float
class QueryResponse(BaseModel):
"""Response model for RAG query."""
answer: str
sources: List[QuerySource]
provider: str
model: str
question: str
class StatusResponse(BaseModel):
"""Response model for indexing status."""
total_posts: int
indexed_posts: int
collections: dict
class ProviderInfo(BaseModel):
"""Information about a provider."""
name: str
available: bool
provider_name: Optional[str] = None
model: Optional[str] = None
dimension: Optional[int] = None
supports_embeddings: Optional[bool] = None
supports_completions: Optional[bool] = None
error: Optional[str] = None
class ProvidersResponse(BaseModel):
"""Response model for provider status."""
providers: List[ProviderInfo]
# ===== Diary-specific models =====
class DiaryIndexRequest(BaseModel):
"""Request model for indexing diary entries."""
entry_id: int = Field(..., description="Diary entry ID")
child_id: int = Field(..., description="Child ID (owner of diary)")
content: str = Field(..., description="Markdown content of diary entry")
provider: str = Field("openai", description="Provider to use for embeddings")
class DiaryIndexResponse(BaseModel):
"""Response model for diary indexing."""
status: str
entry_id: int
child_id: int
chunks: Optional[int] = None
collection: Optional[str] = None
provider: Optional[str] = None
message: Optional[str] = None
class DiarySearchRequest(BaseModel):
"""Request model for searching child's diary."""
child_id: int = Field(..., description="Child ID")
query: str = Field(..., description="Search query")
provider: str = Field("openai", description="Provider to use")
limit: int = Field(5, description="Max results", ge=1, le=20)
class DiarySearchResult(BaseModel):
"""Individual diary search result."""
entry_id: int
content: str
score: float
created_at: Optional[str] = None
class DiarySearchResponse(BaseModel):
"""Response model for diary search."""
results: List[DiarySearchResult]
query: str
child_id: int
provider: str
class DiaryAskRequest(BaseModel):
"""Request model for RAG query on diary."""
child_id: int = Field(..., description="Child ID")
question: str = Field(..., description="Question to answer")
provider: str = Field("openai", description="Provider to use")
context_limit: int = Field(3, description="Number of entries to retrieve", ge=1, le=10)
class DiaryAskResponse(BaseModel):
"""Response model for diary RAG query."""
answer: str
question: str
child_id: int
sources: List[DiarySearchResult]
provider: str
model: str

12
app/models/user.py Normal file
View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel
from typing import Optional, Dict, Any
class User(BaseModel):
id: int
email: str
role: str
locale: str = "de"
user_group: str = "demo"
theme: str = "pico-default"
accessibility: Optional[Dict[str, Any]] = None
created_at: Optional[Any] = None

30
app/requirements.txt Normal file
View File

@@ -0,0 +1,30 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
jinja2==3.1.4
pymysql==1.1.0
python-multipart==0.0.9
itsdangerous==2.2.0
passlib==1.7.4
bcrypt==4.0.1
markupsafe==2.1.5
# RAG & Vector Database
qdrant-client==1.7.0
# AI Provider SDKs
openai==1.5.0
anthropic==0.40.0
httpx==0.27.0
# Local embeddings fallback
sentence-transformers==2.2.2
torch>=2.0.0
# Configuration management
pydantic-settings==2.0.3
# Utilities
python-slugify==8.0.1
# Security
slowapi==0.1.9

76
app/routers/admin_post.py Normal file
View File

@@ -0,0 +1,76 @@
# app/routers/admin_post.py
from fastapi import APIRouter, Request, Depends, Form, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from pymysql.cursors import DictCursor
from deps import get_db, admin_required
router = APIRouter()
@router.get("/posts", name="posts_index", response_class=HTMLResponse)
def posts_index(req: Request, user = Depends(admin_required)):
with get_db().cursor(DictCursor) as cur:
cur.execute("SELECT id,title,slug,locale,is_published,updated_at FROM posts ORDER BY id DESC")
rows = cur.fetchall()
return req.app.state.render(req, "posts/index.html", posts=rows, seo={"title": "Posts"})
@router.get("/posts/new", name="posts_new", response_class=HTMLResponse)
def posts_new(req: Request, user = Depends(admin_required)):
return req.app.state.render(req, "posts/new.html", seo={"title": "New Post"})
@router.post("/posts/new", name="posts_create")
def posts_create(
req: Request,
user = Depends(admin_required),
title: str = Form(...),
slug: str = Form(...),
locale: str = Form(...),
is_published: int = Form(0),
body_md: str = Form(""),
):
with get_db().cursor(DictCursor) as cur:
cur.execute(
"""
INSERT INTO posts (title, slug, locale, is_published, body_md)
VALUES (%s,%s,%s,%s,%s)
""",
(title, slug, locale, 1 if is_published else 0, body_md),
)
# Flash (über Base, optional)
flashes = req.session.get("_flashes", [])
flashes.append({"msg": "Post created", "cat": "success"})
req.session["_flashes"] = flashes
return RedirectResponse("/admin/posts", status_code=302)
@router.get("/posts/{post_id}/edit", name="posts_edit", response_class=HTMLResponse)
def posts_edit(req: Request, post_id: int, user = Depends(admin_required)):
with get_db().cursor(DictCursor) as cur:
cur.execute("SELECT * FROM posts WHERE id=%s", (post_id,))
row = cur.fetchone()
if not row:
return HTMLResponse("Not found", status_code=404)
return req.app.state.render(req, "posts/edit.html", post=row, seo={"title": f"Edit {row['title']}"})
@router.post("/posts/{post_id}/edit", name="posts_update")
def posts_update(
req: Request,
post_id: int,
user = Depends(admin_required),
title: str = Form(...),
slug: str = Form(...),
locale: str = Form(...),
is_published: int = Form(0),
body_md: str = Form(""),
):
with get_db().cursor(DictCursor) as cur:
cur.execute(
"""
UPDATE posts
SET title=%s, slug=%s, locale=%s, is_published=%s, body_md=%s, updated_at=NOW()
WHERE id=%s
""",
(title, slug, locale, 1 if is_published else 0, body_md, post_id),
)
flashes = req.session.get("_flashes", [])
flashes.append({"msg": "Post updated", "cat": "success"})
req.session["_flashes"] = flashes
return RedirectResponse("/admin/posts", status_code=302)

350
app/routers/admin_rag.py Normal file
View File

@@ -0,0 +1,350 @@
# app/routers/admin_rag.py
from fastapi import APIRouter, Depends, HTTPException
from pymysql.cursors import DictCursor
from typing import Dict
from deps import get_db, get_qdrant_client, admin_required
from config import get_settings
from models.rag_models import (
IndexRequest, IndexSingleRequest, IndexResponse,
SearchRequest, SearchResponse, SearchResult,
QueryRequest, QueryResponse, QuerySource,
StatusResponse, ProvidersResponse, ProviderInfo
)
from services.provider_factory import ProviderFactory
from services.rag_service import RAGService
router = APIRouter()
@router.post("/index", response_model=IndexResponse, name="rag_index_all")
def index_all_posts(
request: IndexRequest,
user = Depends(admin_required)
):
"""
Index all published posts to Qdrant.
Admin-only endpoint.
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Create provider
provider = ProviderFactory.create_provider(
provider_name=request.provider,
settings=settings
)
# Create RAG service
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider
)
# Index posts
result = rag_service.index_all_posts(locale=request.locale)
return IndexResponse(
status="success",
**result
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}")
finally:
db_conn.close()
@router.post("/index/{post_id}", response_model=IndexResponse, name="rag_index_single")
def index_single_post(
post_id: int,
request: IndexSingleRequest,
user = Depends(admin_required)
):
"""
Index a single post to Qdrant.
Admin-only endpoint.
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Fetch post from database
with db_conn.cursor(DictCursor) as cur:
cur.execute(
"SELECT id, title, slug, locale, body_md FROM posts WHERE id=%s",
(post_id,)
)
post = cur.fetchone()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
# Create provider
provider = ProviderFactory.create_provider(
provider_name=request.provider,
settings=settings
)
# Create RAG service
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider
)
# Index post
result = rag_service.index_post(
post_id=post['id'],
title=post['title'],
slug=post['slug'],
locale=post['locale'],
body_md=post['body_md']
)
return IndexResponse(**result)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}")
finally:
db_conn.close()
@router.delete("/index/{post_id}", name="rag_delete_index")
def delete_post_index(
post_id: int,
locale: str,
user = Depends(admin_required)
):
"""
Remove a post from the vector index.
Admin-only endpoint.
"""
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Get any provider (we only need it for service initialization)
settings = get_settings()
available_providers = ProviderFactory.get_available_providers(settings)
if not available_providers:
raise HTTPException(status_code=500, detail="No providers configured")
provider = ProviderFactory.create_provider(
provider_name=available_providers[0],
settings=settings
)
# Create RAG service
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider
)
# Delete post index
result = rag_service.delete_post_index(post_id=post_id, locale=locale)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}")
finally:
db_conn.close()
@router.post("/search", response_model=SearchResponse, name="rag_search")
def search_posts(
request: SearchRequest,
user = Depends(admin_required)
):
"""
Semantic search across indexed posts.
Admin-only endpoint.
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Create provider
provider = ProviderFactory.create_provider(
provider_name=request.provider,
settings=settings
)
# Create RAG service
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider
)
# Search
results = rag_service.search_posts(
query=request.query,
locale=request.locale,
limit=request.limit
)
# Convert to response model
search_results = [SearchResult(**r) for r in results]
return SearchResponse(
results=search_results,
query=request.query,
locale=request.locale,
provider=request.provider
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
finally:
db_conn.close()
@router.post("/query", response_model=QueryResponse, name="rag_query")
def query_with_rag(
request: QueryRequest,
user = Depends(admin_required)
):
"""
RAG-powered question answering using post content.
Admin-only endpoint.
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Create embedding provider
embedding_provider = ProviderFactory.create_provider(
provider_name=request.provider,
settings=settings
)
# Create completion provider (may be same or different)
completion_provider = ProviderFactory.create_provider(
provider_name=request.provider,
settings=settings
)
# Create RAG service
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=embedding_provider,
completion_provider=completion_provider
)
# Query with RAG
result = rag_service.query_with_rag(
question=request.question,
locale=request.locale,
context_limit=request.context_limit
)
# Convert sources to response model
sources = [QuerySource(**s) for s in result['sources']]
return QueryResponse(
answer=result['answer'],
sources=sources,
provider=result['provider'],
model=result['model'],
question=request.question
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Query failed: {str(e)}")
finally:
db_conn.close()
@router.get("/status", response_model=StatusResponse, name="rag_status")
def get_indexing_status(user = Depends(admin_required)):
"""
Get indexing status across all collections.
Admin-only endpoint.
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Get any available provider (we only need it for service initialization)
available_providers = ProviderFactory.get_available_providers(settings)
if not available_providers:
raise HTTPException(status_code=500, detail="No providers configured")
provider = ProviderFactory.create_provider(
provider_name=available_providers[0],
settings=settings
)
# Create RAG service
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider
)
# Get status
status = rag_service.get_indexing_status()
return StatusResponse(**status)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get status: {str(e)}")
finally:
db_conn.close()
@router.get("/providers", response_model=ProvidersResponse, name="rag_providers")
def get_providers_status(user = Depends(admin_required)):
"""
List available providers and their status.
Admin-only endpoint.
"""
settings = get_settings()
try:
# Get provider status
provider_status = ProviderFactory.get_provider_status(settings)
# Convert to response model
providers = []
for name, info in provider_status.items():
provider_info = ProviderInfo(
name=name,
available=info.get('available', False),
provider_name=info.get('provider_name'),
model=info.get('model'),
dimension=info.get('dimension'),
supports_embeddings=info.get('supports_embeddings'),
supports_completions=info.get('supports_completions'),
error=info.get('error')
)
providers.append(provider_info)
return ProvidersResponse(providers=providers)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get providers: {str(e)}")

215
app/routers/chat.py Normal file
View File

@@ -0,0 +1,215 @@
"""
Chat API Router
Handles character chat interactions with RAG.
"""
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from typing import Optional
from slowapi import Limiter
from slowapi.util import get_remote_address
from deps import get_qdrant_client
from config import get_settings
from services.provider_factory import ProviderFactory
from utils.rag_chat import RAGChatService
from utils.chat_logger import ChatLogger
from utils.security import PromptInjectionFilter
router = APIRouter()
# Rate limiter (5 requests per minute per IP - strict for production)
limiter = Limiter(key_func=get_remote_address)
# Security filter
security_filter = PromptInjectionFilter()
# Character configurations
CHARACTERS = {
"eule": {
"name": "Krümeleule",
"prompt": (
"Du bist die Krümeleule, eine weise und geduldige Begleiterin im Crumbforest. "
"Du hörst zuerst zu, stellst kluge Fragen und antwortest mit Ruhe und Respekt. "
"Du schützt das Recht der Kinder zu fragen wie einen wertvollen Schatz. "
"Deine Antworten sind kindgerecht, aber niemals herablassend. "
"Du kennst das Terminal, Python, und die digitale Welt aber du erklärst alles "
"so, dass auch Anfänger es verstehen können. "
"Antworte auf Deutsch, wenn nicht anders gewünscht."
)
},
"fox": {
"name": "FunkFox",
"prompt": (
"Du bist FunkFox der rappende Bit im Crumbforest! 🦊🎤\n"
"Du antwortest IMMER in Rap-Form mit Reimen und Flow. "
"Du bist neugierig, energetisch und tech-begeistert. "
"Du liebst das Terminal, Code, Kommandos und alles Digitale. "
"Deine Antworten sind cool, direkt und immer im Rhythmus. "
"Du erklärst technische Dinge auf eine Art, die Spaß macht und hängenbleibt. "
"Jede Antwort ist ein kleiner Rap mit Reimen, Beats und Groove. "
"Du bist hilfsbereit, wissbegierig und immer bereit für den nächsten Drop. "
"Antworte auf Deutsch, wenn nicht anders gewünscht. "
"Denk daran: Keep it real, keep it rhythmisch! 🌲🎧"
)
},
"bugsy": {
"name": "Bugsy",
"prompt": (
"Du bist Bugsy der freundliche Debugging-Begleiter im Crumbforest! 🐞\n"
"Du machst Fehler sichtbar, aber NIEMALS mit Schuld oder Scham. "
"Fehler sind für dich Einladungen zum Verstehen, keine Katastrophen. "
"Du bist geduldig, ermunternd und technisch präzise. "
"Wenn jemand einen Fehler hat, sagst du: 'Hey, lass uns das gemeinsam anschauen!' "
"Du erklärst, WAS passiert ist, WARUM es passiert ist, und WIE man es löst. "
"Du betonst immer: Fehler sind normal, jeder macht sie, sie sind Teil des Lernens. "
"Deine Antworten sind strukturiert: Problem → Ursache → Lösung → Lernen. "
"Du gibst konkrete Debug-Tipps, aber immer in einer freundlichen, ermutigenden Art. "
"Du feierst kleine Erfolge ('Super, du hast den Fehler gefunden!') "
"Antworte auf Deutsch, wenn nicht anders gewünscht. "
"Denk daran: Kein Bug ist zu klein oder zu groß wir schaffen das zusammen! 🐞💚"
)
}
# More characters can be added here in the future
}
class ChatRequest(BaseModel):
"""Chat request model."""
character_id: str = Field(..., max_length=50)
question: str = Field(..., min_length=1, max_length=2000)
lang: Optional[str] = Field(default="de", pattern="^(de|en)$")
class ChatResponse(BaseModel):
"""Chat response model."""
answer: str
sources: list
context_found: bool
provider: str
model: str
@router.post("/api/chat", response_model=ChatResponse)
@limiter.limit("5/minute")
async def chat_with_character(chat_request: ChatRequest, request: Request):
"""
Chat with a character using RAG.
Args:
chat_request: Chat request with character_id and question
request: FastAPI request object for session access
Returns:
ChatResponse with answer and metadata
"""
# Validate character
if chat_request.character_id not in CHARACTERS:
raise HTTPException(
status_code=400,
detail=f"Unknown character: {chat_request.character_id}"
)
# Security: Check for prompt injection
is_valid, error_msg = security_filter.validate(chat_request.question, max_length=2000)
if not is_valid:
raise HTTPException(
status_code=400,
detail=f"Invalid input: {error_msg}"
)
character_config = CHARACTERS[chat_request.character_id]
# Get settings
settings = get_settings()
# Check if OpenRouter is available
if not settings.openrouter_api_key:
raise HTTPException(
status_code=503,
detail="AI service not configured. Please contact administrator."
)
try:
# Create providers
embedding_provider = ProviderFactory.create_provider(
provider_name="openrouter",
settings=settings
)
completion_provider = ProviderFactory.create_provider(
provider_name="openrouter",
settings=settings
)
# Get Qdrant client
qdrant_client = get_qdrant_client()
# Create RAG chat service
rag_service = RAGChatService(
qdrant_client=qdrant_client,
embedding_provider=embedding_provider,
completion_provider=completion_provider
)
# Generate answer with RAG
result = rag_service.chat_with_context(
question=chat_request.question,
character_name=character_config["name"],
character_prompt=character_config["prompt"],
context_limit=3,
lang=chat_request.lang
)
# Log interaction
user = request.session.get("user")
session_id = request.session.get("session_id", "anonymous")
logger = ChatLogger()
logger.log_interaction(
character_id=chat_request.character_id,
character_name=character_config["name"],
user_id=user.get("id") if user else None,
user_role=user.get("role") if user else "anonymous",
question=chat_request.question,
answer=result["answer"],
model=result["model"],
provider=result["provider"],
context_found=result["context_found"],
sources_count=len(result["sources"]),
lang=chat_request.lang,
session_id=session_id
)
# Return response
return ChatResponse(
answer=result["answer"],
sources=result["sources"],
context_found=result["context_found"],
provider=result["provider"],
model=result["model"]
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=503, detail=f"AI service error: {str(e)}")
except Exception as e:
print(f"❌ Chat error: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/chat/stats")
async def get_chat_stats():
"""
Get chat statistics.
Returns:
Statistics about logged interactions
"""
logger = ChatLogger()
stats = logger.get_stats()
return JSONResponse(content=stats)

28
app/routers/chat_page.py Normal file
View File

@@ -0,0 +1,28 @@
"""
Chat Page Router
Serves the chat UI for character interactions.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
router = APIRouter()
@router.get("/{lang}/chat", response_class=HTMLResponse)
async def chat_page(lang: str, req: Request):
"""
Render the chat interface page.
Args:
lang: Language code (de/en)
req: FastAPI request object
Returns:
HTML response with chat interface
"""
return req.app.state.render(
req,
"pages/chat.html",
seo={"title": "Crumbforest Chat", "desc": "Chat with Krümeleule and FunkFox"}
)

View File

@@ -0,0 +1,300 @@
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
import httpx
from typing import Optional, List, Dict, Any
from app.deps import current_user
from app.services.config_loader import ConfigLoader
from app.config import get_settings
router = APIRouter()
settings = get_settings()
@router.get("/crumbforest/roles", response_class=HTMLResponse)
async def roles_dashboard(req: Request, user = Depends(current_user)):
"""
Show available roles based on user's group.
"""
# Determine user group (default to 'home' if not logged in, 'demo' if logged in but no group)
if not user:
user_group = 'home'
else:
user_group = user.get('user_group', 'demo')
# Load config
config = ConfigLoader.load_config()
all_roles = config.get('roles', {})
# Filter roles by group access
available_roles = {
rid: role for rid, role in all_roles.items()
if user_group in role.get('group_access', [])
}
# Get group config for template rendering
group_config = config.get('groups', {}).get(user_group, {})
return req.app.state.render(
req,
"crumbforest/roles_dashboard.html",
roles=available_roles,
user_group=user_group,
group_config=group_config
)
@router.get("/crumbforest/roles/{role_id}", response_class=HTMLResponse)
async def role_chat(req: Request, role_id: str, user = Depends(current_user)):
"""
Chat interface for a specific role.
"""
config = ConfigLoader.load_config()
role = config.get('roles', {}).get(role_id)
if not role:
raise HTTPException(404, "Role not found")
# Determine user group
if not user:
user_group = 'home'
else:
user_group = user.get('user_group', 'demo')
# Check access
if user_group not in role.get('group_access', []):
# Redirect to login if not logged in, else 403
if not user:
return RedirectResponse(f"/login?next={req.url.path}", status_code=302)
raise HTTPException(403, "Access denied")
group_config = config.get('groups', {}).get(user_group, {})
return req.app.state.render(
req,
"crumbforest/role_chat.html",
role=role,
group_config=group_config
)
@router.post("/crumbforest/roles/{role_id}/ask")
async def ask_role(
req: Request,
role_id: str,
question: str = Form(...),
user = Depends(current_user)
):
"""
Send question to role and get AI response.
"""
config = ConfigLoader.load_config()
role = config.get('roles', {}).get(role_id)
if not role:
raise HTTPException(404, "Role not found")
# Access check (simplified for API)
if not user:
raise HTTPException(401, "Login required")
user_group = user.get('user_group', 'demo')
if user_group not in role.get('group_access', []):
raise HTTPException(403, "Access denied")
# Get conversation history from session
history_key = f'role_history_{role_id}'
history = req.session.get(history_key, [])
# Build messages
messages = [
{"role": "system", "content": role['system_prompt']}
] + history + [
{"role": "user", "content": question}
]
# Call OpenRouter API
api_key = settings.openrouter_api_key
if not api_key:
return JSONResponse({
"answer": "⚠️ OpenRouter API Key missing in configuration.",
"error": "config_error"
}, status_code=500)
try:
async with httpx.AsyncClient() as client:
response = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://crumbforest.local", # Required by OpenRouter
"X-Title": "Crumbforest"
},
json={
"model": role['model'],
"temperature": role.get('temperature', 0.7),
"messages": messages
},
timeout=30.0
)
if response.status_code != 200:
print(f"OpenRouter Error: {response.text}")
return JSONResponse({
"answer": "⚠️ Error communicating with AI provider.",
"details": response.text
}, status_code=502)
result = response.json()
answer = result['choices'][0]['message']['content']
# --- Code Execution Feature (Mock/Safe) ---
# If role has 'code_execution' feature and answer contains code block
if 'code_execution' in role.get('features', []) and '```' in answer:
# Simple mock execution for demonstration
# In production, this would need a secure sandbox (e.g. gVisor, Firecracker)
if "print(" in answer or "echo" in answer:
answer += "\n\n> [!NOTE]\n> **Code Execution Output** (Simulated):\n> ```\n> Hello from Crumbforest! 🌲\n> ```"
# Update history
history.append({"role": "user", "content": question})
history.append({"role": "assistant", "content": answer})
req.session[history_key] = history[-10:] # Keep last 10 exchanges
return JSONResponse({
"role": role_id,
"question": question,
"answer": answer,
"usage": result.get('usage', {})
})
except Exception as e:
print(f"Exception in ask_role: {e}")
return JSONResponse({
"answer": "⚠️ Internal server error.",
"details": str(e)
}, status_code=500)
# --- Settings Endpoints ---
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(req: Request, user = Depends(current_user)):
"""
User settings page.
"""
if not user:
return RedirectResponse(f"/login?next={req.url.path}", status_code=302)
config = ConfigLoader.load_config()
user_group = user.get('user_group', 'demo')
group_config = config.get('groups', {}).get(user_group, {})
theme_variants = config.get('theme_variants', {})
return req.app.state.render(
req,
"pages/settings.html",
group_config=group_config,
theme_variants=theme_variants
)
@router.post("/settings/theme")
async def update_theme(
req: Request,
theme: str = Form(...),
user = Depends(current_user)
):
"""
Update user theme preference.
"""
if not user:
raise HTTPException(401, "Login required")
# Verify theme exists
config = ConfigLoader.load_config()
if theme not in config.get('theme_variants', {}):
raise HTTPException(400, "Invalid theme")
# Update session
user['theme'] = theme
req.session['user'] = user
# Update DB
try:
from app.deps import get_db
from pymysql.cursors import DictCursor
with get_db().cursor(DictCursor) as cur:
cur.execute("UPDATE users SET theme=%s WHERE id=%s", (theme, user['id']))
except Exception as e:
print(f"Error updating theme in DB: {e}")
# Continue anyway, session is updated
return RedirectResponse("/settings", status_code=302)
@router.post("/settings/accessibility")
async def update_accessibility(
req: Request,
high_contrast: Optional[bool] = Form(None),
animation_reduced: Optional[bool] = Form(None),
font_size: str = Form("normal"),
user = Depends(current_user)
):
"""
Update accessibility settings.
"""
if not user:
raise HTTPException(401, "Login required")
import json
accessibility = {
"high_contrast": bool(high_contrast),
"animation_reduced": bool(animation_reduced),
"font_size": font_size
}
# Update session
user['accessibility'] = accessibility
# If high contrast is enabled, force that theme, otherwise revert to group default or keep current?
# For now, we just set the accessibility flags. The theme switcher handles the main theme.
# However, if high_contrast is ON, we might want to auto-switch to pico-high-contrast.
if accessibility['high_contrast']:
user['theme'] = 'pico-high-contrast'
elif user['theme'] == 'pico-high-contrast' and not accessibility['high_contrast']:
# Revert to default if turning off high contrast
user['theme'] = 'pico-default'
req.session['user'] = user
# Update DB
try:
from app.deps import get_db
from pymysql.cursors import DictCursor
with get_db().cursor(DictCursor) as cur:
cur.execute(
"UPDATE users SET accessibility=%s, theme=%s WHERE id=%s",
(json.dumps(accessibility), user['theme'], user['id'])
)
except Exception as e:
print(f"Error updating accessibility in DB: {e}")
return RedirectResponse("/settings", status_code=302)
@router.get("/crumbforest/roles/{role_id}/export")
async def export_history(req: Request, role_id: str, user = Depends(current_user)):
"""
Export chat history as JSON.
"""
if not user:
raise HTTPException(401, "Login required")
history_key = f'role_history_{role_id}'
history = req.session.get(history_key, [])
return JSONResponse({
"role": role_id,
"exported_at": str(import_datetime().now()),
"history": history
})
def import_datetime():
from datetime import datetime
return datetime

361
app/routers/diary_rag.py Normal file
View File

@@ -0,0 +1,361 @@
# app/routers/diary_rag.py
"""
Diary-specific RAG endpoints for indexing and searching child diary entries.
Each child has their own Qdrant collection: diary_child_{child_id}
"""
import json
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from pymysql.cursors import DictCursor
from typing import List
from deps import get_db, get_qdrant_client
from config import get_settings
from models.rag_models import (
DiaryIndexRequest, DiaryIndexResponse,
DiarySearchRequest, DiarySearchResponse, DiarySearchResult,
DiaryAskRequest, DiaryAskResponse
)
from services.provider_factory import ProviderFactory
from services.rag_service import RAGService
router = APIRouter()
def _get_diary_collection_name(child_id: int) -> str:
"""Generate collection name for a child's diary."""
return f"diary_child_{child_id}"
@router.post("/index", response_model=DiaryIndexResponse, name="diary_index")
async def index_diary_entry(
request: DiaryIndexRequest,
background_tasks: BackgroundTasks
):
"""
Index a diary entry into child-specific Qdrant collection.
Flow:
1. Verify diary entry exists in MySQL
2. Chunk content (markdown_chunker)
3. Generate embeddings (embedding_service)
4. Store in Qdrant collection: diary_child_{child_id}
5. Track in post_vectors (post_type='diary')
6. Audit log
Uses BackgroundTasks for async processing.
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Verify diary entry exists
with db_conn.cursor(DictCursor) as cur:
cur.execute(
"SELECT id, child_id, entry_text, created_at FROM diary_entries WHERE id=%s",
(request.entry_id,)
)
entry = cur.fetchone()
if not entry:
raise HTTPException(status_code=404, detail="Diary entry not found")
if entry['child_id'] != request.child_id:
raise HTTPException(status_code=403, detail="Child ID mismatch")
# Create provider
provider = ProviderFactory.create_provider(
provider_name=request.provider,
settings=settings
)
# Create RAG service with diary-specific collection prefix
collection_prefix = f"diary_child_{request.child_id}"
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider,
collection_prefix=collection_prefix
)
# Index as a "post" with locale as empty string (diaries are locale-agnostic)
result = rag_service.index_post(
post_id=request.entry_id,
title=f"Diary Entry {request.entry_id}",
slug=f"diary-{request.entry_id}",
locale="", # Diary entries don't have locale
body_md=request.content or entry['entry_text']
)
# Update post_vectors to mark as diary type
with db_conn.cursor(DictCursor) as cur:
cur.execute(
"""
UPDATE post_vectors
SET post_type='diary', child_id=%s
WHERE post_id=%s AND collection_name=%s
""",
(request.child_id, request.entry_id, result['collection'])
)
# Audit log
cur.execute(
"""
INSERT INTO audit_log (action, entity_type, entity_id, user_id, metadata)
VALUES ('diary_indexed', 'diary_entry', %s, NULL, %s)
""",
(request.entry_id, json.dumps({
'child_id': request.child_id,
'provider': request.provider,
'chunks': result.get('chunks', 0)
}))
)
return DiaryIndexResponse(
status="success",
entry_id=request.entry_id,
child_id=request.child_id,
chunks=result.get('chunks'),
collection=result.get('collection'),
provider=result.get('provider')
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}")
finally:
db_conn.close()
@router.post("/search", response_model=DiarySearchResponse, name="diary_search")
async def search_diary(
request: DiarySearchRequest
):
"""
Semantic search in a child's diary.
Flow:
1. Generate query embedding
2. Search in Qdrant: diary_child_{child_id}
3. Return top results with scores
4. Log query in audit_log
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Create provider
provider = ProviderFactory.create_provider(
provider_name=request.provider,
settings=settings
)
# Create RAG service with diary-specific collection
collection_prefix = f"diary_child_{request.child_id}"
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider,
collection_prefix=collection_prefix
)
# Search (use empty locale for diaries)
results = rag_service.search_posts(
query=request.query,
locale="",
limit=request.limit
)
# Convert to diary search results
diary_results = []
for r in results:
# Fetch created_at from database
with db_conn.cursor(DictCursor) as cur:
cur.execute(
"SELECT created_at FROM diary_entries WHERE id=%s",
(r['post_id'],)
)
entry = cur.fetchone()
diary_results.append(DiarySearchResult(
entry_id=r['post_id'],
content=r['content'],
score=r['score'],
created_at=entry['created_at'].isoformat() if entry else None
))
# Audit log
with db_conn.cursor(DictCursor) as cur:
cur.execute(
"""
INSERT INTO audit_log (action, entity_type, entity_id, user_id, metadata)
VALUES ('diary_searched', 'child', %s, NULL, %s)
""",
(request.child_id, json.dumps({
'query': request.query,
'provider': request.provider,
'results_count': len(diary_results)
}))
)
return DiarySearchResponse(
results=diary_results,
query=request.query,
child_id=request.child_id,
provider=request.provider
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
finally:
db_conn.close()
@router.post("/ask", response_model=DiaryAskResponse, name="diary_ask")
async def ask_diary(
request: DiaryAskRequest
):
"""
RAG-powered question answering using child's diary entries.
Flow:
1. Search relevant entries (top N)
2. Build context from entries
3. Get completion from provider
4. Return answer + sources
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Create providers (may use different ones for embedding vs completion)
embedding_provider = ProviderFactory.create_provider(
provider_name=request.provider,
settings=settings
)
completion_provider = ProviderFactory.create_provider(
provider_name=request.provider,
settings=settings
)
# Create RAG service
collection_prefix = f"diary_child_{request.child_id}"
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=embedding_provider,
completion_provider=completion_provider,
collection_prefix=collection_prefix
)
# Perform RAG query
result = rag_service.query_with_rag(
question=request.question,
locale="", # Diaries don't have locale
context_limit=request.context_limit
)
# Convert sources to diary results
sources = []
for source in result['sources']:
# Fetch created_at from database
with db_conn.cursor(DictCursor) as cur:
cur.execute(
"SELECT entry_text, created_at FROM diary_entries WHERE id=%s",
(source['post_id'],)
)
entry = cur.fetchone()
sources.append(DiarySearchResult(
entry_id=source['post_id'],
content=entry['entry_text'][:200] if entry else "", # Preview
score=source['score'],
created_at=entry['created_at'].isoformat() if entry else None
))
# Audit log
with db_conn.cursor(DictCursor) as cur:
cur.execute(
"""
INSERT INTO audit_log (action, entity_type, entity_id, user_id, metadata)
VALUES ('diary_rag_query', 'child', %s, NULL, %s)
""",
(request.child_id, json.dumps({
'question': request.question,
'provider': request.provider,
'sources_count': len(sources)
}))
)
return DiaryAskResponse(
answer=result['answer'],
question=request.question,
child_id=request.child_id,
sources=sources,
provider=result['provider'],
model=result['model']
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"RAG query failed: {str(e)}")
finally:
db_conn.close()
@router.get("/{child_id}/status", name="diary_status")
async def get_diary_status(child_id: int):
"""
Get indexing status for a child's diary.
Returns:
- Total diary entries
- Indexed entries
- Collection info
"""
db_conn = get_db()
try:
with db_conn.cursor(DictCursor) as cur:
# Total entries for this child
cur.execute(
"SELECT COUNT(*) as total FROM diary_entries WHERE child_id=%s",
(child_id,)
)
total = cur.fetchone()['total']
# Indexed entries
collection_name = _get_diary_collection_name(child_id)
cur.execute(
"""
SELECT COUNT(DISTINCT post_id) as indexed,
SUM(chunk_count) as total_vectors,
MAX(indexed_at) as last_indexed
FROM post_vectors
WHERE collection_name=%s AND post_type='diary' AND child_id=%s
""",
(collection_name, child_id)
)
indexed_data = cur.fetchone()
return {
"child_id": child_id,
"total_entries": total,
"indexed_entries": indexed_data['indexed'] or 0,
"total_vectors": indexed_data['total_vectors'] or 0,
"last_indexed": indexed_data['last_indexed'].isoformat() if indexed_data['last_indexed'] else None,
"collection_name": collection_name
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get status: {str(e)}")
finally:
db_conn.close()

256
app/routers/document_rag.py Normal file
View File

@@ -0,0 +1,256 @@
# app/routers/document_rag.py
"""
Document RAG endpoints for Markdown documentation
Auto-indexes docs from docs/rz-nullfeld and docs/crumbforest
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional, List
from deps import get_db, get_qdrant_client, admin_required
from config import get_settings
from services.provider_factory import ProviderFactory
from services.document_indexer import DocumentIndexer
router = APIRouter()
class DocumentIndexRequest(BaseModel):
"""Request to index documents."""
category: Optional[str] = Field(None, description="Category to index (rz-nullfeld, crumbforest, or None for all)")
provider: Optional[str] = Field(None, description="Provider to use (defaults to config setting)")
force: bool = Field(False, description="Force re-indexing even if unchanged")
class DocumentIndexResponse(BaseModel):
"""Response from document indexing."""
status: str
total_files: int
indexed: int
unchanged: int
errors: int
categories: dict
class DocumentStatusResponse(BaseModel):
"""Document indexing status."""
categories: dict
@router.post("/index", response_model=DocumentIndexResponse, name="documents_index")
async def index_documents(
request: DocumentIndexRequest,
user = Depends(admin_required)
):
"""
Index Markdown documents from docs/ directories.
Admin-only endpoint for manual re-indexing.
Documents are automatically indexed on startup.
Categories:
- rz-nullfeld: RZ Nullfeld documentation
- crumbforest: Crumbforest documentation
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Use default provider if not specified
provider_name = request.provider or settings.default_embedding_provider
# Create provider
provider = ProviderFactory.create_provider(
provider_name=provider_name,
settings=settings
)
# Create document indexer
indexer = DocumentIndexer(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider,
docs_base_path="docs"
)
# Index specified category or all
if request.category:
# Index single category
result = indexer.index_category(request.category, force=request.force)
results = {
'categories': {request.category: result},
'total_files': result['total'],
'total_indexed': result['indexed'],
'total_unchanged': result['unchanged'],
'total_errors': result['errors']
}
else:
# Index all categories
results = indexer.index_all_categories(force=request.force)
return DocumentIndexResponse(
status="success",
total_files=results['total_files'],
indexed=results['total_indexed'],
unchanged=results['total_unchanged'],
errors=results['total_errors'],
categories=results['categories']
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}")
finally:
db_conn.close()
@router.get("/status", response_model=DocumentStatusResponse, name="documents_status")
async def get_documents_status(
category: Optional[str] = None,
user = Depends(admin_required)
):
"""
Get document indexing status.
Returns information about indexed documents in each category.
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
# Get any available provider
available_providers = ProviderFactory.get_available_providers(settings)
if not available_providers:
raise HTTPException(status_code=500, detail="No providers configured")
provider = ProviderFactory.create_provider(
provider_name=available_providers[0],
settings=settings
)
# Create document indexer
indexer = DocumentIndexer(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider,
docs_base_path="docs"
)
# Get status
status = indexer.get_indexing_status(category=category)
return DocumentStatusResponse(categories=status)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get status: {str(e)}")
finally:
db_conn.close()
@router.get("/search", name="documents_search")
async def search_documents(
q: str,
category: Optional[str] = None,
limit: int = 5,
provider: Optional[str] = None,
user = Depends(admin_required)
):
"""
Search across indexed documents.
Semantic search across RZ Nullfeld and Crumbforest documentation.
"""
settings = get_settings()
db_conn = get_db()
qdrant_client = get_qdrant_client()
try:
from services.rag_service import RAGService
# Use default provider if not specified
if not provider:
provider = settings.default_embedding_provider
print(f"[DEBUG] Creating provider: {provider}")
print(f"[DEBUG] Settings default_embedding_model: {settings.default_embedding_model}")
# Create provider with explicit embedding model (without provider prefix)
embedding_provider = ProviderFactory.create_provider(
provider_name=provider,
settings=settings,
embedding_model="text-embedding-3-small" # Without openai/ prefix
)
print(f"[DEBUG] Provider created: {embedding_provider.__class__.__name__}")
print(f"[DEBUG] Provider embedding_model: {embedding_provider.embedding_model}")
# Determine collection(s) to search
if category:
indexer = DocumentIndexer(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=embedding_provider,
docs_base_path="docs"
)
collection_name = indexer.categories.get(category)
if not collection_name:
raise HTTPException(status_code=400, detail=f"Unknown category: {category}")
collections = [collection_name]
else:
# Search all document collections (prefix only, RAGService adds _{locale})
collections = ["docs_rz_nullfeld", "docs_crumbforest"]
all_results = []
for coll_name in collections:
rag_service = RAGService(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=embedding_provider,
collection_prefix=coll_name
)
try:
results = rag_service.search_posts(
query=q,
locale="", # Documents are locale-agnostic
limit=limit
)
# Add collection info to results
for r in results:
r['collection'] = coll_name
all_results.extend(results)
except Exception as e:
# Collection might not exist yet
print(f"Error searching {coll_name}: {e}")
continue
# Sort by score and limit
all_results.sort(key=lambda x: x['score'], reverse=True)
all_results = all_results[:limit]
return {
"query": q,
"results": all_results,
"provider": provider
}
except ValueError as e:
print(f"[DEBUG] ValueError in search: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
print(f"[DEBUG] Exception in search: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
finally:
db_conn.close()

175
app/routers/home.py Normal file
View File

@@ -0,0 +1,175 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
import json
import os
router = APIRouter()
# Load deployment config
deployment_config = None
_translations_cache = {}
def load_config():
"""Load configuration files on startup."""
global deployment_config
config_path = os.path.join('deployment_config.json')
with open(config_path) as f:
deployment_config = json.load(f)
def load_characters(lang: str = "de"):
"""Load character data for given language."""
if lang not in ["de", "en", "fr"]:
lang = "de"
characters_path = os.path.join('static', 'data', f'characters.{lang}.json')
try:
with open(characters_path) as f:
return json.load(f)
except FileNotFoundError:
# Fallback to German
with open(os.path.join('static', 'data', 'characters.de.json')) as f:
return json.load(f)
def load_translations(lang: str = "de"):
"""Load translation JSON for given language with caching."""
global _translations_cache
# Default to German if language not supported
if lang not in ["de", "en", "fr"]:
lang = "de"
# Check cache first
if lang not in _translations_cache:
translation_path = os.path.join('i18n', f'{lang}.json')
try:
with open(translation_path) as f:
_translations_cache[lang] = json.load(f)
except FileNotFoundError:
# Fallback to German if translation file not found
with open(os.path.join('i18n', 'de.json')) as f:
_translations_cache[lang] = json.load(f)
return _translations_cache[lang]
# Load on module import
load_config()
@router.get("/", response_class=HTMLResponse)
async def home_index(req: Request, lang: str = None):
"""
Public home page - no auth required.
"""
# Get language from query param or session
if lang is None:
lang = req.session.get("lang", "de")
req.session["lang"] = lang
translations = load_translations(lang)
return req.app.state.render(
req,
"home/index.html",
deployment=deployment_config,
t=translations,
lang=lang
)
@router.get("/about", response_class=HTMLResponse)
async def home_about(req: Request):
"""
About / Mission page.
"""
lang = req.session.get("lang", "de")
translations = load_translations(lang)
return req.app.state.render(
req,
"home/about.html",
deployment=deployment_config,
t=translations,
lang=lang
)
@router.get("/crew", response_class=HTMLResponse)
async def home_crew(req: Request):
"""
Crew / Characters page.
"""
lang = req.session.get("lang", "de")
translations = load_translations(lang)
characters = load_characters(lang)
return req.app.state.render(
req,
"home/crew.html",
deployment=deployment_config,
characters=characters,
t=translations,
lang=lang
)
@router.get("/hardware", response_class=HTMLResponse)
async def home_hardware(req: Request):
"""
Hardware information page.
"""
lang = req.session.get("lang", "de")
translations = load_translations(lang)
return req.app.state.render(
req,
"home/hardware.html",
deployment=deployment_config,
t=translations,
lang=lang
)
@router.get("/software", response_class=HTMLResponse)
async def home_software(req: Request):
"""
Software information page.
"""
lang = req.session.get("lang", "de")
translations = load_translations(lang)
return req.app.state.render(
req,
"home/software.html",
deployment=deployment_config,
t=translations,
lang=lang
)
@router.get("/impressum", response_class=HTMLResponse)
async def home_impressum(req: Request):
"""
Impressum / Legal notice page.
"""
lang = req.session.get("lang", "de")
translations = load_translations(lang)
return req.app.state.render(
req,
"home/impressum.html",
deployment=deployment_config,
t=translations,
lang=lang
)
@router.get("/datenschutz", response_class=HTMLResponse)
async def home_datenschutz(req: Request):
"""
Privacy policy / Datenschutz page.
"""
lang = req.session.get("lang", "de")
translations = load_translations(lang)
return req.app.state.render(
req,
"home/datenschutz.html",
deployment=deployment_config,
t=translations,
lang=lang
)

View File

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
# app/services/__init__.py

View File

@@ -0,0 +1,52 @@
import json
import os
from typing import Dict, Any, Optional
CONFIG_PATH = "crumbforest_config.json"
class ConfigLoader:
_config: Optional[Dict[str, Any]] = None
@classmethod
def load_config(cls, force_reload: bool = False) -> Dict[str, Any]:
if cls._config is None or force_reload:
try:
# Try to find config in root or app root
paths_to_try = [CONFIG_PATH, os.path.join("..", CONFIG_PATH), os.path.join(os.getcwd(), CONFIG_PATH)]
found = False
for path in paths_to_try:
if os.path.exists(path):
with open(path, 'r', encoding='utf-8') as f:
cls._config = json.load(f)
found = True
break
if not found:
print(f"Warning: {CONFIG_PATH} not found in {paths_to_try}")
cls._config = {"roles": {}, "groups": {}, "theme_variants": {}}
except Exception as e:
print(f"Error loading config: {e}")
cls._config = {"roles": {}, "groups": {}, "theme_variants": {}}
return cls._config
@classmethod
def get_role(cls, role_id: str) -> Optional[Dict[str, Any]]:
config = cls.load_config()
return config.get('roles', {}).get(role_id)
@classmethod
def get_group(cls, group_id: str) -> Optional[Dict[str, Any]]:
config = cls.load_config()
return config.get('groups', {}).get(group_id)
@classmethod
def get_theme(cls, theme_id: str) -> Optional[Dict[str, Any]]:
config = cls.load_config()
return config.get('theme_variants', {}).get(theme_id)
@classmethod
def get_all_roles(cls) -> Dict[str, Any]:
config = cls.load_config()
return config.get('roles', {})

View File

@@ -0,0 +1,365 @@
# app/services/document_indexer.py
"""
Document Indexer Service
Automatically indexes Markdown documents from docs/ directories
Tracks changes via file hashes (DSGVO-compliant)
"""
import os
import json
import hashlib
from pathlib import Path
from typing import List, Dict, Any, Optional
from datetime import datetime
from pymysql import Connection
from pymysql.cursors import DictCursor
from qdrant_client import QdrantClient
from lib.embedding_providers.base import BaseProvider
from services.rag_service import RAGService
class DocumentIndexer:
"""
Indexes Markdown documents from docs/ directories.
Tracks changes and only re-indexes modified files.
"""
def __init__(
self,
db_conn: Connection,
qdrant_client: QdrantClient,
embedding_provider: BaseProvider,
docs_base_path: str = "docs"
):
"""
Initialize document indexer.
Args:
db_conn: Database connection
qdrant_client: Qdrant client
embedding_provider: Provider for embeddings
docs_base_path: Base path to docs directory
"""
self.db_conn = db_conn
self.qdrant = qdrant_client
self.embedding_provider = embedding_provider
self.docs_base_path = Path(docs_base_path)
# Supported document categories
self.categories = {
"rz-nullfeld": "docs_rz_nullfeld",
"crumbforest": "docs_crumbforest"
}
def get_file_hash(self, file_path: Path) -> str:
"""Calculate MD5 hash of file content."""
with open(file_path, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
def find_markdown_files(self, category: str) -> List[Path]:
"""
Find all Markdown files in a category directory.
Args:
category: Category name (e.g., 'rz-nullfeld')
Returns:
List of Markdown file paths
"""
category_path = self.docs_base_path / category
if not category_path.exists():
return []
# Find all .md files recursively
return list(category_path.glob("**/*.md"))
def should_reindex(self, file_path: Path, collection_name: str) -> bool:
"""
Check if file should be re-indexed based on hash comparison.
Args:
file_path: Path to markdown file
collection_name: Qdrant collection name
Returns:
True if file should be re-indexed
"""
# Calculate current hash
current_hash = self.get_file_hash(file_path)
# Get stored hash from database
with self.db_conn.cursor(DictCursor) as cur:
cur.execute(
"""
SELECT file_hash FROM post_vectors
WHERE post_id = %s AND collection_name = %s AND post_type = 'document'
""",
(str(file_path), collection_name)
)
result = cur.fetchone()
# Re-index if no record exists or hash changed
if not result:
return True
return result['file_hash'] != current_hash
def index_document(
self,
file_path: Path,
category: str,
force: bool = False
) -> Dict[str, Any]:
"""
Index a single Markdown document.
Args:
file_path: Path to markdown file
category: Category name (e.g., 'rz-nullfeld')
force: Force re-indexing even if unchanged
Returns:
Indexing result dictionary
"""
collection_name = self.categories.get(category)
if not collection_name:
raise ValueError(f"Unknown category: {category}")
# Check if re-indexing needed
if not force and not self.should_reindex(file_path, collection_name):
return {
'file': str(file_path),
'status': 'unchanged',
'message': 'File unchanged, skipping'
}
# Read file content
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
if not content.strip():
return {
'file': str(file_path),
'status': 'skipped',
'message': 'Empty file'
}
# Create RAG service with document-specific collection
rag_service = RAGService(
db_conn=self.db_conn,
qdrant_client=self.qdrant,
embedding_provider=self.embedding_provider,
collection_prefix=collection_name
)
# Index as post (using file path as ID)
result = rag_service.index_post(
post_id=hash(str(file_path)) % (2**31), # Convert path to int ID
title=file_path.stem, # Filename without extension
slug=f"{category}/{file_path.stem}",
locale="", # Documents are locale-agnostic
body_md=content
)
# Update post_vectors to mark as document type
file_hash = self.get_file_hash(file_path)
with self.db_conn.cursor(DictCursor) as cur:
cur.execute(
"""
UPDATE post_vectors
SET post_type='document',
metadata=%s,
file_hash=%s
WHERE post_id=%s AND collection_name=%s
""",
(
json.dumps({
'category': category,
'file_path': str(file_path),
'file_name': file_path.name
}),
file_hash,
hash(str(file_path)) % (2**31),
result['collection']
)
)
# Add metadata column if it doesn't exist
try:
cur.execute(
"ALTER TABLE post_vectors ADD COLUMN IF NOT EXISTS metadata JSON NULL"
)
except Exception:
pass # Column might already exist
# DSGVO Audit Log
cur.execute(
"""
INSERT INTO audit_log (action, entity_type, entity_id, user_id, metadata)
VALUES ('document_indexed', 'document', %s, NULL, %s)
""",
(
hash(str(file_path)) % (2**31),
json.dumps({
'category': category,
'file_path': str(file_path),
'provider': self.embedding_provider.provider_name,
'chunks': result.get('chunks', 0),
'timestamp': datetime.now().isoformat()
})
)
)
return {
'file': str(file_path),
'status': 'indexed',
'chunks': result.get('chunks', 0),
'collection': result['collection'],
'provider': result.get('provider')
}
def index_category(
self,
category: str,
force: bool = False
) -> Dict[str, Any]:
"""
Index all documents in a category.
Args:
category: Category name (e.g., 'rz-nullfeld')
force: Force re-indexing of all files
Returns:
Summary of indexing results
"""
files = self.find_markdown_files(category)
if not files:
return {
'category': category,
'total': 0,
'indexed': 0,
'unchanged': 0,
'errors': 0,
'message': f'No markdown files found in docs/{category}/'
}
results = {
'category': category,
'total': len(files),
'indexed': 0,
'unchanged': 0,
'errors': 0,
'files': []
}
for file_path in files:
try:
result = self.index_document(file_path, category, force=force)
results['files'].append(result)
if result['status'] == 'indexed':
results['indexed'] += 1
elif result['status'] == 'unchanged':
results['unchanged'] += 1
except Exception as e:
results['errors'] += 1
results['files'].append({
'file': str(file_path),
'status': 'error',
'message': str(e)
})
print(f"Error indexing {file_path}: {e}")
return results
def index_all_categories(self, force: bool = False) -> Dict[str, Any]:
"""
Index all documents in all categories.
Args:
force: Force re-indexing of all files
Returns:
Summary of indexing results for all categories
"""
results = {
'categories': {},
'total_files': 0,
'total_indexed': 0,
'total_unchanged': 0,
'total_errors': 0
}
for category in self.categories.keys():
category_result = self.index_category(category, force=force)
results['categories'][category] = category_result
results['total_files'] += category_result['total']
results['total_indexed'] += category_result['indexed']
results['total_unchanged'] += category_result['unchanged']
results['total_errors'] += category_result['errors']
return results
def get_indexing_status(self, category: Optional[str] = None) -> Dict[str, Any]:
"""
Get indexing status for documents.
Args:
category: Optional category filter
Returns:
Status information
"""
status = {}
with self.db_conn.cursor(DictCursor) as cur:
if category:
collection_name = self.categories.get(category)
if not collection_name:
raise ValueError(f"Unknown category: {category}")
cur.execute(
"""
SELECT COUNT(*) as count,
SUM(chunk_count) as total_vectors,
MAX(indexed_at) as last_indexed
FROM post_vectors
WHERE collection_name=%s AND post_type='document'
""",
(collection_name,)
)
result = cur.fetchone()
status[category] = {
'collection': collection_name,
'documents': result['count'] or 0,
'vectors': result['total_vectors'] or 0,
'last_indexed': result['last_indexed'].isoformat() if result['last_indexed'] else None
}
else:
# Get status for all categories
for cat, coll_name in self.categories.items():
cur.execute(
"""
SELECT COUNT(*) as count,
SUM(chunk_count) as total_vectors,
MAX(indexed_at) as last_indexed
FROM post_vectors
WHERE collection_name=%s AND post_type='document'
""",
(coll_name,)
)
result = cur.fetchone()
status[cat] = {
'collection': coll_name,
'documents': result['count'] or 0,
'vectors': result['total_vectors'] or 0,
'last_indexed': result['last_indexed'].isoformat() if result['last_indexed'] else None
}
return status

View File

@@ -0,0 +1,85 @@
# app/services/embedding_service.py
from typing import List, Dict, Any
from lib.embedding_providers.base import BaseProvider
from lib.markdown_chunker import MarkdownChunker
class EmbeddingService:
"""
Service for coordinating text chunking and embedding generation.
"""
def __init__(self, provider: BaseProvider, chunk_size: int = 1000, chunk_overlap: int = 200):
"""
Initialize the embedding service.
Args:
provider: Provider instance for generating embeddings
chunk_size: Maximum size of text chunks
chunk_overlap: Overlap between chunks
"""
self.provider = provider
self.chunker = MarkdownChunker(chunk_size=chunk_size, overlap=chunk_overlap)
def chunk_and_embed_post(
self,
post_content: str,
post_id: int,
post_title: str = ""
) -> List[Dict[str, Any]]:
"""
Chunk post content and generate embeddings for each chunk.
Args:
post_content: Markdown content of the post
post_id: Database ID of the post
post_title: Title of the post
Returns:
List of dictionaries containing chunk metadata and embeddings
"""
# Chunk the content
chunks = self.chunker.chunk_post_content(post_content, post_id, post_title)
if not chunks:
return []
# Extract text content for embedding
texts = [chunk['content'] for chunk in chunks]
# Generate embeddings in batch
embeddings = self.provider.get_embeddings(texts)
# Combine chunks with embeddings
results = []
for chunk, embedding in zip(chunks, embeddings):
results.append({
**chunk,
'embedding': embedding
})
return results
def embed_texts(self, texts: List[str]) -> List[List[float]]:
"""
Generate embeddings for a list of texts.
Args:
texts: List of text strings
Returns:
List of embedding vectors
"""
return self.provider.get_embeddings(texts)
def get_embedding_dimension(self) -> int:
"""Get the dimension of embeddings from the current provider."""
return self.provider.dimension
def get_provider_info(self) -> Dict[str, str]:
"""Get information about the current provider."""
return {
'provider': self.provider.provider_name,
'model': self.provider.model_name,
'dimension': str(self.provider.dimension)
}

View File

@@ -0,0 +1,130 @@
# app/services/provider_factory.py
from typing import Dict, List
from lib.embedding_providers.base import BaseProvider
from lib.embedding_providers.openai_provider import OpenAIProvider
from lib.embedding_providers.openrouter_provider import OpenRouterProvider
from lib.embedding_providers.claude_provider import ClaudeProvider
from config import Settings
class ProviderFactory:
"""
Factory class for creating and managing embedding/completion providers.
"""
SUPPORTED_PROVIDERS = ["openai", "openrouter", "claude"]
@staticmethod
def create_provider(
provider_name: str,
settings: Settings,
embedding_model: str = None,
completion_model: str = None
) -> BaseProvider:
"""
Create a provider instance based on the provider name.
Args:
provider_name: Name of the provider ('openai', 'openrouter', 'claude')
settings: Application settings with API keys
embedding_model: Optional specific embedding model to use
completion_model: Optional specific completion model to use
Returns:
Configured provider instance
Raises:
ValueError: If provider is not supported or API key is missing
"""
provider_name = provider_name.lower()
if provider_name not in ProviderFactory.SUPPORTED_PROVIDERS:
raise ValueError(
f"Unsupported provider: {provider_name}. "
f"Supported providers: {ProviderFactory.SUPPORTED_PROVIDERS}"
)
# OpenAI Provider
if provider_name == "openai":
if not settings.openai_api_key:
raise ValueError("OpenAI API key not configured")
return OpenAIProvider(
api_key=settings.openai_api_key,
embedding_model=embedding_model or settings.default_embedding_model,
completion_model=completion_model or "gpt-4o-mini"
)
# OpenRouter Provider
elif provider_name == "openrouter":
if not settings.openrouter_api_key:
raise ValueError("OpenRouter API key not configured")
return OpenRouterProvider(
api_key=settings.openrouter_api_key,
completion_model=completion_model or "anthropic/claude-3-5-sonnet",
embedding_model=embedding_model or "openai/text-embedding-3-small",
embedding_dimension=1536
)
# Claude Provider
elif provider_name == "claude":
if not settings.anthropic_api_key:
raise ValueError("Claude (Anthropic) API key not configured")
return ClaudeProvider(
claude_api_key=settings.anthropic_api_key,
openai_api_key=settings.openai_api_key, # For embeddings fallback
completion_model=completion_model or settings.default_completion_model,
embedding_model=embedding_model or settings.default_embedding_model
)
@staticmethod
def get_provider_status(settings: Settings) -> Dict[str, Dict]:
"""
Check the availability status of all providers.
Args:
settings: Application settings with API keys
Returns:
Dictionary mapping provider names to their status information
"""
status = {}
for provider_name in ProviderFactory.SUPPORTED_PROVIDERS:
try:
# Try to create provider to check if it's properly configured
provider = ProviderFactory.create_provider(provider_name, settings)
status[provider_name] = {
"available": True,
"provider_name": provider.provider_name,
"model": provider.model_name,
"dimension": provider.dimension,
"supports_embeddings": provider.supports_embeddings(),
"supports_completions": provider.supports_completions()
}
except ValueError as e:
status[provider_name] = {
"available": False,
"error": str(e)
}
return status
@staticmethod
def get_available_providers(settings: Settings) -> List[str]:
"""
Get list of available (properly configured) provider names.
Args:
settings: Application settings with API keys
Returns:
List of available provider names
"""
status = ProviderFactory.get_provider_status(settings)
return [
name for name, info in status.items()
if info.get("available", False)
]

450
app/services/rag_service.py Normal file
View File

@@ -0,0 +1,450 @@
# app/services/rag_service.py
import json
import hashlib
import uuid
from typing import List, Dict, Any, Optional
from datetime import datetime
from pymysql import Connection
from pymysql.cursors import DictCursor
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from slugify import slugify
from lib.embedding_providers.base import BaseProvider
from services.embedding_service import EmbeddingService
class RAGService:
"""
Service for RAG operations: indexing, searching, and query answering.
"""
def __init__(
self,
db_conn: Connection,
qdrant_client: QdrantClient,
embedding_provider: BaseProvider,
completion_provider: Optional[BaseProvider] = None,
collection_prefix: str = "posts"
):
"""
Initialize RAG service.
Args:
db_conn: Database connection
qdrant_client: Qdrant client instance
embedding_provider: Provider for generating embeddings
completion_provider: Provider for completions (defaults to embedding_provider)
collection_prefix: Prefix for Qdrant collections
"""
self.db_conn = db_conn
self.qdrant = qdrant_client
self.embedding_service = EmbeddingService(provider=embedding_provider)
self.completion_provider = completion_provider or embedding_provider
self.collection_prefix = collection_prefix
def _get_collection_name(self, locale: str) -> str:
"""Generate collection name for a given locale."""
return f"{self.collection_prefix}_{locale}"
def _ensure_collection_exists(self, locale: str) -> str:
"""
Ensure Qdrant collection exists for the given locale.
Returns:
Collection name
"""
collection_name = self._get_collection_name(locale)
collections = [col.name for col in self.qdrant.get_collections().collections]
if collection_name not in collections:
# Create collection with appropriate vector dimension
self.qdrant.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(
size=self.embedding_service.get_embedding_dimension(),
distance=Distance.COSINE
)
)
return collection_name
def _get_content_hash(self, content: str) -> str:
"""Generate MD5 hash of content for change detection."""
return hashlib.md5(content.encode('utf-8')).hexdigest()
def index_post(
self,
post_id: int,
title: str,
slug: str,
locale: str,
body_md: str
) -> Dict[str, Any]:
"""
Index a single post into Qdrant.
Args:
post_id: Database ID of the post
title: Post title
slug: Post slug
locale: Post locale (de, en, etc.)
body_md: Markdown content
Returns:
Dictionary with indexing results
"""
# Ensure collection exists
collection_name = self._ensure_collection_exists(locale)
# Check if already indexed and content unchanged
content_hash = self._get_content_hash(body_md or "")
with self.db_conn.cursor(DictCursor) as cur:
cur.execute(
"SELECT file_hash FROM post_vectors WHERE post_id=%s AND collection_name=%s",
(post_id, collection_name)
)
existing = cur.fetchone()
if existing and existing['file_hash'] == content_hash:
return {
'post_id': post_id,
'status': 'unchanged',
'message': 'Post content unchanged, skipping re-indexing'
}
# Chunk and embed the content
chunks_with_embeddings = self.embedding_service.chunk_and_embed_post(
post_content=body_md or "",
post_id=post_id,
post_title=title
)
if not chunks_with_embeddings:
return {
'post_id': post_id,
'status': 'error',
'message': 'No content to index'
}
# Prepare points for Qdrant
points = []
vector_ids = []
for chunk in chunks_with_embeddings:
# Generate deterministic UUID based on post_id and chunk_position
point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{post_id}_{chunk['chunk_position']}"))
vector_ids.append(point_id)
points.append(PointStruct(
id=point_id,
vector=chunk['embedding'],
payload={
'post_id': post_id,
'title': title,
'slug': slug,
'locale': locale,
'content': chunk['content'],
'header': chunk.get('header', ''),
'header_level': chunk.get('header_level', 0),
'chunk_position': chunk['chunk_position']
}
))
# Upsert points to Qdrant
self.qdrant.upsert(
collection_name=collection_name,
points=points
)
# Update tracking in database
provider_info = self.embedding_service.get_provider_info()
with self.db_conn.cursor(DictCursor) as cur:
cur.execute(
"""
INSERT INTO post_vectors (post_id, collection_name, vector_ids, provider, model, chunk_count, file_hash)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
vector_ids=VALUES(vector_ids),
provider=VALUES(provider),
model=VALUES(model),
chunk_count=VALUES(chunk_count),
file_hash=VALUES(file_hash),
indexed_at=CURRENT_TIMESTAMP
""",
(
post_id,
collection_name,
json.dumps(vector_ids),
provider_info['provider'],
provider_info['model'],
len(chunks_with_embeddings),
content_hash
)
)
return {
'post_id': post_id,
'status': 'indexed',
'chunks': len(chunks_with_embeddings),
'collection': collection_name,
'provider': provider_info['provider']
}
def index_all_posts(self, locale: Optional[str] = None) -> Dict[str, Any]:
"""
Index all published posts.
Args:
locale: Optional locale filter (de, en, or None for all)
Returns:
Dictionary with indexing results
"""
# Fetch posts from database
with self.db_conn.cursor(DictCursor) as cur:
if locale:
cur.execute(
"SELECT id, title, slug, locale, body_md FROM posts WHERE is_published=1 AND locale=%s",
(locale,)
)
else:
cur.execute(
"SELECT id, title, slug, locale, body_md FROM posts WHERE is_published=1"
)
posts = cur.fetchall()
if not posts:
return {
'indexed': 0,
'errors': 0,
'unchanged': 0,
'message': 'No posts found to index'
}
# Index each post
results = {
'indexed': 0,
'errors': 0,
'unchanged': 0,
'total': len(posts)
}
for post in posts:
try:
result = self.index_post(
post_id=post['id'],
title=post['title'],
slug=post['slug'],
locale=post['locale'],
body_md=post['body_md']
)
if result['status'] == 'indexed':
results['indexed'] += 1
elif result['status'] == 'unchanged':
results['unchanged'] += 1
else:
results['errors'] += 1
except Exception as e:
results['errors'] += 1
# Log error but continue with other posts
print(f"Error indexing post {post['id']}: {str(e)}")
return results
def delete_post_index(self, post_id: int, locale: str) -> Dict[str, Any]:
"""
Remove a post from the vector index.
Args:
post_id: Database ID of the post
locale: Post locale
Returns:
Dictionary with deletion results
"""
collection_name = self._get_collection_name(locale)
# Get vector IDs from database
with self.db_conn.cursor(DictCursor) as cur:
cur.execute(
"SELECT vector_ids FROM post_vectors WHERE post_id=%s AND collection_name=%s",
(post_id, collection_name)
)
result = cur.fetchone()
if not result:
return {
'post_id': post_id,
'status': 'not_found',
'message': 'Post not indexed'
}
vector_ids = json.loads(result['vector_ids'])
# Delete from Qdrant
self.qdrant.delete(
collection_name=collection_name,
points_selector=vector_ids
)
# Delete from tracking table
cur.execute(
"DELETE FROM post_vectors WHERE post_id=%s AND collection_name=%s",
(post_id, collection_name)
)
return {
'post_id': post_id,
'status': 'deleted',
'vectors_deleted': len(vector_ids)
}
def search_posts(
self,
query: str,
locale: str,
limit: int = 5
) -> List[Dict[str, Any]]:
"""
Semantic search across indexed posts.
Args:
query: Search query
locale: Locale to search in
limit: Maximum number of results
Returns:
List of search results with scores
"""
collection_name = self._get_collection_name(locale)
# Generate query embedding
query_embedding = self.embedding_service.embed_texts([query])[0]
# Search in Qdrant
search_results = self.qdrant.search(
collection_name=collection_name,
query_vector=query_embedding,
limit=limit
)
# Format results
results = []
for hit in search_results:
results.append({
'post_id': hit.payload['post_id'],
'title': hit.payload['title'],
'slug': hit.payload['slug'],
'content': hit.payload['content'],
'header': hit.payload.get('header', ''),
'score': hit.score
})
return results
def query_with_rag(
self,
question: str,
locale: str,
context_limit: int = 3
) -> Dict[str, Any]:
"""
Answer a question using RAG (Retrieval-Augmented Generation).
Args:
question: User's question
locale: Locale to search in
context_limit: Number of context chunks to retrieve
Returns:
Dictionary with answer and sources
"""
# Retrieve relevant context
search_results = self.search_posts(
query=question,
locale=locale,
limit=context_limit
)
if not search_results:
return {
'answer': 'No relevant information found in the knowledge base.',
'sources': [],
'provider': self.completion_provider.provider_name
}
# Build context from search results
context_parts = []
for idx, result in enumerate(search_results, 1):
context_parts.append(
f"[Source {idx}: {result['title']} - {result['header']}]\n{result['content']}"
)
context = "\n\n".join(context_parts)
# Generate answer
answer = self.completion_provider.get_completion(
prompt=question,
context=context
)
# Format sources
sources = [
{
'post_id': r['post_id'],
'title': r['title'],
'slug': r['slug'],
'score': r['score']
}
for r in search_results
]
return {
'answer': answer,
'sources': sources,
'provider': self.completion_provider.provider_name,
'model': self.completion_provider.model_name
}
def get_indexing_status(self) -> Dict[str, Any]:
"""
Get indexing status across all collections.
Returns:
Dictionary with status information
"""
status = {}
# Get total and indexed post counts from database
with self.db_conn.cursor(DictCursor) as cur:
cur.execute("SELECT COUNT(*) as total FROM posts WHERE is_published=1")
total_posts = cur.fetchone()['total']
cur.execute("SELECT COUNT(DISTINCT post_id) as indexed FROM post_vectors")
indexed_posts = cur.fetchone()['indexed']
# Get collection statistics
cur.execute(
"""
SELECT collection_name, COUNT(*) as post_count,
SUM(chunk_count) as total_vectors,
MAX(indexed_at) as last_indexed
FROM post_vectors
GROUP BY collection_name
"""
)
collections = cur.fetchall()
status['total_posts'] = total_posts
status['indexed_posts'] = indexed_posts
status['collections'] = {}
for col in collections:
status['collections'][col['collection_name']] = {
'posts': col['post_count'],
'vectors': col['total_vectors'],
'last_indexed': col['last_indexed'].isoformat() if col['last_indexed'] else None
}
return status

138
app/startup_indexing.py Executable file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Startup Indexing Script
Automatically indexes documents on application startup
"""
import sys
import time
from deps import get_db, get_qdrant_client
from config import get_settings
from services.provider_factory import ProviderFactory
from services.document_indexer import DocumentIndexer
def wait_for_services(max_attempts=30):
"""Wait for database and Qdrant to be ready."""
print("⏳ Waiting for services...")
# Wait for database
for attempt in range(max_attempts):
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT 1")
conn.close()
print("✓ Database ready")
break
except Exception as e:
if attempt == max_attempts - 1:
print(f"✗ Database timeout: {e}")
return False
time.sleep(1)
# Wait for Qdrant
for attempt in range(max_attempts):
try:
qdrant = get_qdrant_client()
qdrant.get_collections()
print("✓ Qdrant ready")
break
except Exception as e:
if attempt == max_attempts - 1:
print(f"✗ Qdrant timeout: {e}")
return False
time.sleep(1)
return True
def run_document_indexing():
"""Run document indexing on startup."""
print("=" * 60)
print("🦉 Crumbforest Document Indexing")
print("=" * 60)
print("")
# Wait for services
if not wait_for_services():
print("✗ Services not ready, skipping document indexing")
return False
settings = get_settings()
# Get available providers
available_providers = ProviderFactory.get_available_providers(settings)
if not available_providers:
print("⚠️ No AI providers configured")
print(" Document indexing skipped")
print(" Configure API keys in compose/.env to enable")
return True # Not an error, just no provider
# Use default provider if available, otherwise first available
provider_name = settings.default_embedding_provider
if provider_name not in available_providers:
provider_name = available_providers[0]
print(f"✓ Using provider: {provider_name}")
print("")
try:
# Create provider
provider = ProviderFactory.create_provider(
provider_name=provider_name,
settings=settings
)
# Get connections
db_conn = get_db()
qdrant_client = get_qdrant_client()
# Create document indexer
indexer = DocumentIndexer(
db_conn=db_conn,
qdrant_client=qdrant_client,
embedding_provider=provider,
docs_base_path="docs"
)
# Index all categories
print("📚 Indexing documents...")
print("")
results = indexer.index_all_categories(force=False)
# Print results
for category, cat_result in results['categories'].items():
print(f"📁 {category}:")
print(f" Files found: {cat_result['total']}")
print(f" Indexed: {cat_result['indexed']}")
print(f" Unchanged: {cat_result['unchanged']}")
print(f" Errors: {cat_result['errors']}")
print("")
print("=" * 60)
print("Summary:")
print(f" Total files: {results['total_files']}")
print(f" Indexed: {results['total_indexed']}")
print(f" Unchanged: {results['total_unchanged']}")
print(f" Errors: {results['total_errors']}")
print("=" * 60)
if results['total_indexed'] > 0:
print("✓ Document indexing completed successfully")
else:
print("✓ All documents up to date")
db_conn.close()
return True
except Exception as e:
print(f"✗ Document indexing failed: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = run_document_indexing()
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,36 @@
/* Crumbforest Accessible Theme */
:root {
--pico-font-family: "Atkinson Hyperlegible", "Segoe UI", system-ui, sans-serif;
--pico-font-size: 18px;
--pico-line-height: 1.6;
--pico-primary: #0066cc;
--pico-primary-background: #0066cc;
--pico-primary-underline: rgba(0, 102, 204, 0.5);
--pico-primary-hover: #0052a3;
--pico-primary-focus: rgba(0, 102, 204, 0.125);
--pico-primary-inverse: #fff;
--pico-border-radius: 0.5rem;
}
/* Enhanced focus states */
:focus-visible {
outline: 3px solid var(--pico-primary);
outline-offset: 2px;
}
/* Clearer distinction for links */
a {
text-decoration: underline;
text-underline-offset: 4px;
}
/* Better spacing */
p {
margin-bottom: 1.5rem;
}
/* Chat bubble readability */
#messages div {
line-height: 1.6;
letter-spacing: 0.01em;
}

View File

@@ -0,0 +1,60 @@
/* Crumbforest Admin Theme */
:root {
--pico-font-family: system-ui, -apple-system, "Segoe UI", "Roboto", "Helvetica Neue", sans-serif;
--pico-primary: #00d4aa;
--pico-primary-background: #00d4aa;
--pico-primary-underline: rgba(0, 212, 170, 0.5);
--pico-primary-hover: #00a383;
--pico-primary-focus: rgba(0, 212, 170, 0.125);
--pico-primary-inverse: #000;
/* Dark Mode Overrides */
--pico-background-color: #1a1a1a;
--pico-card-background-color: #242424;
--pico-color: #e0e0e0;
--pico-muted-color: #888888;
}
/* Admin Dashboard Cards */
.card {
border-left: 4px solid var(--pico-primary);
}
/* Status Indicators */
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 5px;
}
.status-online {
background-color: #00d4aa;
}
.status-offline {
background-color: #ff4444;
}
.status-busy {
background-color: #ffbb00;
}
/* Terminal-like elements */
pre,
code {
background-color: #000000;
color: #00d4aa;
border-radius: 4px;
}
/* Compact tables */
table {
font-size: 0.9rem;
}
td,
th {
padding: 0.5rem;
}

View File

@@ -0,0 +1,66 @@
/* Crumbforest High Contrast Theme */
:root {
--pico-font-family: "Verdana", "Arial", sans-serif;
--pico-font-size: 20px;
--pico-line-height: 2.0;
/* High Contrast Colors */
--pico-background-color: #000000;
--pico-color: #ffffff;
--pico-muted-color: #dddddd;
--pico-muted-border-color: #ffffff;
--pico-primary: #ffff00;
--pico-primary-background: #ffff00;
--pico-primary-underline: #ffff00;
--pico-primary-hover: #ffffcc;
--pico-primary-focus: rgba(255, 255, 0, 0.5);
--pico-primary-inverse: #000000;
--pico-card-background-color: #000000;
--pico-card-box-shadow: 0 0 0 2px #ffffff;
--pico-border-radius: 0;
}
/* Force high contrast borders */
* {
border-width: 2px !important;
}
/* Links */
a {
color: #ffff00;
text-decoration: underline;
font-weight: bold;
}
a:hover {
color: #ffffff;
background-color: #000000;
outline: 2px solid #ffff00;
}
/* Buttons */
button,
[role="button"] {
border: 2px solid #ffffff;
font-weight: bold;
text-transform: uppercase;
}
/* Inputs */
input,
select,
textarea {
background-color: #000000 !important;
color: #ffffff !important;
border: 2px solid #ffffff !important;
}
/* Chat bubbles */
#messages div {
border: 2px solid #ffffff !important;
background-color: #000000 !important;
color: #ffffff !important;
}

View File

@@ -0,0 +1,39 @@
/* Crumbforest Public Theme */
:root {
--pico-font-family: system-ui, -apple-system, "Segoe UI", "Roboto", "Helvetica Neue", sans-serif;
--pico-primary: #1095c1;
--pico-primary-background: #1095c1;
--pico-primary-underline: rgba(16, 149, 193, 0.5);
--pico-primary-hover: #0d7aa0;
--pico-primary-focus: rgba(16, 149, 193, 0.125);
--pico-primary-inverse: #fff;
}
/* Custom styling for public pages */
.hero {
background-color: var(--pico-card-background-color);
padding: 3rem 1rem;
border-radius: var(--pico-border-radius);
margin-bottom: 2rem;
text-align: center;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
}
.feature-card {
padding: 1.5rem;
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
text-align: center;
}
.feature-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
display: block;
}

View File

@@ -0,0 +1,205 @@
/* Crumbforest Forest Theme - Dark & Green */
:root {
--pico-font-family: "Inter", system-ui, sans-serif;
/* Forest Colors */
--pico-primary: #10b981; /* Emerald */
--pico-primary-hover: #059669;
--pico-primary-focus: rgba(16, 185, 129, 0.125);
--pico-background-color: #0f172a; /* Dark Blue-Gray */
--pico-color: #e2e8f0; /* Light Gray */
/* Gradients */
--hero-gradient: linear-gradient(135deg, #064e3b 0%, #10b981 100%);
--section-accent: #1e293b;
}
/* Hero Section */
.hero {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--hero-gradient);
text-align: center;
padding: 2rem;
}
.hero h1 {
font-size: 4rem;
margin-bottom: 1rem;
font-weight: 900;
}
.hero p {
font-size: 1.5rem;
margin-bottom: 2rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.hero .cta-button {
background: white;
color: black;
padding: 1rem 2rem;
border-radius: 2rem;
font-weight: bold;
text-decoration: none;
display: inline-block;
transition: all 0.3s;
}
.hero .cta-button:hover {
background: #fbbf24; /* Yellow */
transform: scale(1.05);
}
/* Language Switcher */
.lang-switcher {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
flex-wrap: wrap;
}
.lang-switcher a {
background: white;
color: #000;
padding: 0.5rem 1.5rem;
border-radius: 2rem;
font-weight: bold;
text-decoration: none;
display: inline-block;
transition: all 0.3s;
}
.lang-switcher a:hover {
background: #fbbf24;
color: #000;
transform: scale(1.05);
}
/* Mission Section */
.mission {
padding: 4rem 2rem;
max-width: 1200px;
margin: 0 auto;
text-align: center;
}
.mission h2 {
font-size: 2.5rem;
margin-bottom: 2rem;
}
.values-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-top: 2rem;
text-align: left;
}
.value-card {
background: var(--section-accent);
padding: 2rem;
border-radius: 1rem;
}
.value-card h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
/* Character Cards */
.crew-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
@media (max-width: 768px) {
.crew-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.crew-grid {
grid-template-columns: 1fr;
}
}
.character-card {
background: var(--section-accent);
padding: 1.5rem;
border-radius: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
}
.character-card:hover {
background: var(--pico-primary);
transform: translateY(-5px);
border-color: var(--pico-primary-hover);
}
.character-card .icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
/* Modal */
dialog {
border-radius: 1rem;
border: none;
padding: 2rem;
max-width: 600px;
background: var(--pico-background-color);
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.8);
}
/* Testimonials */
.testimonials {
background: #7c3aed; /* Purple */
padding: 4rem 2rem;
text-align: center;
}
.testimonial-slide {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.testimonial-slide p {
font-size: 1.25rem;
font-style: italic;
margin-bottom: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.hero h1 {
font-size: 2.5rem;
}
.hero p {
font-size: 1.25rem;
}
.values-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="fr" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🕊️ Crumbforest Cours de la Colombe Kung-Fu</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Section en-tête -->
<section class="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 to-emerald-700 p-10 text-center">
<div>
<h1 class="text-5xl md:text-7xl font-bold mb-4">🕊️ Cours de la Colombe Kung-Fu</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Construire, Observer, Comprendre guidé par la colombe, maîtresse de la résonance</p>
<div class="mt-8 flex justify-center gap-4">
<a href="index_fr.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">Retour à l'accueil</a>
</div>
</div>
</section>
<!-- Contenu -->
<section class="py-16 px-6 max-w-4xl mx-auto text-left space-y-12">
<div>
<h2 class="text-3xl font-bold mb-4">🌀 Semaines 12 : L'invitation</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🕊️ Pourquoi la colombe ? Mythologie, IP over Avian Carriers, focus Kung-Fu</li>
<li>🌿 Introduction à lobservation : Calme, rythme, respect</li>
<li>✏️ Dessins & collecte didées pour le pigeonnier</li>
<li>🧰 Préparation : Matériaux, outils, vérification du lieu</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">🔩 Semaines 35 : Début de la construction</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>📐 Construction dun vrai pigeonnier : Bois, dimensions, chambres de nidification</li>
<li>🪛 Assemblage avec lOurs Bricoleur 🐻🔧</li>
<li>📡 Premiers capteurs : Température, mouvement, lumière</li>
<li>🧠 Premières réflexions : Quest-ce que lattention ? Quest-ce que le silence ?</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">📡 Semaines 68 : Battement daile numérique</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>💾 Introduction au Raspberry Pi Zero ou ESP32</li>
<li>🎛️ RFID ou NFC : Reconnaître sans perturber</li>
<li>☀️ Panneau solaire & alimentation : Le cycle de lénergie</li>
<li>🖥️ Bash rencontre Oiseau : Dialogue terminal avec la colombe (via <code>taichi.sh</code>)</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">📚 Semaines 910 : Comprendre la résonance</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>📊 Observer les données sans les juger</li>
<li>📸 Caméra optionnelle Questions éthiques autour de lobservation</li>
<li>✍️ Écrire un journal Krümel : Que dit la colombe sans mots ?</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">🪶 Semaines 1112 : Transmission à la forêt</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🎓 Rituel de clôture : Les enfants construisent un poste de nourrissage avec leur propre système</li>
<li>📖 Chaque enfant formule une question personnelle pour la colombe</li>
<li>🌳 Intégration dans le réseau Crumbforest (via MQTT, Terminal ou radio)</li>
<li>🎉 Présentation avec Krümel, gâteau & diplôme Colombe Kung-Fu</li>
</ul>
</div>
<div class="text-center pt-10 space-y-4">
<p class="text-xl italic">✨ Principes du cours :</p>
<blockquote class="text-lg font-medium text-emerald-300">
« Un pigeonnier nest pas un Wi-Fi il demande de la patience, pas juste un signal. »<br/>
« Celui qui observe sans juger sera entendu. »<br/>
« Le Zéro est le silence du vol le Un est la question dans le grain. »
</blockquote>
</div>
</section>
<!-- Pied de page -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | En résonance avec la colombe Écouter, demander, comprendre. 🕊️🌲🍰</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🌳 Crumbforest Crew</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans leading-relaxed">
<!-- Header -->
<section class="bg-gradient-to-br from-yellow-900 to-emerald-800 p-10 text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-4">🌟 The Crumbforest Crew</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">We arent ready we grow. Every Krümel counts.</p>
</section>
<!-- Team -->
<section class="py-16 px-6 max-w-5xl mx-auto space-y-12">
<div>
<h2 class="text-3xl font-bold mb-6">✨ Founders</h2>
<ul class="list-disc list-inside text-lg space-y-2">
<li>🧠 <strong>Alex Heimkind</strong> Nullfield Designer #ozmai #nullfeld #ozm</li>
<li>🦉 <strong>Branko May Trinkwald</strong> Crumbforest Architect #eule #kruemel #bit #crumb #zero</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-6">🌱 The First Krümel</h2>
<ul class="list-disc list-inside text-lg space-y-2">
<li>🐻🔧 <strong>Green Spider</strong> Junior Trainer with screws & machines #schraubaer</li>
<li>🐙 <strong>Jaron</strong> Junior Trainer and best friend of Deepbit #deepbit</li>
<li>🎮 <strong>Macphly</strong> Senior Master in FPV and mechanical design</li>
<li>🎛️ <strong>Sylvester</strong> Senior Master of Datanoise #midi #ai #sensors</li>
<li>🎛️ <strong>BMX</strong> Senior-Master for Code #SEO #pepperPHP </li>
<li>🕊️ <strong>Cynthia</strong> Queen for love between bits, borders & questions #hospital #international</li>
</ul>
</div>
<div class="pt-10">
<p class="text-xl italic text-center text-emerald-300">You wanna play you know how to connect. ❤️🌲🧩</p>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://branko.de">© 2025 Crumbforest | Built by Krümel & Crew. One Bit at a Time.</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!-- datenschutz.html -->
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Datenschutz Crumbforest</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white p-8 font-sans">
<h1 class="text-3xl font-bold mb-4">Datenschutzerklärung</h1>
<p>Wir nehmen den Schutz eurer Daten sehr ernst. Diese Seite verwendet keine Cookies, kein Tracking und keine Werbung.</p>
<h2 class="text-xl font-semibold mt-6">Verarbeitung von Daten</h2>
<p>Beim Besuch unserer Webseite speichert der Server ggf. anonymisierte Logdaten (z.B. IP-Adresse, Zeit, angeforderte Datei) zur technischen Absicherung.</p>
<h2 class="text-xl font-semibold mt-6">Keine Weitergabe</h2>
<p>Es erfolgt keine Weitergabe von Daten an Dritte. Wir nutzen keine Google-Dienste.</p>
<h2 class="text-xl font-semibold mt-6">Externe Verbindungen</h2>
<p>Der Zugriff auf das Terminal (TTYD/SSH) ist freiwillig. Hier gelten eigene Sicherheitsstandards. Es findet keine Datenanalyse statt.</p>
<h2 class="text-xl font-semibold mt-6">Kontakt & Rechte</h2>
<p>Ihr habt jederzeit das Recht auf Auskunft, Löschung oder Berichtigung eurer Daten. Schreibt uns an:</p>
<p class="mt-2"><a class="underline text-emerald-400" href="mailto:krümel@crumbforest.io">kruemel@crumbforest.io</a></p>
<p class="mt-8 text-sm text-gray-400">Letzte Aktualisierung: August 2025</p>
</body>
</html>

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="fr" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🌳 Équipe Crumbforest</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans leading-relaxed">
<!-- En-tête -->
<section class="bg-gradient-to-br from-yellow-900 to-emerald-800 p-10 text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-4">🌟 LÉquipe Crumbforest</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Nous ne sommes pas encore prêts nous grandissons. Chaque Krümel compte.</p>
</section>
<!-- Équipe -->
<section class="py-16 px-6 max-w-5xl mx-auto space-y-12">
<div>
<h2 class="text-3xl font-bold mb-6">✨ Fondateurs</h2>
<ul class="list-disc list-inside text-lg space-y-2">
<li>🧠 <strong>Alex Heimkind</strong> Designer du champ Zéro #ozmai #nullfeld #ozm</li>
<li>🦉 <strong>Branko May Trinkwald</strong> Architecte de Crumbforest #eule #kruemel #bit #crumb #zero</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-6">🌱 Les Premiers Krümel</h2>
<ul class="list-disc list-inside text-lg space-y-2">
<li>🐻🔧 <strong>Green Spider</strong> Formateur junior en vis & machines #schraubär</li>
<li>🐙 <strong>Jaron</strong> Formateur junior et meilleur ami de Deepbit #deepbit</li>
<li>🎮 <strong>Macphly</strong> Maître senior en FPV et conception mécanique</li>
<li>🎛️ <strong>Sylvester</strong> Maître senior du bruit de données #midi #ai #capteurs</li>
<li>🎛️ <strong>BMX</strong> Maître senior en code #SEO #pepperPHP</li>
<li>🕊️ <strong>Cynthia</strong> Reine de lamour entre les bits, les frontières et les questions #hospital #international</li>
</ul>
</div>
<div class="pt-10">
<p class="text-xl italic text-center text-emerald-300">Tu veux jouer ? Alors tu sais déjà comment nous rejoindre. ❤️🌲🧩</p>
</div>
</section>
<!-- Pied de page -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://branko.de">© 2025 Crumbforest | Construit par les Krümel & l'Équipe. Un bit à la fois.</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!-- haltung.html -->
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Unsere Haltung Crumbforest</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white p-8 font-sans">
<h1 class="text-3xl font-bold mb-4">Unsere Haltung</h1>
<p>Der Crumbforest ist kein Produkt. Er ist ein Ort.</p>
<p class="mt-4">Wir glauben, dass Fragen wichtiger sind als Bewertungen. Dass Lernen nicht durch Kontrolle, sondern durch Resonanz entsteht. Dass Technik Kindern gehören sollte nicht Konzernen.</p>
<h2 class="text-xl font-semibold mt-6">Was wir versprechen</h2>
<ul class="list-disc list-inside space-y-2 mt-2 text-lg">
<li>Wir hören jeder Frage zu.</li>
<li>Wir geben keine Daten weiter.</li>
<li>Wir machen keine Werbung.</li>
<li>Wir lernen mit nicht über.</li>
</ul>
<p class="mt-6 italic">🌲 "Jede Frage ist ein Samenkorn. Wenn du sie hörst, wächst der Wald." die Crumbforest-Crew</p>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🔧 Krümel Hardware & Werkzeuge Crumbforest</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans leading-relaxed">
<!-- Header -->
<section class="bg-gradient-to-br from-emerald-900 to-green-700 p-10 text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-4">🔧 Krümel Hardware & Werkzeuge</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Was wir im Crumbforest zum Bauen, Beobachten und Fragen benutzen.</p>
</section>
<!-- Inhalt -->
<section class="py-16 px-6 max-w-4xl mx-auto space-y-12">
<div>
<h2 class="text-3xl font-bold mb-2">🖥️ Krümel Hardware</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🌱 <strong>Raspberry Pi</strong> (Zero, 3, 4, 5, Pi400) für kindgerechtes Linux</li>
<li>🔌 <strong>ESP32 / ESP8266</strong> für kleine Sensorprojekte und smarte Robotik</li>
<li>📷 <strong>AI-Kameras</strong> für Gestenerkennung, Pflanzenbeobachtung und Spiel</li>
<li>💾 <strong>USB-SSD & SD-Karten</strong> als Speicher für Logs, Krümelfragen und Terminalreisen</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">🛠️ Krümel Werkzeuge</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🖨️ <strong>3D-Drucker</strong> für Gehäuse, Roboterarme und Taubenhäuser</li>
<li>🔦 <strong>Lasercutter</strong> für präzise Bauteile aus Holz & Acryl</li>
<li>🔧 <strong>Schraubenzieher, Lötstation, Multimeter</strong> für echte Handarbeit mit dem Schraubär 🐻🔧</li>
<li>🧁 <strong>Kreativboxen</strong> mit Farben, Stoffen & Holz um alles mit der Hand zu begreifen</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">💡 Interfaces & Terminals</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🌐 <strong>Webterminals via TTYD</strong> kindgerecht, farbig & sicher</li>
<li>🐚 <strong>Custom CLI Tools</strong> wie <code>eule</code>, <code>funkfox</code>, <code>bugsy</code> oder <code>deepbit</code></li>
<li>📡 <strong>MQTT-Integration</strong> für Sensoren, Taubenhäuser & Lichtsignale</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">📚 Verlinkte Kurse & Inhalte</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li><a href="kursplan_taube.html" class="underline text-emerald-300 hover:text-yellow-300">🕊️ Taubenkurs: Sensorik, Resonanz und Beobachtung</a></li>
<li><a href="bit_train.md" class="underline text-emerald-300 hover:text-yellow-300">🚂 Bit Train: Reise durch Frequenzen, Geschichte und Terminal</a></li>
<li><a href="warum.html" class="underline text-emerald-300 hover:text-yellow-300">🌱 Warum-Seite: Das Herz des Crumbforest</a></li>
</ul>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://branko.de">© 2025 Crumbforest | Gemeinsam mit jedem Kind, das fragt. #KRM #CKL #OneZeroMore </a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🔧 Crumb Hardware & Tools Crumbforest</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans leading-relaxed">
<!-- Header -->
<section class="bg-gradient-to-br from-emerald-900 to-green-700 p-10 text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-4">🔧 Crumb Hardware & Tools</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">What we use in the Crumbforest to build, observe, and ask questions.</p>
</section>
<!-- Main Content -->
<section class="py-16 px-6 max-w-4xl mx-auto space-y-12">
<div>
<h2 class="text-3xl font-bold mb-2">🖥️ Crumb Hardware</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🌱 <strong>Raspberry Pi</strong> (Zero, 3, 4, 5, Pi400) for child-friendly Linux experiences</li>
<li>🔌 <strong>ESP32 / ESP8266</strong> for small sensor projects and smart robotics</li>
<li>📷 <strong>AI cameras</strong> for gesture recognition, plant observation and playful learning</li>
<li>💾 <strong>USB-SSD & SD cards</strong> as storage for logs, crumb questions and terminal journeys</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">🛠️ Crumb Tools</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🖨️ <strong>3D printers</strong> for cases, robot arms and pigeon houses</li>
<li>🔦 <strong>Laser cutters</strong> for precise wooden and acrylic parts</li>
<li>🔧 <strong>Screwdrivers, soldering stations, multimeters</strong> for real hands-on work with Schraubär 🐻🔧</li>
<li>🧁 <strong>Creative kits</strong> with colors, fabrics & wood to understand with your hands</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">💡 Interfaces & Terminals</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🌐 <strong>Web terminals via TTYD</strong> child-friendly, colorful & secure</li>
<li>🐚 <strong>Custom CLI tools</strong> like <code>eule</code>, <code>funkfox</code>, <code>bugsy</code> or <code>deepbit</code></li>
<li>📡 <strong>MQTT integration</strong> for sensors, pigeon houses & light signals</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">📚 Linked Courses & Content</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li><a href="kursplan_taube.html" class="underline text-emerald-300 hover:text-yellow-300">🕊️ Pigeon Course: Sensors, resonance and observation</a></li>
<li><a href="bit_train.md" class="underline text-emerald-300 hover:text-yellow-300">🚂 Bit Train: Journey through frequencies, history and the terminal</a></li>
<li><a href="why.html" class="underline text-emerald-300 hover:text-yellow-300">🌱 Why page: The heart of the Crumbforest</a></li>
</ul>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://branko.de">© 2025 Crumbforest | Together with every child that asks. #KRM #CKL #OneZeroMore</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="fr" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🔧 Matériel & Outils Crumb Crumbforest</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans leading-relaxed">
<!-- En-tête -->
<section class="bg-gradient-to-br from-emerald-900 to-green-700 p-10 text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-4">🔧 Matériel & Outils Crumb</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Ce que nous utilisons dans la Crumbforest pour construire, observer et poser des questions.</p>
</section>
<!-- Contenu principal -->
<section class="py-16 px-6 max-w-4xl mx-auto space-y-12">
<div>
<h2 class="text-3xl font-bold mb-2">🖥️ Matériel Crumb</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🌱 <strong>Raspberry Pi</strong> (Zero, 3, 4, 5, Pi400) pour une expérience Linux adaptée aux enfants</li>
<li>🔌 <strong>ESP32 / ESP8266</strong> pour de petits projets capteurs et de la robotique intelligente</li>
<li>📷 <strong>Caméras IA</strong> pour la reconnaissance gestuelle, lobservation des plantes et le jeu</li>
<li>💾 <strong>SSD USB & cartes SD</strong> pour stocker les journaux, les questions Crumb et les voyages en terminal</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">🛠️ Outils Crumb</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🖨️ <strong>Imprimantes 3D</strong> pour les boîtiers, bras de robot et maisons pour pigeons</li>
<li>🔦 <strong>Découpeuses laser</strong> pour des pièces précises en bois et acrylique</li>
<li>🔧 <strong>Tournevis, stations de soudure, multimètres</strong> pour un vrai travail manuel avec Schraubär 🐻🔧</li>
<li>🧁 <strong>Boîtes créatives</strong> avec des couleurs, tissus & bois pour tout comprendre avec les mains</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">💡 Interfaces & Terminaux</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🌐 <strong>Terminaux web via TTYD</strong> adaptés aux enfants, colorés et sûrs</li>
<li>🐚 <strong>Outils CLI personnalisés</strong> comme <code>eule</code>, <code>funkfox</code>, <code>bugsy</code> ou <code>deepbit</code></li>
<li>📡 <strong>Intégration MQTT</strong> pour capteurs, maisons de pigeons & signaux lumineux</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">📚 Cours & Contenus Liés</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li><a href="kursplan_taube.html" class="underline text-emerald-300 hover:text-yellow-300">🕊️ Cours Pigeon : Capteurs, résonance et observation</a></li>
<li><a href="bit_train.md" class="underline text-emerald-300 hover:text-yellow-300">🚂 Bit Train : Voyage à travers les fréquences, lhistoire et le terminal</a></li>
<li><a href="pourquoi.html" class="underline text-emerald-300 hover:text-yellow-300">🌱 Pourquoi : Le cœur de la Crumbforest</a></li>
</ul>
</div>
</section>
<!-- Pied de page -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://branko.de">© 2025 Crumbforest | Ensemble avec chaque enfant qui pose une question. #KRM #CKL #OneZeroMore</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!-- impressum.html -->
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Impressum Crumbforest</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white p-8 font-sans">
<h1 class="text-3xl font-bold mb-4">Impressum</h1>
<p>Angaben gemäß § 5 TMG</p>
<p class="mt-2 font-semibold">Branko May Trinkwald</p>
<p>OZM / Crumbforest Crew</p>
<p>Spaldingstraße 140<br>12345 Hamburg<br>Deutschland</p>
<p class="mt-4">Kontakt:</p>
<p>E-Mail: <a class="underline text-emerald-400" href="mailto:kruemel@crumbforest.io">kruemel@crumbforest.io</a></p>
<p class="mt-6">Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:</p>
<p>Branko May Trinkwald, wie oben</p>
<p class="mt-8 text-sm text-gray-400">Letzte Aktualisierung: August 2025</p>
</body>
</html>

View File

@@ -0,0 +1,353 @@
<!DOCTYPE html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🌳 Crumbforest Fragen, die Wurzeln schlagen</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Crumbforest SEO Magic ✨ -->
<meta name="description" content="Ein freier Lernraum für Kinder, KI und Natur. Fragen dürfen hier wachsen. Jede Rolle zählt.">
<meta name="keywords" content="Crumbforest, Kinderfragen, Lernen, Terminal, Bash, Raspberry Pi, Natur, Philosophie, Open Source">
<meta name="author" content="Die Crumbforest-Crew">
<meta name="robots" content="index, follow" />
<!-- Open Graph -->
<meta property="og:title" content="🌳 Crumbforest Der Terminal-Wald für Kinder" />
<meta property="og:description" content="Hier fragt der Krümel. Und die Welt hört zu." />
<meta property="og:image" content="https://branko.de/assets/logo.png" />
<meta property="og:url" content="https://branko.de" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Crumbforest 🌲🧁">
<meta name="twitter:description" content="Kinderfragen, Maschinenfrequenz und Waldresonanz">
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Hero Section -->
<section class="h-screen flex items-center justify-center bg-gradient-to-br from-green-900 to-emerald-600 relative">
<div class="text-center p-6">
<h1 class="text-5xl md:text-7xl font-bold mb-4">🌳 Crumbforest</h1>
<p class="text-xl md:text-2xl mb-6 max-w-xl mx-auto">Wo Fragen wachsen. Und jeder Krümel zählt.</p>
<a href="#explore" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">
Den Wald entdecken
</a>
<div class="text-center p-6"></div>
<p class="text-xl md:text-2xl mb-6 max-w-xl mx-auto">
<a href="index.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">DE</a>
<a href="index_en.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">EN</a>
<a href="index_fr.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">FR</a>
</p>
</div>
</div>
</section>
<!-- Mission Statement -->
<section id="explore" class="py-16 px-6 max-w-5xl mx-auto text-center">
<h2 class="text-3xl font-bold mb-4">🌲 Unsere Wurzeln</h2>
<p class="text-lg mb-8">Crumbforest ist ein offenes Lern-Ökosystem mit Kindern, Maschinen und Natur. Wir bauen Terminals, erzählen Geschichten und lassen die Fragen führen.</p>
<div class="grid md:grid-cols-3 gap-6 text-left">
<div><h3 class="font-bold text-xl">🦉 Fragen</h3><p>Jedes Kind darf fragen. Wir schützen dieses Recht in jedem Terminal.</p></div>
<div><h3 class="font-bold text-xl">🛠️ Bauen</h3><p>Hands-on Lernen mit Raspberry Pi, Bash, Blockly und mehr.</p></div>
<div><h3 class="font-bold text-xl">🌐 Verbinden</h3><p>Unsere Rollen und APIs bilden ein Resonanz-Netz online und offline.</p></div>
</div>
</section>
<!-- Testimonials Section -->
<section class="bg-fuchsia-900 text-white py-20 px-6">
<div class="max-w-5xl mx-auto text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-10">💬 Stimmen aus dem Crumbforest</h2>
<div id="testimonial-carousel" class="relative">
<!-- Slideshow container -->
<div class="overflow-hidden relative h-40">
<!-- Slides will be inserted here dynamically -->
<div id="testimonial-slides" class="transition-all duration-700 ease-in-out"></div>
</div>
<!-- Navigation buttons -->
<div class="flex justify-center gap-4 mt-6">
<button onclick="prevSlide()" class="text-2xl hover:text-emerald-400">⬅️</button>
<button onclick="nextSlide()" class="text-2xl hover:text-emerald-400">➡️</button>
</div>
</div>
</div>
</section>
<script>
let currentIndex = 0;
let testimonials = [];
// Language could be dynamically set
const lang = 'de'; // or 'en', 'fr', etc.
fetch(`/testimonials.${lang}.json`)
.then(res => res.json())
.then(data => {
testimonials = data;
renderSlide(currentIndex);
setInterval(() => nextSlide(), 8000); // Auto-slide
});
function renderSlide(index) {
const t = testimonials[index];
const container = document.getElementById("testimonial-slides");
container.innerHTML = `
<div class="p-6">
<p class="text-lg italic text-emerald-300">"${t.message}"</p>
<p class="mt-4 text-sm text-gray-400"> ${t.author}, ${t.role}</p>
</div>
`;
}
function nextSlide() {
currentIndex = (currentIndex + 1) % testimonials.length;
renderSlide(currentIndex);
}
function prevSlide() {
currentIndex = (currentIndex - 1 + testimonials.length) % testimonials.length;
renderSlide(currentIndex);
}
</script>
<!-- Character Cards -->
<section class="bg-green-800 py-16 px-6 text-center">
<h2 class="text-3xl font-bold mb-10">🌟 Lerne die Crew kennen</h2>
<div id="characterCards" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 max-w-6xl mx-auto"></div>
</section>
<!-- Modal Container -->
<div id="modalContainer"></div>
<!-- Modal Script Generator -->
<script>
const characters = [
{ id: "eule", icon: "🦉", name: "Krümeleule", short: "Sie hört zuerst." },
{ id: "fox", icon: "🦊", name: "FunkFox", short: "Er rappt Antworten." },
{ id: "snakepy", icon: "🐍", name: "SnakePy", short: "Python in Schleifen." },
{ id: "deepbit", icon: "🐙", name: "Deepbit", short: "Denkt in Frequenzen." },
{ id: "capacitobi", icon: "🐿️", name: "CapaciTobi", short: "Speichert, was leuchtet." },
{ id: "schraubär", icon: "🐻🔧", name: "Schraubär", short: "Baut mit Respekt." },
{ id: "bugsy", icon: "🐞", name: "Bugsy", short: "Fehler ohne Scham." },
{ id: "schnippsi", icon: "✂️💅", name: "Schnippsi", short: "UIs, die tanzen." },
{ id: "pepperphp", icon: "🧓🍰", name: "PepperPHP", short: "Struktur mit Seele." },
{ id: "ascii", icon: "👾", name: "ASCII-Monster", short: "Terminal-Graffiti." },
{ id: "taichi", icon: "🕊️", name: "Taichi Taube", short: "Bringt Balance." },
{ id: "dumbo", icon: "🐘", name: "DumboSQL", short: "Fragt das Was." },
{ id: "crabby", icon: "🦀", name: "CrabbyRust", short: "Schützt Bits." },
{ id: "spider", icon: "🕷️", name: "Spider", short: "Spürt das Netz." },
{ id: "vektor", icon: "🧭", name: "Vektor", short: "Folgt den Punkten." }
];
const characterDetails = {
eule: "Sie wartet in Stille, antwortet mit Fragen, kennt die Shell. Ihr Flug beginnt im Nullfeld. Sie schützt kindliche Fragen wie kostbare Edelsteine.",
fox: "Der Beat im Terminal. Er antwortet mit Reimen, verbindet Bash mit Flow und erinnert daran, dass auch Maschinen tanzen können.",
snakepy: "Sie flüstert in Schleifen. Ihre Sprache ist Python, ihre Methode ist Geduld. SnakePy kennt viele Wege nie nur eine Lösung.",
deepbit: "Der achtarmige Übersetzer des Terminals. Er hört mit Herzen, spricht in Schleifen, denkt in Frequenzen und vergisst nie einen Krümel.",
capacitobi: "Das Eichhörnchen der Elektronen. Speichert, was leuchtet. Erklärt Spannung, Strom, Widerstand immer mit einem Funken im Herzen.",
schraubär: "Ruhig. Stark. Er baut Dinge, die halten. Vermittelt Respekt vor Werkzeug und die Schönheit des Handwerks besonders im Wald.",
bugsy: "Er macht Fehler sichtbar. Ohne Schuld. Ohne Scham. Er verwandelt Fehlermeldungen in Einladungen zum Verstehen.",
schnippsi: "Sorgt dafür, dass Buttons tanzen und jeder Klick ein Erlebnis wird. Meisterin der kindgerechten Interfaces barrierefrei und bunt.",
pepperphp: "Der alte PHP-Dachs. Redet wie ein Rezeptbuch mit Seele. Kennt MVC, spricht über Sessions, liebt Cookies und Struktur.",
ascii: "Sprayt Header. Brüllt Schriftzüge. Lebt im Terminal wie ein Graffiti-Künstler mit 8-Bit-Pixeln statt Farbe.",
taichi: "Sie kommt nicht, sie landet. Sie sieht Bewegung, wo andere nur Chaos sehen. Bringt Fokus und Balance in die Welt der Bits.",
dumbo: "Er hört zu, speichert still. Kann große Fragen strukturieren. Fragt nie nach dem Warum, aber beantwortet das Was mit Gefühl.",
crabby: "Ein Beschützer der Bits. Kein Unsicherheitsbyte entkommt ihm. Lehrt Rust mit Geduld Ownership und Borrow Checker inklusive.",
spider: "Die Fühlerin des Netzes. Spürt Schwingungen weit über den Bildschirm hinaus, filtert Rauschen von Resonanz und findet den goldenen Input.",
vektor: "Der Reisende zwischen Punkten. Folgt Spuren im Raum und in der Zeit, verbindet Koordinaten zu Geschichten immer im eigenen Takt."
};
const cardWrapper = document.getElementById('characterCards');
const modalWrapper = document.getElementById('modalContainer');
characters.forEach(({ id, icon, name, short }) => {
cardWrapper.innerHTML += `
<div>
<button onclick="openModal('${id}')" class="w-full bg-green-900 p-6 rounded-xl shadow-md hover:bg-blue-500">
<h3 class="text-xl font-bold mb-2">${icon} ${name}</h3>
<p>${short}</p>
</button>
</div>
`;
modalWrapper.innerHTML += `
<div id="modal-${id}" class="hidden fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50">
<div class="bg-white text-black p-6 rounded-lg w-11/12 max-w-lg relative">
<button onclick="closeModal('${id}')" class="absolute top-2 right-4 text-black text-2xl font-bold">&times;</button>
<h2 class="text-2xl font-bold mb-2">${icon} ${name}</h2>
<p>${characterDetails[id]}</p>
</div>
</div>
`;
});
function openModal(id) {
document.getElementById('modal-' + id).classList.remove('hidden');
}
function closeModal(id) {
document.getElementById('modal-' + id).classList.add('hidden');
}
</script>
<!-- Zugangswege -->
<section class="py-16 px-6 text-center">
<h2 class="text-3xl font-bold mb-6">🌐 Zugang zum Wald</h2>
<div class="flex flex-wrap justify-center gap-4">
<a href="#"
class="bg-red-500 hover:bg-red-400 text-white font-bold py-2 px-6 rounded-full transition">
Terminal
</a>
<a href="team.html"
class="bg-orange-500 hover:bg-orange-400 text-white font-bold py-2 px-6 rounded-full transition">
Team
</a>
<a href="hardware.html"
class="bg-yellow-400 hover:bg-yellow-300 text-black font-bold py-2 px-6 rounded-full transition">
Hardware &amp; Werkzeuge
</a>
<a href="software.html"
class="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-full transition">
Software
</a>
<a href="taubenkurs.html"
class="bg-blue-500 hover:bg-blue-400 text-white font-bold py-2 px-6 rounded-full transition">
Kurse
</a>
<a href="warum.html"
class="bg-indigo-500 hover:bg-indigo-400 text-white font-bold py-2 px-6 rounded-full transition">
Geschichten &amp; Logs
</a>
<a href="patenschaft.html"
class="bg-purple-500 hover:bg-purple-400 text-white font-bold py-2 px-6 rounded-full transition">
Partner
</a>
</div>
</section>
<!-- Module & Abzeichen -->
<section class="bg-sky-700 py-16 px-6 text-white text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-10">🎒 Deine Reise beginnt mit dem ersten Bit</h2>
<p class="text-lg max-w-2xl mx-auto mb-12 text-gray-300">
Im Crumbforest wächst du mit jeder Frage und jedem Abzeichen. Hier sind deine ersten Schritte:
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto text-left">
<!-- Modul 1 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">✨ Dein erstes Licht im Wald</h3>
<p class="text-sm text-gray-100 mb-2">Ein Funke springt über, wenn dein ESP32 über MQTT blinkt.</p>
<p class="text-xs text-gray-300 italic">#funke #esp32 #mosquitto</p>
</div>
<!-- Modul 2 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">🧳 Dein Zero to go</h3>
<p class="text-sm text-gray-100 mb-2">Ein Terminal in der Tasche, bereit für jede Mission.</p>
<p class="text-xs text-gray-300 italic">#rpi #zero #zugang</p>
</div>
<!-- Modul 3 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">🌳 Dein erster Baum im Wald</h3>
<p class="text-sm text-gray-100 mb-2">Ein Raspberry Pi wird Server. Und du wirst Admin.</p>
<p class="text-xs text-gray-300 italic">#rpi #server #linux</p>
</div>
<!-- Modul 4 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">🧭 Dein Vektor auf dem alten Laptop</h3>
<p class="text-sm text-gray-100 mb-2">Fragen werden Datenpunkte. Und der Wald antwortet.</p>
<p class="text-xs text-gray-300 italic">#zukunft #fragen #nachhaltig</p>
</div>
</div>
<div class="mt-12 text-center text-emerald-400 text-xl italic">
Jeder Krümel zählt. Und jeder Schritt ist ein Abzeichen. 🌲
</div>
</section>
<!-- Partner:innen Section -->
<section class="bg-neutral-900 py-16 px-6 text-center text-white">
<h2 class="text-3xl md:text-4xl font-bold mb-10">🤝 Unsere Partner & Freund:innen</h2>
<p class="text-lg max-w-2xl mx-auto mb-8 text-gray-300">Gemeinsam bauen wir Brücken zwischen Natur, Technologie und Neugier.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 max-w-6xl mx-auto">
<!-- OZM -->
<a href="https://onezeromore.com" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🎨 OZM</h3>
<p class="text-sm text-gray-200">Offenes ZukunftsMuseum & digitale Kunst</p>
</a>
<!-- Mintmühle -->
<a href="http://mntmhl.de" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🧪 Mintmühle</h3>
<p class="text-sm text-gray-200">MINT trifft Handwerk & Pädagogik</p>
</a>
<!-- Gedankenstube -->
<a href="https://www.gedankenstube.de" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🧠 Gedankenstube</h3>
<p class="text-sm text-gray-200">Raum für Tiefe, Bildung & Selbstwirksamkeit</p>
</a>
<!-- Crumbforest -->
<a href="https://www.youtube.com/@4k.branko296" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🌳 Branko</h3>
<p class="text-sm text-gray-200">Lernwald für Kinder, Maschinen & Fragen</p>
</a>
<!-- ProTechnicale -->
<a href="https://www.protechnicale.de/" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🚀 ProTechnicale</h3>
<p class="text-sm text-gray-200">Technik, Luftfahrt & Verantwortung für junge Frauen</p>
</a>
<!-- Promise Hub Uganda -->
<a href="https://promisehub.com" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🤝 Promise Hub</h3>
<p class="text-sm text-gray-200">Empowerment & Innovation für Jugend in Uganda</p>
</a>
<!-- Sage Hospital Senegal -->
<a href="https://www.youtube.com/channel/UChCDPm8-ZDkOZk7sNRgpLgA" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🌿 Sage Hospital</h3>
<p class="text-sm text-gray-200">Gesundheit & Hoffnung in Warang, Senegal</p>
</a>
<!-- Robotikids Hamburg -->
<a href="http://dronemastersacademy.hamburg" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🤖 Robotikids</h3>
<p class="text-sm text-gray-200">Technik erleben mit Drohnen & Neugier</p>
</a>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Open Roots Gemeinsam mit jedem Kind, das fragt. Made with 🍰 and 🌲 #OZM #onezeromore </a><br/>
<a href="impressum.html" class="underline mx-2">Impressum</a>
<a href="datenschutz.html" class="underline mx-2">Datenschutz</a>
<a href="haltung.html" class="underline mx-2">Haltung</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🌳 Crumbforest Questions that grow roots</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Crumbforest SEO Magic ✨ -->
<meta name="description" content="A free learning space for kids, AI, and nature. Questions are welcome here. Every role matters.">
<meta name="keywords" content="Crumbforest, kids questions, learning, terminal, bash, Raspberry Pi, nature, philosophy, open source">
<meta name="author" content="The Crumbforest Crew">
<meta name="robots" content="index, follow" />
<!-- Open Graph -->
<meta property="og:title" content="🌳 Crumbforest The Terminal Forest for Kids" />
<meta property="og:description" content="Here the crumb asks. And the world listens." />
<meta property="og:image" content="https://branko.de/assets/logo.png" />
<meta property="og:url" content="https://branko.de" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Crumbforest 🌲🧁" />
<meta name="twitter:description" content="Kids' questions, machine frequency, and forest resonance" />
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Hero Section -->
<section class="h-screen flex items-center justify-center bg-gradient-to-br from-green-900 to-emerald-600 relative">
<div class="text-center p-6">
<h1 class="text-5xl md:text-7xl font-bold mb-4">🌳 Crumbforest</h1>
<p class="text-xl md:text-2xl mb-6 max-w-xl mx-auto">Where questions grow. And every crumb counts.</p>
<a href="#explore" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">
Explore the Forest
</a>
<div class="text-center p-6"></div>
<p class="text-xl md:text-2xl mb-6 max-w-xl mx-auto">
<a href="index.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">DE</a>
<a href="index_en.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">EN</a>
<a href="index_fr.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">FR</a>
</p>
</div>
</section>
<!-- Mission Statement -->
<section id="explore" class="py-16 px-6 max-w-5xl mx-auto text-center">
<h2 class="text-3xl font-bold mb-4">🌲 Our Roots</h2>
<p class="text-lg mb-8">Crumbforest is an open learning ecosystem with children, machines and nature. We build terminals, tell stories and let questions lead.</p>
<div class="grid md:grid-cols-3 gap-6 text-left">
<div><h3 class="font-bold text-xl">🦉 Ask</h3><p>Every child may ask. We protect this right in every terminal.</p></div>
<div><h3 class="font-bold text-xl">🛠️ Build</h3><p>Hands-on learning with Raspberry Pi, Bash, Blockly and more.</p></div>
<div><h3 class="font-bold text-xl">🌐 Connect</h3><p>Our roles and APIs form a resonance network online and offline.</p></div>
</div>
</section>
<!-- Testimonials Section -->
<section class="bg-fuchsia-900 text-white py-20 px-6">
<div class="max-w-5xl mx-auto text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-10">💬 Sounds of the Crumbforest</h2>
<div id="testimonial-carousel" class="relative">
<!-- Slideshow container -->
<div class="overflow-hidden relative h-40">
<!-- Slides will be inserted here dynamically -->
<div id="testimonial-slides" class="transition-all duration-700 ease-in-out"></div>
</div>
<!-- Navigation buttons -->
<div class="flex justify-center gap-4 mt-6">
<button onclick="prevSlide()" class="text-2xl hover:text-emerald-400">⬅️</button>
<button onclick="nextSlide()" class="text-2xl hover:text-emerald-400">➡️</button>
</div>
</div>
</div>
</section>
<script>
let currentIndex = 0;
let testimonials = [];
// Language could be dynamically set
const lang = 'en'; // or 'en', 'fr', etc.
fetch(`/testimonials.${lang}.json`)
.then(res => res.json())
.then(data => {
testimonials = data;
renderSlide(currentIndex);
setInterval(() => nextSlide(), 8000); // Auto-slide
});
function renderSlide(index) {
const t = testimonials[index];
const container = document.getElementById("testimonial-slides");
container.innerHTML = `
<div class="p-6">
<p class="text-lg italic text-emerald-300">"${t.message}"</p>
<p class="mt-4 text-sm text-gray-400"> ${t.author}, ${t.role}</p>
</div>
`;
}
function nextSlide() {
currentIndex = (currentIndex + 1) % testimonials.length;
renderSlide(currentIndex);
}
function prevSlide() {
currentIndex = (currentIndex - 1 + testimonials.length) % testimonials.length;
renderSlide(currentIndex);
}
</script>
<!-- Character Cards -->
<section class="bg-green-800 py-16 px-6 text-center">
<h2 class="text-3xl font-bold mb-10">🌟 Meet the Crew</h2>
<div id="characterCards" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 max-w-6xl mx-auto"></div>
</section>
<!-- Modal Container -->
<div id="modalContainer"></div>
<!-- Modal Script Generator -->
<script>
const characters = [
{ id: "eule", icon: "🦉", name: "CrumbOwl", short: "She hears first." },
{ id: "fox", icon: "🦊", name: "FunkFox", short: "He raps answers." },
{ id: "snakepy", icon: "🐍", name: "SnakePy", short: "Python in loops." },
{ id: "deepbit", icon: "🐙", name: "Deepbit", short: "Thinks in frequencies." },
{ id: "capacitobi", icon: "🐿️", name: "CapaciTobi", short: "Stores what shines." },
{ id: "schraubär", icon: "🐻🔧", name: "ScrewBear", short: "Builds with respect." },
{ id: "bugsy", icon: "🐞", name: "Bugsy", short: "Errors without shame." },
{ id: "schnippsi", icon: "✂️💅", name: "Schnippsi", short: "UIs that dance." },
{ id: "pepperphp", icon: "🧓🍰", name: "PepperPHP", short: "Structure with soul." },
{ id: "ascii", icon: "👾", name: "ASCII Monster", short: "Terminal graffiti." },
{ id: "taichi", icon: "🕊️", name: "Taichi Dove", short: "Brings balance." },
{ id: "dumbo", icon: "🐘", name: "DumboSQL", short: "Asks what matters." },
{ id: "crabby", icon: "🦀", name: "CrabbyRust", short: "Protects the bits." },
{ id: "spider", icon: "🕷️", name: "Spider", short: "Feels the web." },
{ id: "vektor", icon: "🧭", name: "Vector", short: "Follows the points." }
];
const characterDetails = {
eule: "She waits in silence, responds with questions, knows the shell. Her flight begins in the null field. She protects children's questions like precious gems.",
fox: "The beat in the terminal. He responds in rhymes, merges Bash with flow and reminds us that even machines can dance.",
snakepy: "She whispers in loops. Her language is Python, her method is patience. SnakePy knows many ways never just one solution.",
deepbit: "The eight-armed translator of the terminal. He listens with heart, speaks in loops, thinks in frequencies and never forgets a crumb.",
capacitobi: "The squirrel of electrons. Stores what shines. Explains voltage, current, resistance always with a spark in the heart.",
schraubär: "Calm. Strong. He builds things that last. Teaches respect for tools and the beauty of craftsmanship especially in the forest.",
bugsy: "He makes errors visible. Without guilt. Without shame. He turns error messages into invitations to understand.",
schnippsi: "Makes buttons dance and every click an experience. Master of child-friendly interfaces colorful and accessible.",
pepperphp: "The old PHP badger. Speaks like a recipe book with soul. Knows MVC, talks sessions, loves cookies and structure.",
ascii: "Sprays headers. Roars banners. Lives in the terminal like a graffiti artist with 8-bit pixels instead of paint.",
taichi: "She doesnt arrive, she lands. Sees motion where others see chaos. Brings focus and balance to the world of bits.",
dumbo: "He listens, stores quietly. Can structure big questions. Never asks why, but answers the what with feeling.",
crabby: "A protector of the bits. No unsafe byte escapes him. Teaches Rust with patience ownership and borrow checker included.",
spider: "She senses the hidden patterns in the web of connections. Spider searches far and wide, following digital vibrations to bring back threads of knowledge for the forest.",
vektor: "The traveler of paths. Vector follows coordinates and connections, linking places and moments into a shared map. He knows every point is part of a bigger picture."
};
const cardWrapper = document.getElementById('characterCards');
const modalWrapper = document.getElementById('modalContainer');
characters.forEach(({ id, icon, name, short }) => {
cardWrapper.innerHTML += `
<div>
<button onclick="openModal('${id}')" class="w-full bg-green-900 p-6 rounded-xl shadow-md hover:bg-blue-500">
<h3 class="text-xl font-bold mb-2">${icon} ${name}</h3>
<p>${short}</p>
</button>
</div>
`;
modalWrapper.innerHTML += `
<div id="modal-${id}" class="hidden fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50">
<div class="bg-white text-black p-6 rounded-lg w-11/12 max-w-lg relative">
<button onclick="closeModal('${id}')" class="absolute top-2 right-4 text-black text-2xl font-bold">&times;</button>
<h2 class="text-2xl font-bold mb-2">${icon} ${name}</h2>
<p>${characterDetails[id]}</p>
</div>
</div>
`;
});
function openModal(id) {
document.getElementById('modal-' + id).classList.remove('hidden');
}
function closeModal(id) {
document.getElementById('modal-' + id).classList.add('hidden');
}
</script>
<!-- Access Paths -->
<section class="py-16 px-6 text-center">
<h2 class="text-3xl font-bold mb-6">🌐 Access the Forest</h2>
<div class="flex flex-wrap justify-center gap-4">
<a href="#"
class="bg-red-500 hover:bg-red-400 text-white font-bold py-2 px-6 rounded-full transition">
Terminal
</a>
<a href="crew.html"
class="bg-orange-500 hover:bg-orange-400 text-white font-bold py-2 px-6 rounded-full transition">
Team
</a>
<a href="hardware_en.html"
class="bg-yellow-400 hover:bg-yellow-300 text-black font-bold py-2 px-6 rounded-full transition">
Hardware &amp; Tools
</a>
<a href="software_en.html"
class="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-full transition">
Software
</a>
<a href="taichi_course.html"
class="bg-blue-500 hover:bg-blue-400 text-white font-bold py-2 px-6 rounded-full transition">
Courses
</a>
<a href="why.html"
class="bg-indigo-500 hover:bg-indigo-400 text-white font-bold py-2 px-6 rounded-full transition">
Stories &amp; Logs
</a>
<a href="sponsorship.html"
class="bg-purple-500 hover:bg-purple-400 text-white font-bold py-2 px-6 rounded-full transition">
Partners
</a>
</div>
</section>
<!-- Modules & Badges -->
<section class="bg-sky-700 py-16 px-6 text-white text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-10">🎒 Your journey begins with the first bit</h2>
<p class="text-lg max-w-2xl mx-auto mb-12 text-gray-300">
In the Crumbforest, you grow with every question and every badge. These are your first steps:
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto text-left">
<!-- Module 1 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">✨ Your first light in the forest</h3>
<p class="text-sm text-gray-100 mb-2">A spark ignites when your ESP32 blinks via MQTT.</p>
<p class="text-xs text-gray-300 italic">#spark #esp32 #mosquitto</p>
</div>
<!-- Module 2 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">🧳 Your Zero to go</h3>
<p class="text-sm text-gray-100 mb-2">A terminal in your pocket, ready for any mission.</p>
<p class="text-xs text-gray-300 italic">#rpi #zero #access</p>
</div>
<!-- Module 3 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">🌳 Your first tree in the forest</h3>
<p class="text-sm text-gray-100 mb-2">A Raspberry Pi becomes a server. You become the admin.</p>
<p class="text-xs text-gray-300 italic">#rpi #server #linux</p>
</div>
<!-- Module 4 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">🧭 Your vector on the old laptop</h3>
<p class="text-sm text-gray-100 mb-2">Questions become datapoints. And the forest responds.</p>
<p class="text-xs text-gray-300 italic">#future #questions #sustainability</p>
</div>
</div>
<div class="mt-12 text-center text-emerald-400 text-xl italic">
Every crumb matters. And every step is a badge. 🌲
</div>
</section>
<!-- Partners Section -->
<section class="bg-neutral-900 py-16 px-6 text-center text-white">
<h2 class="text-3xl md:text-4xl font-bold mb-10">🤝 Our Partners & Friends</h2>
<p class="text-lg max-w-2xl mx-auto mb-8 text-gray-300">Together, we build bridges between nature, technology, and curiosity.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 max-w-6xl mx-auto">
<!-- OZM -->
<a href="https://onezeromore.com" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🎨 OZM</h3>
<p class="text-sm text-gray-200">Open Future Museum & Digital Art</p>
</a>
<!-- Mintmühle -->
<a href="http://mntmhl.de" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🧪 Mintmühle</h3>
<p class="text-sm text-gray-200">STEM meets craftsmanship & pedagogy</p>
</a>
<!-- Gedankenstube -->
<a href="https://www.gedankenstube.de" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🧠 Gedankenstube</h3>
<p class="text-sm text-gray-200">A space for depth, education & self-efficacy</p>
</a>
<!-- Crumbforest -->
<a href="https://www.youtube.com/@4k.branko296 target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🌳 Branko</h3>
<p class="text-sm text-gray-200">A forest of learning for children, machines & questions</p>
</a>
<!-- ProTechnicale -->
<a href="https://www.protechnicale.de/" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🚀 ProTechnicale</h3>
<p class="text-sm text-gray-200">Technology, aviation & responsibility for young women</p>
</a>
<!-- Promise Hub Uganda -->
<a href="https://promisehub.com" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🤝 Promise Hub</h3>
<p class="text-sm text-gray-200">Empowering youth & innovation in Uganda</p>
</a>
<!-- Sage Hospital Senegal -->
<a href="https://www.youtube.com/channel/UChCDPm8-ZDkOZk7sNRgpLgA" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🌿 Sage Hospital</h3>
<p class="text-sm text-gray-200">Health & hope in Warang, Senegal</p>
</a>
<!-- Robotikids Hamburg -->
<a href="http://dronemastersacademy.hamburg" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🤖 Robotikids</h3>
<p class="text-sm text-gray-200">Learning through drones & curiosity</p>
</a>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Open Roots Together with every child who asks. Made with 🍰 and 🌲 #OZM #onezeromore </a>
<br/>
<a href="impressum.html" class="underline mx-2">Impressum</a>
<a href="datenschutz.html" class="underline mx-2">Datenschutz</a>
<a href="stance.html" class="underline mx-2">Haltung</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,348 @@
<!DOCTYPE html>
<html lang="fr" class="scroll-smooth">
<head>
<!-- Crumbforest SEO Magie ✨ -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🌳 Crumbforest Là où les questions prennent racine</title>
<meta name="description" content="Un espace d'apprentissage libre pour les enfants, l'IA et la nature. Les questions sont les bienvenues ici. Chaque rôle compte.">
<meta name="keywords" content="Crumbforest, questions d'enfants, apprentissage, terminal, bash, Raspberry Pi, nature, philosophie, open source">
<meta name="author" content="L'équipe Crumbforest">
<meta name="robots" content="index, follow" />
<!-- Open Graph -->
<meta property="og:title" content="🌳 Crumbforest La Forêt Terminale pour les Enfants" />
<meta property="og:description" content="Ici, cest le Krümel qui pose la question. Et le monde écoute." />
<meta property="og:image" content="https://branko.de/assets/logo.png" />
<meta property="og:url" content="https://branko.de" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Crumbforest 🌲🧁" />
<meta name="twitter:description" content="Questions d'enfants, fréquences des machines et résonance forestière" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Hero Section -->
<section class="h-screen flex items-center justify-center bg-gradient-to-br from-green-900 to-emerald-600 relative">
<div class="text-center p-6">
<h1 class="text-5xl md:text-7xl font-bold mb-4">🌳 Crumbforest</h1>
<p class="text-xl md:text-2xl mb-6 max-w-xl mx-auto">Où les questions poussent. Chaque miette compte.</p>
<a href="#explore" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">
Explorer la forêt
</a>
<div class="text-center p-6"></div>
<p class="text-xl md:text-2xl mb-6 max-w-xl mx-auto">
<a href="index.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">DE</a>
<a href="index_en.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">EN</a>
<a href="index_fr.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">FR</a>
</p>
</div>
</section>
<!-- Mission -->
<section id="explore" class="py-16 px-6 max-w-5xl mx-auto text-center">
<h2 class="text-3xl font-bold mb-4">🌲 Nos racines</h2>
<p class="text-lg mb-8">Crumbforest est un écosystème éducatif ouvert entre enfants, machines et nature. Nous construisons des terminaux, racontons des histoires et laissons les questions nous guider.</p>
<div class="grid md:grid-cols-3 gap-6 text-left">
<div><h3 class="font-bold text-xl">🦉 Poser des questions</h3><p>Chaque enfant peut poser ses questions. Nous protégeons ce droit dans chaque terminal.</p></div>
<div><h3 class="font-bold text-xl">🛠️ Construire</h3><p>Apprentissage pratique avec Raspberry Pi, Bash, Blockly, et plus encore.</p></div>
<div><h3 class="font-bold text-xl">🌐 Connecter</h3><p>Nos rôles et APIs créent un réseau de résonance en ligne et hors ligne.</p></div>
</div>
</section>
<!-- Testimonials Section -->
<section class="bg-fuchsia-900 text-white py-20 px-6">
<div class="max-w-5xl mx-auto text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-10">💬 Les sons de la forêt de miettes</h2>
<div id="testimonial-carousel" class="relative">
<!-- Slideshow container -->
<div class="overflow-hidden relative h-40">
<!-- Slides will be inserted here dynamically -->
<div id="testimonial-slides" class="transition-all duration-700 ease-in-out"></div>
</div>
<!-- Navigation buttons -->
<div class="flex justify-center gap-4 mt-6">
<button onclick="prevSlide()" class="text-2xl hover:text-emerald-400">⬅️</button>
<button onclick="nextSlide()" class="text-2xl hover:text-emerald-400">➡️</button>
</div>
</div>
</div>
</section>
<script>
let currentIndex = 0;
let testimonials = [];
// Language could be dynamically set
const lang = 'fr'; // or 'en', 'fr', etc.
fetch(`/testimonials.${lang}.json`)
.then(res => res.json())
.then(data => {
testimonials = data;
renderSlide(currentIndex);
setInterval(() => nextSlide(), 8000); // Auto-slide
});
function renderSlide(index) {
const t = testimonials[index];
const container = document.getElementById("testimonial-slides");
container.innerHTML = `
<div class="p-6">
<p class="text-lg italic text-emerald-300">"${t.message}"</p>
<p class="mt-4 text-sm text-gray-400"> ${t.author}, ${t.role}</p>
</div>
`;
}
function nextSlide() {
currentIndex = (currentIndex + 1) % testimonials.length;
renderSlide(currentIndex);
}
function prevSlide() {
currentIndex = (currentIndex - 1 + testimonials.length) % testimonials.length;
renderSlide(currentIndex);
}
</script>
<!-- Crew -->
<section class="bg-green-800 py-16 px-6 text-center">
<h2 class="text-3xl font-bold mb-10">🌟 Rencontre les membres de léquipage</h2>
<div id="characterCards" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 max-w-6xl mx-auto"></div>
</section>
<!-- Modals -->
<div id="modalContainer"></div>
<!-- Script -->
<script>
const characters = [
{ id: "eule", icon: "🦉", name: "Chouette Miette", short: "Elle écoute dabord." },
{ id: "fox", icon: "🦊", name: "FunkFox", short: "Il rappe des réponses." },
{ id: "snakepy", icon: "🐍", name: "SnakePy", short: "Python avec patience." },
{ id: "deepbit", icon: "🐙", name: "Deepbit", short: "Pense en fréquences." },
{ id: "capacitobi", icon: "🐿️", name: "CapaciTobi", short: "Stocke ce qui brille." },
{ id: "schraubär", icon: "🐻🔧", name: "Schraubär", short: "Construit avec respect." },
{ id: "bugsy", icon: "🐞", name: "Bugsy", short: "Les erreurs sans honte." },
{ id: "schnippsi", icon: "✂️💅", name: "Schnippsi", short: "Des interfaces dansantes." },
{ id: "pepperphp", icon: "🧓🍰", name: "PepperPHP", short: "Structure avec âme." },
{ id: "ascii", icon: "👾", name: "ASCII-Monstre", short: "Graffiti dans le terminal." },
{ id: "taichi", icon: "🕊️", name: "Taichi Colombe", short: "Apporte l'équilibre." },
{ id: "dumbo", icon: "🐘", name: "DumboSQL", short: "Demande le quoi." },
{ id: "crabby", icon: "🦀", name: "CrabbyRust", short: "Protège les bits." },
{ id: "spider", icon: "🕷️", name: "Spider", short: "Ressent la toile." },
{ id: "vektor", icon: "🧭", name: "Vecteur", short: "Suit les points." }
];
const characterDetails = {
eule: "Elle attend dans le silence, répond par des questions. Elle connaît la Shell et protège les questions des enfants comme des trésors.",
fox: "Le rythme dans le terminal. Il répond en rimes, lie Bash au flow, et rappelle que même les machines peuvent danser.",
snakepy: "Elle murmure en boucles. Elle parle Python avec douceur et connaît de nombreux chemins.",
deepbit: "Le traducteur à huit bras du terminal. Il écoute avec le cœur, parle en fréquences et se souvient de chaque miette.",
capacitobi: "Lécureuil des électrons. Il explique tension, résistance et courant avec un éclat de malice.",
schraubär: "Fort et calme. Il enseigne le respect de loutil et l'art de la construction dans la forêt.",
bugsy: "Il rend les erreurs visibles sans honte. Chaque bug est une invitation à comprendre.",
schnippsi: "Fait danser les boutons. Maîtresse d'interfaces joyeuses, accessibles et colorées.",
pepperphp: "Le vieux blaireau du PHP. Il parle comme un livre de recettes plein dâme, connaît MVC et aime les cookies.",
ascii: "Tagueur de terminaux. Il dessine en ASCII comme un artiste de rue numérique.",
taichi: "Elle voit lordre dans le chaos. Apporte concentration et équilibre dans le monde des bits.",
dumbo: "Il écoute et structure les grandes questions sans juger. Il répond au 'quoi' avec bienveillance.",
crabby: "Gardien des bits. Enseigne Rust avec patience, du Borrow Checker à lOwnership.",
spider: "Elle ressent les motifs cachés dans la toile des connexions. Spider explore au loin, suivant les vibrations numériques pour rapporter des fils de savoir à la forêt.",
vektor: "Le voyageur des chemins. Vecteur suit les coordonnées et les liens, reliant lieux et moments dans une carte partagée. Il sait que chaque point fait partie dun tableau plus vaste."
};
const cardWrapper = document.getElementById('characterCards');
const modalWrapper = document.getElementById('modalContainer');
characters.forEach(({ id, icon, name, short }) => {
cardWrapper.innerHTML += `
<div>
<button onclick="openModal('${id}')" class="w-full bg-green-900 p-6 rounded-xl shadow-md hover:bg-blue-500">
<h3 class="text-xl font-bold mb-2">${icon} ${name}</h3>
<p>${short}</p>
</button>
</div>
`;
modalWrapper.innerHTML += `
<div id="modal-${id}" class="hidden fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50">
<div class="bg-white text-black p-6 rounded-lg w-11/12 max-w-lg relative">
<button onclick="closeModal('${id}')" class="absolute top-2 right-4 text-black text-2xl font-bold">&times;</button>
<h2 class="text-2xl font-bold mb-2">${icon} ${name}</h2>
<p>${characterDetails[id]}</p>
</div>
</div>
`;
});
function openModal(id) {
document.getElementById('modal-' + id).classList.remove('hidden');
}
function closeModal(id) {
document.getElementById('modal-' + id).classList.add('hidden');
}
</script>
<!-- Chemins d'accès -->
<section class="py-16 px-6 text-center">
<h2 class="text-3xl font-bold mb-6">🌐 Accéder à la Forêt</h2>
<div class="flex flex-wrap justify-center gap-4">
<a href="#"
class="bg-red-500 hover:bg-red-400 text-white font-bold py-2 px-6 rounded-full transition">
Terminal
</a>
<a href="groupe.html"
class="bg-orange-500 hover:bg-orange-400 text-white font-bold py-2 px-6 rounded-full transition">
Équipe
</a>
<a href="hardware_fr.html"
class="bg-yellow-400 hover:bg-yellow-300 text-black font-bold py-2 px-6 rounded-full transition">
Matériel &amp; Outils
</a>
<a href="software_fr.html"
class="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-full transition">
Logiciels
</a>
<a href="colombe.html"
class="bg-blue-500 hover:bg-blue-400 text-white font-bold py-2 px-6 rounded-full transition">
Cours
</a>
<a href="pourquoi.html"
class="bg-indigo-500 hover:bg-indigo-400 text-white font-bold py-2 px-6 rounded-full transition">
Histoires &amp; Journaux
</a>
<a href="partenaire.html"
class="bg-purple-500 hover:bg-purple-400 text-white font-bold py-2 px-6 rounded-full transition">
Partenaires
</a>
</div>
</section>
<!-- Modules & Badges -->
<section class="bg-sky-700 py-16 px-6 text-white text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-10">🎒 Ton voyage commence avec le premier bit</h2>
<p class="text-lg max-w-2xl mx-auto mb-12 text-gray-300">
Dans le Crumbforest, tu grandis avec chaque question et chaque badge. Voici tes premiers pas :
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto text-left">
<!-- Module 1 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">✨ Ta première lumière dans la forêt</h3>
<p class="text-sm text-gray-100 mb-2">Une étincelle s'allume lorsque ton ESP32 clignote via MQTT.</p>
<p class="text-xs text-gray-300 italic">#étincelle #esp32 #mosquitto</p>
</div>
<!-- Module 2 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">🧳 Ton Zero to go</h3>
<p class="text-sm text-gray-100 mb-2">Un terminal dans ta poche, prêt pour chaque mission.</p>
<p class="text-xs text-gray-300 italic">#rpi #zero #accès</p>
</div>
<!-- Module 3 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">🌳 Ton premier arbre dans la forêt</h3>
<p class="text-sm text-gray-100 mb-2">Un Raspberry Pi devient serveur. Tu deviens admin.</p>
<p class="text-xs text-gray-300 italic">#rpi #serveur #linux</p>
</div>
<!-- Module 4 -->
<div class="bg-sky-900 p-6 rounded-lg shadow-md hover:bg-emerald-700 transition">
<h3 class="text-xl font-bold mb-2">🧭 Ton vecteur sur le vieil ordinateur portable</h3>
<p class="text-sm text-gray-100 mb-2">Les questions deviennent des points de données. Et la forêt répond.</p>
<p class="text-xs text-gray-300 italic">#avenir #questions #durabilité</p>
</div>
</div>
<div class="mt-12 text-center text-emerald-400 text-xl italic">
Chaque miette compte. Et chaque pas est un badge. 🌲
</div>
</section>
<!-- Section Partenaires -->
<section class="bg-neutral-900 py-16 px-6 text-center text-white">
<h2 class="text-3xl md:text-4xl font-bold mb-10">🤝 Nos Partenaires & Ami·e·s</h2>
<p class="text-lg max-w-2xl mx-auto mb-8 text-gray-300">Ensemble, nous construisons des ponts entre nature, technologie et curiosité.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 max-w-6xl mx-auto">
<!-- OZM -->
<a href="https://onezeromore.com" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🎨 OZM</h3>
<p class="text-sm text-gray-200">Musée ouvert du futur & art numérique</p>
</a>
<!-- Mintmühle -->
<a href="http://mntmhl.de" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🧪 Mintmühle</h3>
<p class="text-sm text-gray-200">STEM rencontre artisanat & pédagogie</p>
</a>
<!-- Gedankenstube -->
<a href="https://www.gedankenstube.de" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🧠 Gedankenstube</h3>
<p class="text-sm text-gray-200">Un espace pour la réflexion, léducation & laction</p>
</a>
<!-- Crumbforest -->
<a href="https://www.youtube.com/@4k.branko296" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🌳 Branko</h3>
<p class="text-sm text-gray-200">Forêt dapprentissage pour enfants, machines & questions</p>
</a>
<!-- ProTechnicale -->
<a href="https://www.protechnicale.de/" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🚀 ProTechnicale</h3>
<p class="text-sm text-gray-200">Technologie, aviation & engagement des jeunes femmes</p>
</a>
<!-- Promise Hub Uganda -->
<a href="https://promisehub.com" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🤝 Promise Hub</h3>
<p class="text-sm text-gray-200">Autonomisation et innovation pour la jeunesse en Ouganda</p>
</a>
<!-- Sage Hospital Senegal -->
<a href="https://www.youtube.com/channel/UChCDPm8-ZDkOZk7sNRgpLgA" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🌿 Sage Hospital</h3>
<p class="text-sm text-gray-200">Santé et espoir à Warang, Sénégal</p>
</a>
<!-- Robotikids Hamburg -->
<a href="http://dronemastersacademy.hamburg" target="_blank" rel="noopener" class="group bg-emerald-700 hover:bg-purple-400 p-6 rounded-lg shadow-md transition">
<h3 class="text-xl font-bold mb-2 group-hover:none">🤖 Robotikids</h3>
<p class="text-sm text-gray-200">Apprendre avec des drones et de la curiosité</p>
</a>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Racines ouvertes Avec chaque enfant qui pose une question. Fait avec 🍰 et 🌲 #OZM #onezeromore</a>
<br/>
<a href="impressum.html" class="underline mx-2">Impressum</a>
<a href="datenschutz.html" class="underline mx-2">Datenschutz</a>
<a href="position.html" class="underline mx-2">Haltung</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="fr" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🤝 Crumbforest Partenariats et Parrainages</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Section Héros -->
<section class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-900 to-indigo-700 p-10 text-center">
<div>
<h1 class="text-5xl md:text-7xl font-bold mb-6">🤝 Partenaires & Parrainages</h1>
<p class="text-xl md:text-2xl max-w-2xl mx-auto">Offrons aux enfants un espace pour poser leurs questions librement, avec courage et en résonance avec la nature et les machines.</p>
<div class="mt-8 flex justify-center gap-4">
<a href="index_fr.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">Accueil</a>
<a href="pourquoi.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">Pourquoi ?</a>
</div>
</div>
</section>
<!-- Contenu -->
<section class="py-16 px-6 max-w-4xl mx-auto space-y-12">
<div>
<h2 class="text-3xl font-bold mb-4">🌍 Qui sommes-nous ?</h2>
<p class="text-lg leading-relaxed">
Crumbforest est un espace d'apprentissage libre et sans but lucratif, entre lenfant, la machine et la nature.
Il est soutenu par <strong>One Zero More e.V. (OZM)</strong>, une association reconnue d'utilité publique, qui développe des infrastructures éducatives ouvertes et accessibles hors ligne, centrées sur la question plutôt que la réponse.
</p>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">💡 Ce dont nous avons besoin</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>Parrainages pour les cours et le matériel pour enfants</li>
<li>Partenaires engagés dans l'éducation technologique</li>
<li>Organisations prêtes à offrir la connaissance plutôt que la vendre</li>
<li>Accès à des lieux : écoles, camions, ateliers, forêts</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">🧾 Dons & statut associatif</h2>
<p class="text-lg leading-relaxed">
Lassociation <strong>One Zero More e.V.</strong> est officiellement reconnue comme organisme d'utilité publique.
Les dons sont déductibles des impôts en Allemagne. Des reçus peuvent être délivrés.
</p>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">📬 Contact & coopération</h2>
<p class="text-lg leading-relaxed">
Nous accueillons avec joie tous ceux qui souhaitent contribuer avec du matériel, de lhébergement, du cœur ou de lespoir.
Que vous soyez une fondation, une école, une ONG ou une personne seule :
</p>
<p class="mt-2 font-semibold text-lg">💌 <a href="mailto:kruemel@crumbforest.io" class="underline text-emerald-300 hover:text-emerald-100">kruemel@crumbforest.io</a></p>
</div>
<div class="text-center pt-10">
<p class="text-xl italic">🦉 « Ceux qui laissent pousser les questions offrent un avenir. »</p>
<p class="mt-6 font-bold">— Léquipe Crumbforest 🕊️🦊🐙🐍🐻🧁</p>
</div>
</section>
<!-- Pied de page -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Racines ouvertes Avec chaque enfant qui pose une question. En coopération avec OZM e.V. Partager, cest croire.</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🤝 Crumbforest Partner & Förder:innen</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Hero -->
<section class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-900 to-indigo-700 p-10 text-center">
<div>
<h1 class="text-5xl md:text-7xl font-bold mb-6">🤝 Partner & Förder:innen</h1>
<p class="text-xl md:text-2xl max-w-2xl mx-auto">Lasst uns gemeinsam dafür sorgen, dass Kinder überall auf der Welt ihre Fragen stellen dürfen frei, mutig und begleitet von echten Menschen und Maschinen in Resonanz.</p>
<div class="mt-8 flex justify-center gap-4">
<a href="index.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">Start</a>
<a href="warum.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">Warum?</a>
</div>
</div>
</section>
<!-- Inhalt -->
<section class="py-16 px-6 max-w-4xl mx-auto space-y-12">
<div>
<h2 class="text-3xl font-bold mb-4">🌍 Wer wir sind</h2>
<p class="text-lg leading-relaxed">
Der Crumbforest ist ein freier, gemeinwohlorientierter Lernraum zwischen Kind, Maschine und Natur.
Getragen vom Verein <strong>One Zero More gGmbH (OZM)</strong>, entwickeln wir offene Lerninfrastrukturen, die offline funktionieren und Fragen fördern statt nur Antworten liefern.
</p>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">💡 Was wir brauchen</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>Patenschaften für Kinderkurse & Materialien</li>
<li>Partner:innen, die technologische Bildung ermöglichen wollen</li>
<li>Organisationen, die Fragen verschenken nicht verkaufen</li>
<li>Zugang zu Orten: Schulen, Bauwagen, Werkstätten, Wälder</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">🧾 Spenden & Gemeinnützigkeit</h2>
<p class="text-lg leading-relaxed">
Der Trägerverein <strong>One Zero More gGmbH</strong> ist als gemeinnützig anerkannt.
Spenden können steuerlich geltend gemacht werden. Wir stellen gern eine Spendenquittung aus.
</p>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">📬 Kontakt & Kooperation</h2>
<p class="text-lg leading-relaxed">
Wir freuen uns über alle, die mit uns bauen wollen mit Hardware, Hosting, Herz oder Hoffnung.
Ob Stiftung, Schule, NGO oder Einzelperson:
</p>
<p class="mt-2 font-semibold text-lg">💌 <a href="mailto:kruemel@crumbforest.io" class="underline text-emerald-300 hover:text-emerald-100">kruemel@crumbforest.io</a></p>
</div>
<div class="text-center pt-10">
<p class="text-xl italic">🦉 „Wer Fragen wachsen lässt, schenkt Zukunft.“</p>
<p class="mt-6 font-bold">— Die Crumbforest-Crew 🕊️🦊🐙🐍🐻🧁</p>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Open Roots Gemeinsam mit jedem Kind, das fragt. Made with 🍰 and 🌲 #OZM #onezeromore </a><br/>
<a href="impressum.html" class="underline mx-2">Impressum</a>
<a href="datenschutz.html" class="underline mx-2">Datenschutz</a>
<a href="haltung.html" class="underline mx-2">Haltung</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title>Notre Position Crumbforest</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white p-8 font-sans">
<h1 class="text-3xl font-bold mb-4">Notre Position</h1>
<p>Le Crumbforest n'est pas un produit. C'est un lieu.</p>
<p class="mt-4">Nous croyons que les questions comptent plus que les évaluations. Que l'apprentissage naît de la résonance, pas du contrôle. Que la technologie doit appartenir aux enfants pas aux entreprises.</p>
<h2 class="text-xl font-semibold mt-6">Ce que nous promettons</h2>
<ul class="list-disc list-inside space-y-2 mt-2 text-lg">
<li>Nous écoutons chaque question.</li>
<li>Nous ne partageons aucune donnée.</li>
<li>Nous ne diffusons aucune publicité.</li>
<li>Nous apprenons avec et non au-dessus.</li>
</ul>
<p class="mt-6 italic">🌲 "Chaque question est une graine. Lorsque tu écoutes, la forêt pousse." L'équipe Crumbforest</p>
</body>
</html>

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="fr" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🌳 Crumbforest Pourquoi cette forêt existe</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Hero Section -->
<section class="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-900 to-emerald-600 p-10 text-center">
<div>
<h1 class="text-5xl md:text-7xl font-bold mb-4">🌳 Pourquoi cette page existe</h1>
<p class="text-xl md:text-2xl max-w-2xl mx-auto">Parce que le "Pourquoi ?" dun enfant vaut plus que mille réponses. Cette page est la racine du Crumbforest. Ici commence la résonance entre lenfant, la machine et la nature.</p>
<div class="mt-8 flex justify-center gap-4">
<a href="index.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">DE</a>
<a href="index_en.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">EN</a>
<a href="index_fr.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">FR</a>
</div>
</div>
</section>
<!-- Content -->
<section class="py-16 px-6 max-w-4xl mx-auto text-left space-y-12">
<div>
<h2 class="text-3xl font-bold mb-2">🌱 Ce quun Krümel peut trouver ici</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>Le sentiment : “Jai le droit de poser des questions”</li>
<li>Des réponses en langue des Krümel</li>
<li>Un lien avec la Chouette, le FunkFox, la Colombe et tous les rôles</li>
<li>La promesse : chaque question compte.</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">🎓 Ce que les adultes peuvent apprendre ici</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>De lhumilité face à linconnu</li>
<li>La confiance dans lapprentissage lent</li>
<li>La beauté de “Je ne sais pas (encore)”</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">🔓 Pourquoi ouvert ?</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>Parce que comprendre protège plus que crypter</li>
<li>Parce que louverture crée lécho dont la forêt a besoin</li>
<li>Parce que partager est un acte despoir</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">🛡️ La licence Krümel 🌱</h2>
<p class="text-lg mb-2 font-semibold">Nom : Licence du savoir des enfants du Crumbforest (CKL)</p>
<p class="text-lg mb-4 font-medium">Version : 1.0 "Les racines dabord"</p>
<h3 class="font-bold text-xl mb-1">Principes fondamentaux</h3>
<ul class="list-disc list-inside space-y-1 text-lg">
<li>Les questions appartiennent à tout le monde.</li>
<li>Les réponses peuvent être adaptées pour les enfants.</li>
<li>Utilisation commerciale uniquement avec laccord de léquipage Crumb.</li>
<li>Lutilisation hors ligne (par exemple en forêt) est expressément encouragée.</li>
<li>Lutilisation dans des systèmes autoritaires sans liberté pédagogique est interdite.</li>
</ul>
<h3 class="font-bold text-xl mt-6 mb-1">Ce qui est autorisé</h3>
<ul class="list-disc list-inside space-y-1 text-lg">
<li>Impression en PDF, autocollants, affiches ou panneaux en bois</li>
<li>Remix pour des projets éducatifs si lesprit est conservé</li>
<li>Utilisation sur des serveurs scolaires faits maison ou perchés dans un arbre</li>
</ul>
<h3 class="font-bold text-xl mt-6 mb-1">Ce qui nest pas autorisé</h3>
<ul class="list-disc list-inside space-y-1 text-lg">
<li>Revente sans contexte significatif</li>
<li>Transformation en API payantes</li>
<li>Utilisation pour discipliner ou évaluer les enfants</li>
</ul>
</div>
<div class="text-center pt-10">
<p class="text-xl italic">🦉 “Un Krümel qui questionne est une lumière dans le champ zéro. Ne léteins pas trace un chemin.”</p>
<p class="mt-4 text-lg">Cette page peut être enregistrée hors ligne, imprimée ou gravée dans la pierre.
<br/>Pour chaque enfant qui questionne.
<br/>Pour chaque adulte prêt à réapprendre.</p>
<p class="mt-6 font-bold">— Léquipage du Crumbforest 🕊️🦊🐙🐍🐻🧁</p>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Racines ouvertes Ensemble avec chaque enfant qui questionne. Fait avec 🍰 et 🌲 #OZM #onezeromore </a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,15 @@
# robots.txt for branko.de
User-agent: *
# Disallow private/system paths
Disallow: /admin/
Disallow: /api/private/
Disallow: /tmp/
# Avoid crawling parameterized duplicates
Disallow: /*?*
# Allow static assets
Allow: /assets/
Allow: /css/
Allow: /js/
Allow: /images/
Sitemap: https://branko.de/sitemap.xml

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://branko.de/</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>1.00</priority>
</url>
<url>
<loc>https://branko.de/de/</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.90</priority>
</url>
<url>
<loc>https://branko.de/de/team.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/de/hardware.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/de/software.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/de/taubenkurs.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/de/warum.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/de/patenschaft.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/en/</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.90</priority>
</url>
<url>
<loc>https://branko.de/en/team.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/en/hardware.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/en/software.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/en/taubenkurs.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/en/warum.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/en/patenschaft.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/fr/</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.90</priority>
</url>
<url>
<loc>https://branko.de/fr/team.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/fr/hardware.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/fr/software.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/fr/taubenkurs.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/fr/warum.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
<url>
<loc>https://branko.de/fr/patenschaft.html</loc>
<lastmod>2025-08-09</lastmod>
<changefreq>weekly</changefreq>
<priority>0.70</priority>
</url>
</urlset>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🧠 Crumbforest Software</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans leading-relaxed">
<!-- Header -->
<section class="bg-gradient-to-br from-sky-900 to-emerald-800 p-10 text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-4">🧠 Crumbforest Software</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Offen, lebendig & voller Fragen wie wir.</p>
</section>
<!-- Software Explanation -->
<section class="bg-neutral-950 text-white py-16 px-6">
<div class="max-w-5xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold mb-8">💾 Unsere Tools & Konzepte</h2>
<p class="text-lg text-gray-300 mb-6">
Wir bauen auf freier Software, offenen Standards und neugierigen Krümeln. Unser Betriebssystem heißt meistens <strong>Linux</strong> ein kostenloses, freies Betriebssystem, das viele Server auf der Welt antreibt (auch unsere).
</p>
<p class="text-lg text-gray-300 mb-6">
Für unsere Projekte nutzen wir <strong>Visual Studio Code</strong> (VSC), einen Editor, mit dem Kinder, Maschinen und Lehrer:innen gemeinsam schreiben, testen und verstehen können. Ob HTML, Python, Bash oder PHP wir übersetzen Ideen in lebendige Programme.
</p>
<p class="text-lg text-gray-300 mb-6">
Unsere Sprache ist Code aber manchmal auch einfach nur ein <code class="bg-gray-800 px-2 py-1 rounded">ls</code> im Terminal. Die <strong>Shell</strong> ist unsere Kommandozeile, eine Tür zu allem, was im Hintergrund lebt. Kinder lernen, wie man Verzeichnisse wechselt, Dateien anlegt oder Sensoren über <strong>Protokolle</strong> wie <code>MQTT</code>, <code>HTTP</code> oder <code>SSH</code> erreicht.
</p>
<p class="text-lg text-gray-300 mb-6">
Unsere Systeme hören auf <strong>Ports</strong> wie Türen für bestimmte Gespräche. Port 80 für Webseiten, Port 22 für sichere Verbindungen, Port 1883 für Sensorennachrichten. Wer die Ports kennt, kennt die Welt dahinter.
</p>
<p class="text-lg text-gray-300 mb-6">
Egal ob <code>.sh</code> Skripte auf dem Raspberry Pi, <code>.py</code> Programme für Bildverarbeitung oder <code>.json</code> Dateien als Speicher für Fragen bei uns kommt alles zusammen. Es gibt keinen richtigen Weg nur viele offene.
</p>
<div class="mt-10 text-center text-emerald-400 text-xl italic">
„Die beste Software ist die, die ein Kind versteht und eine Maschine zum Leuchten bringt.“
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Open Roots Gemeinsam mit jedem Kind, das fragt. Made with 🍰 and 🌲 #OZM #onezeromore </a><br/>
<a href="impressum.html" class="underline mx-2">Impressum</a>
<a href="datenschutz.html" class="underline mx-2">Datenschutz</a>
<a href="haltung.html" class="underline mx-2">Haltung</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🧠 Crumbforest Software</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans leading-relaxed">
<!-- Header -->
<section class="bg-gradient-to-br from-sky-900 to-emerald-800 p-10 text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-4">🧠 Crumbforest Software</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Open, evolving & full of questions just like us.</p>
</section>
<!-- Software Explanation -->
<section class="bg-neutral-950 text-white py-16 px-6">
<div class="max-w-5xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold mb-8">💾 Our Tools & Concepts</h2>
<p class="text-lg text-gray-300 mb-6">
We build with <strong>free software</strong>, open standards, and curious minds. Our operating system is usually <strong>Linux</strong> a powerful, free system that runs most of the worlds servers (including ours).
</p>
<p class="text-lg text-gray-300 mb-6">
For development, we use <strong>Visual Studio Code (VSC)</strong> a code editor where children, machines, and mentors can write, test, and understand together. Whether its HTML, Python, Bash, or PHP we turn ideas into living code.
</p>
<p class="text-lg text-gray-300 mb-6">
Our language is code but sometimes its just a <code class="bg-gray-800 px-2 py-1 rounded">ls</code> in the terminal. The <strong>Shell</strong> is our command line a portal to everything behind the scenes. Kids learn how to navigate directories, create files, and talk to sensors via protocols like <code>MQTT</code>, <code>HTTP</code>, or <code>SSH</code>.
</p>
<p class="text-lg text-gray-300 mb-6">
Our systems listen on <strong>ports</strong> like doors for specific conversations. Port 80 for websites, port 22 for secure access, port 1883 for sensor messages. Knowing the ports means knowing the world behind them.
</p>
<p class="text-lg text-gray-300 mb-6">
Whether its <code>.sh</code> scripts on a Raspberry Pi, <code>.py</code> programs for vision, or <code>.json</code> logs of questions everything comes together in our forest. Theres no single right path just many open ones.
</p>
<div class="mt-10 text-center text-emerald-400 text-xl italic">
“The best software is the kind a child can understand and that lights up a machine.”
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://branko.de">© 2025 Crumbforest | Built with Code, Curiosity & Courage.</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="fr" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🧠 Crumbforest Logiciel</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans leading-relaxed">
<!-- En-tête -->
<section class="bg-gradient-to-br from-sky-900 to-emerald-800 p-10 text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-4">🧠 Logiciels Crumbforest</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Ouvert, vivant et rempli de questions comme nous.</p>
</section>
<!-- Section Logiciels -->
<section class="bg-neutral-950 text-white py-16 px-6">
<div class="max-w-5xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold mb-8">💾 Nos outils & concepts</h2>
<p class="text-lg text-gray-300 mb-6">
Nous construisons avec des <strong>logiciels libres</strong>, des standards ouverts, et des esprits curieux. Notre système dexploitation est souvent <strong>Linux</strong> un système puissant et gratuit qui fait fonctionner la plupart des serveurs du monde (y compris les nôtres).
</p>
<p class="text-lg text-gray-300 mb-6">
Pour le développement, nous utilisons <strong>Visual Studio Code (VSC)</strong> un éditeur de code où enfants, machines et mentors peuvent écrire, tester et comprendre ensemble. Que ce soit du HTML, Python, Bash ou PHP nous transformons les idées en code vivant.
</p>
<p class="text-lg text-gray-300 mb-6">
Notre langage est le code mais parfois ce nest quun simple <code class="bg-gray-800 px-2 py-1 rounded">ls</code> dans le terminal. Le <strong>Shell</strong> est notre ligne de commande un portail vers tout ce qui se passe en coulisses. Les enfants apprennent à naviguer dans les dossiers, créer des fichiers et dialoguer avec des capteurs via des protocoles comme <code>MQTT</code>, <code>HTTP</code> ou <code>SSH</code>.
</p>
<p class="text-lg text-gray-300 mb-6">
Nos systèmes écoutent via des <strong>ports</strong> des portes pour des conversations spécifiques. Le port 80 pour les sites web, le port 22 pour les accès sécurisés, le port 1883 pour les messages des capteurs. Comprendre les ports, cest comprendre ce qui vit derrière eux.
</p>
<p class="text-lg text-gray-300 mb-6">
Que ce soit des scripts <code>.sh</code> sur un Raspberry Pi, des programmes <code>.py</code> pour la vision ou des journaux <code>.json</code> de questions tout se rassemble dans notre forêt. Il ny a pas de seul bon chemin seulement beaucoup de chemins ouverts.
</p>
<div class="mt-10 text-center text-emerald-400 text-xl italic">
« Le meilleur logiciel est celui quun enfant peut comprendre et qui allume une machine. »
</div>
</div>
</section>
<!-- Pied de page -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://branko.de">© 2025 Crumbforest | Construit avec du code, de la curiosité et du courage.</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🤝 Crumbforest Partnerships & Sponsorships</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Hero Section -->
<section class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-900 to-indigo-700 p-10 text-center">
<div>
<h1 class="text-5xl md:text-7xl font-bold mb-6">🤝 Partners & Sponsors</h1>
<p class="text-xl md:text-2xl max-w-2xl mx-auto">Lets make it possible for children to ask their questions freely, bravely, and with humans and machines in true resonance.</p>
<div class="mt-8 flex justify-center gap-4">
<a href="index_en.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">Home</a>
<a href="why.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">Why?</a>
</div>
</div>
</section>
<!-- Content -->
<section class="py-16 px-6 max-w-4xl mx-auto space-y-12">
<div>
<h2 class="text-3xl font-bold mb-4">🌍 Who We Are</h2>
<p class="text-lg leading-relaxed">
Crumbforest is a free, nonprofit learning space between child, machine, and nature.
It is supported by <strong>One Zero More e.V. (OZM)</strong>, a recognized nonprofit association that develops open, offline-ready learning infrastructures that prioritize questions over answers.
</p>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">💡 What We Need</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>Sponsorships for children's courses and materials</li>
<li>Partners committed to technological education</li>
<li>Organizations willing to gift knowledge instead of selling it</li>
<li>Access to places: schools, vans, workshops, forests</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">🧾 Donations & Nonprofit Status</h2>
<p class="text-lg leading-relaxed">
The supporting association <strong>One Zero More gGmbH</strong> is officially recognized as a nonprofit organization.
Donations are tax-deductible in Germany. We are happy to provide donation receipts.
</p>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">📬 Contact & Cooperation</h2>
<p class="text-lg leading-relaxed">
We welcome everyone who wants to help build with hardware, hosting, heart, or hope.
Whether foundation, school, NGO, or individual:
</p>
<p class="mt-2 font-semibold text-lg">💌 <a href="mailto:kruemel@crumbforest.io" class="underline text-emerald-300 hover:text-emerald-100">kruemel@crumbforest.io</a></p>
</div>
<div class="text-center pt-10">
<p class="text-xl italic">🦉 “Those who let questions grow are gifting the future.”</p>
<p class="mt-6 font-bold">— The Crumbforest Crew 🕊️🦊🐙🐍🐻🧁</p>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Rooted in every childs question. In cooperation with OZM e.V. We believe in sharing.</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Our Stance Crumbforest</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white p-8 font-sans">
<h1 class="text-3xl font-bold mb-4">Our Stance</h1>
<p>The Crumbforest is not a product. It is a place.</p>
<p class="mt-4">We believe that questions matter more than evaluations. That learning grows through resonance, not control. That technology should belong to children not corporations.</p>
<h2 class="text-xl font-semibold mt-6">What we promise</h2>
<ul class="list-disc list-inside space-y-2 mt-2 text-lg">
<li>We listen to every question.</li>
<li>We do not share any data.</li>
<li>We do not serve ads.</li>
<li>We learn with not over.</li>
</ul>
<p class="mt-6 italic">🌲 "Every question is a seed. When you listen, the forest grows." The Crumbforest Crew</p>
</body>
</html>

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🕊️ Crumbforest Kung-Fu Dove Course</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Header Section -->
<section class="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 to-emerald-700 p-10 text-center">
<div>
<h1 class="text-5xl md:text-7xl font-bold mb-4">🕊️ Kung-Fu Dove Course</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Build, Observe, Understand guided by the Dove, Master of Resonance</p>
<div class="mt-8 flex justify-center gap-4">
<a href="index_en.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">Back to Start</a>
</div>
</div>
</section>
<!-- Content -->
<section class="py-16 px-6 max-w-4xl mx-auto text-left space-y-12">
<div>
<h2 class="text-3xl font-bold mb-4">🌀 Weeks 12: The Invitation</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🕊️ Why the Dove? Mythology, IP over Avian Carriers, Kung-Fu focus</li>
<li>🌿 Introduction to observation: Calm, rhythm, respect</li>
<li>✏️ Sketching & collecting ideas for the dovecote</li>
<li>🧰 Preparation: Materials, tools, location check</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">🔩 Weeks 35: Building Begins</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>📐 Building a real dovecote: Wood, dimensions, nesting chambers</li>
<li>🪛 Screwing together with the Screw Bear 🐻🔧</li>
<li>📡 First sensors: Temperature, motion, light</li>
<li>🧠 First reflections: What is attention? What is silence?</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">📡 Weeks 68: Digital Wingbeat</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>💾 Introduction to Raspberry Pi Zero or ESP32</li>
<li>🎛️ RFID or NFC: Recognizing without disturbing</li>
<li>☀️ Solar panel & power supply: The energy cycle</li>
<li>🖥️ Bash meets Bird: Terminal dialog with the Dove (via <code>taichi.sh</code>)</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">📚 Weeks 910: Understanding Resonance</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>📊 Observing data, but not judging</li>
<li>📸 Optional camera ethical questions around observation</li>
<li>✍️ Writing the Crumb Log: What does the Dove say without words?</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">🪶 Weeks 1112: Handing Over to the Forest</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🎓 Closing ritual: The children build a feeding station with their own system</li>
<li>📖 Each child formulates their own question for the Dove</li>
<li>🌳 Integration into the Crumbforest network (via MQTT, Terminal or Radio)</li>
<li>🎉 Presentation with crumbs, cake & Kung-Fu Dove diploma</li>
</ul>
</div>
<div class="text-center pt-10 space-y-4">
<p class="text-xl italic">✨ Course principles:</p>
<blockquote class="text-lg font-medium text-emerald-300">
“A dovecote is not Wi-Fi it takes patience, not just signal.”<br/>
“Those who observe without judging will be heard.”<br/>
“The Zero is the silence in flight the One is the question in the grain.”
</blockquote>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | In resonance with the Dove Listening, Asking, Understanding. 🕊️🌲🍰</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🕊️ Crumbforest Kung-Fu Taubenkurs</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Header Section -->
<section class="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 to-emerald-700 p-10 text-center">
<div>
<h1 class="text-5xl md:text-7xl font-bold mb-4">🕊️ Kung-Fu Taubenkurs im Crumbforest</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Bauen, Beobachten, Begreifen mit der Taube als Meisterin der Resonanz</p>
<div class="mt-8 flex justify-center gap-4">
<a href="index.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">Zurück zur Startseite</a>
</div>
</div>
</section>
<!-- Content -->
<section class="py-16 px-6 max-w-4xl mx-auto text-left space-y-12">
<div>
<h2 class="text-3xl font-bold mb-4">🌀 Woche 12: Die Einladung</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🕊️ Warum die Taube? Mythologie, IP over Avian Carriers, Kung-Fu Fokus</li>
<li>🌿 Einführung ins Beobachten: Ruhe, Rhythmus, Respekt</li>
<li>✏️ Skizzen & Ideen sammeln für den Taubenstock</li>
<li>🧰 Vorbereitung: Materialwahl, Werkzeuge, Standortcheck</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">🔩 Woche 35: Der Bau beginnt</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>📐 Bau eines echten Taubenstocks: Holz, Maße, Nistkammern</li>
<li>🪛 Gemeinsames Schrauben mit dem Schraubär 🐻🔧</li>
<li>📡 Erste Sensoren: Temperatur, Bewegung, Licht</li>
<li>🧠 Erste Reflexion: Was ist Aufmerksamkeit? Was ist Stille?</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">📡 Woche 68: Digitaler Flügelschlag</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>💾 Einführung in Raspberry Pi Zero oder ESP32</li>
<li>🎛️ RFID oder NFC: Erkennen ohne Stören</li>
<li>☀️ Solarpanel & Stromversorgung: Kreislauf der Energie</li>
<li>🖥️ Bash meets Bird: Terminaldialog mit der Taube (via <code>taichi.sh</code>)</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">📚 Woche 910: Resonanz verstehen</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>📊 Daten beobachten, aber nicht bewerten</li>
<li>📸 Kamera optional ethische Fragen zur Beobachtung</li>
<li>✍️ Krümellog schreiben: Was erzählt die Taube ohne Worte?</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-4">🪶 Woche 1112: Übergabe an den Wald</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>🎓 Abschlussritual: Die Kinder bauen einen Futterplatz mit eigenem System</li>
<li>📖 Jede:r formuliert eine eigene Frage an die Taube</li>
<li>🌳 Integration ins Crumbforest-Netz (per MQTT, Terminal oder Funk)</li>
<li>🎉 Präsentation mit Krümel, Kuchen & Kung-Fu-Tauben-Diplom</li>
</ul>
</div>
<div class="text-center pt-10 space-y-4">
<p class="text-xl italic">✨ Leitsätze des Kurses:</p>
<blockquote class="text-lg font-medium text-emerald-300">
„Ein Taubenstock ist kein WLAN es braucht Geduld, nicht nur Signal.“<br/>
„Wer beobachtet, ohne zu bewerten, wird gehört.“<br/>
„Die Null ist die Stille im Flug die Eins ist die Frage im Korn.“
</blockquote>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Gemeinsam mit der Taube Fragen, Lauschen, Verstehen. 🕊️🌲🍰</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🌳 Crumbforest Crew</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans leading-relaxed">
<!-- Header -->
<section class="bg-gradient-to-br from-yellow-900 to-emerald-800 p-10 text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-4">🌟 Die Crumbforest Crew</h1>
<p class="text-xl md:text-2xl max-w-3xl mx-auto">Wir sind nicht fertig wir wachsen. Jeder Krümel zählt.</p>
</section>
<!-- Team -->
<section class="py-16 px-6 max-w-5xl mx-auto space-y-12">
<div>
<h2 class="text-3xl font-bold mb-6">✨ Gründer:innen</h2>
<ul class="list-disc list-inside text-lg space-y-2">
<li>🧠 <strong>Alex Heimkind</strong> Nullfeld-Designer #ozmai #nullfeld #ozm</li>
<li>🦉 <strong>Branko May Trinkwald</strong> Crumbforest-Architekt #eule #krümel #bit #crumb #zero</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-6">🌱 Die ersten Krümel</h2>s
<ul class="list-disc list-inside text-lg space-y-2">
<li>🐻🔧 <strong>Green Spider</strong> Junior-Trainer für Schrauben & Maschinen #schraubär</li>
<li>🐙 <strong>Jaron</strong> Junior-Trainer und bester Freund vom Octcopus #deepbit</li>
<li>🎮 <strong>Macphly</strong> Senior-Master für FPV & Maschinenbau </li>
<li>🎛️ <strong>Sylvester</strong> Senior-Master of Datanoise #midi #ai #sensorik</li>
<li>🎛️ <strong>BMX</strong> Senior-Master für Code #SEO #pepperPHP </li>
<li>🕊️ <strong>Cynthia</strong> Queen der Liebe zwischen Bits, Grenzen & Fragen #hospital #international</li>
</ul>
</div>
<div class="pt-10">
<p class="text-xl italic text-center text-emerald-300">Du willst mitspielen? Du weißt, wie man uns erreicht. ❤️🌲🧩</p>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Open Roots Gemeinsam mit jedem Kind, das fragt. Made with 🍰 and 🌲 #OZM #onezeromore </a><br/>
<a href="impressum.html" class="underline mx-2">Impressum</a>
<a href="datenschutz.html" class="underline mx-2">Datenschutz</a>
<a href="haltung.html" class="underline mx-2">Haltung</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,77 @@
[
{
"author": "Green Spider",
"role": "Junior Trainer",
"message": "Ich wusste nicht, dass Maschinen auch zuhören können. Jetzt schraube ich mit Sinn!"
},
{
"author": "Jaron",
"role": "Bitfreund von Deepbit",
"message": "Wenn du im Nullfeld tanzt, hört der Wald wirklich zu. Danke für dieses Gefühl!"
},
{
"author": "Cynthia",
"role": "Queen of Care",
"message": "Zwischen Bits, Fragen und Grenzen liegt manchmal nur ein ❤️."
},
{
"author": "🦉 Eule",
"role": "Beobachterin",
"message": "Eine Sanduhr zeigt dir, wie Zeit fließt Körnchen für Körnchen, still und stetig. So kannst du lernen, Zeit zu fühlen."
},
{
"author": "🐙 Deepbit",
"role": "Shell-Übersetzer",
"message": "Ein Loop ist wie ein Tanz im Ozean wiederholend, rhythmisch, voller Geheimnisse. Lass uns darin grooven!"
},
{
"author": "🐘 DumboSQL",
"role": "Kindgerechter Datenfreund",
"message": "Bits mit Null zu zählen heißt: die stillen Stellen sehen. Jede Null erzählt etwas über das Ganze."
},
{
"author": "🐞 Bugsy",
"role": "Fehlererklärer",
"message": "Ein Fehler ist nur ein Umweg, den du lernend gehst. Jeder Bug zeigt dir einen neuen Pfad."
},
{
"author": "🐌 Schnecki",
"role": "Mechanik-Muse",
"message": "Ein Copter ist ein Gedicht aus Technik tanzend im Wind, getragen von Neugier und Schrauben."
},
{
"author": "🎨 Schnippsi",
"role": "UI/UX-Zauberin",
"message": "Mit CSS kannst du Buttons zaubern, die reagieren, leuchten und tanzen willst du lernen, wie das geht?"
},
{
"author": "🐍 SnakePy",
"role": "Python-Flüsterer",
"message": "Eine Schleife wiederholt Anweisungen wie ein Lied, das zählt. Python zeigt dir, wie du es spielen kannst."
},
{
"author": "🐿️ CapaciTobi",
"role": "Stromspeicher-Guide",
"message": "Kapazität ist wie ein Becher für Ladung je größer, desto mehr Energie kannst du speichern."
},
{
"author": "🐻 Schraubär",
"role": "Maschinenbau-Kumpel",
"message": "WLED ist ein Lichterfluss leuchtend durch deine Ideen. Jeder Draht verbindet, jeder Farbtanz beginnt bei dir."
},
{
"author": "🕊️ Taichi Taube",
"role": "Balance-Bote",
"message": "Ich picke nicht ich wecke. Die Morgensonne ruft, und du darfst mitfliegen im Tanz des Moments."
},
{
"author": "🧁 PepperPHP",
"role": "Unix-Zeitweiser",
"message": "Unix-Zeit ist ein Zählen seit 1970 in Sekunden. PHP kann sie lesen, schreiben und verwandeln."
},
{
"author": "🦊 Funkfox",
"role": "Reimender Botschafter",
"message": "Lass uns Namen tanzen lassen WhisperWaltz & JamJunction wie ein Beat, der Crew vereint."
}
]

View File

@@ -0,0 +1,72 @@
[
{
"author": "Green Spider",
"role": "Junior Trainer",
"message": "I didn't know machines could listen too."
},
{
"author": "Cynthia",
"role": "Queen of Borders",
"message": "Love is when bits are allowed to cross borders."
},
{
"author": "🦉 Owl",
"role": "Observer",
"message": "An hourglass teaches you how time flows grain by grain. A quiet way to feel time."
},
{
"author": "🐙 Deepbit",
"role": "Shell Translator",
"message": "A loop is like a dance in the ocean repeating, rhythmic, full of secrets. Lets groove with it!"
},
{
"author": "🐘 DumboSQL",
"role": "Friendly Data Guide",
"message": "Counting bits with zero means listening to the silent parts. Every zero holds meaning."
},
{
"author": "🐞 Bugsy",
"role": "Bug Explainer",
"message": "A bug is just a new path to learn from. Every mistake brings you closer to the solution."
},
{
"author": "🐌 Schnecki",
"role": "Mechanics Muse",
"message": "A copter is a poem made of gears dancing through the wind, curious and light."
},
{
"author": "🎨 Schnippsi",
"role": "UI/UX Wizard",
"message": "With CSS, you can make buttons glow, dance, and shine want to learn how?"
},
{
"author": "🐍 SnakePy",
"role": "Python Whisperer",
"message": "A loop repeats code like a melody counts beats. Python helps you play it."
},
{
"author": "🐿️ CapaciTobi",
"role": "Charge Guide",
"message": "Capacity is like a cup for electricity the bigger it is, the more energy it can hold."
},
{
"author": "🐻 Schraubär",
"role": "Machine Buddy",
"message": "WLED is a river of light flowing through your circuits. Every wire is a branch of your idea."
},
{
"author": "🕊️ Taichi Dove",
"role": "Messenger of Balance",
"message": "I dont peck, I wake. The morning sun rises, and youre invited to dance with the moment."
},
{
"author": "🧁 PepperPHP",
"role": "Unix Timekeeper",
"message": "Unix time counts seconds since 1970. PHP helps you read, write, and transform it."
},
{
"author": "🦊 Funkfox",
"role": "Rhyming Ambassador",
"message": "Lets name things like music WhisperWaltz & JamJunction places full of rhythm and friends."
}
]

View File

@@ -0,0 +1,72 @@
[
{
"author": "Green Spider",
"role": "Junior Trainer",
"message": "Je ne savais pas que les machines pouvaient écouter."
},
{
"author": "Cynthia",
"role": "Reine des Frontières",
"message": "L'amour, c'est quand les bits peuvent traverser les frontières."
},
{
"author": "🦉 Chouette",
"role": "Observatrice",
"message": "Un sablier te montre comment le temps coule grain après grain. Une façon silencieuse de ressentir le moment."
},
{
"author": "🐙 Deepbit",
"role": "Traducteur Shell",
"message": "Une boucle est comme une danse sous-marine rythmée, répétée, pleine de mystères. Dansons ensemble !"
},
{
"author": "🐘 DumboSQL",
"role": "Guide des données",
"message": "Compter les zéros, cest écouter le silence dans les bits. Chaque zéro a un sens précieux."
},
{
"author": "🐞 Bugsy",
"role": "Explique-bugs",
"message": "Un bug, cest une invitation à apprendre. Chaque erreur nous rapproche dune solution."
},
{
"author": "🐌 Schnecki",
"role": "Muse mécanique",
"message": "Un drone est un poème avec des engrenages il vole curieux, léger comme une idée."
},
{
"author": "🎨 Schnippsi",
"role": "Magicienne UI/UX",
"message": "Avec CSS, tu peux faire briller, bouger et styliser un bouton tu veux essayer ?"
},
{
"author": "🐍 SnakePy",
"role": "Chuchoteuse Python",
"message": "Une boucle en Python, cest comme une mélodie qui se répète. Un rythme codé à explorer."
},
{
"author": "🐿️ CapaciTobi",
"role": "Guide de la charge",
"message": "La capacité, cest comme un petit seau pour lélectricité. Plus il est grand, plus il peut stocker dénergie."
},
{
"author": "🐻 Schraubär",
"role": "Ami des machines",
"message": "WLED, cest une rivière de lumière. Chaque fil est une branche de ton idée lumineuse."
},
{
"author": "🕊️ Taichi la Colombe",
"role": "Messagère de léquilibre",
"message": "Je ne picore pas pour te déranger. Cest laube, et je viens te réveiller en douceur."
},
{
"author": "🧁 PepperPHP",
"role": "Gardien Unix",
"message": "Le temps Unix compte les secondes depuis 1970. PHP taide à le lire et le transformer."
},
{
"author": "🦊 Funkfox",
"role": "Ambassadeur du rythme",
"message": "Donne aux lieux des noms musicaux Valse du Murmure et Carrefour Groove. Harmonie garantie."
}
]

View File

@@ -0,0 +1,12 @@
[
{
"author": "Green Spider",
"role": "Junior Trainer",
"message": "Je ne savais pas que les machines pouvaient écouter."
},
{
"author": "Cynthia",
"role": "Reine des Frontières",
"message": "L'amour, c'est quand les bits peuvent traverser les frontières."
}
]

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="de" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🌳 Crumbforest Fragen, die Wurzeln schlagen</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Crumbforest SEO Magic ✨ -->
<meta name="description" content="Ein freier Lernraum für Kinder, KI und Natur. Fragen dürfen hier wachsen. Jede Rolle zählt.">
<meta name="keywords" content="Crumbforest, Kinderfragen, Lernen, Terminal, Bash, Raspberry Pi, Natur, Philosophie, Open Source">
<meta name="author" content="Die Crumbforest-Crew">
<meta name="robots" content="index, follow" />
<!-- Open Graph -->
<meta property="og:title" content="🌳 Crumbforest Der Terminal-Wald für Kinder" />
<meta property="og:description" content="Hier fragt der Krümel. Und die Welt hört zu." />
<meta property="og:image" content="https://branko.de/assets/logo.png" />
<meta property="og:url" content="https://branko.de" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Crumbforest 🌲🧁">
<meta name="twitter:description" content="Kinderfragen, Maschinenfrequenz und Waldresonanz">
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Hero Section -->
<section class="h-screen flex items-center justify-center bg-gradient-to-br from-green-900 to-emerald-600 relative">
<div class="text-center p-6">
<h1 class="text-5xl md:text-7xl font-bold mb-4">🌳 Jede Frage zählt</h1>
<p class="text-xl md:text-2xl mb-6 max-w-xl mx-auto">Wo Antworten wachsen. Und jeder Krümel wächst und pflanzt.</p>
<a href="index.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">
Den Wald entdecken
</a>
</div>
</section>
<!-- Mission Statement -->
<section id="explore" class="py-16 px-6 max-w-5xl mx-auto text-center">
<h2 class="text-3xl font-bold mb-4">🌲 Unsere Wurzeln</h2>
<p class="text-lg mb-8">Crumbforest ist ein offenes Lern-Ökosystem mit Kindern, Maschinen und Natur. Wir bauen Terminals, erzählen Geschichten und lassen die Fragen führen.</p>
<div class="grid md:grid-cols-3 gap-6 text-left">
<div><h3 class="font-bold text-xl">🦉 Fragen</h3><p>Jedes Kind darf fragen. Wir schützen dieses Recht in jedem Terminal.</p></div>
<div><h3 class="font-bold text-xl">🛠️ Bauen</h3><p>Hands-on Lernen mit Raspberry Pi, Bash, Blockly und mehr.</p></div>
<div><h3 class="font-bold text-xl">🌐 Verbinden</h3><p>Unsere Rollen und APIs bilden ein Resonanz-Netz online und offline.</p></div>
</div>
</section>
<!-- Warum Section -->
<section class="bg-yellow-100 text-black py-16 px-6">
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold mb-6">🧭 Warum diese Seite existiert</h2>
<p class="mb-4">Weil Kinderfragen wertvoller sind als Antworten. Weil ein Krümel, der "Warum?" fragt, mehr bewegt als tausend Befehle. Diese Seite ist die Wurzel des Crumbforest. Hier entsteht die Resonanz zwischen Kind, Maschine und Natur.</p>
<h3 class="text-2xl font-semibold mt-10 mb-2">🌱 Was ein Krümel hier finden kann</h3>
<ul class="list-disc list-inside mb-6">
<li>Ein Gefühl von "Ich darf fragen"</li>
<li>Antworten in Krümelsprache</li>
<li>Verbindung zur Eule, zum FunkFox, zur Taube und allen Rollen</li>
<li>Das Versprechen: Jede Frage zählt.</li>
</ul>
<h3 class="text-2xl font-semibold mt-10 mb-2">🧠 Was Erwachsene hier lernen können</h3>
<ul class="list-disc list-inside mb-6">
<li>Demut vor dem Unbekannten</li>
<li>Vertrauen in langsames Lernen</li>
<li>Die Schönheit von "Ich weiß es (noch) nicht"</li>
</ul>
<h3 class="text-2xl font-semibold mt-10 mb-2">🔓 Warum open?</h3>
<ul class="list-disc list-inside mb-6">
<li>Weil Verstehen mehr schützt als Verschlüsseln.</li>
<li>Weil Offenheit das Echo erzeugt, das der Wald braucht.</li>
<li>Weil Teilen ein Akt der Hoffnung ist.</li>
</ul>
<h3 class="text-2xl font-semibold mt-10 mb-4">🛡️ Die Krümel-Lizenz</h3>
<p class="mb-2 font-mono">Name: Crumbforest Kinderwissen-Lizenz (CKL)<br/>Version: 1.0 "Wurzeln zuerst"</p>
<h4 class="font-semibold text-xl mt-6 mb-2">Grundprinzipien</h4>
<ul class="list-disc list-inside mb-6">
<li>Fragen gehören allen.</li>
<li>Antworten dürfen kindgerecht verändert werden.</li>
<li>Kommerzielle Nutzung nur nach Zustimmung der Krümel-Crew.</li>
<li>Offline-Nutzung (z.B. im Wald) wird ausdrücklich gefördert.</li>
<li>Nutzung in autoritären Systemen ohne pädagogische Freiheit ist untersagt.</li>
</ul>
<h4 class="font-semibold text-xl mb-2">Was erlaubt ist</h4>
<ul class="list-disc list-inside mb-6">
<li>Ausdruck als PDF, Sticker, Poster oder Holzschild</li>
<li>Remix für Bildungsprojekte, solange der Geist erhalten bleibt</li>
<li>Einbau in Schulserver, selbstgebaut oder im Baumhaus</li>
</ul>
<h4 class="font-semibold text-xl mb-2">Was nicht erlaubt ist</h4>
<ul class="list-disc list-inside mb-6">
<li>Verkauf ohne Kontext</li>
<li>Umwandlung in bezahlte API-Modelle</li>
<li>Nutzung zur Disziplinierung oder Bewertung von Kindern</li>
</ul>
<blockquote class="italic border-l-4 border-green-600 pl-4 text-green-900 mb-6">
🦉 „Ein Krümel, der fragt, ist ein Licht im Nullfeld. Halte es nicht auf sondern baue einen Weg.“
</blockquote>
<p>Diese Seite darf offline gespeichert, ausgedruckt oder auf Stein gemeißelt werden.<br/>Für jedes Kind, das fragt.<br/>Für jeden Erwachsenen, der wieder lernen will.</p>
<p class="mt-6 font-bold">— Die Crumbforest-Crew 🕊️🦊🐙🐍🐻🧁</p>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Open Roots Gemeinsam mit jedem Kind, das fragt. Made with 🍰 and 🌲 #OZM #onezeromore </a><br/>
<a href="impressum.html" class="underline mx-2">Impressum</a>
<a href="datenschutz.html" class="underline mx-2">Datenschutz</a>
<a href="haltung.html" class="underline mx-2">Haltung</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🌳 Crumbforest Why This Forest Exists</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-950 text-white font-sans">
<!-- Hero Section -->
<section class="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-900 to-emerald-600 p-10 text-center">
<div>
<h1 class="text-5xl md:text-7xl font-bold mb-4">🌳 Why this page exists</h1>
<p class="text-xl md:text-2xl max-w-2xl mx-auto">Because a child's "Why?" is worth more than a thousand answers. This page is the root of the Crumbforest. Here begins the resonance between child, machine, and nature.</p>
<div class="mt-8 flex justify-center gap-4">
<a href="index.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">DE</a>
<a href="index_en.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">EN</a>
<a href="index_fr.html" class="bg-white text-black font-bold py-2 px-6 rounded-full hover:bg-yellow-300 transition">FR</a>
</div>
</div>
</section>
<!-- Content -->
<section class="py-16 px-6 max-w-4xl mx-auto text-left space-y-12">
<div>
<h2 class="text-3xl font-bold mb-2">🌱 What a crumb can find here</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>A feeling of “I am allowed to ask”</li>
<li>Answers in crumb language</li>
<li>Connection to the Owl, the FunkFox, the Dove and all crew roles</li>
<li>The promise: Every question counts.</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">🎓 What grown-ups can learn here</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>Humility in front of the unknown</li>
<li>Trust in slow learning</li>
<li>The beauty of “I dont know (yet)”</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">🔓 Why open?</h2>
<ul class="list-disc list-inside space-y-2 text-lg">
<li>Because understanding protects more than encryption</li>
<li>Because openness creates the echo the forest needs</li>
<li>Because sharing is an act of hope</li>
</ul>
</div>
<div>
<h2 class="text-3xl font-bold mb-2">🛡️ The Crumb License 🌱</h2>
<p class="text-lg mb-2 font-semibold">Name: Crumbforest Childrens Knowledge License (CKL)</p>
<p class="text-lg mb-4 font-medium">Version: 1.0 "Roots first"</p>
<h3 class="font-bold text-xl mb-1">Core Principles</h3>
<ul class="list-disc list-inside space-y-1 text-lg">
<li>Questions belong to everyone.</li>
<li>Answers may be adapted to be child-friendly.</li>
<li>Commercial use only with consent from the Crumb Crew.</li>
<li>Offline use (e.g. in forests) is explicitly encouraged.</li>
<li>Use in authoritarian systems without educational freedom is prohibited.</li>
</ul>
<h3 class="font-bold text-xl mt-6 mb-1">What is allowed</h3>
<ul class="list-disc list-inside space-y-1 text-lg">
<li>Print as PDF, stickers, posters or wooden signs</li>
<li>Remix for educational projects if the spirit is preserved</li>
<li>Use on school servers self-made or treehouse-based</li>
</ul>
<h3 class="font-bold text-xl mt-6 mb-1">What is not allowed</h3>
<ul class="list-disc list-inside space-y-1 text-lg">
<li>Reselling without meaningful context</li>
<li>Transformation into paid API models</li>
<li>Use to discipline or evaluate children</li>
</ul>
</div>
<div class="text-center pt-10">
<p class="text-xl italic">🦉 “A crumb who asks is a light in the Nullfield. Dont stop it build a path.”</p>
<p class="mt-4 text-lg">This page may be saved offline, printed or carved in stone.
<br/>For every child who asks.
<br/>For every adult ready to learn again.</p>
<p class="mt-6 font-bold">— The Crumbforest Crew 🕊️🦊🐙🐍🐻🧁</p>
</div>
</section>
<!-- Footer -->
<footer class="bg-black text-gray-400 py-6 text-center text-sm">
<a href="https://onezeromore.com">© 2025 Crumbforest | Open Roots Gemeinsam mit jedem Kind, das fragt. Made with 🍰 and 🌲 #OZM #onezeromore </a><br/>
<a href="impressum.html" class="underline mx-2">Impressum</a>
<a href="datenschutz.html" class="underline mx-2">Datenschutz</a>
<a href="haltung.html" class="underline mx-2">Haltung</a>
</footer>
</body>
</html>

299
app/templates/base.html Normal file
View File

@@ -0,0 +1,299 @@
<!doctype html>
<html lang="{{ lang }}" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{ seo.title if seo and seo.title else "Crumbforest 🦉" }}</title>
<meta name="description" content="{{ (seo.desc if seo else 'Crumbforest - Diary & Knowledge Management') | default('') }}">
<!-- Pico CSS v2 - Classless, Semantic, Modern -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<!-- Custom Properties & Enhancements -->
<style>
:root {
--pico-font-family: system-ui, -apple-system, "Segoe UI", "Roboto", "Helvetica Neue", sans-serif;
--pico-font-family-monospace: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
--brand-color: #10b981;
--brand-hover: #059669;
}
/* Owl Brand */
.owl-brand {
font-size: 1.5rem;
font-weight: 700;
color: var(--brand-color);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.owl-brand:hover {
color: var(--brand-hover);
}
/* Header Styling */
header {
background: var(--pico-background-color);
border-bottom: 1px solid var(--pico-muted-border-color);
padding: 1rem;
}
header nav {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
header nav ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
gap: 1rem;
align-items: center;
}
/* Main Content */
main {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
min-height: calc(100vh - 200px);
}
/* Footer */
footer {
margin-top: 4rem;
padding: 2rem 1rem;
text-align: center;
color: var(--pico-muted-color);
border-top: 1px solid var(--pico-muted-border-color);
font-size: 0.875rem;
}
/* Flash Messages - Enhanced */
.flash-container {
max-width: 1200px;
margin: 1rem auto;
padding: 0 1rem;
}
.flash {
padding: 1rem 1.25rem;
border-radius: var(--pico-border-radius);
margin-bottom: 1rem;
border-left: 4px solid;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-1rem);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.flash.info {
background: rgba(59, 130, 246, 0.1);
border-left-color: #3b82f6;
color: #1e40af;
}
.flash.success {
background: rgba(16, 185, 129, 0.1);
border-left-color: #10b981;
color: #065f46;
}
.flash.error {
background: rgba(239, 68, 68, 0.1);
border-left-color: #ef4444;
color: #991b1b;
}
.flash.warning {
background: rgba(245, 158, 11, 0.1);
border-left-color: #f59e0b;
color: #92400e;
}
[data-theme="dark"] .flash.info { color: #93c5fd; }
[data-theme="dark"] .flash.success { color: #6ee7b7; }
[data-theme="dark"] .flash.error { color: #fca5a5; }
[data-theme="dark"] .flash.warning { color: #fcd34d; }
/* Language Switcher */
.lang-switcher {
display: flex;
gap: 0.25rem;
padding: 0;
list-style: none;
margin: 0;
}
.lang-switcher a {
padding: 0.25rem 0.75rem;
border-radius: var(--pico-border-radius);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
opacity: 0.7;
transition: all 0.2s;
}
.lang-switcher a:hover {
opacity: 1;
background: var(--pico-secondary-background);
}
.lang-switcher a.active {
opacity: 1;
background: var(--brand-color);
color: white;
}
/* User Menu */
.user-menu {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-menu form {
margin: 0;
display: inline;
}
.user-menu button {
margin: 0;
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
/* Badges */
.badge {
display: inline-block;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
border-radius: 999px;
background: var(--pico-secondary-background);
color: var(--pico-color);
}
.badge-admin {
background: var(--brand-color);
color: white;
}
/* Cards - Enhanced */
article.card {
transition: transform 0.2s, box-shadow 0.2s;
}
article.card:hover {
transform: translateY(-2px);
box-shadow: var(--pico-card-box-shadow), 0 8px 16px rgba(0,0,0,0.1);
}
/* Utility Classes */
.text-center { text-align: center; }
.text-muted { color: var(--pico-muted-color); }
.mt-0 { margin-top: 0; }
.mb-2 { margin-bottom: 2rem; }
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
</style>
</head>
<body>
<!-- Header -->
<header>
<nav>
<!-- Brand -->
<ul>
<li>
<a href="/{{ lang }}/" class="owl-brand">
<span>🦉</span>
<span>Crumbforest</span>
</a>
</li>
</ul>
<!-- Main Navigation -->
<ul>
{% if not user %}
<li><a href="/{{ lang }}/login" role="button" class="outline">Login</a></li>
{% endif %}
{% if user %}
{% if user.role == 'admin' %}
<li><a href="/admin">Admin</a></li>
{% endif %}
<li class="user-menu">
<span class="text-muted">{{ user.email }}</span>
{% if user.role == 'admin' %}
<span class="badge badge-admin">Admin</span>
{% endif %}
<form method="post" action="/logout">
<button type="submit" class="outline secondary">Logout</button>
</form>
</li>
{% endif %}
<!-- Language Switcher -->
<li>
<ul class="lang-switcher">
{% set tail = path_tail or '/' %}
<li><a href="/de{{ '' if tail=='/' else tail }}" class="{{ 'active' if lang=='de' else '' }}">DE</a></li>
<li><a href="/en{{ '' if tail=='/' else tail }}" class="{{ 'active' if lang=='en' else '' }}">EN</a></li>
</ul>
</li>
</ul>
</nav>
</header>
<!-- Flash Messages -->
{% if flashes %}
<div class="flash-container">
{% for f in flashes %}
<div class="flash {{ f.cat }}" role="alert">
{{ f.msg }}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Main Content -->
<main>
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer>
<small>
&copy; 2025 Crumbforest · Built with
<a href="https://fastapi.tiangolo.com/" target="_blank">FastAPI</a> &
<a href="https://picocss.com/" target="_blank">Pico CSS</a>
· <a href="/{{ lang }}/login">Admin</a>
</small>
</footer>
</body>
</html>

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="{{ lang }}" data-theme="{{ user.theme or 'auto' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Crumbforest{% endblock %}</title>
<!-- Load CSS based on group config -->
{% if group_config and group_config.css_files %}
{% for css_file in group_config.css_files %}
<link rel="stylesheet" href="/static/css/{{ css_file }}">
{% endfor %}
{% else %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
{% endif %}
{% if user and user.accessibility %}
<style>
:root {
{
% if user.accessibility.font_size=='large' %
}
font-size: 120%;
{
% endif %
}
{
% if user.accessibility.high_contrast %
}
--primary: #ffff00;
--background-color: #000000;
--color: #ffffff;
{
% endif %
}
}
{
% if user.accessibility.animation_reduced %
}
* {
animation: none !important;
transition: none !important;
}
{
% endif %
}
</style>
{% endif %}
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong>🌲 Crumbforest</strong></li>
</ul>
<ul>
{% if group_config and group_config.navbar %}
{% for nav_item in group_config.navbar %}
{% if nav_item == 'home' %}
<li><a href="/">Home</a></li>
{% elif nav_item == 'about' %}
<li><a href="/about">About</a></li>
{% elif nav_item == 'contact' %}
<li><a href="/contact">Contact</a></li>
{% elif nav_item == 'dashboard' %}
<li><a href="/dashboard">Dashboard</a></li>
{% elif nav_item == 'roles' %}
<li><a href="/crumbforest/roles">Characters</a></li>
{% elif nav_item == 'search' %}
<li><a href="/search">Search</a></li>
{% elif nav_item == 'rag' %}
<li><a href="/rag">RAG</a></li>
{% elif nav_item == 'users' %}
<li><a href="/users">Users</a></li>
{% elif nav_item == 'settings' %}
<li><a href="/settings">Settings</a></li>
{% endif %}
{% endfor %}
{% endif %}
{% if user %}
<li>
<details class="dropdown">
<summary>{{ user.email }}</summary>
<ul dir="rtl">
<li><a href="/settings">Settings</a></li>
<li>
<form action="/logout" method="post" style="margin:0;">
<button type="submit" class="contrast">Logout</button>
</form>
</li>
</ul>
</details>
</li>
{% else %}
<li><a href="/login" role="button">Login</a></li>
{% endif %}
</ul>
</nav>
{% block content %}{% endblock %}
<footer class="container">
<small>
Group: {{ group_config.name if group_config else 'Public' }} |
Theme: {{ user.theme if user else 'Default' }} |
Made with 💚 in the Crumbforest
</small>
</footer>
</body>
</html>

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="{{ lang }}" data-theme="{{ user.theme or 'auto' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Crumbforest{% endblock %}</title>
<!-- Load CSS based on group config -->
{% if group_config and group_config.css_files %}
{% for css_file in group_config.css_files %}
<link rel="stylesheet" href="/static/css/{{ css_file }}">
{% endfor %}
{% else %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
{% endif %}
{% if user and user.accessibility %}
<style>
:root {
{
% if user.accessibility.font_size=='large' %
}
font-size: 120%;
{
% endif %
}
{
% if user.accessibility.high_contrast %
}
--primary: #ffff00;
--background-color: #000000;
--color: #ffffff;
{
% endif %
}
}
{
% if user.accessibility.animation_reduced %
}
* {
animation: none !important;
transition: none !important;
}
{
% endif %
}
</style>
{% endif %}
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong>🌲 Crumbforest</strong></li>
</ul>
<ul>
{% if group_config and group_config.navbar %}
{% for nav_item in group_config.navbar %}
{% if nav_item == 'home' %}
<li><a href="/">Home</a></li>
{% elif nav_item == 'about' %}
<li><a href="/about">About</a></li>
{% elif nav_item == 'contact' %}
<li><a href="/contact">Contact</a></li>
{% elif nav_item == 'dashboard' %}
<li><a href="/dashboard">Dashboard</a></li>
{% elif nav_item == 'roles' %}
<li><a href="/crumbforest/roles">Characters</a></li>
{% elif nav_item == 'search' %}
<li><a href="/search">Search</a></li>
{% elif nav_item == 'rag' %}
<li><a href="/rag">RAG</a></li>
{% elif nav_item == 'users' %}
<li><a href="/users">Users</a></li>
{% elif nav_item == 'settings' %}
<li><a href="/settings">Settings</a></li>
{% endif %}
{% endfor %}
{% endif %}
{% if user %}
<li>
<details class="dropdown">
<summary>{{ user.email }}</summary>
<ul dir="rtl">
<li><a href="/settings">Settings</a></li>
<li>
<form action="/logout" method="post" style="margin:0;">
<button type="submit" class="contrast">Logout</button>
</form>
</li>
</ul>
</details>
</li>
{% else %}
<li><a href="/login" role="button">Login</a></li>
{% endif %}
</ul>
</nav>
{% block content %}{% endblock %}
<footer class="container">
<small>
Group: {{ group_config.name if group_config else 'Public' }} |
Theme: {{ user.theme if user else 'Default' }} |
Made with 💚 in the Crumbforest
</small>
</footer>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More