🚀 Initial commit: BSN Chatbot — Multi-Channel Intake System
WhatsApp webhook, admin dashboard, public frontend, media handling, LLM auto-summarize, hall/stand detection, iOS-style backend
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
media/
|
||||||
|
*.db
|
||||||
|
app.py.bak*
|
||||||
|
*.bak
|
||||||
|
.cloudflare_token
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Initialize the BSN Intake SQLite database with schema + sample data."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "bsn_intake.db")
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
-- Incoming submissions from all channels
|
||||||
|
CREATE TABLE IF NOT EXISTS submissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel TEXT NOT NULL, -- 'whatsapp', 'telegram', 'web_form'
|
||||||
|
sender_id TEXT NOT NULL, -- phone number, telegram ID, email
|
||||||
|
type TEXT NOT NULL, -- 'text', 'image', 'voice', 'video'
|
||||||
|
content TEXT, -- transcribed text or message body
|
||||||
|
media_path TEXT, -- local path to saved media file
|
||||||
|
status TEXT DEFAULT 'new', -- 'new', 'processing', 'done', 'flagged'
|
||||||
|
category TEXT, -- Hermes-classified category
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Knowledge base for the query chatbot
|
||||||
|
CREATE TABLE IF NOT EXISTS knowledge (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT UNIQUE NOT NULL, -- lookup key (e.g. 'autor_uwe_rosenberg')
|
||||||
|
question_pattern TEXT, -- "Where is Uwe Rosenberg?"
|
||||||
|
answer TEXT NOT NULL, -- "Hall 3, Booth B12, 10am-6pm"
|
||||||
|
category TEXT,
|
||||||
|
tags TEXT, -- comma-separated
|
||||||
|
valid_from DATE,
|
||||||
|
valid_until DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for fast lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submissions_channel ON submissions(channel);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submissions_category ON submissions(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_key ON knowledge(key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_category ON knowledge(category);
|
||||||
|
"""
|
||||||
|
|
||||||
|
SAMPLE_SUBMISSIONS = [
|
||||||
|
("whatsapp", "+491234567890", "text", "Wo ist der Stand von Feuerland? Hallo?", "new", None),
|
||||||
|
("whatsapp", "+491234567891", "text", "Catan ist ausverkauft in Halle 5!", "new", "ausverkauft"),
|
||||||
|
("telegram", "8520780873", "text", "Habe eben gesehen: Kosmos Halle 3, Stand A42 hat noch Restbestände von Die Crew", "new", "restbestände"),
|
||||||
|
("web_form", "besucher@example.com", "text", "Warum gibt es keine barrierefreien Zugänge in Halle 8?", "flagged", "kritik"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SAMPLE_KNOWLEDGE = [
|
||||||
|
("feuerland_stand", "Wo ist Feuerland?", "Feuerland Spiele findest du in Halle 3, Stand C17. Geöffnet von 10–18 Uhr.", "verlage", "feuerland, stand, messe", "2026-10-01", "2026-10-04"),
|
||||||
|
("catan_verfuegbar", "Ist Catan noch verfügbar?", "Catan war Stand 12.06.2026 in Halle 5 ausverkauft. Kosmos meldet Nachschub für Morgen früh.", "verlage", "catan, kosmos, ausverkauft", "2026-10-01", "2026-10-04"),
|
||||||
|
("oeffnungszeiten", "Wann öffnet die Messe?", "Die Spiel Essen 2026 öffnet Donnerstag bis Samstag von 10–18 Uhr, Sonntag von 10–17 Uhr.", "messe", "öffnungszeiten, messe, essen", None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def init():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
# Check if data already exists
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM submissions").fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO submissions (channel, sender_id, type, content, status, category) VALUES (?,?,?,?,?,?)",
|
||||||
|
SAMPLE_SUBMISSIONS,
|
||||||
|
)
|
||||||
|
print(f"Inserted {len(SAMPLE_SUBMISSIONS)} sample submissions")
|
||||||
|
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM knowledge").fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO knowledge (key, question_pattern, answer, category, tags, valid_from, valid_until) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
SAMPLE_KNOWLEDGE,
|
||||||
|
)
|
||||||
|
print(f"Inserted {len(SAMPLE_KNOWLEDGE)} sample knowledge entries")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"Database initialized: {DB_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start the BSN Chatbot Intake server (uses venv Python for Flask + requests)
|
||||||
|
set -a
|
||||||
|
source "$(dirname "$0")/.env"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
echo "META_TOKEN set: $([ -n "$META_TOKEN" ] && echo 'YES' || echo 'NO')"
|
||||||
|
echo "PHONE_NUMBER_ID: $META_PHONE_NUMBER_ID"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
exec /home/hermes/venv/bin/python3 "$(dirname "$0")/app.py"
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start permanent Cloudflare Tunnel for BSN Chatbot
|
||||||
|
# Usage: bash start-tunnel.sh <CLOUDFLARE_TUNNEL_TOKEN>
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TOKEN="$1"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "Usage: bash start-tunnel.sh <CLOUDFLARE_TUNNEL_TOKEN>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save token
|
||||||
|
echo "$TOKEN" > /home/hermes/workspace/bsn-chatbot/.cloudflare_token
|
||||||
|
chmod 600 /home/hermes/workspace/bsn-chatbot/.cloudflare_token
|
||||||
|
echo "Token saved ($(wc -c < /home/hermes/workspace/bsn-chatbot/.cloudflare_token) chars)"
|
||||||
|
|
||||||
|
# Kill any existing cloudflared
|
||||||
|
pkill -9 -f cloudflared 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Start permanent tunnel
|
||||||
|
echo "Starting tunnel..."
|
||||||
|
exec /home/hermes/.local/bin/cloudflared tunnel run --token "$TOKEN"
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
BSN Intake Supervisor — starts Flask app + Cloudflare tunnel.
|
||||||
|
Keeps both alive, restarts on crash, prints the public URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
|
BASE_DIR = "/home/hermes/workspace/bsn-chatbot"
|
||||||
|
VENV_PYTHON = "/home/hermes/venv/bin/python3"
|
||||||
|
CLOUDFLARED = "/home/hermes/.local/bin/cloudflared"
|
||||||
|
|
||||||
|
# Load .env
|
||||||
|
env = os.environ.copy()
|
||||||
|
env_file = os.path.join(BASE_DIR, ".env")
|
||||||
|
if os.path.exists(env_file):
|
||||||
|
with open(env_file) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, _, val = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
val = val.strip().strip('"').strip("'")
|
||||||
|
env[key] = val
|
||||||
|
|
||||||
|
def start_flask():
|
||||||
|
"""Start the Flask app as a subprocess."""
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[VENV_PYTHON, os.path.join(BASE_DIR, "app.py")],
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
def start_cloudflared():
|
||||||
|
"""Start the Cloudflare tunnel."""
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[CLOUDFLARED, "tunnel", "--url", "http://127.0.0.1:5002"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
LOG = open("/tmp/bsn_supervisor.log", "w")
|
||||||
|
def log(msg):
|
||||||
|
LOG.write(msg + "\n")
|
||||||
|
LOG.flush()
|
||||||
|
|
||||||
|
log("🚀 BSN Intake Supervisor starting...")
|
||||||
|
|
||||||
|
# Start Flask
|
||||||
|
flask = start_flask()
|
||||||
|
log("[flask] started")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Verify Flask is up
|
||||||
|
import urllib.request
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen("http://127.0.0.1:5002/health", timeout=5)
|
||||||
|
log("[flask] health check: OK")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[flask] health check FAILED: {e}")
|
||||||
|
|
||||||
|
# Start Cloudflared
|
||||||
|
cf = start_cloudflared()
|
||||||
|
log("[cloudflared] started")
|
||||||
|
|
||||||
|
# Wait for tunnel URL to appear in output
|
||||||
|
tunnel_url = None
|
||||||
|
deadline = time.time() + 20
|
||||||
|
buffer = ""
|
||||||
|
while time.time() < deadline:
|
||||||
|
line = cf.stdout.readline() if cf.stdout else ""
|
||||||
|
if line:
|
||||||
|
buffer += line
|
||||||
|
m = re.search(r"(https://[a-z-]+\.trycloudflare\.com)", buffer)
|
||||||
|
if m:
|
||||||
|
tunnel_url = m.group(1)
|
||||||
|
break
|
||||||
|
if flask.poll() is not None:
|
||||||
|
log(f"[flask] CRASHED! exit={flask.returncode}")
|
||||||
|
break
|
||||||
|
if cf.poll() is not None:
|
||||||
|
log(f"[cloudflared] CRASHED! exit={cf.returncode}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if tunnel_url:
|
||||||
|
log(f"\n✅ TUNNEL: {tunnel_url}")
|
||||||
|
log(f" Webhook: {tunnel_url}/whatsapp/webhook")
|
||||||
|
log(f" Admin: {tunnel_url}/admin")
|
||||||
|
else:
|
||||||
|
log("❌ Failed to get tunnel URL")
|
||||||
|
|
||||||
|
log("\n[supervisor] Running. Press Ctrl+C to stop.\n")
|
||||||
|
|
||||||
|
# Keep running, restart children if they die
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if flask.poll() is not None:
|
||||||
|
log("[flask] died, restarting...")
|
||||||
|
flask = start_flask()
|
||||||
|
if cf.poll() is not None:
|
||||||
|
log("[cloudflared] died, restarting...")
|
||||||
|
cf = start_cloudflared()
|
||||||
|
time.sleep(5)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("\n[supervisor] Shutting down...")
|
||||||
|
flask.terminate()
|
||||||
|
cf.terminate()
|
||||||
|
flask.wait()
|
||||||
|
cf.wait()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user