Spaces:
Running
Running
| """ | |
| ================================================================================ | |
| 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_corrections(): | |
| return jsonify(load_corrections()) | |
| 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)}) | |
| 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}) | |
| 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, | |
| }) | |
| 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") | |
| 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> | |
| """ | |
| 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) | |