Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """Web demo: paste/upload a text, get human-vs-AI verdict and model attribution.""" | |
| import os | |
| import argparse | |
| import threading | |
| import time | |
| from pathlib import Path | |
| import joblib | |
| import numpy as np | |
| import pandas as pd | |
| import requests | |
| from flask import Flask, jsonify, request | |
| from scipy.sparse import hstack | |
| from huggingface_hub import hf_hub_download | |
| BASE = Path(__file__).parent | |
| UDPIPE_URL = "https://lindat.mff.cuni.cz/services/udpipe/api/process" | |
| REPO_ID = "milicka/ai-text-detector-models" | |
| # Updated to use just the filenames since they are at the root of your model repo | |
| LANGS = { | |
| "en": {"detector": "detector_brown.joblib", | |
| "attribution": "attribution_brown.joblib", | |
| "detector_local": "detector_brown_wordskip.joblib", | |
| "attribution_local": "attribution_brown_wordskip.joblib", | |
| "udpipe": "english-ewt-ud-2.17-251125"}, | |
| "cs": {"detector": "detector_koditex.joblib", | |
| "attribution": "attribution_koditex.joblib", | |
| "detector_local": "detector_koditex_wordskip.joblib", | |
| "attribution_local": "attribution_koditex_wordskip.joblib", | |
| "udpipe": "czech-pdtc-ud-2.17-251125"}, | |
| } | |
| MIN_TOKENS = 30 # refuse shorter inputs | |
| CHUNK = 200 | |
| CONLLU_COLS = {"words": 1, "lemmata": 2, "pos": 3, "TAG": 4, "FUN": 7} | |
| app = Flask(__name__) | |
| _bundles = {} | |
| _lock = threading.Lock() | |
| def get_bundles(lang, mode="udpipe"): | |
| """Lazy-load detector+attribution bundles for a language and mode from HF Hub.""" | |
| key = (lang, mode) | |
| suffix = "_local" if mode == "local" else "" | |
| with _lock: | |
| if key not in _bundles: | |
| cfg = LANGS[lang] | |
| print(f"loading {key} bundles from Hugging Face Hub ...", flush=True) | |
| # Načtení tokenu z tajných proměnných Space | |
| token = os.environ.get("for_models") | |
| # Přidání parametru token=token | |
| det_path = hf_hub_download(repo_id=REPO_ID, filename=cfg["detector" + suffix], token=token) | |
| attr_path = hf_hub_download(repo_id=REPO_ID, filename=cfg["attribution" + suffix], token=token) | |
| _bundles[key] = { | |
| "det": joblib.load(det_path), | |
| "attr": joblib.load(attr_path), | |
| } | |
| print(f"{key} bundles ready", flush=True) | |
| return _bundles[key] | |
| def detect_language(text): | |
| """Crude Czech/English heuristic based on characters and stopwords.""" | |
| czech_chars = sum(text.count(c) for c in "ěščřžýáíéůúďťň") | |
| if czech_chars / max(len(text), 1) > 0.005: | |
| return "cs" | |
| words = set(text.lower().split()) | |
| cs_hits = len(words & {"je", "se", "že", "na", "ale", "jako", "podle", | |
| "byl", "byla", "být", "jsou", "však"}) | |
| en_hits = len(words & {"the", "of", "and", "to", "is", "was", "that", | |
| "with", "for", "have"}) | |
| return "cs" if cs_hits > en_hits else "en" | |
| def udpipe_parse(text, model): | |
| resp = requests.post(UDPIPE_URL, data={ | |
| "model": model, "tokenizer": "", "tagger": "", "parser": "", | |
| "data": text}, timeout=300) | |
| resp.raise_for_status() | |
| return resp.json()["result"] | |
| def conllu_to_columns(conllu): | |
| cols = {c: [] for c in CONLLU_COLS} | |
| for line in conllu.splitlines(): | |
| if not line.strip() or line.startswith("#"): | |
| continue | |
| f = line.split("\t") | |
| if "-" in f[0] or "." in f[0]: | |
| continue | |
| for c, i in CONLLU_COLS.items(): | |
| cols[c].append(f[i]) | |
| return cols | |
| def make_chunks(cols, chunk_size): | |
| """Non-overlapping full chunks; the remainder is replaced by one full | |
| chunk anchored at the END of the text (overlapping the previous chunk), | |
| so every scored chunk has the full length. Texts shorter than one chunk | |
| yield a single short chunk.""" | |
| n = len(cols["words"]) | |
| if n < chunk_size: | |
| bounds, overlaps = [(0, n)], [False] | |
| else: | |
| bounds = [(s, s + chunk_size) | |
| for s in range(0, n - chunk_size + 1, chunk_size)] | |
| rem = n - len(bounds) * chunk_size | |
| overlaps = [False] * len(bounds) | |
| if rem >= MIN_TOKENS: | |
| bounds.append((n - chunk_size, n)) | |
| overlaps.append(True) | |
| chunks = [{c: " ".join(v[a:b]) for c, v in cols.items()} | |
| for a, b in bounds] | |
| lengths = [b - a for a, b in bounds] | |
| return chunks, lengths, overlaps | |
| def featurize(bundle, chunk_df): | |
| mats = [vec.transform(chunk_df[col]) for col, vec in bundle["vectorizers"]] | |
| return mats[0] if len(mats) == 1 else hstack(mats).tocsr() | |
| def anchored_probs(det, scores): | |
| """P(AI) re-anchored so that p=0.5 at the tuned decision threshold.""" | |
| A = float(det["platt"].coef_[0][0]) | |
| z = A * (scores - det["threshold"]) | |
| return 1.0 / (1.0 + np.exp(-z)) | |
| def attribute_nonhuman(attr, X): | |
| """Most likely non-human model line per chunk.""" | |
| clf = attr["classifier"] | |
| dec = clf.decision_function(X) | |
| if dec.ndim == 1: | |
| dec = np.stack([-dec, dec], axis=1) | |
| classes = np.asarray(clf.classes_) | |
| dec[:, classes == "human"] = -np.inf | |
| return classes[dec.argmax(axis=1)] | |
| def index(): | |
| return _HTML, 200, {"Content-Type": "text/html; charset=utf-8"} | |
| def info(): | |
| return jsonify({"languages": {k: v["udpipe"] for k, v in LANGS.items()}, | |
| "loaded": list(_bundles), "chunk_size": CHUNK}) | |
| def classify(): | |
| data = request.get_json(silent=True) or {} | |
| text = str(data.get("text", "")).strip() | |
| lang = data.get("lang", "auto") | |
| mode = data.get("mode", "udpipe") | |
| if mode not in ("udpipe", "local"): | |
| mode = "udpipe" | |
| if not text: | |
| return jsonify({"error": "No text provided."}), 400 | |
| if lang not in ("en", "cs"): | |
| lang = detect_language(text) | |
| try: | |
| t0 = time.time() | |
| if mode == "udpipe": | |
| conllu = udpipe_parse(text, LANGS[lang]["udpipe"]) | |
| t_udpipe = time.time() - t0 | |
| cols = conllu_to_columns(conllu) | |
| else: | |
| # local mode: approximate tokenizer, surface features only | |
| from ud_tokenize import tokenize | |
| cols = {"words": tokenize(text, lang)} | |
| t_udpipe = 0.0 | |
| n_tokens = len(cols["words"]) | |
| if n_tokens < MIN_TOKENS: | |
| return jsonify({"error": | |
| f"Text too short: {n_tokens} tokens (need >= {MIN_TOKENS})."}), 400 | |
| chunks, lengths, overlaps = make_chunks(cols, CHUNK) | |
| chunk_df = pd.DataFrame(chunks) | |
| b = get_bundles(lang, mode) | |
| det, attr = b["det"], b["attr"] | |
| X = featurize(det, chunk_df) | |
| scores = det["classifier"].decision_function(X) | |
| probs = anchored_probs(det, scores) | |
| attributed = attribute_nonhuman(attr, X) | |
| chunk_out = [{ | |
| "idx": i, | |
| "n_tokens": lengths[i], | |
| "p_ai": round(float(probs[i]), 4), | |
| "attributed": str(attributed[i]), | |
| "text": chunks[i]["words"], | |
| "short": lengths[i] < CHUNK, | |
| "overlap": overlaps[i], | |
| } for i in range(len(chunks))] | |
| return jsonify({ | |
| "lang": lang, "mode": mode, "n_tokens": n_tokens, | |
| "n_chunks": len(chunks), "chunks": chunk_out, | |
| "seconds": {"udpipe": round(t_udpipe, 1), | |
| "total": round(time.time() - t0, 1)}, | |
| }) | |
| except requests.RequestException as exc: | |
| return jsonify({"error": f"UDPipe service error: {exc}"}), 502 | |
| except Exception as exc: | |
| return jsonify({"error": str(exc)}), 500 | |
| _HTML = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Human or LLM? — corpus text classifier</title> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| body{font-family:-apple-system,'Segoe UI',Roboto,sans-serif;background:#f0f4f8;color:#1a202c} | |
| header{background:linear-gradient(135deg,#1e3a5f,#0f2341);color:#fff;padding:1.3rem 2rem; | |
| display:flex;align-items:center;gap:1rem;box-shadow:0 2px 12px rgba(0,0,0,.25)} | |
| header h1{font-size:1.4rem} | |
| header p{font-size:.78rem;opacity:.65;margin-top:.15rem} | |
| .badge-hdr{margin-left:auto;font-size:.7rem;background:rgba(255,255,255,.12); | |
| border:1px solid rgba(255,255,255,.2);border-radius:20px;padding:.3rem .75rem} | |
| .container{max-width:1100px;margin:1.6rem auto;padding:0 1.2rem; | |
| display:grid;grid-template-columns:1fr 1fr;gap:1.2rem} | |
| @media(max-width:760px){.container{grid-template-columns:1fr}} | |
| .card{background:#fff;border-radius:14px;padding:1.4rem; | |
| box-shadow:0 1px 3px rgba(0,0,0,.06),0 6px 20px rgba(0,0,0,.05)} | |
| .card-label{font-size:.7rem;font-weight:700;text-transform:uppercase; | |
| letter-spacing:.09em;color:#a0aec0;margin-bottom:.9rem} | |
| textarea{width:100%;height:200px;border:1.5px solid #e2e8f0;border-radius:9px; | |
| padding:.7rem .85rem;font-size:.88rem;font-family:inherit;line-height:1.5;resize:vertical} | |
| textarea:focus{outline:none;border-color:#4a7fc1;box-shadow:0 0 0 3px rgba(74,127,193,.15)} | |
| .row{display:flex;gap:.7rem;margin-top:.8rem;align-items:center;flex-wrap:wrap} | |
| select,.filebtn{border:1.5px solid #e2e8f0;border-radius:8px;padding:.45rem .7rem; | |
| font-size:.83rem;background:#fff;color:#2d3748;cursor:pointer} | |
| .count{font-size:.72rem;color:#b0bac7;margin-left:auto} | |
| .prior-section{margin-top:1rem} | |
| .prior-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:.4rem} | |
| .prior-row label{font-size:.82rem;font-weight:500;color:#4a5568} | |
| .prior-pill{font-size:.8rem;font-weight:700;color:#4a7fc1;background:#ebf4ff; | |
| border-radius:20px;padding:.2rem .65rem} | |
| input[type=range]{width:100%;height:5px;border-radius:3px;-webkit-appearance:none; | |
| background:linear-gradient(to right,#4a7fc1 50%,#e2e8f0 50%);outline:none;cursor:pointer} | |
| input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:17px;height:17px; | |
| border-radius:50%;background:#4a7fc1;box-shadow:0 1px 4px rgba(0,0,0,.22);cursor:pointer} | |
| .prior-hint{font-size:.72rem;color:#a0aec0;margin-top:.35rem;min-height:1.1em} | |
| .btn{width:100%;margin-top:1rem;padding:.8rem;font-size:.95rem;font-weight:600;border:none; | |
| border-radius:9px;cursor:pointer;color:#fff; | |
| background:linear-gradient(135deg,#4a7fc1,#1e3a5f);display:flex;align-items:center; | |
| justify-content:center;gap:.5rem} | |
| .btn:disabled{opacity:.45;cursor:not-allowed} | |
| .spinner{width:15px;height:15px;border:2px solid rgba(255,255,255,.35);border-top-color:#fff; | |
| border-radius:50%;animation:spin .65s linear infinite} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center; | |
| min-height:300px;color:#c5cdd8;gap:.4rem;text-align:center} | |
| .result{display:none} | |
| .verdict-wrap{text-align:center;margin-bottom:1.2rem} | |
| .verdict{font-size:2.6rem;font-weight:800;display:inline-block;padding:.25rem 1.1rem;border-radius:14px} | |
| .v-human{background:#d1fae5;color:#065f46} | |
| .v-ai{background:#fde8d0;color:#7c3400} | |
| .v-mixed{background:#fef3c7;color:#92400e} | |
| .v-desc{font-size:.78rem;color:#718096;margin-top:.4rem} | |
| .bar-row{display:flex;align-items:center;gap:.7rem;margin-bottom:.5rem} | |
| .bar-lbl{font-size:.76rem;font-weight:700;width:120px;color:#4a5568} | |
| .track{flex:1;height:11px;background:#f0f4f8;border-radius:6px;overflow:hidden} | |
| .fill{height:100%;border-radius:6px;transition:width .5s;background:linear-gradient(90deg,#f59e0b,#b45309)} | |
| .pct{font-size:.78rem;font-weight:700;width:48px;text-align:right;color:#4a5568} | |
| .meta{border-top:1px solid #f0f4f8;padding-top:.8rem;margin-top:1rem; | |
| display:grid;grid-template-columns:1fr 1fr 1fr;gap:.55rem} | |
| .meta div{font-size:.72rem;color:#a0aec0} | |
| .meta strong{color:#4a5568;display:block;font-size:.82rem} | |
| .attr-section{margin-top:1.1rem} | |
| .chunks{grid-column:1/-1} | |
| table{width:100%;border-collapse:collapse;font-size:.76rem} | |
| th{background:#f7fafc;padding:.45rem .7rem;text-align:left;font-weight:700;color:#718096; | |
| border-bottom:1px solid #e2e8f0} | |
| td{padding:.42rem .7rem;border-bottom:1px solid #f0f4f8;color:#4a5568;vertical-align:top} | |
| .tag{font-weight:700;padding:.1rem .5rem;border-radius:10px;font-size:.72rem;white-space:nowrap} | |
| .tag-h{background:#d1fae5;color:#065f46} | |
| .tag-a{background:#fde8d0;color:#7c3400} | |
| .preview{cursor:pointer;color:#4a7fc1} | |
| .fulltext{display:none;margin-top:.3rem;background:#f7fafc;border:1px solid #e2e8f0; | |
| border-radius:6px;padding:.45rem .6rem;white-space:pre-wrap;max-height:150px;overflow-y:auto} | |
| .status{grid-column:1/-1;font-size:.78rem;color:#718096;text-align:center;min-height:1.2em} | |
| .status.err{color:#e53e3e} | |
| .note{font-size:.72rem;color:#a0aec0;margin-top:.6rem;line-height:1.5} | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div> | |
| <h1>Human or LLM?</h1> | |
| <p>AI Brown / AI Koditex classifier · UDPipe 2 + sparse linear models</p> | |
| </div> | |
| <div class="badge-hdr">chunk: 200 tokens</div> | |
| </header> | |
| <div class="container"> | |
| <div class="card"> | |
| <div class="card-label">Input text</div> | |
| <textarea id="txt" placeholder="Paste text here / Vložte text sem…" oninput="onChange()"></textarea> | |
| <div class="row"> | |
| <select id="lang"> | |
| <option value="auto">Language: auto</option> | |
| <option value="en">English</option> | |
| <option value="cs">Czech</option> | |
| </select> | |
| <select id="mode" title="UDPipe: exact training annotation via the LINDAT service (needs internet). Local: built-in approximate tokenizer, surface features only — instant and self-contained, slightly lower accuracy."> | |
| <option value="udpipe">Mode: UDPipe (accurate)</option> | |
| <option value="local">Mode: local (fast)</option> | |
| </select> | |
| <label class="filebtn">Upload .txt<input type="file" id="file" accept=".txt,text/plain" style="display:none" onchange="loadFile(this)"></label> | |
| <span class="count" id="count">0 characters</span> | |
| </div> | |
| <div class="prior-section"> | |
| <div class="prior-row"> | |
| <label for="prior">Prior P(AI)</label> | |
| <span class="prior-pill" id="prior-pill">0.50</span> | |
| </div> | |
| <input type="range" id="prior" min="0.01" max="0.99" step="0.01" value="0.50" | |
| oninput="onPrior(this.value)"> | |
| <div class="prior-hint" id="prior-hint">0.50 — neutral (the tuned operating point)</div> | |
| </div> | |
| <button class="btn" id="go" onclick="classify()">Classify</button> | |
| <div class="note">The text is sent to the LINDAT UDPipe service for | |
| annotation, cut into 200-token chunks and scored by classifiers trained | |
| on the AI Brown / AI Koditex corpora (19 chat models, 2024–2026). | |
| P(AI) is anchored so that 0.5 = the tuned decision point; the prior | |
| slider shifts it for contexts where false positives are costlier than | |
| false negatives (or vice versa). Verdicts for models newer than the | |
| training sample are less reliable; base-model (non-chat) text is out of | |
| scope.</div> | |
| </div> | |
| <div class="card"> | |
| <div class="placeholder" id="ph"> | |
| <div style="font-size:2rem">🤔</div> | |
| <div style="font-size:.88rem">Result will appear here</div> | |
| </div> | |
| <div class="result" id="res"> | |
| <div class="card-label">Verdict</div> | |
| <div class="verdict-wrap"> | |
| <div class="verdict" id="verdict">?</div> | |
| <div class="v-desc" id="vdesc"></div> | |
| </div> | |
| <div class="bar-row"> | |
| <div class="bar-lbl">mean P(AI)</div> | |
| <div class="track"><div class="fill" id="bar-p"></div></div> | |
| <div class="pct" id="pct-p">0%</div> | |
| </div> | |
| <div class="bar-row"> | |
| <div class="bar-lbl">chunks flagged AI</div> | |
| <div class="track"><div class="fill" id="bar-f"></div></div> | |
| <div class="pct" id="pct-f">0%</div> | |
| </div> | |
| <div class="attr-section" id="attr"></div> | |
| <div class="meta"> | |
| <div><strong id="m-lang">—</strong>language</div> | |
| <div><strong id="m-tokens">—</strong>tokens</div> | |
| <div><strong id="m-chunks">—</strong>chunks</div> | |
| <div><strong id="m-prior">—</strong>prior used</div> | |
| <div><strong id="m-udpipe">—</strong>UDPipe time</div> | |
| <div><strong id="m-total">—</strong>total time</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card chunks" id="chunkcard" style="display:none"> | |
| <div class="card-label">Per-chunk detail</div> | |
| <table> | |
| <thead><tr><th>#</th><th>tokens</th><th>P(AI)</th><th>verdict</th> | |
| <th>attributed model</th><th>text</th></tr></thead> | |
| <tbody id="tbody"></tbody> | |
| </table> | |
| </div> | |
| <div class="status" id="status"></div> | |
| </div> | |
| <script> | |
| var _last = null; // last server response; re-rendered when the prior moves | |
| function onChange(){ | |
| const n=document.getElementById('txt').value.length; | |
| document.getElementById('count').textContent=n.toLocaleString()+' characters'; | |
| } | |
| function loadFile(inp){ | |
| const f=inp.files[0]; if(!f)return; | |
| const r=new FileReader(); | |
| r.onload=e=>{document.getElementById('txt').value=e.target.result;onChange();}; | |
| r.readAsText(f); | |
| } | |
| function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')} | |
| function onPrior(v){ | |
| v=parseFloat(v); | |
| document.getElementById('prior-pill').textContent=v.toFixed(2); | |
| const pct=((v-0.01)/0.98*100).toFixed(1); | |
| document.getElementById('prior').style.background= | |
| `linear-gradient(to right,#4a7fc1 ${pct}%,#e2e8f0 ${pct}%)`; | |
| let hint; | |
| if(Math.abs(v-0.5)<0.005) hint='0.50 — neutral (the tuned operating point)'; | |
| else if(v<0.5) hint=v.toFixed(2)+' — conservative: fewer false AI accusations'; | |
| else hint=v.toFixed(2)+' — sensitive: fewer missed AI texts'; | |
| document.getElementById('prior-hint').textContent=hint; | |
| if(_last) render(_last); | |
| } | |
| // Bayes odds update of the threshold-anchored probability | |
| function applyPrior(p, prior){ | |
| if(p<=0) return 0; if(p>=1) return 1; | |
| const num=p*prior, den=num+(1-p)*(1-prior); | |
| return den>0 ? num/den : 0.5; | |
| } | |
| async function classify(){ | |
| const text=document.getElementById('txt').value.trim(); | |
| const lang=document.getElementById('lang').value; | |
| const mode=document.getElementById('mode').value; | |
| const btn=document.getElementById('go'); | |
| if(!text){setStatus('Please enter some text.',true);return;} | |
| btn.disabled=true;btn.innerHTML='<div class="spinner"></div>Parsing & classifying…'; | |
| setStatus(mode==='udpipe' | |
| ? 'Calling UDPipe… (first request per language+mode also loads the models, ~10 s extra)' | |
| : 'Tokenizing locally… (first request per language+mode also loads the models, ~10 s extra)'); | |
| try{ | |
| const r=await fetch('/classify',{method:'POST', | |
| headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({text,lang,mode})}); | |
| const d=await r.json(); | |
| if(!r.ok||d.error)throw new Error(d.error||('HTTP '+r.status)); | |
| _last=d;render(d);setStatus(''); | |
| }catch(e){setStatus('Error: '+e.message,true);} | |
| finally{btn.disabled=false;btn.textContent='Classify';} | |
| } | |
| function render(d){ | |
| const prior=parseFloat(document.getElementById('prior').value); | |
| const adj=d.chunks.map(c=>applyPrior(c.p_ai,prior)); | |
| const flagged=adj.map(p=>p>=0.5); | |
| const meanP=adj.reduce((a,b)=>a+b,0)/adj.length; | |
| const frac=flagged.filter(Boolean).length/flagged.length; | |
| const verdict = frac>0.8 ? 'ai' : (frac<0.2 ? 'human' : 'mixed'); | |
| document.getElementById('ph').style.display='none'; | |
| document.getElementById('res').style.display='block'; | |
| const map={human:['HUMAN','v-human','No or almost no chunks flagged as machine-generated'], | |
| ai:['AI','v-ai','Most chunks flagged as machine-generated'], | |
| mixed:['MIXED','v-mixed','Both human-like and AI-like chunks present']}; | |
| const m=map[verdict]; | |
| const v=document.getElementById('verdict'); | |
| v.textContent=m[0];v.className='verdict '+m[1]; | |
| document.getElementById('vdesc').textContent=m[2]; | |
| const p=(meanP*100).toFixed(1),f=(frac*100).toFixed(1); | |
| setTimeout(()=>{document.getElementById('bar-p').style.width=p+'%'; | |
| document.getElementById('bar-f').style.width=f+'%';},40); | |
| document.getElementById('pct-p').textContent=p+'%'; | |
| document.getElementById('pct-f').textContent=f+'%'; | |
| document.getElementById('m-lang').textContent=d.lang+' / '+(d.mode==='local'?'local':'UDPipe'); | |
| document.getElementById('m-tokens').textContent=d.n_tokens.toLocaleString(); | |
| document.getElementById('m-chunks').textContent=d.n_chunks; | |
| document.getElementById('m-prior').textContent=prior.toFixed(2); | |
| document.getElementById('m-udpipe').textContent=d.seconds.udpipe+'s'; | |
| document.getElementById('m-total').textContent=d.seconds.total+'s'; | |
| // attribution votes over currently flagged chunks | |
| const votes={}; | |
| d.chunks.forEach((c,i)=>{if(flagged[i])votes[c.attributed]=(votes[c.attributed]||0)+1;}); | |
| const sorted=Object.entries(votes).sort((a,b)=>b[1]-a[1]); | |
| const total=sorted.reduce((a,b)=>a+b[1],0); | |
| const attr=document.getElementById('attr');attr.innerHTML=''; | |
| if(sorted.length){ | |
| attr.innerHTML='<div class="card-label">Attribution of AI-flagged chunks</div>'; | |
| sorted.slice(0,6).forEach(([name,cnt])=>{ | |
| const pct=(cnt/total*100).toFixed(0); | |
| attr.innerHTML+=`<div class="bar-row"><div class="bar-lbl">${esc(name)}</div> | |
| <div class="track"><div class="fill" style="width:${pct}%"></div></div> | |
| <div class="pct">${cnt}×</div></div>`; | |
| }); | |
| } | |
| const tb=document.getElementById('tbody');tb.innerHTML=''; | |
| d.chunks.forEach((c,i)=>{ | |
| const t=esc(c.text),prev=t.length>70?t.slice(0,70)+'…':t; | |
| const uid='c'+c.idx; | |
| const marks=(c.short?' <span title="shorter than 200 tokens — less reliable">⚠</span>':'') | |
| +(c.overlap?' <span title="anchored at the end of the text; overlaps the previous chunk">↺</span>':''); | |
| tb.innerHTML+=`<tr> | |
| <td>${c.idx+1}${marks}</td> | |
| <td>${c.n_tokens}</td><td>${(adj[i]*100).toFixed(1)}%</td> | |
| <td><span class="tag ${flagged[i]?'tag-a':'tag-h'}">${flagged[i]?'AI':'human'}</span></td> | |
| <td>${flagged[i]?esc(c.attributed):'—'}</td> | |
| <td><span class="preview" onclick="tog('${uid}')">${prev}</span> | |
| <div class="fulltext" id="${uid}">${t}</div></td></tr>`; | |
| }); | |
| document.getElementById('chunkcard').style.display='block'; | |
| } | |
| function tog(id){const e=document.getElementById(id); | |
| e.style.display=e.style.display==='block'?'none':'block';} | |
| function setStatus(s,err){const e=document.getElementById('status'); | |
| e.textContent=s;e.className='status'+(err?' err':'');} | |
| onPrior(0.5); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| if __name__ == "__main__": | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--port", type=int, default=8123) | |
| ap.add_argument("--host", default="127.0.0.1") | |
| ap.add_argument("--preload", action="store_true", | |
| help="load both language bundles at startup") | |
| args = ap.parse_args() | |
| if args.preload: | |
| for lang in LANGS: | |
| get_bundles(lang) | |
| print(f"Serving at http://{args.host}:{args.port}", flush=True) | |
| app.run(host=args.host, port=args.port, threaded=True) |