from flask import Flask, jsonify, request, render_template_string, Response
import subprocess, json, sqlite3
from urllib.request import urlopen
from urllib.error import URLError, HTTPError
app = Flask(__name__)
SERVICES = [
{
'id': 'hermes-vps',
'name': 'Hermes VPS',
'desc': 'Produktive Hermes-VPS-Instanz.',
'icon': '⚡',
'url': 'https://hermes.agentsolutions-mallorca.com',
'start_type': 'systemd',
'systemd_service': 'hermes-dashboard.service'
},
{
'id': 'worker',
'name': 'Worker',
'desc': 'Worker-Watcher für KI-Aufgaben und laufende Prozesse.',
'icon': '🤖',
'url': 'https://worker.agentsolutions-mallorca.com',
'start_type': 'systemd',
'systemd_service': 'hermes-worker-watcher.service'
},
{
'id': 'mirofish',
'name': 'MiroFish',
'desc': 'Multi-Agenten-Simulation für Analyse und Marktforschung.',
'icon': '🐟',
'url': 'https://mirofish.agentsolutions-mallorca.com',
'start_type': 'docker',
'container': 'mirofish',
'compose': '/opt/struktur/mirofish'
},
]
def get_status(service):
try:
if service.get('start_type') == 'systemd':
result = subprocess.run(
['systemctl', 'is-active', service['systemd_service']],
capture_output=True, text=True, timeout=5
)
return 'online' if result.stdout.strip() == 'active' else 'offline'
if service.get('start_type') == 'docker':
result = subprocess.run(
['docker', 'inspect', '--format', '{{.State.Status}}', service['container']],
capture_output=True, text=True, timeout=5
)
status = result.stdout.strip()
return 'online' if status == 'running' else 'offline'
return 'unbekannt'
except Exception:
return 'unbekannt'
def service_action(service, action):
try:
if service.get('start_type') == 'systemd':
result = subprocess.run(
['systemctl', action, service['systemd_service']],
capture_output=True, text=True, timeout=30
)
return result.returncode == 0
if service.get('start_type') == 'docker':
compose_dir = service['compose']
if action == 'start':
cmd = ['docker', 'compose', 'up', '-d']
elif action == 'stop':
cmd = ['docker', 'compose', 'stop']
elif action == 'restart':
cmd = ['docker', 'compose', 'restart']
else:
return False
result = subprocess.run(
cmd,
cwd=compose_dir,
capture_output=True,
text=True,
timeout=60
)
return result.returncode == 0
return False
except Exception:
return False
HTML = '''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent Solutions · Agents</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0f1117; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; min-height: 100vh; }
header { padding: 2rem 3rem; border-bottom: 1px solid #1e2330; }
header h1 { font-size: 1.5rem; font-weight: 600; color: #fff; }
header span { font-size: 0.85rem; color: #64748b; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem; padding: 3rem; }
.card { background: #161b27; border: 1px solid #1e2330; border-radius: 12px; padding: 1.5rem; }
.card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.card-icon { font-size: 2rem; }
.badge { font-size: 0.7rem; padding: 0.25rem 0.7rem; border-radius: 99px; font-weight: 600; }
.badge.online { background: #1a3a2a; color: #4ade80; border: 1px solid #166534; }
.badge.offline { background: #3a1a1a; color: #f87171; border: 1px solid #7f1d1d; }
.badge.loading { background: #2a2a1a; color: #fbbf24; border: 1px solid #78350f; }
.badge.planned { background: #1a2a3a; color: #93c5fd; border: 1px solid #1e3a5f; }
.card-title { font-size: 1.1rem; font-weight: 600; color: #fff; margin-bottom: 0.4rem; }
.card-desc { font-size: 0.85rem; color: #64748b; line-height: 1.5; margin-bottom: 1.2rem; }
.card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.btn { font-size: 0.75rem; padding: 0.4rem 0.9rem; border-radius: 6px; border: none; cursor: pointer; font-weight: 500; transition: all 0.15s; }
.btn-open { background: #1e3a5f; color: #60a5fa; }
.btn-open:hover { background: #1e4a7f; }
.btn-restart { background: #1e3a2a; color: #4ade80; }
.btn-restart:hover { background: #1e5a3a; }
.btn-stop { background: #3a1e1e; color: #f87171; }
.btn-stop:hover { background: #5a1e1e; }
.btn-start { background: #1a3a2a; color: #4ade80; }
.btn-start:hover { background: #1a5a3a; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center; }
.modal-overlay.visible { display:flex; }
.modal { background:#1e2330; border:1px solid #2d3748; border-radius:14px; padding:2rem; min-width:420px; max-width:95vw; }
.modal h2 { font-size:1.1rem; font-weight:700; color:#fff; margin-bottom:1.2rem; }
.modal select { width:100%; padding:0.5rem 0.75rem; background:#0f1117; border:1px solid #2d3748; border-radius:7px; color:#e2e8f0; font-size:0.9rem; margin-bottom:1rem; }
.modal-actions { display:flex; gap:0.75rem; justify-content:flex-end; margin-top:0.5rem; }
.btn-cancel { background:#1e2330; border:1px solid #2d3748; color:#94a3b8; padding:0.45rem 1.1rem; border-radius:7px; cursor:pointer; font-size:0.85rem; }
.btn-save { background:#1e3a5f; color:#60a5fa; border:none; padding:0.45rem 1.1rem; border-radius:7px; cursor:pointer; font-size:0.85rem; font-weight:600; }
.current-model { font-size:0.8rem; color:#64748b; margin-bottom:1rem; }
footer { text-align: center; padding: 2rem; color: #334155; font-size: 0.8rem; }
</style>
</head>
<body>
<header style="display:flex; align-items:center; justify-content:space-between;">
<div>
<h1>Agents</h1>
<span>Werkzeuge & Dienste — VPS 31.70.68.228</span>
</div>
<a href="https://agentsolutions-mallorca.com" target="_blank" rel="noopener noreferrer" style="font-size:0.85rem;color:#3b82f6;text-decoration:none;border:1px solid #1e3a5f;padding:0.4rem 1rem;border-radius:8px;">← Agent Solutions</a>
</header>
<div class="grid" id="grid">
{% for s in services %}
<div class="card" id="card-{{s.id}}">
<div class="card-header">
<div class="card-icon">{{s.icon}}</div>
<span class="badge loading" id="badge-{{s.id}}">{% if s.get('external') %}extern{% elif s.get('planned') %}geplant{% else %}...{% endif %}</span>
</div>
<div class="card-title">{{s.name}}</div>
<div class="card-desc">{{s.desc}}</div>
<div class="card-actions">
<a href="{{s.url}}" target="_blank" rel="noopener noreferrer"><button class="btn btn-open">Öffnen</button></a>
{% if not s.get('external') %}
<button class="btn btn-restart" onclick="action('{{s.id}}','restart')">Restart</button>
<button class="btn btn-stop" onclick="action('{{s.id}}','stop')">Stop</button>
<button class="btn btn-start" onclick="action('{{s.id}}','start')">Start</button>
{% if false %}
<button class="btn" style="background:#3a1a1a;color:#f87171;font-weight:700;" onclick="hermesJobsReset()">🗑 Jobs Reset</button>
{% endif %}
{% if s.id == 'mirofish' %}
<button class="btn" style="background:#1e2a3a;color:#a78bfa;" onclick="openMirofishModal()">🧠 Modell</button>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
<a class="card" href="/routing" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">
<div class="card-header"><div class="card-icon">🧭</div><span class="badge planned">read-only</span></div>
<div class="card-title">Routing</div>
<div class="card-desc">Hermes Routing Control Plane: Entscheidungen, Modelle, Feedback und Hygiene.</div>
<div class="card-actions"><span class="btn btn-open">Dashboard öffnen ↗</span></div>
</a>
</div>
<footer>Agent Solutions · VPS IONOS · 31.70.68.228</footer>
<script>
function updateStatus() {
fetch('/api/status').then(r=>r.json()).then(data => {
data.forEach(s => {
const b = document.getElementById('badge-'+s.id);
b.textContent = s.status;
b.className = 'badge ' + (s.status === 'online' ? 'online' : s.status === 'offline' ? 'offline' : s.status === 'geplant' ? 'planned' : 'loading');
});
});
}
function action(id, act) {
const b = document.getElementById('badge-'+id);
b.textContent = '...';
b.className = 'badge loading';
fetch('/api/action', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({id, action: act})
}).then(()=> setTimeout(updateStatus, 3000));
}
updateStatus();
setInterval(updateStatus, 15000);
</script>
<div class="modal-overlay" id="mirofish-modal">
<div class="modal">
<h2>🐟 MiroFish — Modell wechseln</h2>
<div class="current-model">Aktuell: <span id="modal-current-model">...</span></div>
<select id="modal-model-select"></select>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeMirofishModal()">Abbrechen</button>
<button class="btn-save" id="btn-modal-save" onclick="saveMirofishModel()">Speichern & Neustart</button>
</div>
</div>
</div>
<script>
async function openMirofishModal() {
document.getElementById('mirofish-modal').classList.add('visible');
const res = await fetch('/api/mirofish/model');
const data = await res.json();
document.getElementById('modal-current-model').textContent = data.current;
const sel = document.getElementById('modal-model-select');
sel.innerHTML = data.models.map(m =>
`<option value="${m.model}" ${m.model===data.current?'selected':''}>[${m.provider}] ${m.label}</option>`
).join('');
}
function closeMirofishModal() {
document.getElementById('mirofish-modal').classList.remove('visible');
}
async function saveMirofishModel() {
const btn = document.getElementById('btn-modal-save');
btn.disabled = true; btn.textContent = '⏳ Neustart...';
const model = document.getElementById('modal-model-select').value;
const res = await fetch('/api/mirofish/model', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({model})});
const data = await res.json();
if (data.ok) { btn.textContent = '✅ Gespeichert'; setTimeout(closeMirofishModal, 1500); }
else { btn.textContent = '❌ Fehler'; btn.disabled = false; }
}
</script>
</body>
</html>'''
@app.route('/')
def index():
return render_template_string(HTML, services=SERVICES)
@app.route('/api/status')
def status():
return jsonify([{'id': s['id'], 'status': ('geplant' if s.get('planned') else get_status(s))} for s in SERVICES])
PROJECTS_REGISTRY = '/opt/struktur/projects-dashboard/app.py'
SERVICE_REGISTRY = '/opt/struktur/00_INFRASTRUKTUR/service-registry.json'
def _knowledge_scopes():
"""Read existing project metadata; no project names are hard-coded here."""
scopes = [{'id': 'global', 'label': 'Globaler Wissensraum'}]
try:
import ast
tree = ast.parse(open(PROJECTS_REGISTRY, encoding='utf-8').read())
for node in tree.body:
if isinstance(node, ast.Assign) and any(getattr(t, 'id', None) == 'PROJECTS' for t in node.targets):
for item in ast.literal_eval(node.value):
if isinstance(item, dict) and item.get('id'):
scopes.append({'id': 'project:' + str(item['id']), 'label': 'Projekt: ' + str(item.get('name', item['id']))})
break
except (OSError, SyntaxError, ValueError):
pass
return scopes
KNOWLEDGE_HUB_HTML = """<!doctype html><html lang=\"de\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Agent Solutions · Wissen</title><style>
body{margin:0;background:#0f1117;color:#e2e8f0;font:16px system-ui,sans-serif}header{padding:2rem 3rem;border-bottom:1px solid #1e2330}header h1{margin:0;color:#fff}.wrap{max-width:1200px;margin:auto;padding:2rem 3rem}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1rem}.card{background:#161b27;border:1px solid #26324a;border-radius:12px;padding:1.3rem}.card h2{margin:.2rem 0 .5rem;color:#fff;font-size:1.1rem}.card p{color:#94a3b8;line-height:1.5;font-size:.9rem}.btn{display:inline-block;background:#1e3a5f;color:#93c5fd;text-decoration:none;padding:.55rem .9rem;border-radius:7px;font-weight:700}.back{color:#93c5fd;text-decoration:none}.note{color:#94a3b8;margin-bottom:1.5rem}
</style></head><body><header><a class=\"back\" href=\"/\" target=\"_blank\" rel=\"noopener noreferrer\">← Agents</a><h1>Wissen</h1><span>Zentraler Wissensbereich · bestehende Pipeline und Datenpfade</span></header><main class=\"wrap\"><p class=\"note\">Wissensfunktionen sind hier gebündelt. Es wird keine zweite Pipeline angelegt; die vorhandenen Seiten und APIs bleiben die technische Grundlage.</p><div class=\"grid\">
<a class=\"card\" href=\"/wissen/fragen\" target=\"_blank\" rel=\"noopener noreferrer\"><h2>1. Wissensfragen</h2><p>Fragen mit explizitem Projekt-/Wissensraum-Scope.</p><span class=\"btn\">Öffnen</span></a>
<a class=\"card\" href=\"/wissen/pipeline\" target=\"_blank\" rel=\"noopener noreferrer\"><h2>2. Wissenspipeline</h2><p>Bestehendes Pipeline-Dashboard, unverändert wiederverwendet.</p><span class=\"btn\">Öffnen</span></a>
<a class=\"card\" href=\"/wissen/bestand\" target=\"_blank\" rel=\"noopener noreferrer\"><h2>3. Wissensbestand</h2><p>Bestand und Retrieval-Übersicht der bestehenden Pipeline.</p><span class=\"btn\">Öffnen</span></a>
<a class=\"card\" href=\"/wissen/quellen\" target=\"_blank\" rel=\"noopener noreferrer\"><h2>4. Quellen</h2><p>Quellen und Evidenz aus der Frageoberfläche.</p><span class=\"btn\">Öffnen</span></a>
<a class=\"card\" href=\"/wissen/pruefstatus\" target=\"_blank\" rel=\"noopener noreferrer\"><h2>5. Prüfstatus</h2><p>Prüf- und Evidenzstatus im bestehenden Pipeline-Dashboard.</p><span class=\"btn\">Öffnen</span></a>
<a class=\"card\" href=\"/wissen/historie\" target=\"_blank\" rel=\"noopener noreferrer\"><h2>6. Historie / Änderungen</h2><p>Bestehende Eingangs- und Änderungshistorie.</p><span class=\"btn\">Öffnen</span></a>
<a class=\"card\" href=\"/wissen/scope\" target=\"_blank\" rel=\"noopener noreferrer\"><h2>7. Projektzuordnung / Scope</h2><p>Scope-Auswahl direkt bei Wissensfragen.</p><span class=\"btn\">Öffnen</span></a>
</div></main></body></html>"""
@app.route('/wissen')
def wissen():
return Response(KNOWLEDGE_HUB_HTML, mimetype='text/html')
@app.route('/api/knowledge/scopes')
def knowledge_scopes():
return jsonify(_knowledge_scopes())
@app.route('/wissen/fragen')
def wissen_fragen_alias():
return wissenspipeline_fragen()
@app.route('/wissen/pipeline')
def wissen_pipeline_alias():
return wissenspipeline()
@app.route('/wissen/bestand')
def wissen_bestand_alias():
return wissenspipeline()
@app.route('/wissen/quellen')
def wissen_quellen_alias():
return wissenspipeline_fragen()
@app.route('/wissen/pruefstatus')
def wissen_pruefstatus_alias():
return wissenspipeline()
@app.route('/wissen/historie')
def wissen_historie_alias():
return wissenspipeline_hinzufuegen()
@app.route('/wissen/scope')
def wissen_scope_alias():
return wissenspipeline_fragen()
PIPELINE_HTML = '/opt/struktur/knowledge-pipeline-aggregator-staging/dashboard.html'
PIPELINE_API = 'http://127.0.0.1:8655/v1/dashboard'
KNOWLEDGE_QUESTIONS_HTML = '/opt/struktur/agent-solutions-cockpit/knowledge_questions.html'
KNOWLEDGE_ASK_API = 'http://127.0.0.1:8655/api/knowledge/ask'
KNOWLEDGE_INGEST_API = 'http://127.0.0.1:8655'
@app.route('/wissenspipeline')
def wissenspipeline():
try:
page = open(PIPELINE_HTML, encoding='utf-8').read()
nav = '''<nav style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;padding:14px 22px;background:#111827;border-bottom:1px solid #26324a;font-family:system-ui,sans-serif"><a href="/wissen" style="color:#93c5fd;text-decoration:none;font-weight:700">← Wissen</a><a href="/" target="_blank" rel="noopener noreferrer" style="color:#93c5fd;text-decoration:none;font-weight:700">← Agent Solutions</a><a href="/wissenspipeline" target="_blank" rel="noopener noreferrer" style="color:#60a5fa;text-decoration:none;font-weight:700">Dashboard</a><a href="/wissenspipeline/fragen" target="_blank" rel="noopener noreferrer" style="color:#60a5fa;text-decoration:none;font-weight:700">Wissen fragen</a><a href="/wissenspipeline/hinzufuegen" target="_blank" rel="noopener noreferrer" style="color:#60a5fa;text-decoration:none;font-weight:700">Wissen hinzufügen</a><span style="color:#e2e8f0;font-weight:700">Wissenspipeline</span><span style="color:#94a3b8;font-size:12px">read-only · keine Import-/Recovery-Aktionen</span></nav>'''
page = page.replace('<title>Wissenspipeline · Dashboard V2</title>', '<title>Agent Solutions · Wissenspipeline</title>')
page = page.replace('<body>', '<body>' + nav, 1)
return Response(page, mimetype='text/html')
except (OSError, UnicodeError):
return Response('Wissenspipeline nicht verfügbar', status=503, mimetype='text/plain')
@app.route('/wissenspipeline/fragen')
def wissenspipeline_fragen():
try:
return Response(open(KNOWLEDGE_QUESTIONS_HTML, encoding='utf-8').read(), mimetype='text/html')
except (OSError, UnicodeError):
return Response('Wissenspipeline-Frageoberfläche nicht verfügbar', status=503, mimetype='text/plain')
@app.route('/wissenspipeline/hinzufuegen')
def wissenspipeline_hinzufuegen():
try:
return Response(open('/opt/struktur/agent-solutions-cockpit/knowledge_add.html', encoding='utf-8').read(), mimetype='text/html')
except (OSError, UnicodeError):
return Response('Wissenseingang nicht verfügbar', status=503, mimetype='text/plain')
ROUTING_DB = '/home/hermes/.hermes/routing-control/routing.db'
ROUTING_HTML = '''<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Agent Solutions · Routing</title>
<style>
body{margin:0;background:#0f1117;color:#e2e8f0;font:14px system-ui,sans-serif}header{padding:1.5rem 3rem;border-bottom:1px solid #1e2330;display:flex;align-items:center;justify-content:space-between}h1,h2{color:#fff;margin:0 0 .7rem}h1{font-size:1.5rem}.sub,.muted{color:#94a3b8}.back{color:#93c5fd;text-decoration:none;border:1px solid #1e3a5f;padding:.4rem .8rem;border-radius:7px}.wrap{padding:1.8rem 3rem;max-width:1500px;margin:auto}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:1.5rem}.card,section{background:#161b27;border:1px solid #26324a;border-radius:10px;padding:1rem}.metric{font-size:1.8rem;font-weight:700;color:#fff}.label{color:#94a3b8;font-size:.82rem}.two{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:1rem;margin-bottom:1.5rem}table{width:100%;border-collapse:collapse;font-size:.82rem}th,td{text-align:left;padding:.55rem .45rem;border-bottom:1px solid #26324a;vertical-align:top}th{color:#93c5fd;font-weight:600;white-space:nowrap}td{color:#dbe4f0}.scroll{overflow:auto;max-height:650px}.pill{display:inline-block;padding:.15rem .45rem;border-radius:5px;background:#26324a;color:#cbd5e1}.sev-critical{color:#fca5a5}.sev-error{color:#fb923c}.sev-warning{color:#fbbf24}.sev-info{color:#93c5fd}.empty{color:#94a3b8;padding:.5rem 0}
</style></head><body><header><div><h1>🧭 Hermes Routing Control Plane</h1><div class="sub">Read-only Auswertung · Datenquelle: routing.db</div></div><a class="back" href="/" target="_blank" rel="noopener noreferrer">← Agent Solutions</a></header>
<main class="wrap"><div class="grid">{% for label,value in metrics %}<div class="card"><div class="label">{{label}}</div><div class="metric">{{value}}</div></div>{% endfor %}</div>
<div class="two"><section><h2>Modellverteilung</h2><table><tr><th>Modellgruppe</th><th>Anzahl</th></tr>{% for row in model_distribution %}<tr><td>{{row.label}}</td><td>{{row.count}}</td></tr>{% endfor %}</table></section>
<section><h2>Router vs Legacy</h2><table><tr><th>Quelle</th><th>Anzahl</th></tr>{% for row in source_distribution %}<tr><td>{{row.label}}</td><td>{{row.count}}</td></tr>{% endfor %}</table></section></div>
<section><h2>Letzte 20 Routingentscheidungen</h2><div class="scroll"><table><tr><th>Zeit</th><th>Board</th><th>Task</th><th>Assignee</th><th>Modell</th><th>Reasoning</th><th>Risk</th><th>Confidence</th><th>Begründung</th></tr>{% for row in decisions %}<tr><td>{{row.time}}</td><td>{{row.board}}</td><td>{{row.task}}</td><td>{{row.assignee}}</td><td>{{row.model}}</td><td>{{row.reasoning}}</td><td>{{row.risk}}</td><td>{{row.confidence}}</td><td>{{row.reasons}}</td></tr>{% endfor %}</table></div></section>
<div class="two"><section><h2>Feedback-Auswertung</h2><h3>Nach Modell</h3><table><tr><th>Modell</th><th>Erfolge</th><th>Gesamt</th><th>Quote</th></tr>{% for row in feedback_model %}<tr><td>{{row.label}}</td><td>{{row.success}}</td><td>{{row.total}}</td><td>{{row.rate}}</td></tr>{% endfor %}</table><h3>Nach Familie</h3><table><tr><th>Familie</th><th>Erfolge</th><th>Gesamt</th><th>Quote</th></tr>{% for row in feedback_family %}<tr><td>{{row.label}}</td><td>{{row.success}}</td><td>{{row.total}}</td><td>{{row.rate}}</td></tr>{% endfor %}</table></section>
<section><h2>Hygiene-Befunde nach Severity</h2><table><tr><th>Severity</th><th>Anzahl</th></tr>{% for row in hygiene_summary %}<tr><td class="sev-{{row.label|lower}}">{{row.label}}</td><td>{{row.count}}</td></tr>{% endfor %}</table><h3>Befunde</h3><table><tr><th>Severity</th><th>Kategorie</th><th>Item</th><th>Detail</th></tr>{% for row in hygiene %}<tr><td class="sev-{{row.severity|lower}}">{{row.severity}}</td><td>{{row.category}}</td><td>{{row.item}}</td><td>{{row.detail}}</td></tr>{% endfor %}</table></section></div></main></body></html>'''
def _routing_rows():
import sqlite3
from datetime import datetime, timezone
db = sqlite3.connect('file:' + ROUTING_DB + '?mode=ro', uri=True)
db.row_factory = sqlite3.Row
try:
decisions = db.execute('SELECT board, task_id, assignee, model, reasoning, risk_score, risk_class, confidence, reasons_json, source, created_at FROM decisions ORDER BY created_at DESC LIMIT 20').fetchall()
all_decisions = db.execute('SELECT model, source FROM decisions').fetchall()
feedback = db.execute('SELECT model, family, success FROM feedback').fetchall()
hygiene = db.execute('SELECT severity, category, item, detail FROM hygiene ORDER BY observed_at DESC').fetchall()
finally:
db.close()
def count_by(rows, key, labels=None):
counts = {}
for row in rows:
value = row[key] or 'sonstige'
counts[value] = counts.get(value, 0) + 1
if labels is None:
labels = sorted(counts)
return [{'label': label, 'count': counts.get(label, 0)} for label in labels]
def model_group(model):
value = (model or '').lower()
for label in ('Luna', 'Sol', 'Astra'):
if label.lower() in value:
return label
return 'sonstige'
model_counts = {label: 0 for label in ('Luna', 'Sol', 'Astra', 'sonstige')}
source_counts = {'Router': 0, 'Legacy': 0, 'sonstige': 0}
for row in all_decisions:
model_counts[model_group(row['model'])] += 1
source = (row['source'] or '').lower()
source_counts['Router' if source == 'router' else 'Legacy' if source == 'legacy' else 'sonstige'] += 1
def feedback_table(key):
groups = {}
for row in feedback:
label = row[key] or 'unbekannt'
item = groups.setdefault(label, [0, 0])
item[0] += int(row['success'] or 0); item[1] += 1
return [{'label': label, 'success': values[0], 'total': values[1], 'rate': f"{(100 * values[0] / values[1]):.1f}%"} for label, values in sorted(groups.items())]
def timestamp(value):
try: return datetime.fromtimestamp(int(value), timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
except (TypeError, ValueError, OverflowError): return str(value or '')
formatted = []
for row in decisions:
try: reasons = ', '.join(json.loads(row['reasons_json'] or '[]'))
except (TypeError, ValueError): reasons = str(row['reasons_json'] or '')
confidence = '' if row['confidence'] is None else f"{float(row['confidence']):.2f}"
formatted.append({'time': timestamp(row['created_at']), 'board': row['board'], 'task': row['task_id'], 'assignee': row['assignee'] or '—', 'model': row['model'] or '—', 'reasoning': row['reasoning'] or '—', 'risk': f"{row['risk_class']} ({row['risk_score']})", 'confidence': confidence, 'reasons': reasons or '—'})
hygiene_summary = count_by(hygiene, 'severity', ['critical', 'error', 'warning', 'info'])
return {'metrics': [('Gesamtentscheidungen', len(all_decisions)), ('Router', source_counts['Router']), ('Legacy', source_counts['Legacy']), ('Feedback-Einträge', len(feedback)), ('Hygiene-Befunde', len(hygiene))], 'model_distribution': [{'label': label, 'count': model_counts[label]} for label in ('Luna', 'Sol', 'Astra', 'sonstige')], 'source_distribution': [{'label': label, 'count': source_counts[label]} for label in ('Router', 'Legacy', 'sonstige')], 'decisions': formatted, 'feedback_model': feedback_table('model'), 'feedback_family': feedback_table('family'), 'hygiene_summary': hygiene_summary, 'hygiene': [dict(row) for row in hygiene]}
@app.route('/routing')
def routing():
try:
return render_template_string(ROUTING_HTML, **_routing_rows())
except (OSError, sqlite3.Error):
return Response('Routing-Daten nicht verfügbar', status=503, mimetype='text/plain')
@app.route('/favicon.ico')
def favicon():
return Response(status=204)
@app.route('/api/knowledge/ask', methods=['POST'])
@app.route('/wissenspipeline/fragen/ask', methods=['POST'])
def wissenspipeline_fragen_ask():
try:
data=request.get_json(silent=True) or {}
payload=json.dumps({'question': str(data.get('question','')), 'mode': str(data.get('mode') or 'internal'), 'freshness': str(data.get('freshness') or 'auto'), 'scope': str(data.get('scope') or 'global'), 'project_scope': str(data.get('project_scope') or data.get('scope') or 'global'), 'knowledge_space': str(data.get('knowledge_space') or data.get('scope') or 'global')}).encode('utf-8')
upstream_request=__import__('urllib.request', fromlist=['Request']).Request(
KNOWLEDGE_ASK_API, data=payload,
headers={'Content-Type':'application/json'}, method='POST')
with urlopen(upstream_request, timeout=180) as upstream:
body=upstream.read()
return Response(body, status=200, mimetype='application/json')
except HTTPError as exc:
return jsonify({'error':'knowledge_query_upstream_error','status':exc.code}), 502
except (URLError, TimeoutError, OSError):
return jsonify({'error':'knowledge_query_unavailable'}), 503
@app.route('/api/knowledge/ingest/<kind>', methods=['POST'])
def wissenspipeline_ingest(kind):
if kind not in ('url','text','file'):
return jsonify({'error':'unknown_ingest_type'}), 404
try:
from urllib.request import Request
path='/api/knowledge/ingest/'+kind
body=request.get_data(cache=False)
headers={'Content-Type':request.headers.get('Content-Type','application/json')}
upstream=Request(KNOWLEDGE_INGEST_API+path,data=body,headers=headers,method='POST')
with urlopen(upstream,timeout=180) as response:
return Response(response.read(),status=response.status,mimetype='application/json')
except HTTPError as exc:
try: payload=exc.read()
except Exception: payload=json.dumps({'error':'knowledge_ingest_upstream_error'}).encode()
return Response(payload,status=exc.code,mimetype='application/json')
except (URLError, TimeoutError, OSError):
return jsonify({'error':'knowledge_ingest_unavailable'}),503
@app.route('/api/knowledge/ingest/history', methods=['GET'])
def wissenspipeline_ingest_history():
try:
with urlopen(KNOWLEDGE_INGEST_API+'/api/knowledge/ingest/history',timeout=15) as response:
return Response(response.read(),status=response.status,mimetype='application/json')
except (HTTPError, URLError, TimeoutError, OSError):
return jsonify({'error':'knowledge_ingest_unavailable'}),503
@app.route('/v1/dashboard')
def wissenspipeline_api():
try:
with urlopen(PIPELINE_API, timeout=45) as upstream:
payload = upstream.read()
return Response(payload, status=200, mimetype='application/json')
except (HTTPError, URLError, TimeoutError, OSError):
return jsonify({'error': 'knowledge_pipeline_unavailable', 'read_only': True}), 503
@app.route('/api/action', methods=['POST'])
def action():
data = request.json
service = next((s for s in SERVICES if s['id'] == data['id']), None)
if not service:
return jsonify({'ok': False}), 404
ok = service_action(service, data['action'])
return jsonify({'ok': ok})
MIROFISH_ENV = '/opt/struktur/mirofish/.env'
MIROFISH_MODELS = [
{"provider": "OpenAI", "label": "GPT-4.1-mini (empfohlen, ~3€/15Rdn)", "model": "gpt-4.1-mini-2025-04-14", "base_url": "https://api.openai.com/v1", "api_key": ""},
{"provider": "OpenAI", "label": "GPT-4.1 (~15€/15Rdn)", "model": "gpt-4.1-2025-04-14", "base_url": "https://api.openai.com/v1", "api_key": ""},
{"provider": "OpenAI", "label": "GPT-4o (~30€/15Rdn)", "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "api_key": ""},
{"provider": "OpenRouter", "label": "DeepSeek-V3 (~2€/15Rdn)", "model": "deepseek/deepseek-chat", "base_url": "https://openrouter.ai/api/v1", "api_key": ""},
{"provider": "OpenRouter", "label": "DeepSeek-R1 (~5€/15Rdn)", "model": "deepseek/deepseek-reasoner","base_url": "https://openrouter.ai/api/v1", "api_key": ""},
{"provider": "OpenRouter", "label": "Qwen2.5-72B (~2€/15Rdn)", "model": "qwen/qwen2.5-72b-instruct", "base_url": "https://openrouter.ai/api/v1", "api_key": ""},
{"provider": "OpenRouter", "label": "Kimi K2 (~2€/15Rdn)", "model": "moonshotai/kimi-k2", "base_url": "https://openrouter.ai/api/v1", "api_key": ""},
]
def _read_mirofish_model():
try:
with open(MIROFISH_ENV) as f:
for line in f:
if line.startswith('LLM_MODEL_NAME='):
return line.strip().split('=', 1)[1]
except:
pass
return 'unbekannt'
def _write_mirofish_env(model_cfg):
with open(MIROFISH_ENV) as f:
lines = f.readlines()
keys = {
'LLM_API_KEY': model_cfg['api_key'],
'LLM_BASE_URL': model_cfg['base_url'],
'LLM_MODEL_NAME': model_cfg['model'],
'OPENAI_API_KEY': model_cfg['api_key'],
'OPENAI_API_BASE_URL': model_cfg['base_url'],
}
new_lines = []
for line in lines:
replaced = False
for k, v in keys.items():
if line.startswith(k + '='):
new_lines.append(k + '=' + v + chr(10))
replaced = True
break
if not replaced:
new_lines.append(line)
with open(MIROFISH_ENV, 'w') as f:
f.writelines(new_lines)
@app.route('/api/mirofish/model', methods=['GET'])
def mirofish_model_get():
current = _read_mirofish_model()
return jsonify({'current': current, 'models': MIROFISH_MODELS})
@app.route('/api/mirofish/model', methods=['POST'])
def mirofish_model_set():
data = request.json
model_id = data.get('model')
cfg = next((m for m in MIROFISH_MODELS if m['model'] == model_id), None)
if not cfg:
return jsonify({'ok': False, 'error': 'Unbekanntes Modell'}), 400
_write_mirofish_env(cfg)
subprocess.run('cd /opt/struktur/mirofish && docker compose restart mirofish', shell=True, timeout=60)
return jsonify({'ok': True, 'model': model_id})
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5004, debug=False)