# -*- coding: utf-8 -*- """前端对话组件后端 API —— issue #77(Template-Ti 一期)。 父 Issue #11「④ LLM 报警解释 / 交接班 / NL 查询」子任务: 为驾驶舱/移动端提供对话组件所需的后端 API: - `GET /`:返回对话组件页面(chat_widget.html,静态); - `GET /api/health`:服务健康; - `POST /api/chat`:对话接口——按 `scenario` 分发到 Ti 场景 (alarm_explain / shift_handover / nl_query),返回统一 JSON: `{answer, route, answer_id, scenario}`。 纯标准库实现(http.server,无框架依赖),便于联调/内嵌; runner 可注入(测试/替换实现均解耦)。 """ from __future__ import annotations import json import os from typing import Optional from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse #: 组件页面路径(相对本模块) WIDGET_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chat_widget.html") #: 场景 → runner 方法映射 SCENARIO_METHODS = { "alarm_explain": "explain_alarm", "shift_handover": "generate_handover", "nl_query": "query_cockpit", "default": "query_cockpit", } def dispatch(runner, request: dict) -> dict: """按请求分发到场景 runner,返回统一响应。 Args: runner: 提供 explain_alarm / generate_handover / query_cockpit 的对象; request: `{question, scenario, confidence?}`。 Returns: 统一 JSON 字典;场景未知 → 降级 nl_query;异常 → error 响应。 """ question = str(request.get("question", "")).strip() scenario = str(request.get("scenario", "default")) confidence = float(request.get("confidence", 1.0)) if not question: return {"error": "question 不能为空"} method_name = SCENARIO_METHODS.get( scenario, SCENARIO_METHODS["default"]) resolved = scenario if scenario in SCENARIO_METHODS else "default" method = getattr(runner, method_name, None) if method is None: return {"error": f"场景 {scenario!r} 未实现"} try: result = method(question, confidence=confidence) except Exception as exc: # noqa: BLE001 - 统一异常 → error JSON return {"error": f"处理失败: {exc}"} return { "answer": result.answer, "route": getattr(result.route, "target", ""), "answer_id": getattr(result, "answer_id", ""), "scenario": resolved, "needs_human": bool(getattr(result, "needs_human", False)), } class ChatHandler(BaseHTTPRequestHandler): """对话 API HTTP 处理(GET 组件页 / POST /api/chat)。""" #: 场景 runner(由 make_server 注入) runner = None def log_message(self, *args): # 静默访问日志 pass def do_GET(self): path = urlparse(self.path).path if path == "/api/health": self._json(200, {"status": "ok"}) return if path in ("/", "/index.html", "/chat_widget.html"): self._html(WIDGET_PATH) return self._json(404, {"error": "not found"}) def do_POST(self): if urlparse(self.path).path != "/api/chat": self._json(404, {"error": "not found"}) return try: length = int(self.headers.get("Content-Length", 0)) request = json.loads(self.rfile.read(length).decode("utf-8")) except (ValueError, json.JSONDecodeError): self._json(400, {"error": "请求体不是合法 JSON"}) return self._json(200, dispatch(self.runner, request)) # ------------------------------------------------------------------ def _json(self, code: int, payload: dict) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(code) 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 _html(self, path: str) -> None: if not os.path.isfile(path): self._json(404, {"error": "widget 页面缺失"}) return with open(path, "rb") as fh: body = fh.read() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def make_server(host: str, port: int, runner=None) -> ThreadingHTTPServer: """构建对话 API 服务器(runner 可注入,便于测试/替换)。""" ChatHandler.runner = runner return ThreadingHTTPServer((host, port), ChatHandler) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="对话 API 服务(issue #77)") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8080) args = parser.parse_args() print(f"对话 API 服务:http://{args.host}:{args.port}/(组件页)") make_server(args.host, args.port).serve_forever()