Initial commit: Crumbforest Architecture Refinement v1 (Clean)
This commit is contained in:
22
.claude/settings.local.json
Normal file
22
.claude/settings.local.json
Normal 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
26
.gitignore
vendored
Normal 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/
|
||||
718
ARCHITECTURE_ROLES_GROUPS.md
Normal file
718
ARCHITECTURE_ROLES_GROUPS.md
Normal 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
238
CrumbTech.md
Normal 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
370
DIARY_RAG_README.md
Normal 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
1462
HANDBUCH.md
Normal file
File diff suppressed because it is too large
Load Diff
697
HOME_TEMPLATE_PLAN.md
Normal file
697
HOME_TEMPLATE_PLAN.md
Normal 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
21
LICENSE
Normal 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
451
QDRANT_ACCESS.md
Normal 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
395
QUICKSTART.md
Normal 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
73
README.md
Normal 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!
|
||||
208
SESSION_2025-12-03_DOCUMENT_SEARCH_FIX.md
Normal file
208
SESSION_2025-12-03_DOCUMENT_SEARCH_FIX.md
Normal 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
21
app/Dockerfile
Normal 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
56
app/config.py
Normal 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
|
||||
55
app/crumbforest_roles/dumbo_zero.sh
Normal file
55
app/crumbforest_roles/dumbo_zero.sh
Normal 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
|
||||
55
app/crumbforest_roles/funkfox_zero.sh
Normal file
55
app/crumbforest_roles/funkfox_zero.sh
Normal 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
|
||||
55
app/crumbforest_roles/kungfutaube_zero.sh
Normal file
55
app/crumbforest_roles/kungfutaube_zero.sh
Normal 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
|
||||
55
app/crumbforest_roles/pepperphp_zero.sh
Normal file
55
app/crumbforest_roles/pepperphp_zero.sh
Normal 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
|
||||
47
app/crumbforest_roles/schnecki_zero.sh
Normal file
47
app/crumbforest_roles/schnecki_zero.sh
Normal 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
|
||||
50
app/crumbforest_roles/schraubaer_zero.sh
Normal file
50
app/crumbforest_roles/schraubaer_zero.sh
Normal 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
|
||||
55
app/crumbforest_roles/snakepy_zero.sh
Normal file
55
app/crumbforest_roles/snakepy_zero.sh
Normal 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
|
||||
56
app/crumbforest_roles/templatus_zero.sh
Normal file
56
app/crumbforest_roles/templatus_zero.sh
Normal 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"
|
||||
73
app/deployment_config.json
Normal file
73
app/deployment_config.json
Normal 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
48
app/deps.py
Normal 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
19
app/entrypoint.sh
Executable 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
141
app/i18n/de.json
Normal 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
79
app/i18n/de.json.backup
Normal 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
76
app/i18n/de_full.json
Normal 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
141
app/i18n/en.json
Normal 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
76
app/i18n/en_full.json
Normal 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
141
app/i18n/fr.json
Normal 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
76
app/i18n/fr_full.json
Normal 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
1
app/lib/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/lib/__init__.py
|
||||
4
app/lib/embedding_providers/__init__.py
Normal file
4
app/lib/embedding_providers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# app/lib/embedding_providers/__init__.py
|
||||
from .base import BaseProvider
|
||||
|
||||
__all__ = ["BaseProvider"]
|
||||
85
app/lib/embedding_providers/base.py
Normal file
85
app/lib/embedding_providers/base.py
Normal 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
|
||||
137
app/lib/embedding_providers/claude_provider.py
Normal file
137
app/lib/embedding_providers/claude_provider.py
Normal 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)}")
|
||||
128
app/lib/embedding_providers/openai_provider.py
Normal file
128
app/lib/embedding_providers/openai_provider.py
Normal 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)}")
|
||||
160
app/lib/embedding_providers/openrouter_provider.py
Normal file
160
app/lib/embedding_providers/openrouter_provider.py
Normal 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
155
app/lib/markdown_chunker.py
Normal 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
198
app/main.py
Normal 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
1
app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/models/__init__.py
|
||||
166
app/models/rag_models.py
Normal file
166
app/models/rag_models.py
Normal 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
12
app/models/user.py
Normal 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
30
app/requirements.txt
Normal 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
76
app/routers/admin_post.py
Normal 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
350
app/routers/admin_rag.py
Normal 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
215
app/routers/chat.py
Normal 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
28
app/routers/chat_page.py
Normal 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"}
|
||||
)
|
||||
300
app/routers/crumbforest_roles.py
Normal file
300
app/routers/crumbforest_roles.py
Normal 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
361
app/routers/diary_rag.py
Normal 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
256
app/routers/document_rag.py
Normal 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
175
app/routers/home.py
Normal 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
|
||||
)
|
||||
0
app/routers/public_posts.py
Normal file
0
app/routers/public_posts.py
Normal file
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/services/__init__.py
|
||||
52
app/services/config_loader.py
Normal file
52
app/services/config_loader.py
Normal 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', {})
|
||||
365
app/services/document_indexer.py
Normal file
365
app/services/document_indexer.py
Normal 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
|
||||
85
app/services/embedding_service.py
Normal file
85
app/services/embedding_service.py
Normal 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)
|
||||
}
|
||||
130
app/services/provider_factory.py
Normal file
130
app/services/provider_factory.py
Normal 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
450
app/services/rag_service.py
Normal 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
138
app/startup_indexing.py
Executable 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)
|
||||
36
app/static/css/crumbforest_accessible.css
Normal file
36
app/static/css/crumbforest_accessible.css
Normal 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;
|
||||
}
|
||||
60
app/static/css/crumbforest_admin.css
Normal file
60
app/static/css/crumbforest_admin.css
Normal 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;
|
||||
}
|
||||
66
app/static/css/crumbforest_high_contrast.css
Normal file
66
app/static/css/crumbforest_high_contrast.css
Normal 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;
|
||||
}
|
||||
39
app/static/css/crumbforest_public.css
Normal file
39
app/static/css/crumbforest_public.css
Normal 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;
|
||||
}
|
||||
205
app/static/css/home_forest.css
Normal file
205
app/static/css/home_forest.css
Normal 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;
|
||||
}
|
||||
}
|
||||
91
app/templates/Crumbforest_html/colombe.html
Normal file
91
app/templates/Crumbforest_html/colombe.html
Normal 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 1–2 : 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 à l’observation : Calme, rythme, respect</li>
|
||||
<li>✏️ Dessins & collecte d’idé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 3–5 : Début de la construction</h2>
|
||||
<ul class="list-disc list-inside space-y-2 text-lg">
|
||||
<li>📐 Construction d’un vrai pigeonnier : Bois, dimensions, chambres de nidification</li>
|
||||
<li>🪛 Assemblage avec l’Ours Bricoleur 🐻🔧</li>
|
||||
<li>📡 Premiers capteurs : Température, mouvement, lumière</li>
|
||||
<li>🧠 Premières réflexions : Qu’est-ce que l’attention ? Qu’est-ce que le silence ?</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold mb-4">📡 Semaines 6–8 : Battement d’aile 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 9–10 : 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 l’observation</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 11–12 : 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 n’est 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>
|
||||
52
app/templates/Crumbforest_html/crew.html
Normal file
52
app/templates/Crumbforest_html/crew.html
Normal 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 aren’t 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>
|
||||
31
app/templates/Crumbforest_html/datenschutz.html
Normal file
31
app/templates/Crumbforest_html/datenschutz.html
Normal 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>
|
||||
|
||||
52
app/templates/Crumbforest_html/groupe.html
Normal file
52
app/templates/Crumbforest_html/groupe.html
Normal 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 l’amour 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>
|
||||
28
app/templates/Crumbforest_html/haltung.html
Normal file
28
app/templates/Crumbforest_html/haltung.html
Normal 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>
|
||||
|
||||
66
app/templates/Crumbforest_html/hardware.html
Normal file
66
app/templates/Crumbforest_html/hardware.html
Normal 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>
|
||||
66
app/templates/Crumbforest_html/hardware_en.html
Normal file
66
app/templates/Crumbforest_html/hardware_en.html
Normal 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>
|
||||
66
app/templates/Crumbforest_html/hardware_fr.html
Normal file
66
app/templates/Crumbforest_html/hardware_fr.html
Normal 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, l’observation 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, l’histoire 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>
|
||||
26
app/templates/Crumbforest_html/impressum.html
Normal file
26
app/templates/Crumbforest_html/impressum.html
Normal 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>
|
||||
353
app/templates/Crumbforest_html/index.html
Normal file
353
app/templates/Crumbforest_html/index.html
Normal 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">×</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 & 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 & 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>
|
||||
352
app/templates/Crumbforest_html/index_en.html
Normal file
352
app/templates/Crumbforest_html/index_en.html
Normal 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 doesn’t 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">×</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 & 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 & 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>
|
||||
348
app/templates/Crumbforest_html/index_fr.html
Normal file
348
app/templates/Crumbforest_html/index_fr.html
Normal 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, c’est 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 d’abord." },
|
||||
{ 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 l’outil 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 l’ordre 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 à l’Ownership.",
|
||||
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 d’un 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">×</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 & 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 & 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 & l’action</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 d’apprentissage 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>
|
||||
75
app/templates/Crumbforest_html/partenaire.html
Normal file
75
app/templates/Crumbforest_html/partenaire.html
Normal 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 l’enfant, 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">
|
||||
L’association <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 l’hébergement, du cœur ou de l’espoir.
|
||||
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, c’est croire.</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
81
app/templates/Crumbforest_html/patenschaft.html
Normal file
81
app/templates/Crumbforest_html/patenschaft.html
Normal 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>
|
||||
22
app/templates/Crumbforest_html/position.html
Normal file
22
app/templates/Crumbforest_html/position.html
Normal 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>
|
||||
98
app/templates/Crumbforest_html/pourquoi.html
Normal file
98
app/templates/Crumbforest_html/pourquoi.html
Normal 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 ?" d’un enfant vaut plus que mille réponses. Cette page est la racine du Crumbforest. Ici commence la résonance entre l’enfant, 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 qu’un Krümel peut trouver ici</h2>
|
||||
<ul class="list-disc list-inside space-y-2 text-lg">
|
||||
<li>Le sentiment : “J’ai 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 l’humilité face à l’inconnu</li>
|
||||
<li>La confiance dans l’apprentissage 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 l’ouverture crée l’écho dont la forêt a besoin</li>
|
||||
<li>Parce que partager est un acte d’espoir</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 d’abord"</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 l’accord de l’équipage Crumb.</li>
|
||||
<li>L’utilisation hors ligne (par exemple en forêt) est expressément encouragée.</li>
|
||||
<li>L’utilisation 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 l’esprit 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 n’est 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>
|
||||
15
app/templates/Crumbforest_html/robots.txt
Normal file
15
app/templates/Crumbforest_html/robots.txt
Normal 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
|
||||
135
app/templates/Crumbforest_html/sitemap.xml
Normal file
135
app/templates/Crumbforest_html/sitemap.xml
Normal 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>
|
||||
61
app/templates/Crumbforest_html/software.html
Normal file
61
app/templates/Crumbforest_html/software.html
Normal 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>
|
||||
54
app/templates/Crumbforest_html/software_en.html
Normal file
54
app/templates/Crumbforest_html/software_en.html
Normal 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 world’s 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 it’s 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 it’s 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 it’s <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. There’s 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>
|
||||
54
app/templates/Crumbforest_html/software_fr.html
Normal file
54
app/templates/Crumbforest_html/software_fr.html
Normal 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 d’exploitation 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 n’est qu’un 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, c’est 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 n’y 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 qu’un 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>
|
||||
74
app/templates/Crumbforest_html/sponsorship.html
Normal file
74
app/templates/Crumbforest_html/sponsorship.html
Normal 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">Let’s 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 child’s question. In cooperation with OZM e.V. – We believe in sharing.</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
22
app/templates/Crumbforest_html/stance.html
Normal file
22
app/templates/Crumbforest_html/stance.html
Normal 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>
|
||||
91
app/templates/Crumbforest_html/taichi_course.html
Normal file
91
app/templates/Crumbforest_html/taichi_course.html
Normal 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 1–2: 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 3–5: 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 6–8: 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 9–10: 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 11–12: 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>
|
||||
93
app/templates/Crumbforest_html/taubenkurs.html
Normal file
93
app/templates/Crumbforest_html/taubenkurs.html
Normal 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 1–2: 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 3–5: 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 6–8: 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 9–10: 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 11–12: Ü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>
|
||||
59
app/templates/Crumbforest_html/team.html
Normal file
59
app/templates/Crumbforest_html/team.html
Normal 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>
|
||||
77
app/templates/Crumbforest_html/testimonials.de.json
Normal file
77
app/templates/Crumbforest_html/testimonials.de.json
Normal 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."
|
||||
}
|
||||
]
|
||||
72
app/templates/Crumbforest_html/testimonials.en.json
Normal file
72
app/templates/Crumbforest_html/testimonials.en.json
Normal 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. Let’s 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 don’t peck, I wake. The morning sun rises, and you’re 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": "Let’s name things like music – WhisperWaltz & JamJunction – places full of rhythm and friends."
|
||||
}
|
||||
]
|
||||
72
app/templates/Crumbforest_html/testimonials.fr.json
Normal file
72
app/templates/Crumbforest_html/testimonials.fr.json
Normal 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, c’est écouter le silence dans les bits. Chaque zéro a un sens précieux."
|
||||
},
|
||||
{
|
||||
"author": "🐞 Bugsy",
|
||||
"role": "Explique-bugs",
|
||||
"message": "Un bug, c’est une invitation à apprendre. Chaque erreur nous rapproche d’une 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, c’est comme une mélodie qui se répète. Un rythme codé à explorer."
|
||||
},
|
||||
{
|
||||
"author": "🐿️ CapaciTobi",
|
||||
"role": "Guide de la charge",
|
||||
"message": "La capacité, c’est 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, c’est 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. C’est l’aube, et je viens te réveiller en douceur."
|
||||
},
|
||||
{
|
||||
"author": "🧁 PepperPHP",
|
||||
"role": "Gardien Unix",
|
||||
"message": "Le temps Unix compte les secondes depuis 1970. PHP t’aide à 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."
|
||||
}
|
||||
]
|
||||
12
app/templates/Crumbforest_html/testimotionals.fr.json
Normal file
12
app/templates/Crumbforest_html/testimotionals.fr.json
Normal 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."
|
||||
}
|
||||
]
|
||||
126
app/templates/Crumbforest_html/warum.html
Normal file
126
app/templates/Crumbforest_html/warum.html
Normal 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>
|
||||
105
app/templates/Crumbforest_html/why.html
Normal file
105
app/templates/Crumbforest_html/why.html
Normal 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 don’t 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 Children’s 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. Don’t 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
299
app/templates/base.html
Normal 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>
|
||||
© 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>
|
||||
121
app/templates/base_accessible.html
Normal file
121
app/templates/base_accessible.html
Normal 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>
|
||||
121
app/templates/base_admin.html
Normal file
121
app/templates/base_admin.html
Normal 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
Reference in New Issue
Block a user