From 4092854b0bea9e1fc02f29222c6f7cd876e565a5 Mon Sep 17 00:00:00 2001
From: shuishen <1109946754@qq.com>
Date: Mon, 17 Aug 2026 09:43:20 +0800
Subject: [PATCH] feat:项目基础整理

---
 scripts/serve_workbench_console.py |  256 ++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 242 insertions(+), 14 deletions(-)

diff --git a/scripts/serve_workbench_console.py b/scripts/serve_workbench_console.py
index 83126a9..302eb37 100644
--- a/scripts/serve_workbench_console.py
+++ b/scripts/serve_workbench_console.py
@@ -1,44 +1,275 @@
-"""Serve the local GeoAI Workbench console from the repository root."""
+"""Serve the local GeoAI Workbench console and its narrow local-run APIs."""
 
 from __future__ import annotations
 
 import argparse
+import base64
+import binascii
+import json
 import os
-from pathlib import PurePosixPath
+import re
+import subprocess
+import threading
+from datetime import UTC, datetime
 from http import HTTPStatus
 from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
-from pathlib import Path
+from pathlib import Path, PurePosixPath
+from typing import Any
 from urllib.parse import unquote, urlsplit
+from uuid import uuid4
 
 
 DEFAULT_HOST = "127.0.0.1"
 DEFAULT_PORT = 6173
+MAX_REQUEST_BYTES = 128 * 1024 * 1024
+MAX_FILE_BYTES = 96 * 1024 * 1024
+MAX_IMAGES_PER_RUN = 12
 ALLOWED_PATH_PREFIXES = (
     "apps/workbench-console",
     "shared/outputs",
     "shared/data/raw/01-object-detection",
 )
+SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
+RUN_LOCK = threading.Lock()
+
+
+class ApiError(ValueError):
+    """A request error that can be shown to the local console user."""
+
+
+def safe_file_name(value: str, expected_suffixes: set[str]) -> str:
+    name = Path(value).name
+    suffix = Path(name).suffix.lower()
+    if suffix not in expected_suffixes:
+        raise ApiError(f"Unsupported file type: {suffix or '(none)'}.")
+    stem = SAFE_FILE_NAME.sub("_", Path(name).stem).strip("._") or "upload"
+    return f"{stem[:80]}{suffix}"
+
+
+def decode_upload(payload: dict[str, Any], expected_suffixes: set[str]) -> tuple[str, bytes]:
+    if not isinstance(payload, dict) or not isinstance(payload.get("name"), str) or not isinstance(payload.get("content"), str):
+        raise ApiError("Each uploaded file must include name and Base64 content.")
+    name = safe_file_name(payload["name"], expected_suffixes)
+    try:
+        content = base64.b64decode(payload["content"], validate=True)
+    except (binascii.Error, ValueError) as exc:
+        raise ApiError(f"Invalid Base64 file content for {name}.") from exc
+    if not content:
+        raise ApiError(f"Uploaded file is empty: {name}.")
+    if len(content) > MAX_FILE_BYTES:
+        raise ApiError(f"Uploaded file exceeds {MAX_FILE_BYTES // (1024 * 1024)} MB: {name}.")
+    return name, content
+
+
+def make_run_id(prefix: str) -> str:
+    return f"{prefix}-{datetime.now(UTC):%Y%m%d-%H%M%S}-{uuid4().hex[:6]}"
+
+
+def relative_path(root: Path, path: Path) -> str:
+    return path.relative_to(root).as_posix()
+
+
+def load_json(path: Path) -> dict[str, Any]:
+    try:
+        payload = json.loads(path.read_text(encoding="utf-8"))
+    except (OSError, json.JSONDecodeError):
+        return {}
+    return payload if isinstance(payload, dict) else {}
+
+
+def trajectory_runs(root: Path) -> list[dict[str, Any]]:
+    output_root = root / "shared" / "outputs" / "15-trajectory-analysis"
+    records: list[dict[str, Any]] = []
+    for metadata_path in output_root.rglob("run_metadata.json"):
+        artifact = metadata_path.parent
+        if not (artifact / "trajectory_summary.csv").is_file() or not (artifact / "events.json").is_file():
+            continue
+        metadata = load_json(metadata_path)
+        case_id = str(metadata.get("case_id") or artifact.name)
+        is_real = case_id == "tian-dun-flight-19578"
+        records.append(
+            {
+                "id": case_id,
+                "label": "田墩实飞" if is_real else case_id,
+                "note": "区域相交是来源数据的空间结果,不是违规结论。" if is_real else "本地实验运行结果,可继续查看结构化输出。",
+                "artifactRoot": relative_path(root, artifact),
+                "showSpatialContext": (artifact / "zones.geojson").is_file() and (artifact / "reference_routes.geojson").is_file(),
+                "showFlyableZones": (artifact / "flyable_zones.geojson").is_file(),
+                "createdAt": str(metadata.get("created_at") or ""),
+            }
+        )
+    return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
+
+
+def detection_runs(root: Path) -> list[dict[str, Any]]:
+    output_root = root / "shared" / "outputs" / "01-object-detection"
+    records: list[dict[str, Any]] = []
+    for metadata_path in output_root.rglob("run_metadata.json"):
+        artifact = metadata_path.parent
+        if not (artifact / "detections.json").is_file():
+            continue
+        metadata = load_json(metadata_path)
+        input_value = str(metadata.get("input_dir") or "")
+        input_dir = Path(input_value) if input_value else root / "shared" / "data" / "raw" / "01-object-detection"
+        try:
+            input_root = relative_path(root, input_dir.resolve())
+        except ValueError:
+            continue
+        run_id = artifact.name if artifact != output_root else "baseline"
+        records.append(
+            {
+                "id": run_id,
+                "label": "既有基线结果" if run_id == "baseline" else run_id,
+                "note": "CPU 基线:人员与常见车辆;树木不在当前模型有效类别内。",
+                "artifactRoot": relative_path(root, artifact),
+                "inputRoot": input_root,
+                "createdAt": str(metadata.get("created_at") or ""),
+            }
+        )
+    return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
 
 
 class WorkbenchConsoleHandler(SimpleHTTPRequestHandler):
