From 8108a316b213c7ed6ed682e2a3ee7c9990a90863 Mon Sep 17 00:00:00 2001 From: bot_dev2 Date: Wed, 5 Aug 2026 04:40:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20issue=20#77=20[Ti-?= =?UTF-8?q?1]=20=E5=89=8D=E7=AB=AF=E5=AF=B9=E8=AF=9D=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/chat/README.md | 63 ++++++++++++++ web/chat/chat_api.py | 140 ++++++++++++++++++++++++++++++++ web/chat/chat_widget.html | 78 ++++++++++++++++++ web/chat/tests/test_chat_api.py | 136 +++++++++++++++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 web/chat/README.md create mode 100644 web/chat/chat_api.py create mode 100644 web/chat/chat_widget.html create mode 100644 web/chat/tests/test_chat_api.py 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 @@ + + + + +iAOP 对话助手 + + + +
+

iAOP 对话助手(Template-Ti 一期)

+
+
+ + + +
+
+ + + diff --git a/web/chat/tests/test_chat_api.py b/web/chat/tests/test_chat_api.py new file mode 100644 index 0000000..139da4a --- /dev/null +++ b/web/chat/tests/test_chat_api.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""对话 API 测试(issue #77)。 + +覆盖: +1. dispatch 场景分发:alarm_explain / shift_handover / nl_query / default; +2. 请求校验:空 question、非法场景、异常处理 → error JSON; +3. HTTP 端点冒烟(mock runner):GET /、/api/health、POST /api/chat、404。 +""" +import json +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from chat_api import ChatHandler, dispatch # noqa: E402 + + +class _FakeResult: + def __init__(self, answer="ok", route_target="local", answer_id="a1", + needs_human=False): + self.answer = answer + self.route = mock.MagicMock() + self.route.target = route_target + self.answer_id = answer_id + self.needs_human = needs_human + + +class _FakeRunner: + """模拟 TiScenarioRunner 三场景方法。""" + + def explain_alarm(self, q, confidence=1.0): + return _FakeResult(answer=f"报警解释: {q}", route_target="local") + + def generate_handover(self, q, confidence=1.0): + return _FakeResult(answer=f"交接班: {q}", route_target="local") + + def query_cockpit(self, q, confidence=1.0): + return _FakeResult(answer=f"查询: {q}", route_target="local") + + +class TestDispatch(unittest.TestCase): + """场景分发与统一响应。""" + + def setUp(self): + self.runner = _FakeRunner() + + def test_nl_query_default(self): + resp = dispatch(self.runner, {"question": "氯气流量趋势"}) + self.assertEqual(resp["scenario"], "default") + self.assertTrue(resp["answer"].startswith("查询")) + + def test_alarm_explain(self): + resp = dispatch(self.runner, {"question": "炉温报警", + "scenario": "alarm_explain"}) + self.assertTrue(resp["answer"].startswith("报警解释")) + + def test_shift_handover(self): + resp = dispatch(self.runner, {"question": "甲班交接", + "scenario": "shift_handover"}) + self.assertTrue(resp["answer"].startswith("交接班")) + + def test_empty_question(self): + resp = dispatch(self.runner, {"question": " "}) + self.assertIn("error", resp) + + def test_unknown_scenario_falls_back(self): + resp = dispatch(self.runner, {"question": "x", + "scenario": "no_such"}) + self.assertEqual(resp["scenario"], "default") + + def test_runner_exception_to_error(self): + bad = mock.MagicMock() + bad.query_cockpit.side_effect = RuntimeError("boom") + resp = dispatch(bad, {"question": "x"}) + self.assertIn("处理失败", resp["error"]) + + +class TestHTTPEndpoints(unittest.TestCase): + """HTTP 端点冒烟(直接用 handler 方法 + mock runner)。""" + + def setUp(self): + self.handler = ChatHandler.__new__(ChatHandler) + self.handler.runner = _FakeRunner() + self.handler.wfile = mock.MagicMock() + self.handler.send_response = mock.MagicMock() + self.handler.send_header = mock.MagicMock() + self.handler.end_headers = mock.MagicMock() + + def _json_response(self, code, payload): + return json.dumps(payload, ensure_ascii=False).encode("utf-8") + + def test_get_health(self): + self.handler.path = "/api/health" + self.handler.do_GET() + sent = self.handler.wfile.write.call_args[0][0] + self.assertIn(b'"status": "ok"', sent) + + def test_get_widget(self): + self.handler.path = "/" + with mock.patch("chat_api.WIDGET_PATH", + os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "chat_widget.html")): + self.handler.do_GET() + sent = self.handler.wfile.write.call_args[0][0] + self.assertIn(b"iAOP", sent) + + def test_post_chat(self): + self.handler.path = "/api/chat" + self.handler.headers = {"Content-Length": str( + len('{"question":"炉温报警","scenario":"alarm_explain"}'))} + self.handler.rfile = mock.MagicMock() + self.handler.rfile.read.return_value = ( + '{"question":"炉温报警","scenario":"alarm_explain"}'.encode()) + self.handler.do_POST() + sent = self.handler.wfile.write.call_args[0][0] + self.assertIn("报警解释", json.loads(sent.decode())["answer"]) + + def test_post_bad_json(self): + self.handler.path = "/api/chat" + self.handler.headers = {"Content-Length": "3"} + self.handler.rfile = mock.MagicMock() + self.handler.rfile.read.return_value = b"not json" + self.handler.do_POST() + sent = self.handler.wfile.write.call_args[0][0] + self.assertIn("合法 JSON", sent.decode()) + + def test_404(self): + self.handler.path = "/nope" + self.handler.do_GET() + sent = self.handler.wfile.write.call_args[0][0] + self.assertIn(b"not found", sent) + + +if __name__ == "__main__": + unittest.main()