Spaces:
Running
Running
File size: 6,612 Bytes
f425b0b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | #!/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()
|