-    """Read-only static handler rooted at the workbench repository."""
+    """Static UI plus fixed, local-only ingestion and experiment commands."""
+
+    server_version = "GeoAIWorkbench/1.0"
+
+    @property
+    def root(self) -> Path:
+        return Path(self.directory).resolve()
 
     def do_GET(self) -> None:  # noqa: N802 - inherited standard-library method name
-        if urlsplit(self.path).path == "/":
+        path = urlsplit(self.path).path
+        if path == "/api/trajectory/runs":
+            self.send_json(HTTPStatus.OK, {"runs": trajectory_runs(self.root)})
+            return
+        if path == "/api/object-detection/runs":
+            self.send_json(HTTPStatus.OK, {"runs": detection_runs(self.root)})
+            return
+        if path == "/":
             self.send_response(HTTPStatus.FOUND)
             self.send_header("Location", "/apps/workbench-console/")
             self.end_headers()
             return
         super().do_GET()
 
+    def do_POST(self) -> None:  # noqa: N802 - inherited standard-library method name
+        path = urlsplit(self.path).path
+        try:
+            payload = self.read_json_body()
+            if path == "/api/trajectory/runs":
+                self.send_json(HTTPStatus.CREATED, {"run": self.create_trajectory_run(payload)})
+                return
+            if path == "/api/object-detection/runs":
+                self.send_json(HTTPStatus.CREATED, {"run": self.create_detection_run(payload)})
+                return
+            self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."})
+        except ApiError as exc:
+            self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+        except subprocess.TimeoutExpired:
+            self.send_json(HTTPStatus.GATEWAY_TIMEOUT, {"error": "The local run exceeded its time limit; no existing result was overwritten."})
+        except Exception as exc:  # pragma: no cover - defensive server boundary
+            self.log_error("local run failed: %s", exc)
+            self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Local run failed. Check the console terminal for details."})
+
+    def do_OPTIONS(self) -> None:  # noqa: N802
+        self.send_response(HTTPStatus.NO_CONTENT)
+        self.send_header("Allow", "GET, POST, OPTIONS")
+        self.end_headers()
+
+    def read_json_body(self) -> dict[str, Any]:
+        content_length = self.headers.get("Content-Length")
+        if content_length is None or not content_length.isdigit():
+            raise ApiError("A JSON request body with Content-Length is required.")
+        size = int(content_length)
+        if size <= 0 or size > MAX_REQUEST_BYTES:
+            raise ApiError(f"Request must be between 1 byte and {MAX_REQUEST_BYTES // (1024 * 1024)} MB.")
+        if "application/json" not in self.headers.get("Content-Type", ""):
+            raise ApiError("Content-Type must be application/json.")
+        try:
+            payload = json.loads(self.rfile.read(size).decode("utf-8"))
+        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise ApiError("Request body is not valid UTF-8 JSON.") from exc
+        if not isinstance(payload, dict):
+            raise ApiError("JSON request body must be an object.")
+        return payload
+
+    def run_command(self, command: list[str], timeout: int) -> None:
+        completed = subprocess.run(command, cwd=self.root, capture_output=True, text=True, timeout=timeout, check=False)
+        if completed.returncode:
+            message = (completed.stderr or completed.stdout or "Unknown script error.").strip().splitlines()[-1]
+            raise ApiError(f"Processing failed: {message[:600]}")
+
+    def create_trajectory_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+        files = payload.get("files")
+        if not isinstance(files, dict):
+            raise ApiError("Trajectory request must contain a files object.")
+        required = {
+            "flight": {".xlsx"},
+            "route": {".kmz"},
+            "restricted": {".geojson"},
+        }
+        decoded = {key: decode_upload(files.get(key), suffixes) for key, suffixes in required.items()}
+        flyable = decode_upload(files["flyable"], {".gzip"}) if files.get("flyable") else None
+        run_id = make_run_id("trajectory")
+        raw_root = self.root / "shared" / "data" / "raw" / "15-trajectory-analysis" / "runs" / run_id
+        paths = {"flight": raw_root / "tracks" / decoded["flight"][0], "route": raw_root / "routes" / decoded["route"][0], "restricted": raw_root / "areas" / decoded["restricted"][0]}
+        for key, path in paths.items():
+            path.parent.mkdir(parents=True, exist_ok=True)
+            path.write_bytes(decoded[key][1])
+        if flyable:
+            flyable_path = raw_root / "areas" / flyable[0]
+            flyable_path.write_bytes(flyable[1])
+        processed = self.root / "shared" / "data" / "processed" / "15-trajectory-analysis" / run_id
+        output_parent = self.root / "shared" / "outputs" / "15-trajectory-analysis" / "runs" / run_id
+        python = self.root / ".venvs" / "15-trajectory-analysis" / "Scripts" / "python.exe"
+        if not python.is_file():
+            raise ApiError("Trajectory virtual environment is unavailable. Run the capability setup first.")
+        with RUN_LOCK:
+            self.run_command([str(python), str(self.root / "capabilities" / "15-trajectory-analysis" / "prepare_real_flight.py"), "--raw-dir", str(raw_root), "--output", str(processed), "--case-id", run_id], 300)
+            self.run_command([str(python), str(self.root / "capabilities" / "15-trajectory-analysis" / "run_trajectory_analysis.py"), "--input", str(processed / f"{run_id}.case.json"), "--output", str(output_parent)], 300)
+        artifact = output_parent / run_id
+        if not (artifact / "run_metadata.json").is_file():
+            raise ApiError("Trajectory script finished without the expected result metadata.")
+        return next(item for item in trajectory_runs(self.root) if item["id"] == run_id)
+
+    def create_detection_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+        uploads = payload.get("images")
+        if not isinstance(uploads, list) or not uploads:
+            raise ApiError("Object-detection request must include at least one image.")
+        if len(uploads) > MAX_IMAGES_PER_RUN:
+            raise ApiError(f"A local run accepts at most {MAX_IMAGES_PER_RUN} images.")
+        decoded = [decode_upload(item, {".jpg", ".jpeg", ".png"}) for item in uploads]
+        if len({name.casefold() for name, _ in decoded}) != len(decoded):
+            raise ApiError("Uploaded image names must be unique within one run.")
+        run_id = make_run_id("detection")
+        raw_root = self.root / "shared" / "data" / "raw" / "01-object-detection" / "runs" / run_id
+        raw_root.mkdir(parents=True, exist_ok=False)
+        for name, content in decoded:
+            (raw_root / name).write_bytes(content)
+        output = self.root / "shared" / "outputs" / "01-object-detection" / "runs" / run_id
+        python = self.root / ".venvs" / "01-object-detection" / "Scripts" / "python.exe"
+        if not python.is_file():
+            raise ApiError("Object-detection virtual environment is unavailable. Run the capability setup first.")
+        with RUN_LOCK:
+            self.run_command([str(python), str(self.root / "capabilities" / "01-object-detection" / "run_detection.py"), "--input", str(raw_root), "--output", str(output)], 1200)
+        if not (output / "run_metadata.json").is_file():
+            raise ApiError("Detection script finished without the expected result metadata.")
+        return next(item for item in detection_runs(self.root) if item["id"] == run_id)
+
+    def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
+        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+        self.send_response(status)
+        self.send_header("Content-Type", "application/json; charset=utf-8")
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
     def translate_path(self, path: str) -> str:
         """Expose only the static UI and artifacts required by the local console."""
         decoded_path = unquote(urlsplit(path).path).lstrip("/")
         requested = PurePosixPath(decoded_path)
