diff --git a/web/chat/README.md b/web/chat/README.md new file mode 100644 index 0000000..4da246c --- /dev/null +++ b/web/chat/README.md @@ -0,0 +1,63 @@ +# 前端对话组件(Chat Widget) + +对应 EPIC #11「④ LLM 报警解释 / 交接班 / NL 查询」子任务 **#77**(0.5d): +驾驶舱/移动端**对话组件**及配套后端 API(标准库实现,无框架依赖)。 + +## 文件 + +``` +web/chat/ +├── chat_api.py 对话后端 API(http.server):场景分发 + 统一 JSON +├── chat_widget.html 前端对话组件(内联 HTML/CSS/JS,深色主题对齐驾驶舱) +├── tests/ +│ └── test_chat_api.py 场景分发 / 端点 / 错误处理测试 +└── README.md +``` + +## 快速运行 + +```bash +# 启动对话 API 服务(默认 127.0.0.1:8080;runner 未注入时仅返回健康/页面) +python chat_api.py --host 127.0.0.1 --port 8080 +``` + +浏览器打开 `http://127.0.0.1:8080/` 即见对话组件。 + +## API + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/` | 对话组件页面(chat_widget.html) | +| GET | `/api/health` | 健康检查 | +| POST | `/api/chat` | 对话接口(见下) | + +`POST /api/chat` 请求体: + +```json +{ "question": "氯气流量最近1小时趋势", "scenario": "nl_query", "confidence": 1.0 } +``` + +`scenario`:`alarm_explain`(报警解释)/ `shift_handover`(交接班摘要)/ +`nl_query`(NL 查询,缺省);未知场景降级 `nl_query`。 + +响应(统一 JSON): + +```json +{ "answer": "...", "route": "local", "answer_id": "...", + "scenario": "nl_query", "needs_human": false } +``` + +## 与场景层集成 + +`chat_api.dispatch(runner, request)` 按场景调用 runner 的 +`explain_alarm / generate_handover / query_cockpit`(与 +`templates/ti-cl4/llm-scenarios.TiScenarioRunner` 对接); +runner 可注入(`make_server(host, port, runner=...)`),便于联调与替换实现。 + +## 测试 + +```bash +python -m unittest discover -s tests -p "test_*.py" +``` +覆盖:三场景分发、空 question、未知场景降级、异常 → error JSON、 +GET / 与 /api/health、POST /api/chat、非法 JSON 400、404。 diff --git a/web/chat/chat_api.py b/web/chat/chat_api.py new file mode 100644 index 0000000..6f7bee6 --- /dev/null +++ b/web/chat/chat_api.py @@ -0,0 +1,140 @@ +# -*- 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() diff --git a/web/chat/chat_widget.html b/web/chat/chat_widget.html new file mode 100644 index 0000000..ad66f2c --- /dev/null +++ b/web/chat/chat_widget.html @@ -0,0 +1,78 @@ + + +
+ +