kasitbot / trainer.py
snygginghani's picture
Deploy KASITBot RAG chatbot
71e1c4b
"""
================================================================================
KASITBot β€” Training Platform
Lets you chat normally, flag wrong/incomplete answers, provide corrections,
and export the corrections as rag_dataset entries ready for re-indexing.
================================================================================
Run standalone:
python trainer.py
Or mount into your existing app.py by importing and registering the blueprint:
from trainer import trainer_bp
app.register_blueprint(trainer_bp)
Then visit: http://localhost:5001/train
================================================================================
"""
from flask import Flask, request, jsonify, render_template_string
from flask_cors import CORS
import json, os, uuid
from pathlib import Path
from datetime import datetime
CORRECTIONS_FILE = Path("training_corrections.json")
app = Flask(__name__)
CORS(app)
# ══════════════════════════════════════════════════════════════════════════════
# Corrections Storage
# ══════════════════════════════════════════════════════════════════════════════
def load_corrections():
if CORRECTIONS_FILE.exists():
with open(CORRECTIONS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
return []
def save_corrections(corrections):
with open(CORRECTIONS_FILE, "w", encoding="utf-8") as f:
json.dump(corrections, f, ensure_ascii=False, indent=2)
# ══════════════════════════════════════════════════════════════════════════════
# API Routes
# ══════════════════════════════════════════════════════════════════════════════
@app.route("/api/train/corrections", methods=["GET"])
def get_corrections():
return jsonify(load_corrections())
@app.route("/api/train/corrections", methods=["POST"])
def add_correction():
data = request.get_json(force=True)
corrections = load_corrections()
entry = {
"id": str(uuid.uuid4())[:8],
"created_at": datetime.now().isoformat(),
"question": data.get("question", "").strip(),
"bot_answer": data.get("bot_answer", "").strip(),
"issue_type": data.get("issue_type", "wrong"), # wrong | partial | unknown
"correct_text": data.get("correct_text", "").strip(),
"source_hint": data.get("source_hint", "").strip(), # optional: doc name
"language": data.get("language", "en"),
"exported": False,
}
if not entry["question"] or not entry["correct_text"]:
return jsonify({"error": "question and correct_text are required"}), 400
corrections.append(entry)
save_corrections(corrections)
return jsonify({"ok": True, "id": entry["id"], "total": len(corrections)})
@app.route("/api/train/corrections/<correction_id>", methods=["DELETE"])
def delete_correction(correction_id):
corrections = load_corrections()
corrections = [c for c in corrections if c["id"] != correction_id]
save_corrections(corrections)
return jsonify({"ok": True})
@app.route("/api/train/export", methods=["GET"])
def export_for_rag():
"""
Export pending corrections as rag_dataset.json entries.
Each correction becomes a Q&A chunk that will be indexed.
Format matches rag_preprocessor.py output exactly.
"""
corrections = load_corrections()
pending = [c for c in corrections if not c["exported"]]
rag_entries = []
for c in pending:
# Build a rich Q&A chunk β€” question + correct answer together
# so the retriever finds it when the same question is asked again
text = f"Q: {c['question']}\nA: {c['correct_text']}"
rag_entries.append({
"text": text,
"source": c["source_hint"] or "training_corrections",
"chunk_id": c["id"],
"language": "Arabic" if c["language"] == "ar" else "English",
"was_translated": False,
})
# Mark as exported
for c in corrections:
if not c["exported"]:
c["exported"] = True
save_corrections(corrections)
export_path = Path("training_export.json")
with open(export_path, "w", encoding="utf-8") as f:
json.dump(rag_entries, f, ensure_ascii=False, indent=2)
return jsonify({
"ok": True,
"exported": len(rag_entries),
"file": str(export_path),
"entries": rag_entries,
})
@app.route("/api/train/stats", methods=["GET"])
def stats():
corrections = load_corrections()
return jsonify({
"total": len(corrections),
"pending": sum(1 for c in corrections if not c["exported"]),
"exported": sum(1 for c in corrections if c["exported"]),
"wrong": sum(1 for c in corrections if c["issue_type"] == "wrong"),
"partial": sum(1 for c in corrections if c["issue_type"] == "partial"),
"unknown": sum(1 for c in corrections if c["issue_type"] == "unknown"),
})
# ══════════════════════════════════════════════════════════════════════════════
# Proxy to main KASITBot (adjust URL if app.py runs on different port)
# ══════════════════════════════════════════════════════════════════════════════
import urllib.request
import urllib.error
KASIT_BOT_URL = os.environ.get("KASIT_BOT_URL", "http://localhost:5000")
@app.route("/api/train/chat", methods=["POST"])
def proxy_chat():
"""Forward chat to the real KASITBot and return its response."""
body = request.get_data()
try:
req = urllib.request.Request(
f"{KASIT_BOT_URL}/api/chat",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
result = json.loads(resp.read())
return jsonify(result)
except urllib.error.URLError as e:
return jsonify({"error": f"KASITBot unreachable at {KASIT_BOT_URL}: {str(e)}"}), 502
except Exception as e:
return jsonify({"error": str(e)}), 500
# ══════════════════════════════════════════════════════════════════════════════
# Frontend
# ══════════════════════════════════════════════════════════════════════════════
HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KASITBot β€” Training Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0f0f0f;
--surface:#1a1a1a;
--surface2:#222;
--border:#2e2e2e;
--border2:#3d3d3d;
--text:#e8e8e2;
--text2:#8a8a82;
--text3:#555;
--green:#22c55e;
--green-dim:#14532d;
--green-bg:#0d2818;
--amber:#f59e0b;
--amber-bg:#1c1300;
--red:#ef4444;
--red-bg:#1c0000;
--blue:#3b82f6;
--blue-bg:#0a1628;
--accent:#22c55e;
--radius:6px;
--mono:'IBM Plex Mono',monospace;
--sans:'IBM Plex Sans',sans-serif;
}
body{font-family:var(--sans);background:var(--bg);color:var(--text);height:100vh;overflow:hidden;font-size:14px}
.layout{display:grid;grid-template-columns:1fr 380px;grid-template-rows:52px 1fr;height:100vh}
/* ── Header ── */
.header{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-bottom:1px solid var(--border);background:var(--surface)}
.header-left{display:flex;align-items:center;gap:12px}
.logo-badge{font-family:var(--mono);font-size:11px;font-weight:500;padding:3px 8px;background:var(--green-bg);color:var(--green);border:1px solid var(--green-dim);border-radius:3px;letter-spacing:.04em}
.header-title{font-family:var(--mono);font-size:13px;color:var(--text2)}
.header-right{display:flex;align-items:center;gap:8px}
.stat-pill{font-family:var(--mono);font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid var(--border2);color:var(--text2)}
.stat-pill span{color:var(--text);font-weight:500}
/* ── Chat Panel ── */
.chat-panel{display:flex;flex-direction:column;border-right:1px solid var(--border);overflow:hidden}
.chat-area{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:16px;scroll-behavior:smooth}
.chat-area::-webkit-scrollbar{width:4px}
.chat-area::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.msg{display:flex;gap:10px;animation:fadeUp .2s ease}
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.msg.user{flex-direction:row-reverse}
.msg-av{width:28px;height:28px;border-radius:4px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:10px;font-weight:500}
.msg.bot .msg-av{background:var(--green-bg);color:var(--green);border:1px solid var(--green-dim)}
.msg.user .msg-av{background:var(--surface2);color:var(--text2);border:1px solid var(--border)}
.msg-body{max-width:70%;display:flex;flex-direction:column;gap:6px}
.msg.user .msg-body{align-items:flex-end}
.bubble{padding:10px 14px;border-radius:var(--radius);font-size:13.5px;line-height:1.65}
.msg.bot .bubble{background:var(--surface2);border:1px solid var(--border);border-top-left-radius:2px}
.msg.user .bubble{background:#1a2e1a;border:1px solid var(--green-dim);color:var(--text);border-top-right-radius:2px}
.msg-sources{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
.src-tag{font-family:var(--mono);font-size:10px;padding:2px 6px;background:var(--blue-bg);color:var(--blue);border-radius:2px;border:1px solid #1e3a6e}
/* Flag button below each bot message */
.flag-bar{display:flex;gap:6px;margin-top:4px}
.flag-btn{font-family:var(--mono);font-size:11px;padding:3px 8px;border-radius:3px;border:1px solid var(--border2);background:transparent;color:var(--text2);cursor:pointer;transition:all .15s}
.flag-btn:hover{background:var(--surface2);color:var(--text)}
.flag-btn.wrong{border-color:#7f1d1d;color:var(--red)}
.flag-btn.partial{border-color:#78350f;color:var(--amber)}
.flag-btn.unknown{border-color:#1e3a6e;color:var(--blue)}
.flag-btn.active{opacity:.5;pointer-events:none}
/* Typing indicator */
.typing-dots{display:flex;gap:4px;padding:4px 0}
.dot{width:5px;height:5px;border-radius:50%;background:var(--text3);animation:bop 1.2s infinite ease-in-out}
.dot:nth-child(2){animation-delay:.2s}.dot:nth-child(3){animation-delay:.4s}
@keyframes bop{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}
/* Input */
.input-zone{padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:flex-end}
.input-zone textarea{flex:1;background:var(--surface2);border:1px solid var(--border2);border-radius:var(--radius);color:var(--text);font-family:var(--sans);font-size:13.5px;padding:9px 12px;resize:none;min-height:38px;max-height:120px;outline:none;line-height:1.5;transition:border-color .15s}
.input-zone textarea:focus{border-color:var(--green)}
.input-zone textarea::placeholder{color:var(--text3)}
.send-btn{width:36px;height:36px;background:var(--green);border:none;border-radius:var(--radius);cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity .15s}
.send-btn:disabled{opacity:.35;cursor:not-allowed}
.send-btn svg{width:15px;height:15px}
.lang-select{background:var(--surface2);border:1px solid var(--border2);color:var(--text2);font-family:var(--mono);font-size:11px;padding:4px 6px;border-radius:var(--radius);outline:none;cursor:pointer}
/* ── Training Panel ── */
.train-panel{display:flex;flex-direction:column;overflow:hidden;background:var(--surface)}
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.panel-title{font-family:var(--mono);font-size:12px;color:var(--text2);letter-spacing:.05em;text-transform:uppercase}
/* Correction form */
.correction-form{padding:14px 16px;border-bottom:1px solid var(--border);flex-shrink:0}
.form-row{margin-bottom:10px}
.form-label{font-family:var(--mono);font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px;display:block}
.form-input,.form-textarea,.form-select{width:100%;background:var(--bg);border:1px solid var(--border2);border-radius:var(--radius);color:var(--text);font-family:var(--sans);font-size:13px;padding:8px 10px;outline:none;transition:border-color .15s}
.form-input:focus,.form-textarea:focus,.form-select:focus{border-color:var(--accent)}
.form-textarea{resize:vertical;min-height:80px;line-height:1.55}
.form-select{cursor:pointer}
.issue-btns{display:flex;gap:6px}
.issue-btn{flex:1;padding:6px;border-radius:var(--radius);border:1px solid var(--border2);background:transparent;color:var(--text2);font-family:var(--mono);font-size:11px;cursor:pointer;transition:all .15s;text-align:center}
.issue-btn:hover{background:var(--surface2)}
.issue-btn.sel-wrong{background:var(--red-bg);border-color:#7f1d1d;color:var(--red)}
.issue-btn.sel-partial{background:var(--amber-bg);border-color:#78350f;color:var(--amber)}
.issue-btn.sel-unknown{background:var(--blue-bg);border-color:#1e3a6e;color:var(--blue)}
.save-btn{width:100%;padding:9px;background:var(--green);color:#000;font-family:var(--mono);font-size:12px;font-weight:500;border:none;border-radius:var(--radius);cursor:pointer;letter-spacing:.04em;transition:opacity .15s;margin-top:4px}
.save-btn:hover{opacity:.85}
.save-btn:disabled{opacity:.4;cursor:not-allowed}
.preview-box{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:8px 10px;font-size:12px;color:var(--text2);margin-bottom:10px;line-height:1.5;font-style:italic;max-height:72px;overflow-y:auto}
/* Corrections list */
.corr-list{flex:1;overflow-y:auto;padding:10px}
.corr-list::-webkit-scrollbar{width:3px}
.corr-list::-webkit-scrollbar-thumb{background:var(--border2)}
.corr-card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;margin-bottom:8px;transition:border-color .15s}
.corr-card:hover{border-color:var(--border2)}
.corr-card-top{display:flex;align-items:center;gap:6px;margin-bottom:7px}
.badge{font-family:var(--mono);font-size:9px;padding:2px 6px;border-radius:2px;font-weight:500;letter-spacing:.04em;text-transform:uppercase}
.badge-wrong{background:var(--red-bg);color:var(--red);border:1px solid #7f1d1d}
.badge-partial{background:var(--amber-bg);color:var(--amber);border:1px solid #78350f}
.badge-unknown{background:var(--blue-bg);color:var(--blue);border:1px solid #1e3a6e}
.badge-exported{background:var(--green-bg);color:var(--green);border:1px solid var(--green-dim)}
.corr-q{font-size:12px;color:var(--text2);margin-bottom:5px;line-height:1.4}
.corr-a{font-size:12px;color:var(--text);line-height:1.4;border-left:2px solid var(--green-dim);padding-left:8px;margin-bottom:7px}
.corr-meta{display:flex;align-items:center;justify-content:space-between}
.corr-time{font-family:var(--mono);font-size:10px;color:var(--text3)}
.del-btn{font-family:var(--mono);font-size:10px;color:var(--text3);background:none;border:none;cursor:pointer;padding:2px 4px;border-radius:2px;transition:color .15s}
.del-btn:hover{color:var(--red)}
/* Panel footer */
.panel-foot{padding:10px 16px;border-top:1px solid var(--border);flex-shrink:0;display:flex;gap:8px}
.export-btn{flex:1;padding:8px;background:transparent;border:1px solid var(--green-dim);color:var(--green);font-family:var(--mono);font-size:11px;border-radius:var(--radius);cursor:pointer;letter-spacing:.04em;transition:all .15s}
.export-btn:hover{background:var(--green-bg)}
.clear-btn{padding:8px 12px;background:transparent;border:1px solid var(--border2);color:var(--text3);font-family:var(--mono);font-size:11px;border-radius:var(--radius);cursor:pointer;transition:all .15s}
.clear-btn:hover{border-color:var(--red);color:var(--red)}
/* Toast */
.toast{position:fixed;bottom:20px;right:20px;background:var(--surface2);border:1px solid var(--border2);border-radius:var(--radius);padding:10px 16px;font-family:var(--mono);font-size:12px;color:var(--text);opacity:0;transform:translateY(8px);transition:all .2s;pointer-events:none;z-index:999}
.toast.show{opacity:1;transform:none}
.toast.ok{border-color:var(--green-dim);color:var(--green)}
.toast.err{border-color:#7f1d1d;color:var(--red)}
/* Empty state */
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;gap:8px;flex:1}
.empty-icon{font-size:28px;opacity:.3}
.empty-txt{font-family:var(--mono);font-size:11px;color:var(--text3);text-align:center;line-height:1.6}
</style>
</head>
<body>
<div class="layout">
<!-- Header -->
<div class="header">
<div class="header-left">
<span class="logo-badge">KASITBot</span>
<span class="header-title">Training Platform</span>
</div>
<div class="header-right">
<div class="stat-pill">total <span id="s-total">0</span></div>
<div class="stat-pill">pending <span id="s-pending">0</span></div>
<div class="stat-pill">exported <span id="s-exported">0</span></div>
</div>
</div>
<!-- Chat Panel -->
<div class="chat-panel">
<div class="chat-area" id="chat-area">
<div class="empty">
<div class="empty-icon">β—ˆ</div>
<div class="empty-txt">Chat with KASITBot normally.<br>Flag wrong answers to add training data.</div>
</div>
</div>
<div class="input-zone">
<select class="lang-select" id="lang-sel">
<option value="en">EN</option>
<option value="ar">AR</option>
</select>
<textarea id="user-input" placeholder="Ask KASITBot anything…" rows="1"
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="send-btn" id="send-btn" onclick="sendMessage()">
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
<!-- Training Panel -->
<div class="train-panel">
<div class="panel-header">
<span class="panel-title">Correction Queue</span>
</div>
<!-- Form -->
<div class="correction-form" id="corr-form" style="display:none">
<div class="form-row">
<label class="form-label">Question asked</label>
<div class="preview-box" id="form-question">β€”</div>
</div>
<div class="form-row">
<label class="form-label">Issue type</label>
<div class="issue-btns">
<button class="issue-btn" id="ib-wrong" onclick="setIssue('wrong')">Wrong</button>
<button class="issue-btn" id="ib-partial" onclick="setIssue('partial')">Partial</button>
<button class="issue-btn" id="ib-unknown" onclick="setIssue('unknown')">Didn't know</button>
</div>
</div>
<div class="form-row">
<label class="form-label">Correct / complete answer</label>
<textarea class="form-textarea" id="form-answer" placeholder="Type the correct answer here…"></textarea>
</div>
<div class="form-row">
<label class="form-label">Source hint (optional)</label>
<input class="form-input" id="form-source" placeholder="e.g. kasit_handbook.pdf, course catalog…">
</div>
<button class="save-btn" onclick="saveCorrection()" id="save-btn">Save Correction β†’</button>
</div>
<div id="corr-list-wrap" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
<div class="corr-list" id="corr-list"></div>
</div>
<div class="panel-foot">
<button class="export-btn" onclick="exportCorrections()">Export β†’ rag_dataset entries</button>
<button class="clear-btn" onclick="clearForm()">βœ• Clear form</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let busy = false;
let messages = [];
let currentQuestion = '';
let currentBotAnswer = '';
let currentIssue = 'wrong';
let activeFlag = null;
// ── Utils ──────────────────────────────────────────────────────────────────
function toast(msg, type='ok'){
const t=document.getElementById('toast');
t.textContent=msg; t.className='toast show '+(type==='err'?'err':'ok');
setTimeout(()=>t.className='toast',2500);
}
function autoResize(el){
el.style.height='auto';
el.style.height=Math.min(el.scrollHeight,120)+'px';
}
function handleKey(e){
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}
}
function fmt(text){
return text
.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.*?)\*/g,'<em>$1</em>')
.replace(/\n\n/g,'</p><p style="margin-top:6px">')
.replace(/\n/g,'<br>');
}
// ── Chat ───────────────────────────────────────────────────────────────────
async function sendMessage(){
const inp=document.getElementById('user-input');
const text=inp.value.trim();
const lang=document.getElementById('lang-sel').value;
if(!text||busy)return;
inp.value=''; inp.style.height='auto';
// Remove empty state
const empty=document.getElementById('chat-area').querySelector('.empty');
if(empty)empty.remove();
busy=true;
document.getElementById('send-btn').disabled=true;
currentQuestion=text;
messages.push({role:'user',content:text});
appendMsg('user',text);
const typing=appendTyping();
try{
const res=await fetch('/api/train/chat',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({messages,lang}),
});
const data=await res.json();
typing.remove();
if(data.error){
appendMsg('bot','⚠ '+data.error,null,null,true);
} else {
currentBotAnswer=data.answer;
appendMsg('bot',data.answer,data.sources,data.answer);
messages.push({role:'assistant',content:data.answer});
}
} catch(e){
typing.remove();
appendMsg('bot','⚠ Cannot reach KASITBot. Make sure app.py is running on port 5000.',null,null,true);
}
busy=false;
document.getElementById('send-btn').disabled=false;
}
function appendMsg(role,text,sources,botAnswer,isError){
const ca=document.getElementById('chat-area');
const wrap=document.createElement('div');
wrap.className=`msg ${role}`;
const av=document.createElement('div');
av.className='msg-av';
av.textContent=role==='bot'?'K':'U';
const body=document.createElement('div');
body.className='msg-body';
const bub=document.createElement('div');
bub.className='bubble';
bub.innerHTML='<p>'+fmt(text)+'</p>';
if(sources&&sources.length){
const row=document.createElement('div');
row.className='msg-sources';
sources.slice(0,3).forEach(s=>{
const tag=document.createElement('span');
tag.className='src-tag';
tag.textContent=`[${s.rank}] ${s.source}`;
row.appendChild(tag);
});
bub.appendChild(row);
}
body.appendChild(bub);
if(role==='bot'&&!isError&&botAnswer){
const bar=document.createElement('div');
bar.className='flag-bar';
const makeBtn=(label,type,cls)=>{
const b=document.createElement('button');
b.className=`flag-btn ${cls}`;
b.textContent=label;
b.onclick=()=>openCorrectionForm(type,bar);
return b;
};
bar.appendChild(makeBtn('Wrong','wrong','wrong'));
bar.appendChild(makeBtn('Partial','partial','partial'));
bar.appendChild(makeBtn('Didn\'t know','unknown','unknown'));
body.appendChild(bar);
}
wrap.appendChild(av);
wrap.appendChild(body);
ca.appendChild(wrap);
ca.scrollTop=ca.scrollHeight;
return wrap;
}
function appendTyping(){
const ca=document.getElementById('chat-area');
const wrap=document.createElement('div');
wrap.className='msg bot';
const av=document.createElement('div');
av.className='msg-av'; av.textContent='K';
const body=document.createElement('div');
body.className='msg-body';
const bub=document.createElement('div');
bub.className='bubble';
bub.innerHTML='<div class="typing-dots"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>';
body.appendChild(bub);
wrap.appendChild(av); wrap.appendChild(body);
ca.appendChild(wrap);
ca.scrollTop=ca.scrollHeight;
return wrap;
}
// ── Correction Form ────────────────────────────────────────────────────────
function openCorrectionForm(issueType, bar){
if(activeFlag)activeFlag.querySelectorAll('.flag-btn').forEach(b=>b.classList.remove('active'));
activeFlag=bar;
bar.querySelectorAll('.flag-btn').forEach(b=>{
if((b.textContent.toLowerCase().includes('wrong')&&issueType==='wrong')||
(b.textContent.toLowerCase().includes('partial')&&issueType==='partial')||
(b.textContent.toLowerCase().includes('didn')&&issueType==='unknown')){
b.classList.add('active');
}
});
const form=document.getElementById('corr-form');
form.style.display='block';
document.getElementById('form-question').textContent=currentQuestion;
document.getElementById('form-answer').value='';
document.getElementById('form-source').value='';
document.getElementById('form-answer').focus();
setIssue(issueType);
}
function setIssue(type){
currentIssue=type;
['wrong','partial','unknown'].forEach(t=>{
const b=document.getElementById('ib-'+t);
b.className='issue-btn';
if(t===type) b.classList.add('sel-'+t);
});
}
async function saveCorrection(){
const answer=document.getElementById('form-answer').value.trim();
const source=document.getElementById('form-source').value.trim();
const lang=document.getElementById('lang-sel').value;
if(!answer){toast('Please enter the correct answer','err');return;}
const btn=document.getElementById('save-btn');
btn.disabled=true; btn.textContent='Saving…';
try{
const res=await fetch('/api/train/corrections',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
question:currentQuestion,
bot_answer:currentBotAnswer,
issue_type:currentIssue,
correct_text:answer,
source_hint:source,
language:lang,
}),
});
const data=await res.json();
if(data.ok){
toast(`Saved! ${data.total} corrections total`);
clearForm();
loadCorrections();
loadStats();
} else {
toast(data.error||'Save failed','err');
}
} catch(e){
toast('Error: '+e.message,'err');
}
btn.disabled=false; btn.textContent='Save Correction β†’';
}
function clearForm(){
document.getElementById('corr-form').style.display='none';
document.getElementById('form-answer').value='';
document.getElementById('form-source').value='';
if(activeFlag){activeFlag.querySelectorAll('.flag-btn').forEach(b=>b.classList.remove('active'));activeFlag=null;}
}
// ── Corrections List ───────────────────────────────────────────────────────
async function loadCorrections(){
const list=document.getElementById('corr-list');
try{
const res=await fetch('/api/train/corrections');
const corrections=await res.json();
if(!corrections.length){
list.innerHTML='<div class="empty"><div class="empty-icon">β—‡</div><div class="empty-txt">No corrections yet.<br>Flag a bad answer to start.</div></div>';
return;
}
list.innerHTML='';
[...corrections].reverse().forEach(c=>{
const card=document.createElement('div');
card.className='corr-card';
const badgeClass=c.exported?'badge-exported':('badge-'+c.issue_type);
const badgeLabel=c.exported?'exported':c.issue_type;
const time=new Date(c.created_at).toLocaleString('en-GB',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'});
card.innerHTML=`
<div class="corr-card-top">
<span class="badge ${badgeClass}">${badgeLabel}</span>
<span style="font-family:var(--mono);font-size:10px;color:var(--text3)">#${c.id}</span>
</div>
<div class="corr-q">Q: ${c.question.substring(0,100)}${c.question.length>100?'…':''}</div>
<div class="corr-a">${c.correct_text.substring(0,150)}${c.correct_text.length>150?'…':''}</div>
<div class="corr-meta">
<span class="corr-time">${time}${c.source_hint?' Β· '+c.source_hint:''}</span>
<button class="del-btn" onclick="deleteCorrection('${c.id}')">βœ•</button>
</div>`;
list.appendChild(card);
});
} catch(e){
list.innerHTML='<div style="padding:20px;font-size:12px;color:var(--text3)">Failed to load corrections.</div>';
}
}
async function deleteCorrection(id){
await fetch('/api/train/corrections/'+id,{method:'DELETE'});
loadCorrections();
loadStats();
toast('Correction deleted');
}
async function loadStats(){
try{
const res=await fetch('/api/train/stats');
const s=await res.json();
document.getElementById('s-total').textContent=s.total;
document.getElementById('s-pending').textContent=s.pending;
document.getElementById('s-exported').textContent=s.exported;
}catch(e){}
}
async function exportCorrections(){
try{
const res=await fetch('/api/train/export');
const data=await res.json();
if(data.ok){
toast(`Exported ${data.exported} entries β†’ training_export.json`);
loadCorrections();
loadStats();
// Also trigger download of the JSON
const blob=new Blob([JSON.stringify(data.entries,null,2)],{type:'application/json'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url; a.download='training_export.json'; a.click();
URL.revokeObjectURL(url);
} else {
toast(data.error||'Export failed','err');
}
} catch(e){
toast('Export error: '+e.message,'err');
}
}
// ── Init ───────────────────────────────────────────────────────────────────
loadCorrections();
loadStats();
setInterval(loadStats,15000);
</script>
</body>
</html>
"""
@app.route("/train")
@app.route("/")
def index():
return render_template_string(HTML)
if __name__ == "__main__":
print("=" * 60)
print(" KASITBot β€” Training Platform")
print("=" * 60)
print(f"\n KASITBot proxy target: {KASIT_BOT_URL}")
print(" Corrections file: training_corrections.json")
print(" Export file: training_export.json")
print("\n Open: http://localhost:5001/train\n")
app.run(debug=True, host="0.0.0.0", port=5001)