#!/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()