Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- app.py +724 -0
- chatbot.py +160 -0
- data_fetcher.py +317 -0
- image_model.py +363 -0
- neural_network.py +439 -0
- requirements.txt +5 -0
app.py
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import threading, time, json
|
| 3 |
+
from datetime import datetime, UTC
|
| 4 |
+
from collections import deque
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from neural_network import LivingNetwork, CATEGORIES as TEXT_CATS, KNOWLEDGE_FILE
|
| 8 |
+
from data_fetcher import DataFetcher
|
| 9 |
+
from image_fetcher import ImageFetcher, IMAGE_DIR, CATEGORIES as IMG_CATS
|
| 10 |
+
from image_model import LivingImageNetwork
|
| 11 |
+
from chatbot import RAGChatbot
|
| 12 |
+
|
| 13 |
+
# ── globals ──────────────────────────────────────────────────────────────────
|
| 14 |
+
network = LivingNetwork()
|
| 15 |
+
fetcher = DataFetcher()
|
| 16 |
+
img_fetcher = ImageFetcher()
|
| 17 |
+
img_network = LivingImageNetwork()
|
| 18 |
+
chatbot = RAGChatbot()
|
| 19 |
+
|
| 20 |
+
app_log = deque(maxlen=300)
|
| 21 |
+
is_alive = False
|
| 22 |
+
img_alive = False
|
| 23 |
+
|
| 24 |
+
def log(msg):
|
| 25 |
+
app_log.appendleft(f"[{datetime.now(UTC).strftime('%H:%M:%S')}] {msg}")
|
| 26 |
+
|
| 27 |
+
log("🧠 Text network ready")
|
| 28 |
+
log("👁️ Image CNN ready")
|
| 29 |
+
kf = network.get_knowledge_file_stats()
|
| 30 |
+
if kf['exists']: log(f"📚 Restored {kf['lines']} articles from knowledge.jsonl")
|
| 31 |
+
if network.epoch: log(f"✅ Text model: epoch {network.epoch:,}")
|
| 32 |
+
if img_network.epoch: log(f"✅ Image CNN: epoch {img_network.epoch:,}")
|
| 33 |
+
|
| 34 |
+
# ── background loops ─────────────────────────────────────────────────────────
|
| 35 |
+
def text_loop():
|
| 36 |
+
global is_alive
|
| 37 |
+
lf = ls = 0
|
| 38 |
+
log("🚀 Text network LIVE")
|
| 39 |
+
while is_alive:
|
| 40 |
+
now = time.time()
|
| 41 |
+
if now - lf >= 40:
|
| 42 |
+
try:
|
| 43 |
+
items = fetcher.fetch_round()
|
| 44 |
+
for it in items:
|
| 45 |
+
network.ingest(it['text'], it['category'], source=it.get('source','web'))
|
| 46 |
+
kf2 = network.get_knowledge_file_stats()
|
| 47 |
+
log(f"📡 +{len(items)} articles | knowledge: {kf2['lines']}")
|
| 48 |
+
lf = now
|
| 49 |
+
except Exception as e:
|
| 50 |
+
log(f"⚠ fetch: {str(e)[:50]}")
|
| 51 |
+
if network.stats['buffer_size'] >= 32 and network.vocab.is_built:
|
| 52 |
+
r = network.train_n_steps(80)
|
| 53 |
+
if r['steps']:
|
| 54 |
+
s = network.stats
|
| 55 |
+
log(f"📝 Epoch {s['epoch']:,} loss {s['loss']} acc {s['accuracy']}%")
|
| 56 |
+
else:
|
| 57 |
+
log(f"⏳ need {max(0,32-network.stats['buffer_size'])} more items")
|
| 58 |
+
if now - ls >= 180:
|
| 59 |
+
network.save_checkpoint(); log("💾 saved"); ls = now
|
| 60 |
+
time.sleep(2)
|
| 61 |
+
log("⏹ text stopped")
|
| 62 |
+
|
| 63 |
+
def image_loop():
|
| 64 |
+
global img_alive
|
| 65 |
+
lf = ls = 0
|
| 66 |
+
log("🚀 Image CNN LIVE")
|
| 67 |
+
while img_alive:
|
| 68 |
+
now = time.time()
|
| 69 |
+
if now - lf >= 60:
|
| 70 |
+
try:
|
| 71 |
+
r = img_fetcher.fetch_round()
|
| 72 |
+
log(f"🖼️ +{r['downloaded']} images | total {r['total']}")
|
| 73 |
+
lf = now
|
| 74 |
+
except Exception as e:
|
| 75 |
+
log(f"⚠ img fetch: {str(e)[:50]}")
|
| 76 |
+
if img_fetcher.get_stats()['total'] >= 16:
|
| 77 |
+
r = img_network.train_n_steps(IMAGE_DIR, 20)
|
| 78 |
+
if r['steps']:
|
| 79 |
+
s = img_network.stats
|
| 80 |
+
log(f"👁️ CNN epoch {s['epoch']:,} loss {s['loss']} acc {s['accuracy']}%")
|
| 81 |
+
else:
|
| 82 |
+
log(f"⏳ img CNN needs {max(0,16-img_fetcher.get_stats()['total'])} more images")
|
| 83 |
+
if now - ls >= 180:
|
| 84 |
+
img_network._save_checkpoint(); log("💾 img saved"); ls = now
|
| 85 |
+
time.sleep(3)
|
| 86 |
+
log("⏹ image stopped")
|
| 87 |
+
|
| 88 |
+
# ── controls ──────────────────────────────────────────────────────────────────
|
| 89 |
+
def start_text():
|
| 90 |
+
global is_alive
|
| 91 |
+
if is_alive: return "⚠ already running"
|
| 92 |
+
is_alive = True
|
| 93 |
+
threading.Thread(target=text_loop, daemon=True).start()
|
| 94 |
+
return "🟢 text network LIVE"
|
| 95 |
+
|
| 96 |
+
def stop_text():
|
| 97 |
+
global is_alive; is_alive = False
|
| 98 |
+
network.save_checkpoint(); return "🔴 stopped + saved"
|
| 99 |
+
|
| 100 |
+
def start_img():
|
| 101 |
+
global img_alive
|
| 102 |
+
if img_alive: return "⚠ already running"
|
| 103 |
+
img_alive = True
|
| 104 |
+
threading.Thread(target=image_loop, daemon=True).start()
|
| 105 |
+
return "🟢 image CNN LIVE"
|
| 106 |
+
|
| 107 |
+
def stop_img():
|
| 108 |
+
global img_alive; img_alive = False
|
| 109 |
+
img_network._save_checkpoint(); return "🔴 stopped + saved"
|
| 110 |
+
|
| 111 |
+
def do_fetch_text():
|
| 112 |
+
items = fetcher.fetch_round()
|
| 113 |
+
for it in items: network.ingest(it['text'], it['category'], source=it.get('source','web'))
|
| 114 |
+
return f"✅ +{len(items)} articles"
|
| 115 |
+
|
| 116 |
+
def do_fetch_img():
|
| 117 |
+
r = img_fetcher.fetch_round()
|
| 118 |
+
return f"✅ +{r['downloaded']} images"
|
| 119 |
+
|
| 120 |
+
def do_train_text(n):
|
| 121 |
+
r = network.train_n_steps(int(n))
|
| 122 |
+
return f"✅ {r['steps']} steps · loss {r['avg_loss']}" if r['steps'] else "⚠ need more data"
|
| 123 |
+
|
| 124 |
+
def do_train_img(n):
|
| 125 |
+
r = img_network.train_n_steps(IMAGE_DIR, int(n))
|
| 126 |
+
return f"✅ {r['steps']} steps · loss {r['avg_loss']}" if r['steps'] else "⚠ need more images"
|
| 127 |
+
|
| 128 |
+
# ── stats fns ─────────────────────────────────────────────────────────────────
|
| 129 |
+
def status_bar():
|
| 130 |
+
s = network.stats; si = img_network.stats
|
| 131 |
+
kf2 = network.get_knowledge_file_stats(); ig = img_fetcher.get_stats()
|
| 132 |
+
return (f"{'🟢' if is_alive else '🔴'} TEXT ep{s['epoch']:,} loss{s['loss']} acc{s['accuracy']}% | "
|
| 133 |
+
f"{'🟢' if img_alive else '🔴'} CNN ep{si['epoch']:,} loss{si['loss']} acc{si['accuracy']}% | "
|
| 134 |
+
f"📚{kf2['lines']} articles 🖼️{ig['total']} images")
|
| 135 |
+
|
| 136 |
+
def text_stats():
|
| 137 |
+
s=network.stats; fs=fetcher.get_stats(); cc=network.category_counts
|
| 138 |
+
hist=network.loss_history[-30:]
|
| 139 |
+
spark=''.join(' ▁▂▃▄▅▆▇█'[min(8,int(((v-min(hist))/(max(hist)-min(hist)+1e-9))*8))] for v in hist) if len(hist)>=2 else '…'
|
| 140 |
+
rows='\n'.join(f"|{c}|{cc.get(c,0)}|" for c in TEXT_CATS)
|
| 141 |
+
return f"""### 📝 Text Network
|
| 142 |
+
|Metric|Value|
|
| 143 |
+
|---|---|
|
| 144 |
+
|Epoch|{s['epoch']:,}|Loss|{s['loss']}|
|
| 145 |
+
|Accuracy|{s['accuracy']}%|Samples|{s['total_samples']:,}|
|
| 146 |
+
|Knowledge|{s['knowledge_count']} articles|Vocab|{s['vocab_size']:,}|
|
| 147 |
+
|LR|{s['lr']}|Buffer|{s['buffer_size']}|
|
| 148 |
+
|
| 149 |
+
Loss `{spark}`
|
| 150 |
+
|
| 151 |
+
### Sources — {fs['total_fetched']} total
|
| 152 |
+
RSS·{fs['sources']['rss']} Reddit·{fs['sources']['reddit']} Wiki·{fs['sources']['wikipedia']} HN·{fs['sources']['hackernews']}
|
| 153 |
+
|
| 154 |
+
### Categories
|
| 155 |
+
|Cat|Count|
|
| 156 |
+
|---|---|
|
| 157 |
+
{rows}
|
| 158 |
+
> _{s['last_text']}_"""
|
| 159 |
+
|
| 160 |
+
def img_stats():
|
| 161 |
+
s=img_network.stats; ifs=img_fetcher.get_stats()
|
| 162 |
+
hist=img_network.loss_history[-30:]
|
| 163 |
+
spark=''.join(' ▁▂▃▄▅▆▇█'[min(8,int(((v-min(hist))/(max(hist)-min(hist)+1e-9))*8))] for v in hist) if len(hist)>=2 else '…'
|
| 164 |
+
rows='\n'.join(f"|{c}|{ifs['by_category'].get(c,0)}|" for c in IMG_CATS)
|
| 165 |
+
return f"""### 👁️ Image CNN
|
| 166 |
+
|Metric|Value|
|
| 167 |
+
|---|---|
|
| 168 |
+
|Epoch|{s['epoch']:,}|Loss|{s['loss']}|
|
| 169 |
+
|Accuracy|{s['accuracy']}%|Images|{s['total_images']:,}|
|
| 170 |
+
|On Disk|{ifs['total']}|Disk|{ifs['disk_mb']} MB|
|
| 171 |
+
|
| 172 |
+
Loss `{spark}`
|
| 173 |
+
|
| 174 |
+
### By Category
|
| 175 |
+
|Cat|Count|
|
| 176 |
+
|---|---|
|
| 177 |
+
{rows}
|
| 178 |
+
|
| 179 |
+
**What each block learns:**
|
| 180 |
+
Block 1 → edges & colours · Block 2 → shapes & textures · Block 3 → objects & parts"""
|
| 181 |
+
|
| 182 |
+
def get_log():
|
| 183 |
+
lines = list(app_log)[:80]
|
| 184 |
+
def color(line):
|
| 185 |
+
if '✅' in line or '🟢' in line: c = '#4ade80'
|
| 186 |
+
elif '⚠' in line or '🔴' in line or 'error' in line.lower(): c = '#f87171'
|
| 187 |
+
elif '🚀' in line or '🤖' in line: c = '#818cf8'
|
| 188 |
+
elif '📰' in line or '🦆' in line or '🟠' in line: c = '#38bdf8'
|
| 189 |
+
elif '💻' in line or '📖' in line: c = '#fb923c'
|
| 190 |
+
elif '🔥' in line or 'loss' in line.lower(): c = '#facc15'
|
| 191 |
+
else: c = '#94a3b8'
|
| 192 |
+
return f'<div style="color:{c};font-family:monospace;font-size:12px;padding:1px 0">{line}</div>'
|
| 193 |
+
rows = ''.join(color(l) for l in lines) or '<div style="color:#64748b;font-style:italic">No logs yet...</div>'
|
| 194 |
+
return f'<div style="background:#0a0f1e;padding:12px;border-radius:8px;max-height:420px;overflow-y:auto;border:1px solid #1e293b">{rows}</div>'
|
| 195 |
+
def get_feed():
|
| 196 |
+
items=fetcher.get_recent_items(10)
|
| 197 |
+
if not items: return "_No data yet_"
|
| 198 |
+
return '\n\n---\n\n'.join(f"**[{i['source'].upper()}]** `{i['category'].upper()}`\n{i['text'][:130]}…" for i in items)
|
| 199 |
+
|
| 200 |
+
def get_knowledge():
|
| 201 |
+
kf2=network.get_knowledge_file_stats(); items=network.get_recent_knowledge(20)
|
| 202 |
+
if not kf2['exists'] or not items: return "### 📭 Empty — start text network"
|
| 203 |
+
rows=[]
|
| 204 |
+
for it in items:
|
| 205 |
+
rows.append(f"**`{it.get('category','?').upper()}`** `{it.get('source','?')}` `{it.get('timestamp','')[:10]}`\n> {it.get('text','')[:130]}…")
|
| 206 |
+
return f"### 📚 {kf2['lines']} articles · {kf2['size_kb']} KB\n\n---\n\n"+'\n\n---\n\n'.join(rows)
|
| 207 |
+
|
| 208 |
+
def predict_text(txt):
|
| 209 |
+
if not txt.strip(): return "Enter text."
|
| 210 |
+
r=network.predict(txt)
|
| 211 |
+
if 'error' in r: return f"⚠ {r['error']}"
|
| 212 |
+
bars=''.join(f"`{c:<15}` {'█'*int(p/5)}{'░'*(20-int(p/5))} **{p:.1f}%**\n\n" for c,p in sorted(r['all_probs'].items(),key=lambda x:-x[1]))
|
| 213 |
+
return f"## → {r['prediction'].upper()}\n**{r['confidence']}% confidence**\n\n{bars}"
|
| 214 |
+
|
| 215 |
+
def predict_img(path):
|
| 216 |
+
if not path: return "Upload image."
|
| 217 |
+
r=img_network.predict_image(path)
|
| 218 |
+
if 'error' in r: return f"⚠ {r['error']}"
|
| 219 |
+
bars=''.join(f"`{c:<15}` {'█'*int(p/5)}{'░'*(20-int(p/5))} **{p:.1f}%**\n\n" for c,p in sorted(r['all_probs'].items(),key=lambda x:-x[1]))
|
| 220 |
+
return f"## → {r['prediction'].upper()}\n**{r['confidence']}% confidence**\n\n{bars}"
|
| 221 |
+
|
| 222 |
+
def chat_fn(msg, history):
|
| 223 |
+
new_history = chatbot.chat(msg, history or [])
|
| 224 |
+
return new_history, "" # also clear the input box
|
| 225 |
+
|
| 226 |
+
# ── 3D VIZ — fully animated with signal pulses ────────────────────────────────
|
| 227 |
+
def build_viz(state_json: str) -> str:
|
| 228 |
+
return """<!DOCTYPE html><html><head><meta charset="UTF-8">
|
| 229 |
+
<style>
|
| 230 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 231 |
+
body{background:#030610;overflow:hidden;font-family:monospace}
|
| 232 |
+
#c{width:100vw;height:100vh;display:block}
|
| 233 |
+
#hud{position:fixed;top:12px;left:14px;color:#00f5c4;font-size:11px;line-height:2;pointer-events:none;text-shadow:0 0 8px #00f5c466}
|
| 234 |
+
#tip{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);font-size:10px;color:#2a3a5a}
|
| 235 |
+
</style></head><body>
|
| 236 |
+
<canvas id="c"></canvas>
|
| 237 |
+
<div id="hud">
|
| 238 |
+
<div id="h1">■ LOADING...</div>
|
| 239 |
+
<div id="h2"></div><div id="h3"></div><div id="h4"></div>
|
| 240 |
+
</div>
|
| 241 |
+
<div id="tip">drag to rotate · scroll to zoom · signals travel left→right every 800ms</div>
|
| 242 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
| 243 |
+
<script>
|
| 244 |
+
// ── initial state from Python ──
|
| 245 |
+
const INIT = """ + state_json + """;
|
| 246 |
+
|
| 247 |
+
// ── renderer ──
|
| 248 |
+
const canvas = document.getElementById('c');
|
| 249 |
+
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, alpha:false});
|
| 250 |
+
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
|
| 251 |
+
renderer.setSize(innerWidth, innerHeight);
|
| 252 |
+
renderer.shadowMap.enabled = true;
|
| 253 |
+
|
| 254 |
+
const scene = new THREE.Scene();
|
| 255 |
+
scene.background = new THREE.Color(0x030610);
|
| 256 |
+
scene.fog = new THREE.FogExp2(0x030610, 0.010);
|
| 257 |
+
|
| 258 |
+
const camera = new THREE.PerspectiveCamera(52, innerWidth/innerHeight, 0.1, 600);
|
| 259 |
+
camera.position.set(0,1,26);
|
| 260 |
+
|
| 261 |
+
// ── lights ──
|
| 262 |
+
scene.add(new THREE.AmbientLight(0x0a1020, 4));
|
| 263 |
+
const kl = new THREE.PointLight(0x00f5c4, 8, 80); kl.position.set(0,10,8); scene.add(kl);
|
| 264 |
+
const bl = new THREE.PointLight(0x5b6fff, 4, 50); bl.position.set(-12,-6,4); scene.add(bl);
|
| 265 |
+
const rl = new THREE.PointLight(0xff6b35, 2, 40); rl.position.set(12,-4,-2); scene.add(rl);
|
| 266 |
+
|
| 267 |
+
// ── orbit ──
|
| 268 |
+
let rX=0.18, rY=0, zoom=26, drag=false, last={x:0,y:0};
|
| 269 |
+
canvas.addEventListener('mousedown', e=>{drag=true;last={x:e.clientX,y:e.clientY}});
|
| 270 |
+
window.addEventListener('mouseup', ()=>drag=false);
|
| 271 |
+
window.addEventListener('mousemove', e=>{
|
| 272 |
+
if(!drag)return;
|
| 273 |
+
rY += (e.clientX-last.x)*0.011;
|
| 274 |
+
rX += (e.clientY-last.y)*0.007;
|
| 275 |
+
rX = Math.max(-1.1, Math.min(1.1, rX));
|
| 276 |
+
last = {x:e.clientX, y:e.clientY};
|
| 277 |
+
});
|
| 278 |
+
canvas.addEventListener('wheel', e=>{zoom=Math.max(7,Math.min(45,zoom+e.deltaY*0.025));e.preventDefault();},{passive:false});
|
| 279 |
+
let lt=null;
|
| 280 |
+
canvas.addEventListener('touchstart',e=>{lt=e.touches[0];},{passive:true});
|
| 281 |
+
canvas.addEventListener('touchmove',e=>{
|
| 282 |
+
if(!lt)return; const t=e.touches[0];
|
| 283 |
+
rY+=(t.clientX-lt.clientX)*0.011; rX+=(t.clientY-lt.clientY)*0.007;
|
| 284 |
+
lt=t; e.preventDefault();
|
| 285 |
+
},{passive:false});
|
| 286 |
+
|
| 287 |
+
// ── starfield ──
|
| 288 |
+
const sg=new THREE.BufferGeometry();
|
| 289 |
+
const sp=new Float32Array(600*3);
|
| 290 |
+
for(let i=0;i<sp.length;i++) sp[i]=(Math.random()-0.5)*120;
|
| 291 |
+
sg.setAttribute('position',new THREE.BufferAttribute(sp,3));
|
| 292 |
+
scene.add(new THREE.Points(sg, new THREE.PointsMaterial({color:0x0d1a33,size:0.06})));
|
| 293 |
+
|
| 294 |
+
// ── build network from state ──
|
| 295 |
+
const group = new THREE.Group();
|
| 296 |
+
scene.add(group);
|
| 297 |
+
|
| 298 |
+
// ── DYNAMIC BRAIN SIZE — grows as the AI learns more ──────────────────────────
|
| 299 |
+
const rawSizes = INIT.layer_sizes || [64,256,128,64,8];
|
| 300 |
+
const kc = (INIT.stats && (INIT.stats.knowledge_count || INIT.stats.total_images)) || 0;
|
| 301 |
+
// Scale: 0 articles = 1 neuron, 10=2, 30=3, 60=4, 100=5, 200=7, 500=10, 1000+=12
|
| 302 |
+
const MAX_N = kc===0 ? 1 : kc<5 ? 2 : kc<15 ? 3 : kc<40 ? 4 : kc<80 ? 5 : kc<150 ? 6 : kc<300 ? 8 : kc<600 ? 10 : 12;
|
| 303 |
+
// Scale all layer sizes proportionally
|
| 304 |
+
const scaleFactor = MAX_N / 12;
|
| 305 |
+
const sizes = rawSizes.map((s,i) => Math.max(1, Math.round(s * scaleFactor)));
|
| 306 |
+
const nL = sizes.length;
|
| 307 |
+
const LGAP = 5.8;
|
| 308 |
+
const startX = -(nL-1)*LGAP/2;
|
| 309 |
+
const acts = INIT.activations || {};
|
| 310 |
+
const aKeys = Object.keys(acts);
|
| 311 |
+
|
| 312 |
+
function getAct(li, ni){
|
| 313 |
+
const k=aKeys[li]; if(!k) return Math.random()*0.4+0.1;
|
| 314 |
+
const a=acts[k];
|
| 315 |
+
return (a&&a[ni]!=null) ? Math.max(0,Math.min(1,a[ni])) : Math.random()*0.3;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Shared geometries
|
| 319 |
+
const nGeo = new THREE.SphereGeometry(0.22, 18, 18);
|
| 320 |
+
const gGeo = new THREE.SphereGeometry(0.34, 8, 8);
|
| 321 |
+
|
| 322 |
+
const LAYER_NAMES = ['INPUT','HIDDEN-1','HIDDEN-2','HIDDEN-3','OUTPUT',
|
| 323 |
+
'CONV-1','CONV-2','FC-1','FC-2'];
|
| 324 |
+
|
| 325 |
+
// Store neuron meshes for animation
|
| 326 |
+
const neurons = []; // neurons[layerIdx] = [{mesh, glowMesh, baseAct, fireTimer, pos}]
|
| 327 |
+
const connections = []; // {from, to, line, fromIdx, toIdx, lFromIdx, lToIdx}
|
| 328 |
+
|
| 329 |
+
for(let li=0;li<nL;li++){
|
| 330 |
+
const show = Math.min(sizes[li], MAX_N);
|
| 331 |
+
const yGap = Math.min(1.9, 11/show);
|
| 332 |
+
const yStart= -(show-1)*yGap/2;
|
| 333 |
+
const layer = [];
|
| 334 |
+
|
| 335 |
+
for(let ni=0;ni<show;ni++){
|
| 336 |
+
const act = getAct(li,ni);
|
| 337 |
+
const x = startX + li*LGAP;
|
| 338 |
+
const y = yStart + ni*yGap;
|
| 339 |
+
const z = (Math.random()-0.5)*0.5;
|
| 340 |
+
|
| 341 |
+
// Neuron colour based on activation
|
| 342 |
+
const r=Math.floor(act*240), g=Math.floor(act*200), b=Math.floor(60+act*100);
|
| 343 |
+
const col = new THREE.Color(`rgb(${r},${g},${b})`);
|
| 344 |
+
|
| 345 |
+
const mat = new THREE.MeshStandardMaterial({
|
| 346 |
+
color: col, emissive: col,
|
| 347 |
+
emissiveIntensity: 0.3+act*1.8,
|
| 348 |
+
roughness:0.2, metalness:0.9,
|
| 349 |
+
});
|
| 350 |
+
const mesh = new THREE.Mesh(nGeo, mat);
|
| 351 |
+
mesh.position.set(x,y,z);
|
| 352 |
+
group.add(mesh);
|
| 353 |
+
|
| 354 |
+
// Glow halo
|
| 355 |
+
const gMat = new THREE.MeshBasicMaterial({
|
| 356 |
+
color:0x00f5c4, transparent:true,
|
| 357 |
+
opacity: act>0.4?(act-0.4)*0.5:0,
|
| 358 |
+
wireframe:true
|
| 359 |
+
});
|
| 360 |
+
const gMesh = new THREE.Mesh(gGeo, gMat);
|
| 361 |
+
gMesh.position.set(x,y,z);
|
| 362 |
+
group.add(gMesh);
|
| 363 |
+
|
| 364 |
+
layer.push({mesh, gMesh, baseAct:act, fireTimer:0, pos:new THREE.Vector3(x,y,z)});
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
// Layer label
|
| 368 |
+
const lc=document.createElement('canvas');
|
| 369 |
+
lc.width=256; lc.height=40;
|
| 370 |
+
const lx=lc.getContext('2d');
|
| 371 |
+
lx.fillStyle='rgba(0,245,196,0.5)';
|
| 372 |
+
lx.font='bold 18px monospace'; lx.textAlign='center';
|
| 373 |
+
lx.fillText(LAYER_NAMES[li]||`L${li}`, 128, 28);
|
| 374 |
+
const lbl = new THREE.Mesh(
|
| 375 |
+
new THREE.PlaneGeometry(2.6,0.45),
|
| 376 |
+
new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(lc),transparent:true,side:THREE.DoubleSide})
|
| 377 |
+
);
|
| 378 |
+
lbl.position.set(startX+li*LGAP, yStart-2.0, 0);
|
| 379 |
+
group.add(lbl);
|
| 380 |
+
neurons.push(layer);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
// Build connections between adjacent layers
|
| 384 |
+
let connDrawn = 0;
|
| 385 |
+
const MAX_CONN = 160;
|
| 386 |
+
for(let li=0;li<nL-1&&connDrawn<MAX_CONN;li++){
|
| 387 |
+
const fromL=neurons[li], toL=neurons[li+1];
|
| 388 |
+
for(let fi=0;fi<fromL.length&&connDrawn<MAX_CONN;fi++){
|
| 389 |
+
for(let ti=0;ti<toL.length&&connDrawn<MAX_CONN;ti++){
|
| 390 |
+
const sig=(fromL[fi].baseAct+toL[ti].baseAct)/2;
|
| 391 |
+
if(sig<0.05&&Math.random()>0.3) continue;
|
| 392 |
+
const positive=Math.random()>0.35;
|
| 393 |
+
const col=positive?0x00f5c4:0xff6b35;
|
| 394 |
+
const pts=[fromL[fi].pos.clone(), toL[ti].pos.clone()];
|
| 395 |
+
const geo=new THREE.BufferGeometry().setFromPoints(pts);
|
| 396 |
+
const mat=new THREE.LineBasicMaterial({color:col,transparent:true,opacity:0.04+sig*0.35});
|
| 397 |
+
const line=new THREE.Line(geo,mat);
|
| 398 |
+
group.add(line);
|
| 399 |
+
connections.push({from:fromL[fi],to:toL[ti],line,mat,lFromIdx:li,lToIdx:li+1,fi,ti});
|
| 400 |
+
connDrawn++;
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
// ── SIGNAL PARTICLE SYSTEM ────────────────────────────────────────────────────
|
| 406 |
+
// This is what makes the network look ALIVE — glowing orbs traveling between neurons
|
| 407 |
+
const signals = [];
|
| 408 |
+
const sigGeo = new THREE.SphereGeometry(0.12, 8, 8);
|
| 409 |
+
|
| 410 |
+
function spawnSignal(fromNeuron, toNeuron, color=0x00f5c4){
|
| 411 |
+
const mat = new THREE.MeshBasicMaterial({color, transparent:true, opacity:0.95});
|
| 412 |
+
const mesh = new THREE.Mesh(sigGeo, mat);
|
| 413 |
+
mesh.position.copy(fromNeuron.pos);
|
| 414 |
+
scene.add(mesh); // add to scene not group so it stays in world space
|
| 415 |
+
|
| 416 |
+
// Trail
|
| 417 |
+
const trailMat = new THREE.MeshBasicMaterial({color, transparent:true, opacity:0.4});
|
| 418 |
+
const trail = new THREE.Mesh(new THREE.SphereGeometry(0.07,6,6), trailMat);
|
| 419 |
+
scene.add(trail);
|
| 420 |
+
|
| 421 |
+
signals.push({
|
| 422 |
+
mesh, trail, mat, trailMat,
|
| 423 |
+
from: fromNeuron.pos.clone(),
|
| 424 |
+
to: toNeuron.pos.clone(),
|
| 425 |
+
toNeuron,
|
| 426 |
+
t: 0,
|
| 427 |
+
speed: 0.028 + Math.random()*0.015,
|
| 428 |
+
color,
|
| 429 |
+
});
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
function fireNeuron(layerIdx, neuronIdx, cascade=true){
|
| 433 |
+
if(layerIdx>=neurons.length || neuronIdx>=neurons[layerIdx].length) return;
|
| 434 |
+
const n = neurons[layerIdx][neuronIdx];
|
| 435 |
+
n.fireTimer = 1.0;
|
| 436 |
+
|
| 437 |
+
// Spawn signals to next layer
|
| 438 |
+
if(cascade && layerIdx < nL-1){
|
| 439 |
+
const nextL = neurons[layerIdx+1];
|
| 440 |
+
const count = Math.min(nextL.length, 2+Math.floor(Math.random()*3));
|
| 441 |
+
const shuffled = [...nextL].sort(()=>Math.random()-0.5).slice(0, count);
|
| 442 |
+
shuffled.forEach(target => {
|
| 443 |
+
spawnSignal(n, target, layerIdx===0?0x00f5c4:0x5b9fff);
|
| 444 |
+
});
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
// Every 800ms: fire random input neurons → cascade through the network
|
| 449 |
+
let fireTimer = 0;
|
| 450 |
+
function triggerFiring(){
|
| 451 |
+
if(neurons.length===0) return;
|
| 452 |
+
const inputLayer = neurons[0];
|
| 453 |
+
const count = 1+Math.floor(Math.random()*3);
|
| 454 |
+
for(let i=0;i<count;i++){
|
| 455 |
+
const ni = Math.floor(Math.random()*inputLayer.length);
|
| 456 |
+
fireNeuron(0, ni, true);
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
// ── LOSS HISTORY GRAPH ───────────────────────────────────────────────────────
|
| 461 |
+
(function(){
|
| 462 |
+
const hist = INIT.loss_history||[];
|
| 463 |
+
if(hist.length<3) return;
|
| 464 |
+
const gc=document.createElement('canvas');
|
| 465 |
+
gc.width=200; gc.height=50;
|
| 466 |
+
gc.style.cssText='position:fixed;bottom:14px;right:14px;border:1px solid #1a2240;border-radius:6px;background:rgba(11,15,30,0.8)';
|
| 467 |
+
const ctx=gc.getContext('2d');
|
| 468 |
+
const mn=Math.min(...hist), mx=Math.max(...hist), rng=mx-mn||1;
|
| 469 |
+
// fill
|
| 470 |
+
ctx.beginPath();
|
| 471 |
+
hist.forEach((v,i)=>{const x=(i/(hist.length-1))*200,y=50-4-((v-mn)/rng)*42; i===0?ctx.moveTo(x,50):ctx.lineTo(x,y)});
|
| 472 |
+
ctx.lineTo(200,50); ctx.closePath();
|
| 473 |
+
ctx.fillStyle='rgba(0,245,196,0.08)'; ctx.fill();
|
| 474 |
+
// line
|
| 475 |
+
ctx.beginPath();
|
| 476 |
+
hist.forEach((v,i)=>{const x=(i/(hist.length-1))*200,y=50-4-((v-mn)/rng)*42; i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)});
|
| 477 |
+
ctx.strokeStyle='#00f5c4'; ctx.lineWidth=1.5; ctx.stroke();
|
| 478 |
+
document.body.appendChild(gc);
|
| 479 |
+
const lb=document.createElement('div');
|
| 480 |
+
lb.style.cssText='position:fixed;bottom:68px;right:14px;font-size:9px;color:#2a3a5a;font-family:monospace';
|
| 481 |
+
lb.textContent='LOSS ↓'; document.body.appendChild(lb);
|
| 482 |
+
})();
|
| 483 |
+
|
| 484 |
+
// ── HUD ──────────────────────────────────────────────────────────────────────
|
| 485 |
+
const st = INIT.stats||{};
|
| 486 |
+
const isImg = INIT.type==='cnn';
|
| 487 |
+
const kCount = st.knowledge_count || st.total_images || 0;
|
| 488 |
+
document.getElementById('h1').textContent = (isImg?'👁️ IMAGE CNN':'📝 TEXT NETWORK')+' — LIVE';
|
| 489 |
+
document.getElementById('h2').textContent = `EPOCH ${(st.epoch||0).toLocaleString()}`;
|
| 490 |
+
document.getElementById('h3').textContent = `LOSS ${st.loss||'—'} ACC ${st.accuracy||'—'}%`;
|
| 491 |
+
document.getElementById('h4').textContent = `BRAIN ${kCount} ${isImg?'images':'articles'} · ${sizes.map((s,i)=>Math.min(s,MAX_N)).join('→')} neurons`;
|
| 492 |
+
|
| 493 |
+
// ── ANIMATE ──────────────────────────────────────────────────────────────────
|
| 494 |
+
let frame = 0;
|
| 495 |
+
const clock = new THREE.Clock();
|
| 496 |
+
|
| 497 |
+
function animate(){
|
| 498 |
+
requestAnimationFrame(animate);
|
| 499 |
+
const dt = clock.getDelta();
|
| 500 |
+
const elaps = clock.getElapsedTime();
|
| 501 |
+
frame++;
|
| 502 |
+
|
| 503 |
+
// ① Auto-fire every 800ms
|
| 504 |
+
fireTimer += dt;
|
| 505 |
+
if(fireTimer > 0.8){
|
| 506 |
+
triggerFiring();
|
| 507 |
+
fireTimer = 0;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
// ② Update neurons — pulse and fire glow
|
| 511 |
+
neurons.forEach((layer, li)=>{
|
| 512 |
+
layer.forEach((n, ni)=>{
|
| 513 |
+
// Base sinusoidal pulse — each neuron breathes at its own rate
|
| 514 |
+
const phase = li*0.7 + ni*0.3;
|
| 515 |
+
const pulse = Math.sin(elaps*2.1+phase)*0.15 + Math.sin(elaps*0.9+phase*2)*0.08;
|
| 516 |
+
const act = Math.max(0, Math.min(1, n.baseAct + pulse + n.fireTimer*0.6));
|
| 517 |
+
|
| 518 |
+
const r=Math.floor(act*240), g=Math.floor(act*200), b=Math.floor(60+act*100);
|
| 519 |
+
n.mesh.material.emissive.setRGB(r/255, g/255, b/255);
|
| 520 |
+
n.mesh.material.emissiveIntensity = 0.3 + act*2.2;
|
| 521 |
+
n.mesh.scale.setScalar(1 + n.fireTimer*0.6 + pulse*0.05);
|
| 522 |
+
|
| 523 |
+
// Glow halo
|
| 524 |
+
n.gMesh.material.opacity = n.fireTimer>0 ? n.fireTimer*0.7 : Math.max(0,(act-0.45)*0.4);
|
| 525 |
+
n.gMesh.scale.setScalar(1 + n.fireTimer*1.0);
|
| 526 |
+
|
| 527 |
+
// Decay fire
|
| 528 |
+
if(n.fireTimer > 0) n.fireTimer = Math.max(0, n.fireTimer - dt*1.8);
|
| 529 |
+
});
|
| 530 |
+
});
|
| 531 |
+
|
| 532 |
+
// ③ Update connection lines — flash when signals pass
|
| 533 |
+
connections.forEach(conn=>{
|
| 534 |
+
const sig=(conn.from.baseAct+conn.to.baseAct)/2;
|
| 535 |
+
const flash = conn.from.fireTimer*0.5;
|
| 536 |
+
conn.mat.opacity = Math.min(0.9, 0.04 + sig*0.3 + flash);
|
| 537 |
+
});
|
| 538 |
+
|
| 539 |
+
// ④ Move signal particles
|
| 540 |
+
for(let i=signals.length-1;i>=0;i--){
|
| 541 |
+
const s=signals[i];
|
| 542 |
+
s.t += s.speed;
|
| 543 |
+
if(s.t>=1){
|
| 544 |
+
scene.remove(s.mesh); scene.remove(s.trail);
|
| 545 |
+
signals.splice(i,1);
|
| 546 |
+
// Fire destination neuron (next layer cascade)
|
| 547 |
+
s.toNeuron.fireTimer = 0.8;
|
| 548 |
+
// If not at output, cascade further
|
| 549 |
+
const lIdx = neurons.findIndex(l=>l.includes(s.toNeuron));
|
| 550 |
+
if(lIdx>=0 && lIdx<nL-1){
|
| 551 |
+
const nextL=neurons[lIdx+1];
|
| 552 |
+
if(Math.random()>0.3){
|
| 553 |
+
const t2=nextL[Math.floor(Math.random()*nextL.length)];
|
| 554 |
+
spawnSignal(s.toNeuron, t2, 0x5b9fff);
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
} else {
|
| 558 |
+
s.mesh.position.lerpVectors(s.from, s.to, s.t);
|
| 559 |
+
s.trail.position.lerpVectors(s.from, s.to, Math.max(0,s.t-0.08));
|
| 560 |
+
// Fade out near end
|
| 561 |
+
s.mat.opacity = s.t<0.8 ? 0.95 : (1-s.t)*4.75;
|
| 562 |
+
s.trailMat.opacity = s.t<0.8 ? 0.35 : (1-s.t)*1.75;
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
// ⑤ Camera orbit + gentle network bob
|
| 567 |
+
const autoY = elaps * 0.12;
|
| 568 |
+
camera.position.x = Math.sin(rY + autoY)*zoom;
|
| 569 |
+
camera.position.y = Math.sin(rX)*zoom*0.45 + 1;
|
| 570 |
+
camera.position.z = Math.cos(rY + autoY)*zoom;
|
| 571 |
+
camera.lookAt(0, 0, 0);
|
| 572 |
+
|
| 573 |
+
kl.intensity = 7 + Math.sin(elaps*1.5)*2;
|
| 574 |
+
bl.intensity = 3 + Math.cos(elaps*2.3)*1;
|
| 575 |
+
kl.position.x = Math.sin(elaps*0.4)*6;
|
| 576 |
+
group.position.y = Math.sin(elaps*0.6)*0.15;
|
| 577 |
+
|
| 578 |
+
renderer.render(scene, camera);
|
| 579 |
+
}
|
| 580 |
+
animate();
|
| 581 |
+
|
| 582 |
+
window.addEventListener('resize',()=>{
|
| 583 |
+
camera.aspect=innerWidth/innerHeight;
|
| 584 |
+
camera.updateProjectionMatrix();
|
| 585 |
+
renderer.setSize(innerWidth,innerHeight);
|
| 586 |
+
});
|
| 587 |
+
</script></body></html>"""
|
| 588 |
+
|
| 589 |
+
def get_text_viz():
|
| 590 |
+
s = network.get_viz_state(); s['type']='text'
|
| 591 |
+
h = build_viz(json.dumps(s))
|
| 592 |
+
e = h.replace('"','"').replace('\n',' ')
|
| 593 |
+
return f'<iframe srcdoc="{e}" style="width:100%;height:650px;border:none;border-radius:12px"></iframe>'
|
| 594 |
+
|
| 595 |
+
def get_img_viz():
|
| 596 |
+
s = img_network.get_viz_state()
|
| 597 |
+
h = build_viz(json.dumps(s))
|
| 598 |
+
e = h.replace('"','"').replace('\n',' ')
|
| 599 |
+
return f'<iframe srcdoc="{e}" style="width:100%;height:650px;border:none;border-radius:12px"></iframe>'
|
| 600 |
+
|
| 601 |
+
# ── UI ────────────────────────────────────────────────────────────────────────
|
| 602 |
+
with gr.Blocks(title="Living Neural Network") as demo:
|
| 603 |
+
gr.Markdown("# 🧠 Living Neural Network\n*Two AIs training on live internet data — one reads, one looks*")
|
| 604 |
+
|
| 605 |
+
sbar = gr.Textbox(label="", value=status_bar(), interactive=False, lines=2)
|
| 606 |
+
|
| 607 |
+
with gr.Tabs():
|
| 608 |
+
|
| 609 |
+
# CONTROL
|
| 610 |
+
with gr.Tab("🎛️ Control"):
|
| 611 |
+
gr.Markdown("### 📝 Text Network")
|
| 612 |
+
with gr.Row():
|
| 613 |
+
gr.Button("▶ Start Text", variant="primary").click(start_text, outputs=gr.Textbox(label="",lines=1,interactive=False))
|
| 614 |
+
gr.Button("⏹ Stop Text", variant="stop" ).click(stop_text, outputs=gr.Textbox(label="",lines=1,interactive=False))
|
| 615 |
+
gr.Button("📡 Fetch Text" ).click(do_fetch_text, outputs=gr.Textbox(label="",lines=1,interactive=False))
|
| 616 |
+
with gr.Row():
|
| 617 |
+
ts=gr.Slider(10,500,100,step=10,label="Steps")
|
| 618 |
+
gr.Button("🔥 Train Text").click(do_train_text,inputs=ts,outputs=gr.Textbox(label="",lines=1,interactive=False))
|
| 619 |
+
gr.Markdown("---\n### 👁️ Image CNN")
|
| 620 |
+
with gr.Row():
|
| 621 |
+
gr.Button("▶ Start Images",variant="primary").click(start_img, outputs=gr.Textbox(label="",lines=1,interactive=False))
|
| 622 |
+
gr.Button("⏹ Stop Images",variant="stop" ).click(stop_img, outputs=gr.Textbox(label="",lines=1,interactive=False))
|
| 623 |
+
gr.Button("🖼️ Fetch Images" ).click(do_fetch_img, outputs=gr.Textbox(label="",lines=1,interactive=False))
|
| 624 |
+
with gr.Row():
|
| 625 |
+
is_=gr.Slider(5,100,20,step=5,label="Steps")
|
| 626 |
+
gr.Button("🔥 Train CNN").click(do_train_img,inputs=is_,outputs=gr.Textbox(label="",lines=1,interactive=False))
|
| 627 |
+
|
| 628 |
+
# STATS
|
| 629 |
+
with gr.Tab("📊 Stats"):
|
| 630 |
+
with gr.Row():
|
| 631 |
+
tmd=gr.Markdown(value=text_stats())
|
| 632 |
+
imd=gr.Markdown(value=img_stats())
|
| 633 |
+
lbox=gr.HTML(value=get_log(),label="Live Logs")
|
| 634 |
+
|
| 635 |
+
# 3D TEXT VIZ
|
| 636 |
+
with gr.Tab("🔮 Text Network 3D"):
|
| 637 |
+
gr.Markdown("**Neurons fire & signals travel left→right in real time.** Drag=rotate Scroll=zoom")
|
| 638 |
+
tvb=gr.Button("🔄 Refresh",variant="primary")
|
| 639 |
+
tvo=gr.HTML('<div style="height:650px;background:#030610;border-radius:12px;display:flex;align-items:center;justify-content:center;color:#00f5c4;font-family:monospace;font-size:14px">Click 🔄 Refresh to load the 3D network</div>')
|
| 640 |
+
tvb.click(get_text_viz,outputs=tvo)
|
| 641 |
+
|
| 642 |
+
# 3D IMAGE VIZ
|
| 643 |
+
with gr.Tab("👁️ Image CNN 3D"):
|
| 644 |
+
gr.Markdown("**Conv layers visualised in 3D.** Each block learns progressively deeper features.")
|
| 645 |
+
ivb=gr.Button("🔄 Refresh",variant="primary")
|
| 646 |
+
ivo=gr.HTML('<div style="height:650px;background:#030610;border-radius:12px;display:flex;align-items:center;justify-content:center;color:#00f5c4;font-family:monospace;font-size:14px">Click 🔄 Refresh to load CNN visualization</div>')
|
| 647 |
+
ivb.click(get_img_viz,outputs=ivo)
|
| 648 |
+
|
| 649 |
+
# CHAT
|
| 650 |
+
with gr.Tab("💬 Chat with my AI"):
|
| 651 |
+
gr.Markdown(
|
| 652 |
+
"### Talk to your AI — it answers from what it has actually learned\n"
|
| 653 |
+
"The more articles it collects, the smarter the answers.\n"
|
| 654 |
+
"Type `stats` to see what it knows. Type `help` for tips."
|
| 655 |
+
)
|
| 656 |
+
chatbox = gr.Chatbot(label="", height=480)
|
| 657 |
+
with gr.Row():
|
| 658 |
+
msg_in = gr.Textbox(label="", placeholder="Ask anything — e.g. 'What's happening in AI?'", scale=5)
|
| 659 |
+
send_b = gr.Button("Send", variant="primary", scale=1)
|
| 660 |
+
send_b.click(chat_fn, inputs=[msg_in, chatbox], outputs=[chatbox, msg_in])
|
| 661 |
+
msg_in.submit(chat_fn, inputs=[msg_in, chatbox], outputs=[chatbox, msg_in])
|
| 662 |
+
gr.Examples(
|
| 663 |
+
[["What's happening in AI and technology?"],
|
| 664 |
+
["Tell me about recent science discoveries"],
|
| 665 |
+
["What's in the news about sports?"],
|
| 666 |
+
["stats"],["help"]],
|
| 667 |
+
inputs=msg_in
|
| 668 |
+
)
|
| 669 |
+
|
| 670 |
+
# KNOWLEDGE
|
| 671 |
+
with gr.Tab("📚 Knowledge Base"):
|
| 672 |
+
gr.Markdown("Everything the AI has read — saved to `knowledge.jsonl` instantly")
|
| 673 |
+
kmd=gr.Markdown(value=get_knowledge())
|
| 674 |
+
|
| 675 |
+
# DATA FEED
|
| 676 |
+
with gr.Tab("📰 Data Feed"):
|
| 677 |
+
fmd=gr.Markdown(value=get_feed())
|
| 678 |
+
|
| 679 |
+
# PREDICT TEXT
|
| 680 |
+
with gr.Tab("🔍 Classify Text"):
|
| 681 |
+
gr.Markdown("Test your trained text model")
|
| 682 |
+
pt=gr.Textbox(label="Text",lines=3,placeholder="Scientists discover...")
|
| 683 |
+
pb=gr.Button("Classify",variant="primary")
|
| 684 |
+
po=gr.Markdown()
|
| 685 |
+
pb.click(predict_text,inputs=pt,outputs=po)
|
| 686 |
+
|
| 687 |
+
# PREDICT IMAGE
|
| 688 |
+
with gr.Tab("🖼️ Classify Image"):
|
| 689 |
+
gr.Markdown("Test your trained image CNN")
|
| 690 |
+
pi=gr.Image(label="Upload image",type="filepath")
|
| 691 |
+
pib=gr.Button("Classify",variant="primary")
|
| 692 |
+
pio=gr.Markdown()
|
| 693 |
+
pib.click(predict_img,inputs=pi,outputs=pio)
|
| 694 |
+
|
| 695 |
+
# timers
|
| 696 |
+
t3=gr.Timer(3); t5=gr.Timer(5); t8=gr.Timer(8); t12=gr.Timer(12)
|
| 697 |
+
t3.tick(status_bar,outputs=sbar)
|
| 698 |
+
t3.tick(get_log,outputs=lbox)
|
| 699 |
+
t5.tick(text_stats,outputs=tmd)
|
| 700 |
+
t5.tick(img_stats,outputs=imd)
|
| 701 |
+
t8.tick(get_knowledge,outputs=kmd)
|
| 702 |
+
t8.tick(get_feed,outputs=fmd)
|
| 703 |
+
t12.tick(get_text_viz,outputs=tvo)
|
| 704 |
+
t12.tick(get_img_viz,outputs=ivo)
|
| 705 |
+
|
| 706 |
+
# ── AUTO-START: both networks begin training immediately on startup ──────────
|
| 707 |
+
def _auto_start():
|
| 708 |
+
import time as _t
|
| 709 |
+
_t.sleep(3) # give Gradio time to finish starting
|
| 710 |
+
try:
|
| 711 |
+
start_text()
|
| 712 |
+
log("🤖 Auto-started TEXT network")
|
| 713 |
+
except Exception as e:
|
| 714 |
+
log(f"⚠ Auto-start text error: {e}")
|
| 715 |
+
try:
|
| 716 |
+
start_img()
|
| 717 |
+
log("🤖 Auto-started IMAGE network")
|
| 718 |
+
except Exception as e:
|
| 719 |
+
log(f"⚠ Auto-start image error: {e}")
|
| 720 |
+
|
| 721 |
+
threading.Thread(target=_auto_start, daemon=True).start()
|
| 722 |
+
|
| 723 |
+
if __name__=="__main__":
|
| 724 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, share=True, ssr_mode=False)
|
chatbot.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json, re, math, os, random
|
| 2 |
+
from collections import Counter, defaultdict
|
| 3 |
+
|
| 4 |
+
KNOWLEDGE_FILE = 'knowledge.jsonl'
|
| 5 |
+
STOPWORDS = {
|
| 6 |
+
'a','an','the','is','it','in','on','at','to','for','of','and','or','but',
|
| 7 |
+
'was','are','were','be','been','have','has','had','do','does','did','will',
|
| 8 |
+
'would','could','should','may','might','that','this','these','those','with',
|
| 9 |
+
'from','by','as','not','also','than','then','so','if','when','what','how',
|
| 10 |
+
'who','which','its','their','our','your','my','his','her','we','they','he',
|
| 11 |
+
'she','you','i','me','him','us','them','said','says',
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
def tokenize(text):
|
| 15 |
+
text = re.sub(r'[^\w\s]', ' ', text.lower())
|
| 16 |
+
return [w for w in text.split() if w not in STOPWORDS and len(w) > 2]
|
| 17 |
+
|
| 18 |
+
class KnowledgeBase:
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.docs = []; self.doc_tokens = []; self.idf = {}; self.last_count = 0
|
| 21 |
+
self._load()
|
| 22 |
+
|
| 23 |
+
def _load(self):
|
| 24 |
+
if not os.path.exists(KNOWLEDGE_FILE): return
|
| 25 |
+
new_docs = []
|
| 26 |
+
try:
|
| 27 |
+
with open(KNOWLEDGE_FILE, 'r', encoding='utf-8') as f:
|
| 28 |
+
for line in f:
|
| 29 |
+
line = line.strip()
|
| 30 |
+
if line:
|
| 31 |
+
try: new_docs.append(json.loads(line))
|
| 32 |
+
except: pass
|
| 33 |
+
except: return
|
| 34 |
+
if len(new_docs) == self.last_count: return
|
| 35 |
+
self.docs = new_docs; self.last_count = len(new_docs)
|
| 36 |
+
self.doc_tokens = [tokenize(d.get('text','')) for d in self.docs]
|
| 37 |
+
N = len(self.docs)
|
| 38 |
+
df = defaultdict(int)
|
| 39 |
+
for tokens in self.doc_tokens:
|
| 40 |
+
for w in set(tokens): df[w] += 1
|
| 41 |
+
self.idf = {w: math.log((N+1)/(c+1))+1 for w,c in df.items()}
|
| 42 |
+
|
| 43 |
+
def search(self, query, top_k=5):
|
| 44 |
+
self._load()
|
| 45 |
+
if not self.docs: return []
|
| 46 |
+
q = tokenize(query)
|
| 47 |
+
if not q: return []
|
| 48 |
+
def score(dt):
|
| 49 |
+
tf = Counter(dt); n = len(dt)
|
| 50 |
+
return sum((tf[w]/n)*self.idf.get(w,0) for w in q if w in tf)
|
| 51 |
+
ranked = sorted(range(len(self.docs)), key=lambda i: -score(self.doc_tokens[i]))
|
| 52 |
+
return [(self.docs[i], score(self.doc_tokens[i])) for i in ranked[:top_k] if score(self.doc_tokens[i]) > 0]
|
| 53 |
+
|
| 54 |
+
def get_stats(self):
|
| 55 |
+
self._load()
|
| 56 |
+
return {
|
| 57 |
+
'total': len(self.docs),
|
| 58 |
+
'categories': dict(Counter(d.get('category','other') for d in self.docs)),
|
| 59 |
+
'sources': dict(Counter(d.get('source','unknown') for d in self.docs)),
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
class RAGChatbot:
|
| 63 |
+
def __init__(self):
|
| 64 |
+
self.kb = KnowledgeBase()
|
| 65 |
+
|
| 66 |
+
def _build_answer(self, query, results, stats):
|
| 67 |
+
total = stats['total']
|
| 68 |
+
if total == 0:
|
| 69 |
+
return ("My brain is empty right now 🧠 — I haven't read any articles yet.\n\n"
|
| 70 |
+
"Go to **Control → ▶ Start Text** to feed me internet data!")
|
| 71 |
+
|
| 72 |
+
if not results:
|
| 73 |
+
return (f"I've read **{total} articles** so far, but I haven't learned anything about that topic yet.\n\n"
|
| 74 |
+
f"My current knowledge covers: **{', '.join(list(stats['categories'].keys())[:5])}**.\n\n"
|
| 75 |
+
"Keep training me and I'll learn more over time! 📚")
|
| 76 |
+
|
| 77 |
+
# Build answer from best matching sentences
|
| 78 |
+
q_tokens = set(tokenize(query))
|
| 79 |
+
seen, parts = set(), []
|
| 80 |
+
|
| 81 |
+
for doc, sc in results[:3]:
|
| 82 |
+
text = doc.get('text','')
|
| 83 |
+
cat = doc.get('category','general').upper()
|
| 84 |
+
src = doc.get('source','?').upper()
|
| 85 |
+
sents = [s.strip() for s in re.split(r'(?<=[.!?])\s+', text) if len(s.strip()) > 40]
|
| 86 |
+
scored = sorted(
|
| 87 |
+
[(len(q_tokens & set(tokenize(s))), s) for s in sents if s not in seen],
|
| 88 |
+
reverse=True
|
| 89 |
+
)[:2]
|
| 90 |
+
for _, s in scored: seen.add(s)
|
| 91 |
+
if scored:
|
| 92 |
+
snippet = ' '.join(s for _, s in scored if _>0) or scored[0][1]
|
| 93 |
+
parts.append(f"📰 **[{cat} / {src}]**\n{snippet}")
|
| 94 |
+
elif len(text) > 40:
|
| 95 |
+
parts.append(f"📰 **[{cat} / {src}]**\n{text[:250]}...")
|
| 96 |
+
|
| 97 |
+
if not parts:
|
| 98 |
+
return f"I found some related articles ({total} total) but couldn't extract a clear answer. Try rephrasing!"
|
| 99 |
+
|
| 100 |
+
intro = random.choice([
|
| 101 |
+
f"Based on what I've learned ({total} articles read):\n\n",
|
| 102 |
+
f"Here's what I know from my training data:\n\n",
|
| 103 |
+
f"From {total} articles I've read:\n\n",
|
| 104 |
+
])
|
| 105 |
+
return intro + '\n\n'.join(parts)
|
| 106 |
+
|
| 107 |
+
def chat(self, user_message, history):
|
| 108 |
+
history = list(history or [])
|
| 109 |
+
msg = user_message.strip()
|
| 110 |
+
if not msg: return history
|
| 111 |
+
|
| 112 |
+
lower = msg.lower()
|
| 113 |
+
stats = self.kb.get_stats()
|
| 114 |
+
|
| 115 |
+
if lower in ('hi','hello','hey','sup','yo','hiya','howdy','helo'):
|
| 116 |
+
total = stats['total']
|
| 117 |
+
if total == 0:
|
| 118 |
+
bot_reply = ("Hey! 👋 I'm your AI — but I haven't learned anything yet.\n\n"
|
| 119 |
+
"Go to **Control → ▶ Start Text** to feed me internet articles!")
|
| 120 |
+
else:
|
| 121 |
+
bot_reply = (f"Hey! 👋 I'm your AI — I've read **{total} articles** so far!\n\n"
|
| 122 |
+
f"Ask me anything about what I've learned. My strongest topics: "
|
| 123 |
+
f"**{', '.join(list(stats['categories'].keys())[:4])}**.")
|
| 124 |
+
|
| 125 |
+
elif any(p in lower for p in ('how are you','how r you',"what's up",'whats up')):
|
| 126 |
+
bot_reply = "I'm learning and growing! 🤖 The more articles I read, the smarter I get. Ask me something!"
|
| 127 |
+
|
| 128 |
+
elif any(p in lower for p in ('who are you','what are you','what can you do','tell me about yourself')):
|
| 129 |
+
bot_reply = (
|
| 130 |
+
"I'm a **Living Neural Network** — an AI that learns from real internet articles in real time! 🧠\n\n"
|
| 131 |
+
f"I've read **{stats['total']} articles** so far covering: "
|
| 132 |
+
f"{', '.join(list(stats['categories'].keys())[:5]) or 'nothing yet'}.\n\n"
|
| 133 |
+
"I only know what I've actually been trained on — no pre-built knowledge. "
|
| 134 |
+
"The more you train me, the smarter I become. Like a brain growing from scratch!"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
elif lower in ('stats','status','what do you know','knowledge'):
|
| 138 |
+
lines = '\n'.join(f" • **{k}**: {v}" for k,v in sorted(stats['categories'].items(), key=lambda x: -x[1]))
|
| 139 |
+
srcs = ', '.join(f"{k}({v})" for k,v in stats['sources'].items())
|
| 140 |
+
bot_reply = (f"## 🧠 My Knowledge\n\n**Total articles:** {stats['total']}\n\n"
|
| 141 |
+
f"**By topic:**\n{lines or ' • nothing yet'}\n\n"
|
| 142 |
+
f"**Sources:** {srcs or 'none yet'}")
|
| 143 |
+
|
| 144 |
+
elif lower in ('help','?','commands'):
|
| 145 |
+
bot_reply = ("## 💡 How to use me\n\n"
|
| 146 |
+
"Just ask me anything — I'll search what I've learned!\n\n"
|
| 147 |
+
"**Try:** *What's happening in AI?* · *Tell me about climate change* · *Who is Elon Musk?*\n\n"
|
| 148 |
+
"**Commands:** `stats` · `help`\n\n"
|
| 149 |
+
"⚡ The more I train, the more I know!")
|
| 150 |
+
|
| 151 |
+
elif any(p in lower for p in ('thanks','thank you','thx','ty')):
|
| 152 |
+
bot_reply = "You're welcome! 😊 Keep training me!"
|
| 153 |
+
|
| 154 |
+
else:
|
| 155 |
+
results = self.kb.search(msg, top_k=5)
|
| 156 |
+
bot_reply = self._build_answer(msg, results, stats)
|
| 157 |
+
|
| 158 |
+
history.append({"role": "user", "content": user_message})
|
| 159 |
+
history.append({"role": "assistant", "content": bot_reply})
|
| 160 |
+
return history
|
data_fetcher.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
data_fetcher.py — Fetches real live data from the internet.
|
| 3 |
+
Sources: RSS, Reddit, Wikipedia, HackerNews, DuckDuckGo Search,
|
| 4 |
+
DEV.to, GitHub Trending, Google News RSS, Al Jazeera, Guardian, NPR, arXiv
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests, feedparser, random, re, time, json
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from collections import deque
|
| 10 |
+
from urllib.parse import quote_plus
|
| 11 |
+
|
| 12 |
+
HEADERS = {
|
| 13 |
+
'User-Agent': 'Mozilla/5.0 (compatible; LivingNeuralNetwork/2.0; educational)',
|
| 14 |
+
'Accept-Language': 'en-US,en;q=0.9',
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
# ─── RSS FEEDS ────────────────────────────────────────────────────────────────
|
| 18 |
+
RSS_FEEDS = {
|
| 19 |
+
'technology': [
|
| 20 |
+
'https://feeds.feedburner.com/TechCrunch',
|
| 21 |
+
'https://feeds.arstechnica.com/arstechnica/index',
|
| 22 |
+
'https://www.wired.com/feed/rss',
|
| 23 |
+
'https://hnrss.org/frontpage',
|
| 24 |
+
'https://www.theverge.com/rss/index.xml',
|
| 25 |
+
'https://feeds.feedburner.com/venturebeat/SZYF',
|
| 26 |
+
'https://dev.to/feed',
|
| 27 |
+
'https://thenextweb.com/feed/',
|
| 28 |
+
],
|
| 29 |
+
'science': [
|
| 30 |
+
'https://www.sciencedaily.com/rss/all.xml',
|
| 31 |
+
'https://www.newscientist.com/feed/home/',
|
| 32 |
+
'https://rss.nytimes.com/services/xml/rss/nyt/Science.xml',
|
| 33 |
+
'https://www.nature.com/nature.rss',
|
| 34 |
+
'http://export.arxiv.org/rss/cs.AI',
|
| 35 |
+
'http://export.arxiv.org/rss/cs.LG',
|
| 36 |
+
'https://phys.org/rss-feed/breaking/',
|
| 37 |
+
],
|
| 38 |
+
'world': [
|
| 39 |
+
'https://feeds.bbci.co.uk/news/world/rss.xml',
|
| 40 |
+
'https://rss.nytimes.com/services/xml/rss/nyt/World.xml',
|
| 41 |
+
'https://www.aljazeera.com/xml/rss/all.xml',
|
| 42 |
+
'https://feeds.npr.org/1004/rss.xml',
|
| 43 |
+
'https://www.theguardian.com/world/rss',
|
| 44 |
+
'https://feeds.reuters.com/reuters/worldNews',
|
| 45 |
+
],
|
| 46 |
+
'sports': [
|
| 47 |
+
'https://feeds.bbci.co.uk/sport/rss.xml',
|
| 48 |
+
'https://rss.nytimes.com/services/xml/rss/nyt/Sports.xml',
|
| 49 |
+
'https://www.espn.com/espn/rss/news',
|
| 50 |
+
],
|
| 51 |
+
'business': [
|
| 52 |
+
'https://feeds.bbci.co.uk/news/business/rss.xml',
|
| 53 |
+
'https://rss.nytimes.com/services/xml/rss/nyt/Business.xml',
|
| 54 |
+
'https://feeds.bloomberg.com/markets/news.rss',
|
| 55 |
+
'https://www.theguardian.com/business/rss',
|
| 56 |
+
'https://feeds.reuters.com/reuters/businessNews',
|
| 57 |
+
],
|
| 58 |
+
'health': [
|
| 59 |
+
'https://feeds.bbci.co.uk/news/health/rss.xml',
|
| 60 |
+
'https://rss.nytimes.com/services/xml/rss/nyt/Health.xml',
|
| 61 |
+
'https://www.theguardian.com/society/health/rss',
|
| 62 |
+
'https://feeds.npr.org/1128/rss.xml',
|
| 63 |
+
],
|
| 64 |
+
'entertainment': [
|
| 65 |
+
'https://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml',
|
| 66 |
+
'https://rss.nytimes.com/services/xml/rss/nyt/Arts.xml',
|
| 67 |
+
'https://www.theguardian.com/culture/rss',
|
| 68 |
+
'https://variety.com/feed/',
|
| 69 |
+
],
|
| 70 |
+
'ai': [
|
| 71 |
+
'http://export.arxiv.org/rss/cs.AI',
|
| 72 |
+
'http://export.arxiv.org/rss/cs.LG',
|
| 73 |
+
'https://hnrss.org/frontpage',
|
| 74 |
+
'https://feeds.feedburner.com/TechCrunch',
|
| 75 |
+
],
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
REDDIT_FEEDS = [
|
| 79 |
+
('technology', 'https://www.reddit.com/r/technology/top.json?limit=25&t=day'),
|
| 80 |
+
('science', 'https://www.reddit.com/r/science/top.json?limit=25&t=day'),
|
| 81 |
+
('world', 'https://www.reddit.com/r/worldnews/top.json?limit=25&t=day'),
|
| 82 |
+
('sports', 'https://www.reddit.com/r/sports/top.json?limit=25&t=day'),
|
| 83 |
+
('business', 'https://www.reddit.com/r/business/top.json?limit=25&t=day'),
|
| 84 |
+
('health', 'https://www.reddit.com/r/Health/top.json?limit=25&t=day'),
|
| 85 |
+
('entertainment', 'https://www.reddit.com/r/movies/top.json?limit=25&t=day'),
|
| 86 |
+
('ai', 'https://www.reddit.com/r/MachineLearning/top.json?limit=25&t=day'),
|
| 87 |
+
('ai', 'https://www.reddit.com/r/artificial/top.json?limit=25&t=day'),
|
| 88 |
+
('science', 'https://www.reddit.com/r/askscience/top.json?limit=25&t=day'),
|
| 89 |
+
('technology', 'https://www.reddit.com/r/programming/top.json?limit=25&t=day'),
|
| 90 |
+
('world', 'https://www.reddit.com/r/geopolitics/top.json?limit=25&t=day'),
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
# Topics to actively search on DuckDuckGo
|
| 94 |
+
DDG_SEARCH_TOPICS = [
|
| 95 |
+
('technology', 'latest AI artificial intelligence news'),
|
| 96 |
+
('technology', 'tech industry news today'),
|
| 97 |
+
('science', 'science discovery research breakthrough'),
|
| 98 |
+
('world', 'world news today'),
|
| 99 |
+
('business', 'business economy markets news'),
|
| 100 |
+
('health', 'health medicine research news'),
|
| 101 |
+
('ai', 'machine learning deep learning news'),
|
| 102 |
+
('ai', 'ChatGPT OpenAI Anthropic Google AI'),
|
| 103 |
+
('world', 'politics international relations'),
|
| 104 |
+
('science', 'space NASA astronomy discovery'),
|
| 105 |
+
('technology', 'cybersecurity data breach'),
|
| 106 |
+
('entertainment', 'movies music entertainment news'),
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
WIKIPEDIA_API = 'https://en.wikipedia.org/api/rest_v1/page/random/summary'
|
| 110 |
+
WIKI_SEARCH = 'https://en.wikipedia.org/api/rest_v1/page/summary/{}'
|
| 111 |
+
HN_TOP_API = 'https://hacker-news.firebaseio.com/v0/topstories.json'
|
| 112 |
+
HN_ITEM_API = 'https://hacker-news.firebaseio.com/v0/item/{}.json'
|
| 113 |
+
|
| 114 |
+
# ─── UTILS ────────────────────────────────────────────────────────────────────
|
| 115 |
+
def clean(text: str, max_chars: int = 700) -> str:
|
| 116 |
+
if not text: return ''
|
| 117 |
+
text = re.sub(r'<[^>]+>', ' ', text)
|
| 118 |
+
text = re.sub(r'http\S+', '', text)
|
| 119 |
+
text = re.sub(r'[^\w\s.,!?;:\'\-–—]', ' ', text)
|
| 120 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 121 |
+
return text[:max_chars]
|
| 122 |
+
|
| 123 |
+
def make_item(text, category, source, extra=None):
|
| 124 |
+
text = clean(text)
|
| 125 |
+
if len(text) < 30: return None
|
| 126 |
+
return {
|
| 127 |
+
'text': text,
|
| 128 |
+
'category': category,
|
| 129 |
+
'source': source,
|
| 130 |
+
'timestamp': datetime.utcnow().isoformat(),
|
| 131 |
+
**(extra or {}),
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
# ─── FETCHERS ─────────────────────────────────────────────────────────────────
|
| 135 |
+
def fetch_rss(category: str, url: str) -> list:
|
| 136 |
+
items = []
|
| 137 |
+
try:
|
| 138 |
+
feed = feedparser.parse(url)
|
| 139 |
+
for entry in feed.entries[:15]:
|
| 140 |
+
title = entry.get('title', '')
|
| 141 |
+
summary = entry.get('summary', entry.get('description', ''))
|
| 142 |
+
text = f"{title}. {summary}"
|
| 143 |
+
item = make_item(text, category, 'rss', {'feed': url.split('/')[2]})
|
| 144 |
+
if item: items.append(item)
|
| 145 |
+
except Exception: pass
|
| 146 |
+
return items
|
| 147 |
+
|
| 148 |
+
def fetch_reddit(category: str, url: str) -> list:
|
| 149 |
+
items = []
|
| 150 |
+
try:
|
| 151 |
+
r = requests.get(url, headers=HEADERS, timeout=10)
|
| 152 |
+
r.raise_for_status()
|
| 153 |
+
posts = r.json().get('data', {}).get('children', [])
|
| 154 |
+
for post in posts:
|
| 155 |
+
d = post.get('data', {})
|
| 156 |
+
title = d.get('title', '')
|
| 157 |
+
selftext = d.get('selftext', '')
|
| 158 |
+
text = f"{title}. {selftext}"
|
| 159 |
+
item = make_item(text, category, 'reddit',
|
| 160 |
+
{'subreddit': d.get('subreddit',''), 'score': d.get('score',0)})
|
| 161 |
+
if item: items.append(item)
|
| 162 |
+
except Exception: pass
|
| 163 |
+
return items
|
| 164 |
+
|
| 165 |
+
def fetch_duckduckgo(query: str, category: str) -> list:
|
| 166 |
+
"""Search DuckDuckGo and extract text snippets — no API key needed."""
|
| 167 |
+
items = []
|
| 168 |
+
try:
|
| 169 |
+
url = f"https://html.duckduckgo.com/html/?q={quote_plus(query)}"
|
| 170 |
+
r = requests.get(url, headers={**HEADERS, 'Accept': 'text/html'}, timeout=12)
|
| 171 |
+
# Extract result snippets
|
| 172 |
+
snippets = re.findall(r'class="result__snippet"[^>]*>(.*?)</[as]>', r.text, re.DOTALL)
|
| 173 |
+
titles = re.findall(r'class="result__a"[^>]*>(.*?)</a>', r.text, re.DOTALL)
|
| 174 |
+
for i, snippet in enumerate(snippets[:8]):
|
| 175 |
+
title = titles[i] if i < len(titles) else query
|
| 176 |
+
title = re.sub(r'<[^>]+>', '', title).strip()
|
| 177 |
+
snippet = re.sub(r'<[^>]+>', '', snippet).strip()
|
| 178 |
+
text = f"{title}. {snippet}"
|
| 179 |
+
item = make_item(text, category, 'duckduckgo', {'query': query})
|
| 180 |
+
if item: items.append(item)
|
| 181 |
+
except Exception: pass
|
| 182 |
+
return items
|
| 183 |
+
|
| 184 |
+
def fetch_wikipedia_random() -> dict | None:
|
| 185 |
+
try:
|
| 186 |
+
r = requests.get(WIKIPEDIA_API, headers=HEADERS, timeout=10)
|
| 187 |
+
r.raise_for_status()
|
| 188 |
+
data = r.json()
|
| 189 |
+
title = data.get('title', '')
|
| 190 |
+
text = f"{title}. {data.get('extract','')}"
|
| 191 |
+
return make_item(text, 'other', 'wikipedia', {'title': title})
|
| 192 |
+
except Exception: return None
|
| 193 |
+
|
| 194 |
+
def fetch_wikipedia_topic(topic: str, category: str) -> dict | None:
|
| 195 |
+
"""Fetch Wikipedia article for a specific topic."""
|
| 196 |
+
try:
|
| 197 |
+
url = WIKI_SEARCH.format(quote_plus(topic))
|
| 198 |
+
r = requests.get(url, headers=HEADERS, timeout=10)
|
| 199 |
+
r.raise_for_status()
|
| 200 |
+
data = r.json()
|
| 201 |
+
title = data.get('title', '')
|
| 202 |
+
text = f"{title}. {data.get('extract','')}"
|
| 203 |
+
return make_item(text, category, 'wikipedia', {'title': title})
|
| 204 |
+
except Exception: return None
|
| 205 |
+
|
| 206 |
+
def fetch_hackernews(n: int = 8) -> list:
|
| 207 |
+
items = []
|
| 208 |
+
try:
|
| 209 |
+
r = requests.get(HN_TOP_API, headers=HEADERS, timeout=10)
|
| 210 |
+
r.raise_for_status()
|
| 211 |
+
ids = r.json()[:50]
|
| 212 |
+
chosen = random.sample(ids, min(n, len(ids)))
|
| 213 |
+
for sid in chosen:
|
| 214 |
+
try:
|
| 215 |
+
sr = requests.get(HN_ITEM_API.format(sid), headers=HEADERS, timeout=6)
|
| 216 |
+
story = sr.json()
|
| 217 |
+
title = story.get('title', '')
|
| 218 |
+
body = story.get('text', '')
|
| 219 |
+
item = make_item(f"{title}. {body}", 'technology', 'hackernews',
|
| 220 |
+
{'score': story.get('score', 0)})
|
| 221 |
+
if item: items.append(item)
|
| 222 |
+
time.sleep(0.08)
|
| 223 |
+
except Exception: continue
|
| 224 |
+
except Exception: pass
|
| 225 |
+
return items
|
| 226 |
+
|
| 227 |
+
# ─── MAIN FETCHER ─────────────────────────────────────────────────────────────
|
| 228 |
+
class DataFetcher:
|
| 229 |
+
def __init__(self):
|
| 230 |
+
self.total_fetched = 0
|
| 231 |
+
self.source_counts = {
|
| 232 |
+
'rss': 0, 'reddit': 0, 'wikipedia': 0,
|
| 233 |
+
'hackernews': 0, 'duckduckgo': 0
|
| 234 |
+
}
|
| 235 |
+
self.recent_items = deque(maxlen=100)
|
| 236 |
+
self.log = deque(maxlen=300)
|
| 237 |
+
self._ddg_idx = 0 # rotate through DDG search topics
|
| 238 |
+
|
| 239 |
+
def _log(self, msg: str):
|
| 240 |
+
ts = datetime.utcnow().strftime('%H:%M:%S')
|
| 241 |
+
entry = f"[{ts}] {msg}"
|
| 242 |
+
self.log.appendleft(entry)
|
| 243 |
+
return entry
|
| 244 |
+
|
| 245 |
+
def fetch_round(self) -> list:
|
| 246 |
+
"""Fetch one full round from ALL sources."""
|
| 247 |
+
all_items = []
|
| 248 |
+
|
| 249 |
+
# 1. RSS — pick 2 random categories
|
| 250 |
+
for _ in range(2):
|
| 251 |
+
try:
|
| 252 |
+
cat = random.choice(list(RSS_FEEDS.keys()))
|
| 253 |
+
url = random.choice(RSS_FEEDS[cat])
|
| 254 |
+
items = fetch_rss(cat, url)
|
| 255 |
+
all_items.extend(items)
|
| 256 |
+
self.source_counts['rss'] += len(items)
|
| 257 |
+
self._log(f"📰 RSS [{cat.upper()}] +{len(items)} ← {url.split('/')[2]}")
|
| 258 |
+
except Exception as e:
|
| 259 |
+
self._log(f"⚠ RSS error: {e}")
|
| 260 |
+
|
| 261 |
+
# 2. Reddit
|
| 262 |
+
try:
|
| 263 |
+
cat, url = random.choice(REDDIT_FEEDS)
|
| 264 |
+
items = fetch_reddit(cat, url)
|
| 265 |
+
all_items.extend(items)
|
| 266 |
+
self.source_counts['reddit'] += len(items)
|
| 267 |
+
sub = url.split('/r/')[1].split('/')[0]
|
| 268 |
+
self._log(f"🟠 Reddit [r/{sub}] +{len(items)}")
|
| 269 |
+
except Exception as e:
|
| 270 |
+
self._log(f"⚠ Reddit error: {e}")
|
| 271 |
+
|
| 272 |
+
# 3. DuckDuckGo search (rotate through topics)
|
| 273 |
+
try:
|
| 274 |
+
cat, query = DDG_SEARCH_TOPICS[self._ddg_idx % len(DDG_SEARCH_TOPICS)]
|
| 275 |
+
self._ddg_idx += 1
|
| 276 |
+
items = fetch_duckduckgo(query, cat)
|
| 277 |
+
all_items.extend(items)
|
| 278 |
+
self.source_counts['duckduckgo'] += len(items)
|
| 279 |
+
self._log(f"🦆 DuckDuckGo [{cat.upper()}] \"{query[:40]}\" +{len(items)}")
|
| 280 |
+
except Exception as e:
|
| 281 |
+
self._log(f"⚠ DDG error: {e}")
|
| 282 |
+
|
| 283 |
+
# 4. Wikipedia (random + topic)
|
| 284 |
+
try:
|
| 285 |
+
item = fetch_wikipedia_random()
|
| 286 |
+
if item:
|
| 287 |
+
all_items.append(item)
|
| 288 |
+
self.source_counts['wikipedia'] += 1
|
| 289 |
+
self._log(f"📖 Wikipedia (random): {item['text'][:50]}…")
|
| 290 |
+
except Exception as e:
|
| 291 |
+
self._log(f"⚠ Wikipedia error: {e}")
|
| 292 |
+
|
| 293 |
+
# 5. HackerNews (every other round)
|
| 294 |
+
if random.random() < 0.5:
|
| 295 |
+
try:
|
| 296 |
+
items = fetch_hackernews(6)
|
| 297 |
+
all_items.extend(items)
|
| 298 |
+
self.source_counts['hackernews'] += len(items)
|
| 299 |
+
self._log(f"💻 HackerNews +{len(items)}")
|
| 300 |
+
except Exception as e:
|
| 301 |
+
self._log(f"⚠ HN error: {e}")
|
| 302 |
+
|
| 303 |
+
for item in all_items:
|
| 304 |
+
self.recent_items.appendleft(item)
|
| 305 |
+
self.total_fetched += len(all_items)
|
| 306 |
+
self._log(f"✅ Round complete — {len(all_items)} new items | total: {self.total_fetched}")
|
| 307 |
+
return all_items
|
| 308 |
+
|
| 309 |
+
def get_stats(self) -> dict:
|
| 310 |
+
return {
|
| 311 |
+
'total_fetched': self.total_fetched,
|
| 312 |
+
'sources': dict(self.source_counts),
|
| 313 |
+
'recent_log': list(self.log)[:30],
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
def get_recent_items(self, n: int = 20) -> list:
|
| 317 |
+
return list(self.recent_items)[:n]
|
image_model.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
image_model.py — A real CNN (Convolutional Neural Network) built from scratch in PyTorch.
|
| 3 |
+
|
| 4 |
+
Architecture:
|
| 5 |
+
Input (3 x 64 x 64 image)
|
| 6 |
+
→ Conv2d(3, 32, 3) + BatchNorm + ReLU + MaxPool → 32 x 31 x 31
|
| 7 |
+
→ Conv2d(32, 64, 3) + BatchNorm + ReLU + MaxPool → 64 x 14 x 14
|
| 8 |
+
→ Conv2d(64,128, 3) + BatchNorm + ReLU + MaxPool → 128 x 6 x 6
|
| 9 |
+
→ Flatten → Linear(128*6*6, 512) → ReLU → Dropout
|
| 10 |
+
→ Linear(512, 128) → ReLU
|
| 11 |
+
→ Linear(128, 8 categories)
|
| 12 |
+
|
| 13 |
+
This CNN learns to LOOK at images the same way your text network learns to READ text.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
import threading
|
| 18 |
+
_CNN_LOCK = threading.Lock()
|
| 19 |
+
import numpy as np
|
| 20 |
+
import os
|
| 21 |
+
import json
|
| 22 |
+
from datetime import datetime, UTC
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from collections import defaultdict
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from PIL import Image
|
| 28 |
+
PIL_AVAILABLE = True
|
| 29 |
+
except ImportError:
|
| 30 |
+
PIL_AVAILABLE = False
|
| 31 |
+
|
| 32 |
+
IMG_SIZE = 64 # resize all images to 64x64 (small = faster on CPU)
|
| 33 |
+
CATEGORIES = [
|
| 34 |
+
'nature', 'technology', 'science', 'people',
|
| 35 |
+
'animals', 'food', 'sports', 'architecture'
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
CNN_CHECKPOINT = 'cnn_checkpoint.pt'
|
| 39 |
+
CNN_STATS_FILE = 'cnn_stats.json'
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ── IMAGE PREPROCESSING ───────────────────────────────────────────────────────
|
| 43 |
+
def load_image_tensor(path: str, size: int = IMG_SIZE):
|
| 44 |
+
"""Load image from disk → normalised float tensor (3, size, size)."""
|
| 45 |
+
if not PIL_AVAILABLE:
|
| 46 |
+
raise RuntimeError("Pillow not installed. Add 'Pillow' to requirements.txt")
|
| 47 |
+
img = Image.open(path).convert('RGB')
|
| 48 |
+
img = img.resize((size, size), Image.BILINEAR)
|
| 49 |
+
arr = np.array(img, dtype=np.float32) / 255.0
|
| 50 |
+
# Normalize with ImageNet mean/std (works well even for non-ImageNet data)
|
| 51 |
+
mean = np.array([0.485, 0.456, 0.406])
|
| 52 |
+
std = np.array([0.229, 0.224, 0.225])
|
| 53 |
+
arr = (arr - mean) / std
|
| 54 |
+
return torch.tensor(arr).permute(2, 0, 1) # HWC → CHW
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ── CNN MODEL ─────────────────────────────────────────────────────────────────
|
| 58 |
+
class ImageCNN(nn.Module):
|
| 59 |
+
"""
|
| 60 |
+
A real Convolutional Neural Network.
|
| 61 |
+
Learns to detect edges → shapes → textures → objects, layer by layer.
|
| 62 |
+
Each Conv2d layer is looking for patterns the previous layer found.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
def __init__(self, num_classes: int = 8):
|
| 66 |
+
super().__init__()
|
| 67 |
+
self.num_classes = num_classes
|
| 68 |
+
|
| 69 |
+
# Convolutional feature extractor
|
| 70 |
+
self.features = nn.Sequential(
|
| 71 |
+
# Block 1 — learns basic edges and colours
|
| 72 |
+
nn.Conv2d(3, 32, kernel_size=3, padding=1),
|
| 73 |
+
nn.BatchNorm2d(32),
|
| 74 |
+
nn.ReLU(),
|
| 75 |
+
nn.Conv2d(32, 32, kernel_size=3, padding=1),
|
| 76 |
+
nn.BatchNorm2d(32),
|
| 77 |
+
nn.ReLU(),
|
| 78 |
+
nn.MaxPool2d(2, 2), # 64→32
|
| 79 |
+
nn.Dropout2d(0.1),
|
| 80 |
+
|
| 81 |
+
# Block 2 — learns corners, curves, textures
|
| 82 |
+
nn.Conv2d(32, 64, kernel_size=3, padding=1),
|
| 83 |
+
nn.BatchNorm2d(64),
|
| 84 |
+
nn.ReLU(),
|
| 85 |
+
nn.Conv2d(64, 64, kernel_size=3, padding=1),
|
| 86 |
+
nn.BatchNorm2d(64),
|
| 87 |
+
nn.ReLU(),
|
| 88 |
+
nn.MaxPool2d(2, 2), # 32→16
|
| 89 |
+
nn.Dropout2d(0.15),
|
| 90 |
+
|
| 91 |
+
# Block 3 — learns complex shapes and object parts
|
| 92 |
+
nn.Conv2d(64, 128, kernel_size=3, padding=1),
|
| 93 |
+
nn.BatchNorm2d(128),
|
| 94 |
+
nn.ReLU(),
|
| 95 |
+
nn.Conv2d(128, 128, kernel_size=3, padding=1),
|
| 96 |
+
nn.BatchNorm2d(128),
|
| 97 |
+
nn.ReLU(),
|
| 98 |
+
nn.MaxPool2d(2, 2), # 16→8
|
| 99 |
+
nn.Dropout2d(0.2),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Classifier head
|
| 103 |
+
self.classifier = nn.Sequential(
|
| 104 |
+
nn.Flatten(),
|
| 105 |
+
nn.Linear(128 * 8 * 8, 512),
|
| 106 |
+
nn.ReLU(),
|
| 107 |
+
nn.Dropout(0.4),
|
| 108 |
+
nn.Linear(512, 128),
|
| 109 |
+
nn.ReLU(),
|
| 110 |
+
nn.Linear(128, num_classes),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Activation tracking for visualization
|
| 114 |
+
self._activations = {}
|
| 115 |
+
self._register_hooks()
|
| 116 |
+
self._initialize_weights()
|
| 117 |
+
|
| 118 |
+
def _initialize_weights(self):
|
| 119 |
+
for m in self.modules():
|
| 120 |
+
if isinstance(m, nn.Conv2d):
|
| 121 |
+
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
|
| 122 |
+
elif isinstance(m, nn.BatchNorm2d):
|
| 123 |
+
nn.init.constant_(m.weight, 1)
|
| 124 |
+
nn.init.constant_(m.bias, 0)
|
| 125 |
+
elif isinstance(m, nn.Linear):
|
| 126 |
+
nn.init.xavier_normal_(m.weight)
|
| 127 |
+
nn.init.constant_(m.bias, 0)
|
| 128 |
+
|
| 129 |
+
def _register_hooks(self):
|
| 130 |
+
def make_hook(name):
|
| 131 |
+
def hook(module, inp, out):
|
| 132 |
+
if isinstance(out, torch.Tensor):
|
| 133 |
+
v = out.detach().float()
|
| 134 |
+
if v.dim() > 1:
|
| 135 |
+
v = v.mean(0)
|
| 136 |
+
if v.dim() > 1:
|
| 137 |
+
v = v.mean(-1).mean(-1) # spatial mean for conv layers
|
| 138 |
+
self._activations[name] = v[:16].tolist()
|
| 139 |
+
return hook
|
| 140 |
+
for i, layer in enumerate(self.features):
|
| 141 |
+
layer.register_forward_hook(make_hook(f'conv_{i}'))
|
| 142 |
+
for i, layer in enumerate(self.classifier):
|
| 143 |
+
layer.register_forward_hook(make_hook(f'fc_{i}'))
|
| 144 |
+
|
| 145 |
+
def forward(self, x):
|
| 146 |
+
x = self.features(x)
|
| 147 |
+
return self.classifier(x)
|
| 148 |
+
|
| 149 |
+
def get_activations(self):
|
| 150 |
+
return dict(self._activations)
|
| 151 |
+
|
| 152 |
+
def get_feature_maps(self, x):
|
| 153 |
+
"""Return intermediate feature maps for visualization."""
|
| 154 |
+
maps = {}
|
| 155 |
+
for i, layer in enumerate(self.features):
|
| 156 |
+
x = layer(x)
|
| 157 |
+
if isinstance(layer, nn.ReLU):
|
| 158 |
+
maps[f'relu_{i}'] = x.detach()
|
| 159 |
+
return maps
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ── LIVING IMAGE NETWORK ──────────────────────────────────────────────────────
|
| 163 |
+
class LivingImageNetwork:
|
| 164 |
+
"""
|
| 165 |
+
Wraps the CNN with training loop, data loading, and stats.
|
| 166 |
+
Trains on images downloaded by ImageFetcher.
|
| 167 |
+
"""
|
| 168 |
+
|
| 169 |
+
def __init__(self):
|
| 170 |
+
self.model = ImageCNN(num_classes=len(CATEGORIES))
|
| 171 |
+
self.optimizer = optim.Adam(self.model.parameters(), lr=0.001, weight_decay=1e-4)
|
| 172 |
+
self.scheduler = optim.lr_scheduler.StepLR(self.optimizer, step_size=50, gamma=0.8)
|
| 173 |
+
self.criterion = nn.CrossEntropyLoss()
|
| 174 |
+
|
| 175 |
+
self.epoch = 0
|
| 176 |
+
self.total_images = 0
|
| 177 |
+
self.loss_history = []
|
| 178 |
+
self.acc_history = []
|
| 179 |
+
self.category_counts = defaultdict(int)
|
| 180 |
+
|
| 181 |
+
self.stats = {
|
| 182 |
+
'epoch': 0,
|
| 183 |
+
'loss': '—',
|
| 184 |
+
'accuracy': '—',
|
| 185 |
+
'total_images': 0,
|
| 186 |
+
'lr': 0.001,
|
| 187 |
+
'last_image': '(none yet)',
|
| 188 |
+
'status': 'idle',
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
self._load_checkpoint()
|
| 192 |
+
|
| 193 |
+
# ── DATA LOADING ──────────────────────────────────────────────────────────
|
| 194 |
+
def _load_batch(self, image_dir: Path, batch_size: int = 16):
|
| 195 |
+
"""
|
| 196 |
+
Load a random batch of images from image_data/ folder.
|
| 197 |
+
Returns (tensor_batch, label_batch) or None if not enough images.
|
| 198 |
+
"""
|
| 199 |
+
if not PIL_AVAILABLE:
|
| 200 |
+
return None
|
| 201 |
+
|
| 202 |
+
all_paths = []
|
| 203 |
+
for cat_idx, cat in enumerate(CATEGORIES):
|
| 204 |
+
cat_dir = image_dir / cat
|
| 205 |
+
if cat_dir.exists():
|
| 206 |
+
for p in cat_dir.iterdir():
|
| 207 |
+
if p.suffix.lower() in ('.jpg', '.jpeg', '.png', '.webp'):
|
| 208 |
+
all_paths.append((str(p), cat_idx))
|
| 209 |
+
|
| 210 |
+
if len(all_paths) < batch_size:
|
| 211 |
+
return None
|
| 212 |
+
|
| 213 |
+
import random
|
| 214 |
+
batch_paths = random.sample(all_paths, batch_size)
|
| 215 |
+
tensors, labels = [], []
|
| 216 |
+
|
| 217 |
+
for path, label in batch_paths:
|
| 218 |
+
try:
|
| 219 |
+
t = load_image_tensor(path)
|
| 220 |
+
tensors.append(t)
|
| 221 |
+
labels.append(label)
|
| 222 |
+
self.category_counts[CATEGORIES[label]] += 1
|
| 223 |
+
except Exception:
|
| 224 |
+
continue
|
| 225 |
+
|
| 226 |
+
if not tensors:
|
| 227 |
+
return None
|
| 228 |
+
|
| 229 |
+
return torch.stack(tensors), torch.tensor(labels, dtype=torch.long)
|
| 230 |
+
|
| 231 |
+
# ── TRAINING ──────────────────────────────────────────────────────────────
|
| 232 |
+
def train_step(self, image_dir: Path, batch_size: int = 16):
|
| 233 |
+
"""One training step — load images, forward pass, backprop."""
|
| 234 |
+
batch = self._load_batch(image_dir, batch_size)
|
| 235 |
+
if batch is None:
|
| 236 |
+
return None
|
| 237 |
+
|
| 238 |
+
x, y = batch
|
| 239 |
+
x = x.float() # ensure float32
|
| 240 |
+
with _CNN_LOCK:
|
| 241 |
+
self.model.train()
|
| 242 |
+
self.model.zero_grad(set_to_none=True)
|
| 243 |
+
logits = self.model(x)
|
| 244 |
+
loss = self.criterion(logits, y)
|
| 245 |
+
loss.backward()
|
| 246 |
+
for p in self.model.parameters():
|
| 247 |
+
if p.grad is not None:
|
| 248 |
+
p.grad.data.clamp_(-1.0, 1.0)
|
| 249 |
+
self.optimizer.step()
|
| 250 |
+
loss_val = loss.detach().item()
|
| 251 |
+
acc = (logits.detach().argmax(1) == y).float().mean().item()
|
| 252 |
+
|
| 253 |
+
self.epoch += 1
|
| 254 |
+
self.total_images += len(x)
|
| 255 |
+
self.scheduler.step()
|
| 256 |
+
|
| 257 |
+
self.loss_history.append(round(loss_val, 5))
|
| 258 |
+
self.acc_history.append(round(acc, 4))
|
| 259 |
+
if len(self.loss_history) > 500:
|
| 260 |
+
self.loss_history = self.loss_history[-500:]
|
| 261 |
+
self.acc_history = self.acc_history[-500:]
|
| 262 |
+
|
| 263 |
+
last_path = batch[0] # just the paths string
|
| 264 |
+
self.stats.update({
|
| 265 |
+
'epoch': self.epoch,
|
| 266 |
+
'loss': round(loss_val, 4),
|
| 267 |
+
'accuracy': round(acc * 100, 1),
|
| 268 |
+
'total_images': self.total_images,
|
| 269 |
+
'lr': round(self.optimizer.param_groups[0]['lr'], 7),
|
| 270 |
+
})
|
| 271 |
+
|
| 272 |
+
if self.epoch % 20 == 0:
|
| 273 |
+
self._save_checkpoint()
|
| 274 |
+
self._write_stats()
|
| 275 |
+
return loss_val
|
| 276 |
+
|
| 277 |
+
def train_n_steps(self, image_dir: Path, n: int = 20):
|
| 278 |
+
losses = []
|
| 279 |
+
for _ in range(n):
|
| 280 |
+
l = self.train_step(image_dir)
|
| 281 |
+
if l is not None:
|
| 282 |
+
losses.append(l)
|
| 283 |
+
return {
|
| 284 |
+
'steps': len(losses),
|
| 285 |
+
'avg_loss': round(sum(losses)/len(losses), 5) if losses else None,
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
# ── INFERENCE ─────────────────────────────────────────────────────────────
|
| 289 |
+
def predict_image(self, image_path: str) -> dict:
|
| 290 |
+
"""Predict category of a single image."""
|
| 291 |
+
if not PIL_AVAILABLE:
|
| 292 |
+
return {'error': 'Pillow not installed'}
|
| 293 |
+
try:
|
| 294 |
+
t = load_image_tensor(image_path).unsqueeze(0)
|
| 295 |
+
self.model.eval()
|
| 296 |
+
with torch.no_grad():
|
| 297 |
+
logits = self.model(t)
|
| 298 |
+
probs = torch.softmax(logits, dim=1)[0].tolist()
|
| 299 |
+
pred = int(logits.argmax(1).item())
|
| 300 |
+
return {
|
| 301 |
+
'prediction': CATEGORIES[pred],
|
| 302 |
+
'confidence': round(probs[pred] * 100, 1),
|
| 303 |
+
'all_probs': {c: round(p*100, 2) for c, p in zip(CATEGORIES, probs)},
|
| 304 |
+
}
|
| 305 |
+
except Exception as e:
|
| 306 |
+
return {'error': str(e)}
|
| 307 |
+
|
| 308 |
+
# ── VIZ STATE ─────────────────────────────────────────────────────────────
|
| 309 |
+
def get_viz_state(self) -> dict:
|
| 310 |
+
self.model.eval()
|
| 311 |
+
with torch.no_grad():
|
| 312 |
+
dummy = torch.zeros(1, 3, IMG_SIZE, IMG_SIZE)
|
| 313 |
+
self.model(dummy)
|
| 314 |
+
return {
|
| 315 |
+
'layer_sizes': [3, 32, 64, 128, 512, 128, len(CATEGORIES)],
|
| 316 |
+
'activations': self.model.get_activations(),
|
| 317 |
+
'loss_history': self.loss_history[-100:],
|
| 318 |
+
'acc_history': self.acc_history[-100:],
|
| 319 |
+
'stats': self.stats,
|
| 320 |
+
'type': 'cnn',
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
# ── PERSISTENCE ───────────────────────────────────────────────────────────
|
| 324 |
+
def _save_checkpoint(self):
|
| 325 |
+
try:
|
| 326 |
+
torch.save({
|
| 327 |
+
'model': self.model.state_dict(),
|
| 328 |
+
'optimizer': self.optimizer.state_dict(),
|
| 329 |
+
'epoch': self.epoch,
|
| 330 |
+
'total_images': self.total_images,
|
| 331 |
+
'loss_history': self.loss_history,
|
| 332 |
+
'acc_history': self.acc_history,
|
| 333 |
+
'category_counts': dict(self.category_counts),
|
| 334 |
+
}, CNN_CHECKPOINT)
|
| 335 |
+
except Exception:
|
| 336 |
+
pass
|
| 337 |
+
|
| 338 |
+
def _load_checkpoint(self):
|
| 339 |
+
if not os.path.exists(CNN_CHECKPOINT):
|
| 340 |
+
return
|
| 341 |
+
try:
|
| 342 |
+
ck = torch.load(CNN_CHECKPOINT, map_location='cpu')
|
| 343 |
+
self.model.load_state_dict(ck['model'])
|
| 344 |
+
self.optimizer.load_state_dict(ck['optimizer'])
|
| 345 |
+
self.epoch = ck.get('epoch', 0)
|
| 346 |
+
self.total_images = ck.get('total_images', 0)
|
| 347 |
+
self.loss_history = ck.get('loss_history', [])
|
| 348 |
+
self.acc_history = ck.get('acc_history', [])
|
| 349 |
+
self.category_counts = defaultdict(int, ck.get('category_counts', {}))
|
| 350 |
+
if self.epoch > 0:
|
| 351 |
+
self.stats.update({
|
| 352 |
+
'epoch': self.epoch,
|
| 353 |
+
'total_images': self.total_images,
|
| 354 |
+
})
|
| 355 |
+
except Exception:
|
| 356 |
+
pass
|
| 357 |
+
|
| 358 |
+
def _write_stats(self):
|
| 359 |
+
try:
|
| 360 |
+
with open(CNN_STATS_FILE, 'w') as f:
|
| 361 |
+
json.dump(self.stats, f, indent=2)
|
| 362 |
+
except Exception:
|
| 363 |
+
pass
|
neural_network.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
neural_network.py — Real PyTorch neural network built from scratch.
|
| 3 |
+
|
| 4 |
+
KEY CHANGES:
|
| 5 |
+
- Every item ingested is IMMEDIATELY written to knowledge.jsonl
|
| 6 |
+
- On startup, knowledge.jsonl is read back → data_buffer is restored
|
| 7 |
+
- Model checkpoint auto-saves every 30 training epochs
|
| 8 |
+
- training_stats.json written after every training step (human-readable)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import threading
|
| 13 |
+
_TEXT_LOCK = threading.Lock()
|
| 14 |
+
import numpy as np
|
| 15 |
+
import os
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
from datetime import datetime, UTC
|
| 19 |
+
from collections import Counter, defaultdict
|
| 20 |
+
|
| 21 |
+
# ─── FILES ON DISK ────────────────────────────────────────────────────────────
|
| 22 |
+
KNOWLEDGE_FILE = 'knowledge.jsonl' # Every article/text the AI has seen
|
| 23 |
+
CHECKPOINT_FILE = 'model_checkpoint.pt' # PyTorch weights + optimizer state
|
| 24 |
+
STATS_FILE = 'training_stats.json' # Human-readable live stats
|
| 25 |
+
|
| 26 |
+
# ─── CATEGORIES ───────────────────────────────────────────────────────────────
|
| 27 |
+
CATEGORIES = ['technology', 'science', 'world', 'sports',
|
| 28 |
+
'business', 'health', 'entertainment', 'other']
|
| 29 |
+
|
| 30 |
+
# ─── VOCABULARY ───────────────────────────────────────────────────────────────
|
| 31 |
+
STOPWORDS = {
|
| 32 |
+
'a','an','the','is','it','in','on','at','to','for','of','and','or','but',
|
| 33 |
+
'was','are','were','be','been','have','has','had','do','does','did','will',
|
| 34 |
+
'would','could','should','may','might','that','this','these','those','with',
|
| 35 |
+
'from','by','as','not','also','than','then','so','if','when','what','how',
|
| 36 |
+
'who','which','its','their','our','your','my','his','her','we','they','he',
|
| 37 |
+
'she','you','i','me','him','us','them','said','says','new','one','two',
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
class Vocabulary:
|
| 41 |
+
def __init__(self, max_size=10000):
|
| 42 |
+
self.word2idx = {'<PAD>': 0, '<UNK>': 1}
|
| 43 |
+
self.idx2word = {0: '<PAD>', 1: '<UNK>'}
|
| 44 |
+
self.word_counts = Counter()
|
| 45 |
+
self.max_size = max_size
|
| 46 |
+
self.is_built = False
|
| 47 |
+
|
| 48 |
+
def update(self, text: str):
|
| 49 |
+
self.word_counts.update(self._tokenize(text))
|
| 50 |
+
|
| 51 |
+
def build(self):
|
| 52 |
+
top = self.word_counts.most_common(self.max_size - 2)
|
| 53 |
+
self.word2idx = {'<PAD>': 0, '<UNK>': 1}
|
| 54 |
+
self.idx2word = {0: '<PAD>', 1: '<UNK>'}
|
| 55 |
+
for i, (word, _) in enumerate(top):
|
| 56 |
+
idx = i + 2
|
| 57 |
+
self.word2idx[word] = idx
|
| 58 |
+
self.idx2word[idx] = word
|
| 59 |
+
self.is_built = True
|
| 60 |
+
|
| 61 |
+
def encode(self, text: str, max_len: int = 64) -> list:
|
| 62 |
+
words = self._tokenize(text)[:max_len]
|
| 63 |
+
ids = [self.word2idx.get(w, 1) for w in words]
|
| 64 |
+
ids += [0] * (max_len - len(ids))
|
| 65 |
+
return ids
|
| 66 |
+
|
| 67 |
+
def _tokenize(self, text: str) -> list:
|
| 68 |
+
text = text.lower()
|
| 69 |
+
text = re.sub(r'[^\w\s]', ' ', text)
|
| 70 |
+
return [w for w in text.split() if w not in STOPWORDS and len(w) > 2]
|
| 71 |
+
|
| 72 |
+
def __len__(self):
|
| 73 |
+
return len(self.word2idx)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ─── MODEL ────────────────────────────────────────────────────────────────────
|
| 77 |
+
class TextClassifier(nn.Module):
|
| 78 |
+
def __init__(self, vocab_size=10002, embed_dim=64,
|
| 79 |
+
hidden=[256, 128, 64], num_classes=8):
|
| 80 |
+
super().__init__()
|
| 81 |
+
self.embed_dim = embed_dim
|
| 82 |
+
self.hidden_dims = hidden
|
| 83 |
+
self.num_classes = num_classes
|
| 84 |
+
|
| 85 |
+
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
|
| 86 |
+
nn.init.normal_(self.embedding.weight, 0, 0.1)
|
| 87 |
+
|
| 88 |
+
layers = []
|
| 89 |
+
in_dim = embed_dim
|
| 90 |
+
for h in hidden:
|
| 91 |
+
layers += [nn.Linear(in_dim, h), nn.LayerNorm(h),
|
| 92 |
+
nn.ReLU(), nn.Dropout(0.25)]
|
| 93 |
+
in_dim = h
|
| 94 |
+
layers.append(nn.Linear(in_dim, num_classes))
|
| 95 |
+
self.net = nn.Sequential(*layers)
|
| 96 |
+
|
| 97 |
+
self._activations = {}
|
| 98 |
+
self._register_hooks()
|
| 99 |
+
|
| 100 |
+
def _register_hooks(self):
|
| 101 |
+
def make_hook(name):
|
| 102 |
+
def hook(module, inp, out):
|
| 103 |
+
if isinstance(out, torch.Tensor):
|
| 104 |
+
v = out.detach().float()
|
| 105 |
+
if v.dim() > 1:
|
| 106 |
+
v = v.mean(0)
|
| 107 |
+
self._activations[name] = v[:32].tolist()
|
| 108 |
+
return hook
|
| 109 |
+
for i, layer in enumerate(self.net):
|
| 110 |
+
layer.register_forward_hook(make_hook(f'net.{i}'))
|
| 111 |
+
|
| 112 |
+
def forward(self, x):
|
| 113 |
+
emb = self.embedding(x)
|
| 114 |
+
mask = (x != 0).float().unsqueeze(-1)
|
| 115 |
+
pooled = (emb * mask).sum(1) / mask.sum(1).clamp(min=1)
|
| 116 |
+
return self.net(pooled)
|
| 117 |
+
|
| 118 |
+
def get_activations(self) -> dict:
|
| 119 |
+
return dict(self._activations)
|
| 120 |
+
|
| 121 |
+
def get_weight_info(self) -> dict:
|
| 122 |
+
info = {}
|
| 123 |
+
for name, param in self.named_parameters():
|
| 124 |
+
if 'weight' in name and param.dim() == 2:
|
| 125 |
+
w = param.detach().float().numpy()
|
| 126 |
+
r, c = min(w.shape[0], 16), min(w.shape[1], 16)
|
| 127 |
+
info[name] = {
|
| 128 |
+
'shape': list(w.shape),
|
| 129 |
+
'mean_abs': float(np.mean(np.abs(w))),
|
| 130 |
+
'std': float(np.std(w)),
|
| 131 |
+
'sample': w[:r, :c].tolist(),
|
| 132 |
+
}
|
| 133 |
+
return info
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ─── LIVING NETWORK ───────────────────────────────────────────────────────────
|
| 137 |
+
class LivingNetwork:
|
| 138 |
+
"""
|
| 139 |
+
The brain. Wraps the PyTorch model with:
|
| 140 |
+
- knowledge.jsonl → persistent record of everything it has read
|
| 141 |
+
- model_checkpoint.pt → saved weights (restored on restart)
|
| 142 |
+
- training_stats.json → live stats readable by the UI
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
AUTO_SAVE_EVERY = 30 # Save checkpoint every N training epochs
|
| 146 |
+
|
| 147 |
+
def __init__(self):
|
| 148 |
+
self.vocab = Vocabulary()
|
| 149 |
+
self.model = TextClassifier()
|
| 150 |
+
self.optimizer = optim.Adam(self.model.parameters(), lr=0.001, weight_decay=1e-5)
|
| 151 |
+
self.scheduler = optim.lr_scheduler.ReduceLROnPlateau(
|
| 152 |
+
self.optimizer, mode='min', patience=20, factor=0.5, min_lr=1e-5)
|
| 153 |
+
self.criterion = nn.CrossEntropyLoss()
|
| 154 |
+
|
| 155 |
+
self.epoch = 0
|
| 156 |
+
self.total_samples = 0
|
| 157 |
+
self.data_buffer = [] # (text, label_int) — in-memory training pool
|
| 158 |
+
self.loss_history = []
|
| 159 |
+
self.acc_history = []
|
| 160 |
+
self.category_counts = defaultdict(int)
|
| 161 |
+
self.knowledge_count = 0 # Total articles ever ingested
|
| 162 |
+
|
| 163 |
+
self.stats = {
|
| 164 |
+
'epoch': 0,
|
| 165 |
+
'loss': '—',
|
| 166 |
+
'accuracy': '—',
|
| 167 |
+
'total_samples': 0,
|
| 168 |
+
'lr': 0.001,
|
| 169 |
+
'buffer_size': 0,
|
| 170 |
+
'knowledge_count': 0,
|
| 171 |
+
'last_text': '(nothing yet)',
|
| 172 |
+
'vocab_size': 2,
|
| 173 |
+
'status': 'idle',
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
# Load checkpoint first, then restore knowledge buffer
|
| 177 |
+
self._load_checkpoint()
|
| 178 |
+
self._load_knowledge()
|
| 179 |
+
|
| 180 |
+
# ── KNOWLEDGE FILE ────────────────────────────────────────────────────────
|
| 181 |
+
def _write_knowledge(self, text: str, category: str, source: str = 'unknown'):
|
| 182 |
+
"""Append one learned item to knowledge.jsonl immediately."""
|
| 183 |
+
record = {
|
| 184 |
+
'text': text,
|
| 185 |
+
'category': category,
|
| 186 |
+
'source': source,
|
| 187 |
+
'timestamp': datetime.now(UTC).isoformat(),
|
| 188 |
+
'epoch_at_ingestion': self.epoch,
|
| 189 |
+
}
|
| 190 |
+
try:
|
| 191 |
+
with open(KNOWLEDGE_FILE, 'a', encoding='utf-8') as f:
|
| 192 |
+
f.write(json.dumps(record, ensure_ascii=False) + '\n')
|
| 193 |
+
self.knowledge_count += 1
|
| 194 |
+
except Exception:
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
def _load_knowledge(self):
|
| 198 |
+
"""On startup: read knowledge.jsonl and rebuild data_buffer + vocab."""
|
| 199 |
+
if not os.path.exists(KNOWLEDGE_FILE):
|
| 200 |
+
return
|
| 201 |
+
loaded = 0
|
| 202 |
+
try:
|
| 203 |
+
with open(KNOWLEDGE_FILE, 'r', encoding='utf-8') as f:
|
| 204 |
+
for line in f:
|
| 205 |
+
line = line.strip()
|
| 206 |
+
if not line:
|
| 207 |
+
continue
|
| 208 |
+
try:
|
| 209 |
+
rec = json.loads(line)
|
| 210 |
+
text = rec.get('text', '')
|
| 211 |
+
cat = rec.get('category', 'other')
|
| 212 |
+
label = CATEGORIES.index(cat) if cat in CATEGORIES else 7
|
| 213 |
+
if len(text) > 20:
|
| 214 |
+
self.data_buffer.append((text, label))
|
| 215 |
+
self.vocab.update(text)
|
| 216 |
+
self.category_counts[cat] += 1
|
| 217 |
+
loaded += 1
|
| 218 |
+
except Exception:
|
| 219 |
+
continue
|
| 220 |
+
self.knowledge_count = loaded
|
| 221 |
+
if loaded > 0:
|
| 222 |
+
self.vocab.build()
|
| 223 |
+
# Trim buffer if huge
|
| 224 |
+
if len(self.data_buffer) > 5000:
|
| 225 |
+
self.data_buffer = self.data_buffer[-4000:]
|
| 226 |
+
except Exception:
|
| 227 |
+
pass
|
| 228 |
+
self.stats['knowledge_count'] = self.knowledge_count
|
| 229 |
+
self.stats['buffer_size'] = len(self.data_buffer)
|
| 230 |
+
self.stats['vocab_size'] = len(self.vocab)
|
| 231 |
+
|
| 232 |
+
def get_knowledge_file_stats(self) -> dict:
|
| 233 |
+
"""Return stats about the knowledge file for the UI."""
|
| 234 |
+
if not os.path.exists(KNOWLEDGE_FILE):
|
| 235 |
+
return {'exists': False, 'lines': 0, 'size_kb': 0}
|
| 236 |
+
size = os.path.getsize(KNOWLEDGE_FILE)
|
| 237 |
+
lines = 0
|
| 238 |
+
try:
|
| 239 |
+
with open(KNOWLEDGE_FILE, 'r', encoding='utf-8') as f:
|
| 240 |
+
lines = sum(1 for l in f if l.strip())
|
| 241 |
+
except Exception:
|
| 242 |
+
pass
|
| 243 |
+
return {'exists': True, 'lines': lines, 'size_kb': round(size / 1024, 1)}
|
| 244 |
+
|
| 245 |
+
def get_recent_knowledge(self, n: int = 20) -> list:
|
| 246 |
+
"""Return last N items from knowledge.jsonl for display."""
|
| 247 |
+
if not os.path.exists(KNOWLEDGE_FILE):
|
| 248 |
+
return []
|
| 249 |
+
lines = []
|
| 250 |
+
try:
|
| 251 |
+
with open(KNOWLEDGE_FILE, 'r', encoding='utf-8') as f:
|
| 252 |
+
all_lines = [l.strip() for l in f if l.strip()]
|
| 253 |
+
for line in reversed(all_lines[-n:]):
|
| 254 |
+
try:
|
| 255 |
+
lines.append(json.loads(line))
|
| 256 |
+
except Exception:
|
| 257 |
+
pass
|
| 258 |
+
except Exception:
|
| 259 |
+
pass
|
| 260 |
+
return lines
|
| 261 |
+
|
| 262 |
+
# ── INGEST ────────────────────────────────────────────────────────────────
|
| 263 |
+
def ingest(self, text: str, category: str, source: str = 'unknown'):
|
| 264 |
+
"""
|
| 265 |
+
Add text to training buffer AND write to knowledge.jsonl immediately.
|
| 266 |
+
This is how the AI 'remembers' what it has learned.
|
| 267 |
+
"""
|
| 268 |
+
cleaned = text.strip()
|
| 269 |
+
if len(cleaned) < 20:
|
| 270 |
+
return
|
| 271 |
+
label = CATEGORIES.index(category) if category in CATEGORIES else 7
|
| 272 |
+
|
| 273 |
+
# ① Write to disk first — never lose this
|
| 274 |
+
self._write_knowledge(cleaned, category, source)
|
| 275 |
+
|
| 276 |
+
# ② Add to in-memory training buffer
|
| 277 |
+
self.data_buffer.append((cleaned, label))
|
| 278 |
+
self.vocab.update(cleaned)
|
| 279 |
+
self.category_counts[category] += 1
|
| 280 |
+
|
| 281 |
+
# Rebuild vocab every 25 items
|
| 282 |
+
if len(self.data_buffer) % 25 == 0:
|
| 283 |
+
self.vocab.build()
|
| 284 |
+
|
| 285 |
+
# Keep buffer bounded (disk has the full history)
|
| 286 |
+
if len(self.data_buffer) > 5000:
|
| 287 |
+
self.data_buffer = self.data_buffer[-4000:]
|
| 288 |
+
|
| 289 |
+
self.stats.update({
|
| 290 |
+
'buffer_size': len(self.data_buffer),
|
| 291 |
+
'vocab_size': len(self.vocab),
|
| 292 |
+
'knowledge_count': self.knowledge_count,
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
# ── TRAINING ──────────────────────────────────────────────────────────────
|
| 296 |
+
def train_step(self, batch_size: int = 32) -> float | None:
|
| 297 |
+
if len(self.data_buffer) < batch_size or not self.vocab.is_built:
|
| 298 |
+
return None
|
| 299 |
+
with _TEXT_LOCK:
|
| 300 |
+
self.model.train()
|
| 301 |
+
idx = np.random.choice(len(self.data_buffer), batch_size, replace=False)
|
| 302 |
+
batch = [self.data_buffer[i] for i in idx]
|
| 303 |
+
texts, labels = zip(*batch)
|
| 304 |
+
|
| 305 |
+
x = torch.tensor([self.vocab.encode(t) for t in texts], dtype=torch.long)
|
| 306 |
+
y = torch.tensor(list(labels), dtype=torch.long)
|
| 307 |
+
|
| 308 |
+
self.model.zero_grad(set_to_none=True)
|
| 309 |
+
logits = self.model(x)
|
| 310 |
+
loss = self.criterion(logits, y)
|
| 311 |
+
loss.backward()
|
| 312 |
+
for p in self.model.parameters():
|
| 313 |
+
if p.grad is not None:
|
| 314 |
+
p.grad.data.clamp_(-1.0, 1.0)
|
| 315 |
+
self.optimizer.step()
|
| 316 |
+
loss_val = loss.detach().item()
|
| 317 |
+
acc = (logits.detach().argmax(1) == y).float().mean().item()
|
| 318 |
+
|
| 319 |
+
self.epoch += 1
|
| 320 |
+
self.total_samples += batch_size
|
| 321 |
+
self.scheduler.step(loss_val)
|
| 322 |
+
|
| 323 |
+
self.loss_history.append(round(loss_val, 5))
|
| 324 |
+
self.acc_history.append(round(acc, 4))
|
| 325 |
+
if len(self.loss_history) > 500:
|
| 326 |
+
self.loss_history = self.loss_history[-500:]
|
| 327 |
+
self.acc_history = self.acc_history[-500:]
|
| 328 |
+
|
| 329 |
+
self.stats.update({
|
| 330 |
+
'epoch': self.epoch,
|
| 331 |
+
'loss': round(loss_val, 4),
|
| 332 |
+
'accuracy': round(acc * 100, 1),
|
| 333 |
+
'total_samples': self.total_samples,
|
| 334 |
+
'lr': round(self.optimizer.param_groups[0]['lr'], 7),
|
| 335 |
+
'buffer_size': len(self.data_buffer),
|
| 336 |
+
'last_text': texts[0][:120],
|
| 337 |
+
'vocab_size': len(self.vocab),
|
| 338 |
+
'knowledge_count': self.knowledge_count,
|
| 339 |
+
})
|
| 340 |
+
|
| 341 |
+
# Auto-save checkpoint every N epochs
|
| 342 |
+
if self.epoch % self.AUTO_SAVE_EVERY == 0:
|
| 343 |
+
self.save_checkpoint()
|
| 344 |
+
|
| 345 |
+
# Always write stats file so UI can read without waiting
|
| 346 |
+
self._write_stats_file()
|
| 347 |
+
|
| 348 |
+
return loss_val
|
| 349 |
+
|
| 350 |
+
def train_n_steps(self, n: int = 50) -> dict:
|
| 351 |
+
losses = []
|
| 352 |
+
for _ in range(n):
|
| 353 |
+
l = self.train_step()
|
| 354 |
+
if l is not None:
|
| 355 |
+
losses.append(l)
|
| 356 |
+
return {
|
| 357 |
+
'steps': len(losses),
|
| 358 |
+
'avg_loss': round(sum(losses) / len(losses), 5) if losses else None,
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
# ── INFERENCE ───────────────────────────────────────────���─────────────────
|
| 362 |
+
def predict(self, text: str) -> dict:
|
| 363 |
+
if not self.vocab.is_built or not text.strip():
|
| 364 |
+
return {'error': 'Model not ready — start the network and let it train first'}
|
| 365 |
+
self.model.eval()
|
| 366 |
+
with torch.no_grad():
|
| 367 |
+
x = torch.tensor([self.vocab.encode(text)], dtype=torch.long)
|
| 368 |
+
logits = self.model(x)
|
| 369 |
+
probs = torch.softmax(logits, dim=1)[0].tolist()
|
| 370 |
+
pred = int(logits.argmax(1).item())
|
| 371 |
+
return {
|
| 372 |
+
'prediction': CATEGORIES[pred],
|
| 373 |
+
'confidence': round(probs[pred] * 100, 1),
|
| 374 |
+
'all_probs': {c: round(p * 100, 2) for c, p in zip(CATEGORIES, probs)},
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
# ── VIZ STATE ─────────────────────────────────────────────────────────────
|
| 378 |
+
def get_viz_state(self) -> dict:
|
| 379 |
+
self.model.eval()
|
| 380 |
+
with torch.no_grad():
|
| 381 |
+
dummy = torch.zeros(1, 64, dtype=torch.long)
|
| 382 |
+
self.model(dummy)
|
| 383 |
+
return {
|
| 384 |
+
'layer_sizes': [self.model.embed_dim] + self.model.hidden_dims + [self.model.num_classes],
|
| 385 |
+
'activations': self.model.get_activations(),
|
| 386 |
+
'weights': self.model.get_weight_info(),
|
| 387 |
+
'loss_history': self.loss_history[-100:],
|
| 388 |
+
'acc_history': self.acc_history[-100:],
|
| 389 |
+
'stats': self.stats,
|
| 390 |
+
'category_counts':dict(self.category_counts),
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
# ── PERSISTENCE ───────────────────────────────────────────────────────────
|
| 394 |
+
def save_checkpoint(self):
|
| 395 |
+
try:
|
| 396 |
+
torch.save({
|
| 397 |
+
'model': self.model.state_dict(),
|
| 398 |
+
'optimizer': self.optimizer.state_dict(),
|
| 399 |
+
'epoch': self.epoch,
|
| 400 |
+
'total_samples': self.total_samples,
|
| 401 |
+
'loss_history': self.loss_history,
|
| 402 |
+
'acc_history': self.acc_history,
|
| 403 |
+
'vocab_word2idx': self.vocab.word2idx,
|
| 404 |
+
'category_counts': dict(self.category_counts),
|
| 405 |
+
'stats': self.stats,
|
| 406 |
+
}, CHECKPOINT_FILE)
|
| 407 |
+
return True
|
| 408 |
+
except Exception:
|
| 409 |
+
return False
|
| 410 |
+
|
| 411 |
+
def _load_checkpoint(self):
|
| 412 |
+
if not os.path.exists(CHECKPOINT_FILE):
|
| 413 |
+
return False
|
| 414 |
+
try:
|
| 415 |
+
ck = torch.load(CHECKPOINT_FILE, map_location='cpu')
|
| 416 |
+
self.model.load_state_dict(ck['model'])
|
| 417 |
+
self.optimizer.load_state_dict(ck['optimizer'])
|
| 418 |
+
self.epoch = ck.get('epoch', 0)
|
| 419 |
+
self.total_samples = ck.get('total_samples', 0)
|
| 420 |
+
self.loss_history = ck.get('loss_history', [])
|
| 421 |
+
self.acc_history = ck.get('acc_history', [])
|
| 422 |
+
self.category_counts = defaultdict(int, ck.get('category_counts', {}))
|
| 423 |
+
self.stats = ck.get('stats', self.stats)
|
| 424 |
+
w2i = ck.get('vocab_word2idx', {})
|
| 425 |
+
if w2i:
|
| 426 |
+
self.vocab.word2idx = w2i
|
| 427 |
+
self.vocab.idx2word = {v: k for k, v in w2i.items()}
|
| 428 |
+
self.vocab.is_built = len(w2i) > 2
|
| 429 |
+
return True
|
| 430 |
+
except Exception:
|
| 431 |
+
return False
|
| 432 |
+
|
| 433 |
+
def _write_stats_file(self):
|
| 434 |
+
"""Write human-readable stats to training_stats.json for easy debugging."""
|
| 435 |
+
try:
|
| 436 |
+
with open(STATS_FILE, 'w') as f:
|
| 437 |
+
json.dump({**self.stats, 'loss_last10': self.loss_history[-10:]}, f, indent=2)
|
| 438 |
+
except Exception:
|
| 439 |
+
pass
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.5.0
|
| 2 |
+
feedparser==6.0.11
|
| 3 |
+
requests==2.31.0
|
| 4 |
+
beautifulsoup4==4.12.3
|
| 5 |
+
numpy>=1.26.4
|