-        is_allowed = any(
-            decoded_path == prefix or decoded_path.startswith(f"{prefix}/")
-            for prefix in ALLOWED_PATH_PREFIXES
-        )
+        is_allowed = any(decoded_path == prefix or decoded_path.startswith(f"{prefix}/") for prefix in ALLOWED_PATH_PREFIXES)
         if ".." in requested.parts or not is_allowed:
             return os.fspath(Path(self.directory) / ".console-forbidden")
         if decoded_path == "apps/workbench-console" or decoded_path.startswith("apps/workbench-console/"):
@@ -71,13 +302,10 @@
     app_dir = root / "apps" / "workbench-console"
     if not (root / "shared").is_dir() or not (app_dir / "dist" / "index.html").is_file():
         raise SystemExit(f"Not a GeoAI Workbench root: {root}")
-
-    handler = lambda *handler_args, **handler_kwargs: WorkbenchConsoleHandler(  # noqa: E731
-        *handler_args, directory=os.fspath(root), **handler_kwargs
-    )
+    handler = lambda *handler_args, **handler_kwargs: WorkbenchConsoleHandler(*handler_args, directory=os.fspath(root), **handler_kwargs)  # noqa: E731
     server = ThreadingHTTPServer((args.host, args.port), handler)
     print(f"GeoAI Workbench console: http://{args.host}:{args.port}")
-    print(f"Serving built console and read-only artifacts from: {root}")
+    print("Local runs use fixed capability scripts and create a new run directory.")
     try:
         server.serve_forever()
     except KeyboardInterrupt:

--
Gitblit v1.9.3