Files
iAOP/web/chat/chat_api.py
T

141 lines
5.1 KiB
Python
Raw Normal View History

# -*- 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()