π Offline First Architecture
Crumbforest Technical Documentation
Version: 1.0
Datum: 2026-01-22
"Der Wald lΓ€uft ohne Internet.
Cloud ist optional, nicht nΓΆtig."
π Architektur-Γbersicht
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TIER 0: IMPORT (Einmalig) β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β Multi-DB Adapter β β
β β ββ MySQL Adapter β β
β β ββ PostgreSQL Adapter β β
β β ββ JSON Adapter β β
β β ββ CSV Adapter β β
β β β β
β β NullfeldImporter β β
β β ββ Normalisierung β kernel.jsonl β β
β β β β
β β Auto-Embedding Pipeline β β
β β ββ Ollama (lokal) β Qdrant β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β Bootstrap mit 5.000+ Embeddings! β¨ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TIER 1: KERNEL (Immer lokal) β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β kernel.jsonl (Events) β β
β β ββ blog.post.* β β
β β ββ way.* β β
β β ββ crew.* β β
β β ββ import.* β β
β β β β
β β PHP 5.6 (Logic) β β
β β ββ kernel_log(), kernel_read() β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β 0 Dependencies, nur Atem β¨ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TIER 2: VEKTOR (Lokal bevorzugt) β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β Qdrant (lokal Port 6333) β β
β β ββ wald_knowledge collection β β
β β β β
β β Ollama Embeddings β β
β β ββ nomic-embed-text (lokal!) β β
β β β β
β β Constellation View β β
β β ββ 3D Wissenslandschaft β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β Import + Laufzeit = wachsend β¨ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TIER 3: AI (Lokal + Optional Cloud) β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β PrimΓ€r: Ollama (lokal) β β
β β ββ llama3 β β
β β ββ mistral β β
β β ββ qwen2 β β
β β β β
β β Fallback: OpenRouter (Cloud) β β
β β ββ Claude (wenn lokal nicht reicht) β β
β β ββ GPT (wenn lokal nicht reicht) β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β Cloud nur fΓΌr <5% Edge Cases β¨ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TIER 4: FEDERATION (Optional) β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β Andere WΓ€lder (JSONL sync) β β
β β Shared Vektor (distributed) β β
β β Aber: Jeder Wald kann solo! β¨ β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π― Decision Tree: Wo lΓ€uft was?
Frage kommt rein
β
[1] Vektor-Search (lokal, 0β¬)
β
Similarity > 0.85?
ββ YES β Antwort aus Vektor (0 Token!)
β ββ DONE β
ββ NO β Weiter zu [2]
[2] Ollama verfΓΌgbar? (lokal, 0β¬)
ββ YES β Ollama antwortet
β ββ Embedding β Vektor (fΓΌr spΓ€ter)
β ββ DONE β
ββ NO β Weiter zu [3]
[3] Cloud AI (OpenRouter/Claude)
ββ Kosten: 0.005β¬-0.02β¬
ββ Embedding β Vektor (fΓΌr spΓ€ter)
ββ DONE β
(aber teuer)
βββββββββββββββββββββββββββββββββββββββ
STATISTIK nach 6 Monaten:
[1] Vektor: 70% der Fragen (0β¬)
[2] Ollama: 25% der Fragen (0β¬)
[3] Cloud: 5% der Fragen (~3β¬/Monat)
β 95% offline! β¨
π§ Technische Implementation
Tier 0: Import (Einmalig)
Multi-DB Adapter Interface
<?php
/**
* DatabaseAdapterInterface
*
* Alle Adapter mΓΌssen dieses Interface implementieren
*/
interface DatabaseAdapterInterface {
/**
* Connect to database
*/
public function connect(): bool;
/**
* Execute query
*/
public function query(string $sql): array;
/**
* Get all posts/content
*/
public function getAllPosts(): array;
/**
* Close connection
*/
public function close(): void;
}
MySQL Adapter (WordPress)
<?php
class MySQLAdapter implements DatabaseAdapterInterface {
private $pdo;
private $config;
public function __construct(array $config) {
$this->config = $config;
}
public function connect(): bool {
try {
$dsn = sprintf(
"mysql:host=%s;dbname=%s;charset=utf8mb4",
$this->config['host'],
$this->config['db']
);
$this->pdo = new PDO(
$dsn,
$this->config['user'],
$this->config['pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
return true;
} catch (PDOException $e) {
error_log("MySQL Connect Error: " . $e->getMessage());
return false;
}
}
public function query(string $sql): array {
try {
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log("MySQL Query Error: " . $e->getMessage());
return [];
}
}
public function getAllPosts(): array {
$sql = "
SELECT
ID,
post_title,
post_content,
post_excerpt,
post_date,
post_status,
post_type
FROM wp_posts
WHERE post_status = 'publish'
AND post_type IN ('post', 'page')
ORDER BY post_date DESC
";
return $this->query($sql);
}
public function close(): void {
$this->pdo = null;
}
}
PostgreSQL Adapter
<?php
class PostgreSQLAdapter implements DatabaseAdapterInterface {
private $pdo;
private $config;
public function __construct(array $config) {
$this->config = $config;
}
public function connect(): bool {
try {
$dsn = sprintf(
"pgsql:host=%s;port=%s;dbname=%s",
$this->config['host'],
$this->config['port'] ?? 5432,
$this->config['db']
);
$this->pdo = new PDO(
$dsn,
$this->config['user'],
$this->config['pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
return true;
} catch (PDOException $e) {
error_log("PostgreSQL Connect Error: " . $e->getMessage());
return false;
}
}
public function query(string $sql): array {
try {
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log("PostgreSQL Query Error: " . $e->getMessage());
return [];
}
}
public function getAllPosts(): array {
$sql = "
SELECT
id,
title,
content,
created_at,
status
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC
";
return $this->query($sql);
}
public function close(): void {
$this->pdo = null;
}
}
JSON Adapter
<?php
class JSONAdapter implements DatabaseAdapterInterface {
private $path;
private $data = [];
public function __construct(string $path) {
$this->path = $path;
}
public function connect(): bool {
if (!file_exists($this->path)) {
error_log("JSON file not found: " . $this->path);
return false;
}
$json = file_get_contents($this->path);
$this->data = json_decode($json, true);
return $this->data !== null;
}
public function query(string $sql): array {
// JSON hat kein SQL, aber wir kΓΆnnen filtern
// Hier vereinfacht, kΓΆnnte JSON-Path oder Γ€hnliches nutzen
return $this->data;
}
public function getAllPosts(): array {
return $this->data['posts'] ?? $this->data;
}
public function close(): void {
$this->data = [];
}
}
Tier 1: Kernel (Immer lokal)
<?php
/**
* KEEP_IT_KERNEL.php
*
* Der minimale Event-Logger der atmen kann.
*/
define('KERNEL_FILE', 'data/kernel.jsonl');
/**
* Log an event
*/
function kernel_log(string $type, array $payload, string $source = 'unknown'): bool {
$event = [
'ts' => microtime(true),
'type' => $type,
'source' => $source,
'payload' => $payload,
'meta' => [
'schema_version' => '1.0',
'logged_at' => date('c')
]
];
$line = json_encode($event, JSON_UNESCAPED_UNICODE) . "\n";
return file_put_contents(
KERNEL_FILE,
$line,
FILE_APPEND | LOCK_EX
) !== false;
}
/**
* Read events
*/
function kernel_read(string $filter = '', int $limit = 100): array {
if (!file_exists(KERNEL_FILE)) {
return [];
}
$lines = file(KERNEL_FILE, FILE_IGNORE_NEW_LINES);
$events = [];
foreach (array_reverse($lines) as $line) {
if (empty($line)) continue;
$event = json_decode($line, true);
if (!$event) continue;
if ($filter && !str_starts_with($event['type'], $filter)) {
continue;
}
$events[] = $event;
if (count($events) >= $limit) {
break;
}
}
return $events;
}
/**
* Check if kernel can breathe
*/
function kernel_breath(): bool {
return file_exists(KERNEL_FILE) || touch(KERNEL_FILE);
}
// Breathing test
if (!kernel_breath()) {
die("β Kernel can't breathe!\n");
}
Tier 2: Vektor (Lokal bevorzugt)
Embedding Pipeline
#!/usr/bin/env python3
"""
embed_pipeline.py
Liest kernel.jsonl und erstellt Embeddings
"""
import json
import requests
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from uuid import uuid4
# Config
OLLAMA_URL = "http://localhost:11434/api/embeddings"
QDRANT_URL = "http://localhost:6333"
KERNEL_FILE = "data/kernel.jsonl"
COLLECTION = "wald_knowledge"
# Clients
qdrant = QdrantClient(url=QDRANT_URL)
def embed_text(text: str) -> list:
"""Create embedding using Ollama"""
response = requests.post(
OLLAMA_URL,
json={
"model": "nomic-embed-text",
"prompt": text
}
)
if response.status_code == 200:
return response.json()['embedding']
else:
raise Exception(f"Ollama error: {response.text}")
def ensure_collection():
"""Create collection if not exists"""
collections = qdrant.get_collections().collections
exists = any(c.name == COLLECTION for c in collections)
if not exists:
qdrant.create_collection(
collection_name=COLLECTION,
vectors_config=VectorParams(
size=768, # nomic-embed-text dimension
distance=Distance.COSINE
)
)
print(f"β
Collection '{COLLECTION}' created")
def process_events():
"""Process kernel events and create embeddings"""
ensure_collection()
with open(KERNEL_FILE, 'r') as f:
for line in f:
if not line.strip():
continue
event = json.loads(line)
# Skip if already processed
if 'embedded' in event.get('meta', {}):
continue
# Extract text
text = None
if event['type'].startswith('blog.post.'):
text = event['payload'].get('title', '') + ' ' + \
event['payload'].get('body_md', '') or \
event['payload'].get('body_html', '')
elif event['type'].startswith('way.'):
text = json.dumps(event['payload'])
elif event['type'].startswith('crew.'):
text = event['payload'].get('message', '')
if not text:
continue
# Create embedding
try:
embedding = embed_text(text[:5000]) # Limit text
# Upsert to Qdrant
qdrant.upsert(
collection_name=COLLECTION,
points=[
PointStruct(
id=str(uuid4()),
vector=embedding,
payload=event
)
]
)
print(f"β
Embedded: {event['type']}")
except Exception as e:
print(f"β Error: {e}")
if __name__ == "__main__":
process_events()
Vektor-Suche
def search_vektor(query: str, limit: int = 5) -> list:
"""Search in vector database"""
# Embed query
query_embedding = embed_text(query)
# Search
results = qdrant.search(
collection_name=COLLECTION,
query_vector=query_embedding,
limit=limit,
score_threshold=0.7 # Minimum similarity
)
return [
{
'score': hit.score,
'payload': hit.payload
}
for hit in results
]
Tier 3: AI (Lokal + Cloud)
def ask_ai(question: str, use_vektor: bool = True) -> dict:
"""
Ask AI (with Vektor RAG if enabled)
"""
# Step 1: Vektor-Suche (lokal, 0β¬)
context = []
if use_vektor:
results = search_vektor(question, limit=3)
context = [r['payload'] for r in results if r['score'] > 0.85]
# Step 2: Wenn gute Matches β Return ohne AI!
if len(context) > 0 and context[0]['score'] > 0.90:
return {
'source': 'vektor',
'answer': context[0]['payload']['content'],
'cost': 0.0
}
# Step 3: Ollama lokal (0β¬)
try:
response = requests.post(
"http://localhost:11434/api/generate",
json={
"model": "llama3",
"prompt": question,
"context": context,
"stream": False
},
timeout=30
)
if response.status_code == 200:
return {
'source': 'ollama',
'answer': response.json()['response'],
'cost': 0.0
}
except:
pass # Fallback to cloud
# Step 4: Cloud AI (teuer!)
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json"
},
json={
"model": "anthropic/claude-3.5-sonnet",
"messages": [
{"role": "user", "content": question}
]
}
)
if response.status_code == 200:
data = response.json()
tokens = data['usage']['total_tokens']
cost = tokens * 0.000003 # ~$0.003 per 1K tokens
return {
'source': 'cloud',
'answer': data['choices'][0]['message']['content'],
'cost': cost
}
π Performance & Costs
Latency (Durchschnitt)
Vektor-Suche (lokal): 50ms (0β¬)
Ollama (lokal): 1-3s (0β¬)
OpenRouter (Cloud): 2-5s (0.005β¬-0.02β¬)
β 95% der Fragen < 3s und 0β¬! β¨
Kosten nach Phase
Phase 0 (Import, Tag 1):
- Vektor: 0%
- Ollama: 0%
- Cloud: 100%
- Kosten: 100β¬/Monat
Phase 1 (Monat 1-3):
- Vektor: 30%
- Ollama: 30%
- Cloud: 40%
- Kosten: 40β¬/Monat
Phase 2 (Monat 3-6):
- Vektor: 60%
- Ollama: 25%
- Cloud: 15%
- Kosten: 15β¬/Monat
Phase 3 (Monat 6-12):
- Vektor: 80%
- Ollama: 15%
- Cloud: 5%
- Kosten: 5β¬/Monat
Phase 4 (Monat 12+):
- Vektor: 90%
- Ollama: 9%
- Cloud: 1%
- Kosten: 1β¬/Monat
β Exponentieller Kostenabfall! β¨
π Offline-Modus (VollstΓ€ndig)
#!/bin/bash
# offline_mode.sh
# Check if system can run offline
check_offline_capability() {
echo "π Checking offline capability..."
# Check Kernel
if [ -f "data/kernel.jsonl" ]; then
echo "β
Kernel present"
else
echo "β Kernel missing"
exit 1
fi
# Check Qdrant
if curl -s http://localhost:6333/collections > /dev/null 2>&1; then
echo "β
Qdrant running"
else
echo "β Qdrant not running"
exit 1
fi
# Check Ollama
if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then
echo "β
Ollama running"
else
echo "β οΈ Ollama not running (optional)"
fi
# Check Vektor size
count=$(curl -s http://localhost:6333/collections/wald_knowledge | \
jq '.result.points_count')
if [ "$count" -gt 1000 ]; then
echo "β
Vektor ready ($count embeddings)"
else
echo "β οΈ Vektor small ($count embeddings)"
fi
echo ""
echo "π Offline Capability:"
if [ "$count" -gt 10000 ]; then
echo " π’ EXCELLENT (>10k embeddings)"
echo " β 90%+ questions answerable offline"
elif [ "$count" -gt 5000 ]; then
echo " π‘ GOOD (>5k embeddings)"
echo " β 70%+ questions answerable offline"
elif [ "$count" -gt 1000 ]; then
echo " π MODERATE (>1k embeddings)"
echo " β 40%+ questions answerable offline"
else
echo " π΄ LIMITED (<1k embeddings)"
echo " β Needs more import/learning"
fi
}
check_offline_capability
π Federation (Optional)
Wie WΓ€lder kommunizieren
βββββββββββββββββββ βββββββββββββββββββ
β Wald Berlin βββββββββΊβ Wald MΓΌnchen β
β - 100 Kinder β JSONL β - 50 Kinder β
β - 10k Embeds β Sync β - 5k Embeds β
βββββββββββββββββββ βββββββββββββββββββ
β² β²
β β
β βββββββββββββββββββ β
ββββββββΊβ Global Wald βββ
JSONL β - Shared Ways β JSONL
Sync β - Shared Crew β Sync
βββββββββββββββββββ
Federation Protocol
def sync_with_forest(remote_url: str):
"""
Sync JSONL events with remote forest
"""
# Get our latest timestamp
our_events = kernel_read(limit=1)
our_ts = our_events[0]['ts'] if our_events else 0
# Fetch remote events after our timestamp
response = requests.get(
f"{remote_url}/api/events/since/{our_ts}"
)
if response.status_code == 200:
remote_events = response.json()
for event in remote_events:
# Import into our kernel
kernel_log(
type=event['type'],
payload=event['payload'],
source=f"federation_{remote_url}"
)
# Embed if needed
if event['type'] in ['blog.post.create', 'way.completed']:
embed_and_store(event)
print(f"β
Synced {len(remote_events)} events")
π Deployment Checklist
Minimales Setup (Offline First)
# 1. Kernel Setup
mkdir -p data
touch data/kernel.jsonl
chmod 666 data/kernel.jsonl
# 2. Qdrant Setup (Docker)
docker run -d \
--name qdrant \
-p 6333:6333 \
-v $(pwd)/data/qdrant:/qdrant/storage \
qdrant/qdrant
# 3. Ollama Setup
curl https://ollama.ai/install.sh | sh
ollama pull llama3
ollama pull nomic-embed-text
# 4. Test
php -r "require 'kernel.php'; echo kernel_breath() ? 'β
' : 'β';"
curl http://localhost:6333/collections
curl http://localhost:11434/api/tags
# 5. Import (optional)
php import.php --source=wp_ozm
python3 embed_pipeline.py
# 6. Verify
curl http://localhost:6333/collections/wald_knowledge
VollstΓ€ndiges Setup (mit Cloud Fallback)
# ZusΓ€tzlich zu oben:
# 7. OpenRouter API Key
echo "OPENROUTER_API_KEY=sk-xxx" > .env
# 8. Cloud Fallback testen
python3 -c "from ai_bridge import ask_ai; print(ask_ai('test'))"
# 9. Monitoring
tail -f data/kernel.jsonl | jq .
π― Zusammenfassung
Offline First bedeutet:
β
Kernel lΓ€uft ohne Internet
β
Vektor lΓ€uft ohne Internet
β
AI lΓ€uft ohne Internet (Ollama)
β
Cloud ist optional, nicht nΓΆtig
β
System ist autonom nach 12-24 Monaten
Multi-DB bedeutet:
β
Import aus beliebigen Quellen
β
Keine Vendor Lock-ins
β
Volle PortabilitΓ€t
β
Instant Knowledge Base
Das Resultat:
Ein System das:
- Γberall lΓ€uft (Raspberry Pi β Server)
- Immer lΓ€uft (auch ohne Internet)
- GΓΌnstig lΓ€uft (95%+ kostenlos)
- FΓΌr immer lΓ€uft (wie Linux Kernel)
Wuuuhuuuu! π¦
Version: 1.0
Lizenz: CKL
Dependencies: Atem
Internet: Optional
Status: Atmet β¨