SMLBuilder's picture
Publish traceable conversation explorer and standalone Python decoder
f425b0b verified
Raw History Blame Contribute Delete
6.61 kB
#!/usr/bin/env python3
"""Public, read-only conversation explorer. No API tokens or inference runtime."""
import gzip
import json
import os
import threading
import time
import urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
from source_store import BASE, SourceStore
from tree_decoder import DecodeError, VERSION
STATIC = {"/": ("site/index.html", "text/html; charset=utf-8"),
"/style.css": ("site/style.css", "text/css; charset=utf-8"),
"/app.js": ("site/app.js", "text/javascript; charset=utf-8"),
"/tree_decoder.py": ("tree_decoder.py", "text/x-python; charset=utf-8"),
"/AAC_cli_original.py": ("public_decoder/AAC_cli_original.py", "text/x-python; charset=utf-8"),
"/favicon.svg": ("site/favicon.svg", "image/svg+xml")}
class Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address, store=None):
self.store = store or SourceStore()
self.slots = threading.BoundedSemaphore(12)
super().__init__(address, Handler)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
try:
request.sendall(b"HTTP/1.1 503 Service Unavailable\r\nRetry-After: 3\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
finally:
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
class Handler(BaseHTTPRequestHandler):
server_version = "SMLTree/" + VERSION
def setup(self):
super().setup()
self.connection.settimeout(65)
def log_message(self, fmt, *args):
# Public tree paths only; no visitor conversation content is submitted or stored.
print("http", self.command, urlsplit(self.path).path, args[1] if len(args) > 1 else "", flush=True)
def send(self, status, raw, content_type="application/json; charset=utf-8", cache="no-store", download=None):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", cache)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; "
"img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'")
if download:
self.send_header("Content-Disposition", 'attachment; filename="' + download + '"')
if len(raw) > 2048 and "gzip" in self.headers.get("Accept-Encoding", ""):
raw = gzip.compress(raw, compresslevel=4)
self.send_header("Content-Encoding", "gzip")
self.send_header("Vary", "Accept-Encoding")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(raw)
def json(self, status, value, **kwargs):
self.send(status, json.dumps(value, ensure_ascii=False).encode(), **kwargs)
def do_HEAD(self):
self.do_GET()
def do_GET(self):
started = time.perf_counter()
try:
if len(self.path) > 8192:
raise DecodeError("Request is too long.")
url = urlsplit(self.path)
if url.path in STATIC:
file, mime = STATIC[url.path]
download = file.split("/")[-1] if file.endswith(".py") else None
return self.send(200, (BASE / file).read_bytes(), mime, "public, max-age=300", download)
if url.path == "/health":
return self.json(200, {"ok": True, "decoder_version": VERSION,
"catalog_files": len(self.server.store.rows)})
if url.path == "/api/catalog":
return self.json(200, self.server.store.catalog(), cache="public, max-age=300")
if url.path not in ("/api/document", "/api/trace", "/api/export"):
return self.json(404, {"error": "Not found."})
query = parse_qs(url.query, keep_blank_values=True, max_num_fields=8)
if any(len(v) != 1 for v in query.values()):
raise DecodeError("Query parameters must appear once.")
ident = query.get("id", [""])[0]
document, source = self.server.store.get(ident)
if url.path == "/api/document":
summaries = document.summaries()
return self.json(200, {"source": source, "conversations": summaries,
"status": "supported" if summaries else "not-a-supported-conversation",
"load_ms": round((time.perf_counter() - started) * 1000, 2)})
conversation = int(query.get("conversation", ["0"])[0])
raw_path = query.get("path", [""])[0]
choices = [int(i) for i in raw_path.split(",")] if raw_path else []
if len(choices) > 128:
raise DecodeError("Web paths are limited to 128 selections; the Python decoder has no path cap.")
result = {**document.trace(conversation, choices), "source": source,
"decode_ms": round((time.perf_counter() - started) * 1000, 2)}
return self.json(200, result, download="sml-conversation-trace.json" if url.path == "/api/export" else None)
except KeyError:
self.json(404, {"error": "Unknown catalog source."})
except (DecodeError, ValueError, RecursionError) as error:
self.json(422, {"error": str(error)})
except (urllib.error.URLError, TimeoutError, OSError):
try:
self.json(502, {"error": "The public source could not be loaded. Retry in a moment; your path is preserved."})
except OSError:
pass
if __name__ == "__main__":
server = Server((os.environ.get("HOST", "0.0.0.0"), int(os.environ.get("PORT", "7860"))))
print("SML Tree Explorer listening on port", server.server_port, flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()