diff --git a/.cache/p2/1.md b/.cache/p2/1.md new file mode 100644 index 0000000..0736ed1 --- /dev/null +++ b/.cache/p2/1.md @@ -0,0 +1,8 @@ +1. 首先阅读 @statements/optimize.md,还有 @README.md 并根据其指引了解跟aide decide有关的所有信息 +2. 对aide decide开始详细设计,我将在完成设计后将所有设计文档打包交给其他开发人员进行开发, + - 设计文档中要有一个用于指导接手人员的入口文档,其中要说明:需要了解哪些信息、需要做什么事 + +对开发文档完善程度的预期是:可以仅靠整套文档和可能含有的其他附属文件的内容(我将会打包aide-program目录),以此为确定目标和完整指导(aide-program/docs/ 目录下的相关文档),开始目标程序的实际开发工作,直到构建产出程序和通过测试运行(运行aide decide -h 及其一系列命令与参数) + +切记,非常重要:开发文档中不允许给出代码片段、实现代码,最多只能出现类型与数据结构伪代码原型、方法签名、业务逻辑流程的plantuml流程图、API约定 + diff --git a/aide-program/aide/__init__.py b/aide-program/aide/__init__.py index 5925810..7028e28 100644 --- a/aide-program/aide/__init__.py +++ b/aide-program/aide/__init__.py @@ -1,5 +1 @@ -""" -Aide 命令行工具包。 - -当前版本聚焦基础环境检测与配置管理,flow/decide 功能尚未实现。 -""" +"""Aide 命令行工具包:环境检测、流程追踪与待定项确认。""" diff --git a/aide-program/aide/core/config.py b/aide-program/aide/core/config.py index d39a7c9..0b4e1d1 100644 --- a/aide-program/aide/core/config.py +++ b/aide-program/aide/core/config.py @@ -42,6 +42,12 @@ path = "requirements.txt" [flow] phases = ["task-optimize", "flow-design", "impl", "verify", "docs", "finish"] + +[decide] +# HTTP 服务起始端口 +port = 3721 +# 超时时间(秒),0 表示不超时 +timeout = 0 """ diff --git a/aide-program/aide/decide/__init__.py b/aide-program/aide/decide/__init__.py new file mode 100644 index 0000000..3981780 --- /dev/null +++ b/aide-program/aide/decide/__init__.py @@ -0,0 +1,9 @@ +"""aide decide 模块入口。""" + +from .cli import cmd_decide, cmd_decide_result, cmd_decide_submit + +__all__ = [ + "cmd_decide", + "cmd_decide_submit", + "cmd_decide_result", +] diff --git a/aide-program/aide/decide/cli.py b/aide-program/aide/decide/cli.py new file mode 100644 index 0000000..9ae6f55 --- /dev/null +++ b/aide-program/aide/decide/cli.py @@ -0,0 +1,99 @@ +"""CLI 入口:解析参数并调度 decide 功能。""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from aide.decide.errors import DecideError +from aide.decide.server import DecideServer +from aide.decide.storage import DecideStorage +from aide.decide.types import DecideInput + + +def cmd_decide(args) -> bool: + """aide decide 统一入口。""" + if getattr(args, "data", None) == "result": + return cmd_decide_result() + if getattr(args, "data", None) is None: + _print_error("缺少参数: 需要传入 JSON 数据或 result") + return False + return cmd_decide_submit(args.data) + + +def cmd_decide_submit(json_data: str) -> bool: + """提交待定项并启动 Web 服务。""" + root = Path.cwd() + storage = DecideStorage(root) + + try: + raw = json.loads(json_data) + except json.JSONDecodeError as exc: + _print_error(f"JSON 解析失败: {exc}", "检查 JSON 格式是否正确") + return False + + try: + decide_input = DecideInput.from_dict(raw) + except DecideError as exc: + _print_error(f"数据验证失败: {exc}", "检查必填字段是否完整") + return False + + try: + storage.save_pending(decide_input) + except DecideError as exc: + _print_error(str(exc)) + return False + + server = DecideServer(root, storage) + return server.start() + + +def cmd_decide_result() -> bool: + """读取最新决策结果并输出 JSON。""" + root = Path.cwd() + storage = DecideStorage(root) + + try: + pending = storage.load_pending() + except DecideError as exc: + _print_error(str(exc)) + return False + + if pending is None: + _print_error("未找到待定项数据", "请先执行 aide decide ''") + return False + + session_id = pending.meta.session_id if pending.meta else None + if not session_id: + _print_error("决策结果已过期", "pending.json 已被更新,请重新执行 aide decide ''") + return False + + try: + result = storage.load_result() + except DecideError as exc: + _print_error(str(exc)) + return False + + if result is None: + has_history = any( + path.is_file() + and path.name.endswith(".json") + and path.name != "pending.json" + for path in storage.decisions_dir.glob("*.json") + ) + if has_history: + _print_error("决策结果已过期", "pending.json 已被更新,请重新执行 aide decide ''") + else: + _print_error("尚无决策结果", "请等待用户在 Web 界面完成操作") + return False + + payload = json.dumps(result.to_dict(), ensure_ascii=False, separators=(",", ":")) + print(payload) + return True + + +def _print_error(message: str, suggestion: str | None = None) -> None: + sys.stderr.write(f"✗ {message}\n") + if suggestion: + sys.stderr.write(f" 建议: {suggestion}\n") diff --git a/aide-program/aide/decide/errors.py b/aide-program/aide/decide/errors.py new file mode 100644 index 0000000..588fd57 --- /dev/null +++ b/aide-program/aide/decide/errors.py @@ -0,0 +1,6 @@ +"""decide 模块内部错误类型。""" + + +class DecideError(RuntimeError): + """用于将内部错误转换为统一的 CLI 输出。""" + diff --git a/aide-program/aide/decide/handlers.py b/aide-program/aide/decide/handlers.py new file mode 100644 index 0000000..d34d364 --- /dev/null +++ b/aide-program/aide/decide/handlers.py @@ -0,0 +1,154 @@ +"""HTTP 请求处理:静态资源与 API。""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Callable +from urllib.parse import urlparse + +from aide.decide.errors import DecideError +from aide.decide.storage import DecideStorage +from aide.decide.types import DecideInput, DecideOutput + +Response = tuple[int, dict[str, str], bytes] + + +class DecideHandlers: + """处理 HTTP 请求,返回 (状态码, 头, 响应体)。""" + + def __init__(self, storage: DecideStorage, web_dir: Path, stop_callback: Callable[[str], None]): + self.storage = storage + self.web_dir = web_dir + self.stop_callback = stop_callback + + def handle(self, method: str, path: str, body: bytes) -> Response: + parsed = urlparse(path) + route = parsed.path + + if method == "OPTIONS": + return 200, self._cors_headers({"Content-Type": "text/plain"}), b"" + + if method == "GET": + if route in {"/", "/index.html"}: + return self.handle_index() + if route == "/style.css": + return self.handle_static("style.css", "text/css; charset=utf-8") + if route == "/app.js": + return self.handle_static("app.js", "application/javascript; charset=utf-8") + if route == "/api/items": + return self.handle_get_items() + return self._not_found() + + if method == "POST" and route == "/api/submit": + return self.handle_submit(body) + + if route in {"/api/items", "/api/submit"}: + return self._method_not_allowed() + return self._not_found() + + def handle_index(self) -> Response: + return self._read_file("index.html", "text/html; charset=utf-8") + + def handle_static(self, filename: str, content_type: str) -> Response: + safe_name = Path(filename).name + if safe_name != filename: + return self._not_found() + return self._read_file(safe_name, content_type) + + def handle_get_items(self) -> Response: + try: + pending = self.storage.load_pending() + except DecideError as exc: + return self._server_error("无法读取待定项数据", str(exc)) + + if pending is None: + return self._server_error("无法读取待定项数据", "文件不存在或格式错误") + + body = json.dumps(pending.to_dict(include_meta=False), ensure_ascii=False).encode("utf-8") + headers = self._cors_headers({"Content-Type": "application/json; charset=utf-8"}) + return 200, headers, body + + def handle_submit(self, body: bytes) -> Response: + try: + pending = self.storage.load_pending() + except DecideError as exc: + return self._server_error("保存失败", str(exc)) + + if pending is None: + return self._server_error("保存失败", "未找到待定项数据") + + try: + payload = json.loads(body.decode("utf-8")) + output = DecideOutput.from_dict(payload) + self._validate_decisions(pending, output) + except DecideError as exc: + return self._bad_request("决策数据无效", str(exc)) + except Exception as exc: + return self._bad_request("决策数据无效", str(exc)) + + try: + self.storage.save_result(output) + except DecideError as exc: + return self._server_error("保存失败", str(exc)) + + # 保存成功后触发关闭 + self.stop_callback("completed") + headers = self._cors_headers({"Content-Type": "application/json; charset=utf-8"}) + body = json.dumps({"success": True, "message": "决策已保存"}, ensure_ascii=False).encode("utf-8") + return 200, headers, body + + def _validate_decisions(self, pending: DecideInput, output: DecideOutput) -> None: + items_by_id = {item.id: item for item in pending.items} + seen: set[int] = set() + for decision in output.decisions: + if decision.id in seen: + raise DecideError(f"待定项 {decision.id} 的决策重复") + seen.add(decision.id) + item = items_by_id.get(decision.id) + if item is None: + raise DecideError(f"存在未知的待定项 {decision.id}") + option_values = {opt.value for opt in item.options} + if decision.chosen not in option_values: + raise DecideError(f"待定项 {decision.id} 的决策值无效: {decision.chosen}") + + missing = [str(item_id) for item_id in items_by_id.keys() if item_id not in seen] + if missing: + raise DecideError(f"缺少待定项 {', '.join(missing)} 的决策") + + def _read_file(self, filename: str, content_type: str) -> Response: + path = self.web_dir / filename + if not path.exists(): + return self._server_error("读取静态资源失败", f"{filename} 不存在") + try: + data = path.read_bytes() + except Exception as exc: + return self._server_error("读取静态资源失败", str(exc)) + headers = self._cors_headers({"Content-Type": content_type}) + return 200, headers, data + + def _not_found(self) -> Response: + headers = self._cors_headers({"Content-Type": "text/plain; charset=utf-8"}) + return 404, headers, b"Not Found" + + def _method_not_allowed(self) -> Response: + headers = self._cors_headers({"Content-Type": "text/plain; charset=utf-8"}) + return 405, headers, b"Method Not Allowed" + + def _bad_request(self, message: str, detail: str) -> Response: + payload = json.dumps({"error": message, "detail": detail}, ensure_ascii=False).encode("utf-8") + headers = self._cors_headers({"Content-Type": "application/json; charset=utf-8"}) + return 400, headers, payload + + def _server_error(self, message: str, detail: str) -> Response: + payload = json.dumps({"error": message, "detail": detail}, ensure_ascii=False).encode("utf-8") + headers = self._cors_headers({"Content-Type": "application/json; charset=utf-8"}) + return 500, headers, payload + + def _cors_headers(self, base: dict[str, str] | None = None) -> dict[str, str]: + headers = base.copy() if base else {} + headers["Access-Control-Allow-Origin"] = "*" + headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + headers["Access-Control-Allow-Headers"] = "Content-Type" + return headers + diff --git a/aide-program/aide/decide/server.py b/aide-program/aide/decide/server.py new file mode 100644 index 0000000..6fa7c0c --- /dev/null +++ b/aide-program/aide/decide/server.py @@ -0,0 +1,202 @@ +"""HTTP 服务器生命周期管理。""" + +from __future__ import annotations + +import json +import socket +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +from aide.core import output +from aide.core.config import ConfigManager +from aide.decide.errors import DecideError +from aide.decide.handlers import DecideHandlers, Response +from aide.decide.storage import DecideStorage + + +class DecideHTTPServer(HTTPServer): + """附带处理器实例的 HTTPServer。""" + + def __init__(self, server_address, RequestHandlerClass, handlers: DecideHandlers): + self.handlers = handlers + super().__init__(server_address, RequestHandlerClass) + + +class DecideServer: + """启动、监听与关闭 HTTP 服务。""" + + def __init__(self, root, storage: DecideStorage): + self.root = root + self.storage = storage + self.port = 3721 + self.timeout = 0 + self.web_dir = root / "aide" / "decide" / "web" + self.should_close = False + self.close_reason: str | None = None + self.httpd: DecideHTTPServer | None = None + + def start(self) -> bool: + try: + config = ConfigManager(self.root).load_config() + start_port = _get_int(config, "decide", "port", default=3721) + self.timeout = _get_int(config, "decide", "timeout", default=0) + end_port = start_port + 9 + available = self._find_available_port(start_port) + if available is None: + output.err(f"无法启动服务: 端口 {start_port}-{end_port} 均被占用") + output.info("建议: 关闭占用端口的程序,或在配置中指定其他端口") + return False + self.port = available + + handlers = DecideHandlers( + storage=self.storage, + web_dir=self.web_dir, + stop_callback=self.stop, + ) + RequestHandler = self._build_request_handler(handlers) + self.httpd = DecideHTTPServer(("127.0.0.1", self.port), RequestHandler, handlers) + self.httpd.timeout = 1.0 + + output.info("Web 服务已启动") + output.info(f"请访问: http://localhost:{self.port}") + output.info("等待用户完成决策...") + + self._serve_forever() + + if self.close_reason == "completed": + output.ok("决策已完成") + return True + if self.close_reason == "timeout": + output.warn("服务超时,已自动关闭") + return True + if self.close_reason == "interrupted": + output.warn("服务已中断") + return True + return True + except DecideError as exc: + output.err(str(exc)) + return False + + def stop(self, reason: str) -> None: + if self.should_close: + return + self.should_close = True + self.close_reason = reason + + def _find_available_port(self, start: int) -> int | None: + attempts = 10 + for offset in range(attempts): + port = start + offset + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", port)) + return port + except OSError: + continue + return None + + def _serve_forever(self) -> None: + if self.httpd is None: + return + deadline = None if self.timeout <= 0 else time.time() + self.timeout + + try: + while not self.should_close: + if deadline is not None and time.time() >= deadline: + self.stop("timeout") + break + self.httpd.handle_request() + except KeyboardInterrupt: + self.stop("interrupted") + finally: + try: + self.httpd.server_close() + except Exception: + pass + + def _build_request_handler(self, handlers: DecideHandlers): + server = self + + class RequestHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + self._dispatch("GET") + + def do_POST(self): + self._dispatch("POST") + + def do_OPTIONS(self): + self._dispatch("OPTIONS") + + def _dispatch(self, method: str) -> None: + length = self.headers.get("Content-Length") + body = b"" + if method == "POST": + try: + content_length = int(length) if length else 0 + except ValueError: + self._send_response( + (400, handlers._cors_headers({"Content-Type": "application/json; charset=utf-8"}), b'{"error":"决策数据无效","detail":"无效的 Content-Length"}') + ) + return + if content_length > 1024 * 1024: + self._send_response( + ( + 413, + handlers._cors_headers({"Content-Type": "application/json; charset=utf-8"}), + b'{"error":"请求体过大","detail":"单次提交限制 1MB"}', + ) + ) + return + body = self.rfile.read(content_length) + + try: + response = handlers.handle(method, self.path, body) + except Exception as exc: # pragma: no cover - 兜底防御 + payload = ( + b'{"error":"服务器内部错误","detail":' + + json.dumps(str(exc), ensure_ascii=False).encode("utf-8") + + b"}" + ) + response = ( + 500, + handlers._cors_headers({"Content-Type": "application/json; charset=utf-8"}), + payload, + ) + self._send_response(response) + + if server.should_close: + # 已由 handlers 设置关闭标志,等待当前请求结束 + pass + + def log_message(self, format: str, *args) -> None: # noqa: A003 + # 静默日志,避免干扰 CLI 输出 + return + + def _send_response(self, response: Response) -> None: + status, headers, body = response + self.send_response(status) + for key, value in headers.items(): + self.send_header(key, value) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + return RequestHandler + + +def _get_int(config: dict, section: str, key: str, default: int) -> int: + try: + section_data = config.get(section, {}) if isinstance(config, dict) else {} + value = section_data.get(key, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + as_int = int(value) + return as_int if as_int >= 0 else default + except Exception: + return default + return default diff --git a/aide-program/aide/decide/storage.py b/aide-program/aide/decide/storage.py new file mode 100644 index 0000000..51242b6 --- /dev/null +++ b/aide-program/aide/decide/storage.py @@ -0,0 +1,117 @@ +"""决策数据的读写与原子化保存。""" + +from __future__ import annotations + +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Any + +from aide.decide.errors import DecideError +from aide.decide.types import DecideInput, DecideOutput, DecisionRecord, MetaInfo +from aide.flow.utils import now_task_id + + +class DecideStorage: + """管理 pending.json 与历史记录文件。""" + + def __init__(self, root: Path): + self.root = root + self.aide_dir = self.root / ".aide" + self.decisions_dir = self.aide_dir / "decisions" + self.pending_path = self.decisions_dir / "pending.json" + + def ensure_ready(self) -> None: + if not self.aide_dir.exists(): + raise DecideError(".aide 目录不存在,请先执行 aide init") + self.decisions_dir.mkdir(parents=True, exist_ok=True) + + def save_pending(self, data: DecideInput) -> str: + """保存待定项数据并生成 session_id。""" + self.ensure_ready() + session_id = now_task_id() + created_at = datetime.now().astimezone().isoformat(timespec="seconds") + meta = MetaInfo(created_at=created_at, session_id=session_id) + payload = data.with_meta(meta) + self._save_atomic(self.pending_path, payload.to_dict()) + return session_id + + def load_pending(self) -> DecideInput | None: + """读取 pending.json,若不存在返回 None。""" + self.ensure_ready() + if not self.pending_path.exists(): + return None + data = self._load_json(self.pending_path) + return DecideInput.from_dict(data) + + def get_session_id(self) -> str | None: + pending = self.load_pending() + if pending is None: + return None + if pending.meta is None: + raise DecideError("pending.json 缺少 _meta.session_id") + return pending.meta.session_id + + def save_result(self, output: DecideOutput) -> None: + """保存用户决策为历史记录。""" + pending = self.load_pending() + if pending is None: + raise DecideError("未找到待定项数据,请先执行 aide decide ''") + if pending.meta is None: + raise DecideError("pending.json 缺少 _meta.session_id") + record = DecisionRecord( + input=pending.without_meta(), + output=output, + completed_at=datetime.now().astimezone().isoformat(timespec="seconds"), + ) + target = self.decisions_dir / f"{pending.meta.session_id}.json" + self._save_atomic(target, record.to_dict()) + + def load_result(self) -> DecideOutput | None: + """读取当前会话的决策结果。""" + session_id = self.get_session_id() + if session_id is None: + return None + record_path = self.decisions_dir / f"{session_id}.json" + if not record_path.exists(): + return None + data = self._load_json(record_path) + record = DecisionRecord.from_dict(data) + return record.output + + def has_pending(self) -> bool: + return self.pending_path.exists() + + def has_result(self) -> bool: + session_id = None + try: + session_id = self.get_session_id() + except DecideError: + return False + if not session_id: + return False + record_path = self.decisions_dir / f"{session_id}.json" + return record_path.exists() + + def _save_atomic(self, path: Path, data: dict[str, Any]) -> None: + payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + tmp_path = path.with_suffix(path.suffix + ".tmp") + try: + tmp_path.write_text(payload, encoding="utf-8") + os.replace(tmp_path, path) + except Exception as exc: + raise DecideError(f"写入 {path.name} 失败: {exc}") + + def _load_json(self, path: Path) -> dict[str, Any]: + try: + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + if not isinstance(data, dict): + raise DecideError(f"{path.name} 顶层必须为对象") + return data + except DecideError: + raise + except Exception as exc: + raise DecideError(f"无法解析 {path.name}: {exc}") + diff --git a/aide-program/aide/decide/types.py b/aide-program/aide/decide/types.py new file mode 100644 index 0000000..c5a0cba --- /dev/null +++ b/aide-program/aide/decide/types.py @@ -0,0 +1,323 @@ +"""decide 模块的数据结构与校验。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from aide.decide.errors import DecideError + + +@dataclass(frozen=True) +class Location: + file: str + start: int + end: int + + @staticmethod + def from_dict(data: Any, path: str) -> "Location": + if not isinstance(data, dict): + raise DecideError(f"{path} 必须为对象") + file = _require_str(data.get("file"), f"{path}.file") + start = _require_int(data.get("start"), f"{path}.start") + end = _require_int(data.get("end"), f"{path}.end") + return Location(file=file, start=start, end=end) + + def to_dict(self) -> dict[str, Any]: + return {"file": self.file, "start": self.start, "end": self.end} + + +@dataclass(frozen=True) +class Option: + value: str + label: str + score: float | None = None + pros: list[str] | None = None + cons: list[str] | None = None + + @staticmethod + def from_dict(data: Any, path: str, used_values: set[str]) -> "Option": + if not isinstance(data, dict): + raise DecideError(f"{path} 必须为对象") + raw_value = data.get("value") + value = _require_str(raw_value, f"{path}.value") + if value in used_values: + raise DecideError(f"{path}.value 在当前待定项中必须唯一,重复值: {value}") + used_values.add(value) + + label = _require_str(data.get("label"), f"{path}.label") + + score = _optional_score(data.get("score"), f"{path}.score") + pros = _optional_str_list(data.get("pros"), f"{path}.pros") + cons = _optional_str_list(data.get("cons"), f"{path}.cons") + return Option(value=value, label=label, score=score, pros=pros, cons=cons) + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = {"value": self.value, "label": self.label} + if self.score is not None: + data["score"] = self.score + if self.pros is not None: + data["pros"] = self.pros + if self.cons is not None: + data["cons"] = self.cons + return data + + +@dataclass(frozen=True) +class DecideItem: + id: int + title: str + options: list[Option] + location: Location | None = None + context: str | None = None + recommend: str | None = None + + @staticmethod + def from_dict(data: Any, index: int) -> "DecideItem": + path = f"items[{index}]" + if not isinstance(data, dict): + raise DecideError(f"{path} 必须为对象") + item_id = _require_positive_int(data.get("id"), f"{path}.id") + title = _require_str(data.get("title"), f"{path}.title") + + raw_options = data.get("options") + if not isinstance(raw_options, list) or not raw_options: + raise DecideError(f"{path}.options 必须为至少 1 个元素的数组") + if len(raw_options) < 2: + raise DecideError(f"{path}.options 至少需要 2 个选项,当前只有 {len(raw_options)} 个") + options: list[Option] = [] + used_values: set[str] = set() + for opt_index, raw_opt in enumerate(raw_options): + options.append( + Option.from_dict(raw_opt, f"{path}.options[{opt_index}]", used_values) + ) + + recommend = data.get("recommend") + if recommend is not None: + recommend = _require_str(recommend, f"{path}.recommend") + if recommend not in {opt.value for opt in options}: + raise DecideError(f'{path}.recommend 值 "{recommend}" 不在 options 中') + + location = None + if "location" in data and data.get("location") is not None: + location = Location.from_dict(data["location"], f"{path}.location") + + context = None + if "context" in data and data.get("context") is not None: + context = _require_str(data.get("context"), f"{path}.context", allow_empty=True) + + return DecideItem( + id=item_id, + title=title, + options=options, + location=location, + context=context, + recommend=recommend, + ) + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "id": self.id, + "title": self.title, + "options": [opt.to_dict() for opt in self.options], + } + if self.location is not None: + data["location"] = self.location.to_dict() + if self.context is not None: + data["context"] = self.context + if self.recommend is not None: + data["recommend"] = self.recommend + return data + + +@dataclass(frozen=True) +class MetaInfo: + created_at: str + session_id: str + + @staticmethod + def from_dict(data: Any) -> "MetaInfo": + if not isinstance(data, dict): + raise DecideError("_meta 必须为对象") + created_at = _require_str(data.get("created_at"), "_meta.created_at") + session_id = _require_str(data.get("session_id"), "_meta.session_id") + return MetaInfo(created_at=created_at, session_id=session_id) + + def to_dict(self) -> dict[str, Any]: + return {"created_at": self.created_at, "session_id": self.session_id} + + +@dataclass(frozen=True) +class DecideInput: + task: str + source: str + items: list[DecideItem] + meta: MetaInfo | None = None + + @staticmethod + def from_dict(data: Any) -> "DecideInput": + if not isinstance(data, dict): + raise DecideError("输入数据必须为对象") + task = _require_str(data.get("task"), "task") + source = _require_str(data.get("source"), "source") + + raw_items = data.get("items") + if not isinstance(raw_items, list) or not raw_items: + raise DecideError("items 必须为至少 1 个元素的数组") + + items: list[DecideItem] = [] + used_ids: set[int] = set() + for idx, raw_item in enumerate(raw_items): + item = DecideItem.from_dict(raw_item, idx) + if item.id in used_ids: + raise DecideError(f"items[{idx}].id 与已有待定项重复: {item.id}") + used_ids.add(item.id) + items.append(item) + + meta = None + if "_meta" in data and data.get("_meta") is not None: + meta = MetaInfo.from_dict(data["_meta"]) + + return DecideInput(task=task, source=source, items=items, meta=meta) + + def to_dict(self, include_meta: bool = True) -> dict[str, Any]: + data: dict[str, Any] = { + "task": self.task, + "source": self.source, + "items": [item.to_dict() for item in self.items], + } + if include_meta and self.meta is not None: + data["_meta"] = self.meta.to_dict() + return data + + def with_meta(self, meta: MetaInfo) -> "DecideInput": + return DecideInput( + task=self.task, + source=self.source, + items=self.items, + meta=meta, + ) + + def without_meta(self) -> "DecideInput": + return DecideInput(task=self.task, source=self.source, items=self.items, meta=None) + + +@dataclass(frozen=True) +class Decision: + id: int + chosen: str + note: str | None = None + + @staticmethod + def from_dict(data: Any, index: int) -> "Decision": + path = f"decisions[{index}]" + if not isinstance(data, dict): + raise DecideError(f"{path} 必须为对象") + item_id = _require_positive_int(data.get("id"), f"{path}.id") + chosen = _require_str(data.get("chosen"), f"{path}.chosen") + note = None + if "note" in data and data.get("note") is not None: + note = _require_str(data.get("note"), f"{path}.note", allow_empty=True) + return Decision(id=item_id, chosen=chosen, note=note) + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = {"id": self.id, "chosen": self.chosen} + if self.note is not None: + data["note"] = self.note + return data + + +@dataclass(frozen=True) +class DecideOutput: + decisions: list[Decision] + + @staticmethod + def from_dict(data: Any) -> "DecideOutput": + if not isinstance(data, dict): + raise DecideError("输出数据必须为对象") + raw_decisions = data.get("decisions") + if not isinstance(raw_decisions, list) or not raw_decisions: + raise DecideError("decisions 必须为至少 1 个元素的数组") + decisions: list[Decision] = [] + for idx, raw in enumerate(raw_decisions): + decisions.append(Decision.from_dict(raw, idx)) + return DecideOutput(decisions=decisions) + + def to_dict(self) -> dict[str, Any]: + return {"decisions": [d.to_dict() for d in self.decisions]} + + +@dataclass(frozen=True) +class DecisionRecord: + input: DecideInput + output: DecideOutput + completed_at: str + + @staticmethod + def from_dict(data: Any) -> "DecisionRecord": + if not isinstance(data, dict): + raise DecideError("决策记录必须为对象") + input_data = data.get("input") + output_data = data.get("output") + completed_at = _require_str(data.get("completed_at"), "completed_at") + if input_data is None: + raise DecideError("决策记录缺少 input") + if output_data is None: + raise DecideError("决策记录缺少 output") + return DecisionRecord( + input=DecideInput.from_dict(input_data), + output=DecideOutput.from_dict(output_data), + completed_at=completed_at, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "input": self.input.to_dict(include_meta=False), + "output": self.output.to_dict(), + "completed_at": self.completed_at, + } + + +def _require_str(value: Any, path: str, *, allow_empty: bool = False) -> str: + if not isinstance(value, str): + raise DecideError(f"{path} 必须为字符串") + if not allow_empty and not value.strip(): + raise DecideError(f"{path} 不能为空") + return value + + +def _require_int(value: Any, path: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise DecideError(f"{path} 必须为整数") + return value + + +def _require_positive_int(value: Any, path: str) -> int: + number = _require_int(value, path) + if number <= 0: + raise DecideError(f"{path} 必须为正整数") + return number + + +def _optional_score(value: Any, path: str) -> float | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise DecideError(f"{path} 必须为数字") + if value < 0 or value > 100: + raise DecideError(f"{path} 必须在 0-100 范围内") + return float(value) + + +def _optional_str_list(value: Any, path: str) -> list[str] | None: + if value is None: + return None + if not isinstance(value, list): + raise DecideError(f"{path} 必须为字符串数组") + normalized: list[str] = [] + for idx, item in enumerate(value): + if not isinstance(item, str): + raise DecideError(f"{path}[{idx}] 必须为字符串") + normalized.append(item) + return normalized + diff --git a/aide-program/aide/decide/web/app.js b/aide-program/aide/decide/web/app.js new file mode 100644 index 0000000..d7ed7d3 --- /dev/null +++ b/aide-program/aide/decide/web/app.js @@ -0,0 +1,313 @@ +const AppState = { + task: "", + source: "", + items: [], + decisions: {}, + notes: {}, + isSubmitting: false, +}; + +async function init() { + try { + const data = await loadItems(); + AppState.task = data.task || ""; + AppState.source = data.source || ""; + AppState.items = Array.isArray(data.items) ? data.items : []; + + renderItems(data); + bindEvents(); + } catch (error) { + showError("无法加载待定项数据,请刷新页面重试"); + } +} + +async function loadItems() { + const response = await fetch("/api/items"); + if (!response.ok) { + throw new Error("加载失败"); + } + return response.json(); +} + +function renderItems(data) { + const container = document.getElementById("items-container"); + container.innerHTML = ""; + + document.getElementById("task-name").textContent = data.task || "-"; + document.getElementById("task-source").textContent = data.source || "-"; + + data.items.forEach((item) => { + container.appendChild(renderItemCard(item)); + }); + + updateProgress(); + updateSubmitButton(); +} + +function renderItemCard(item) { + const card = document.createElement("article"); + card.className = "item-card"; + card.dataset.itemId = String(item.id); + + const header = document.createElement("header"); + header.className = "item-header"; + const title = document.createElement("h2"); + title.className = "item-title"; + const number = document.createElement("span"); + number.className = "item-number"; + number.textContent = `${item.id}.`; + const titleText = document.createElement("span"); + titleText.textContent = item.title || "待定项"; + title.appendChild(number); + title.appendChild(titleText); + + const recommend = document.createElement("span"); + recommend.className = "recommend-badge"; + if (item.recommend) { + recommend.textContent = `推荐: ${item.recommend}`; + } else { + recommend.hidden = true; + } + + header.appendChild(title); + header.appendChild(recommend); + card.appendChild(header); + + if (item.context) { + const context = document.createElement("div"); + context.className = "item-context"; + context.textContent = item.context; + card.appendChild(context); + } + + if (item.location && item.location.file) { + const location = document.createElement("div"); + location.className = "item-location"; + location.textContent = `位置: ${item.location.file}:${item.location.start}-${item.location.end}`; + card.appendChild(location); + } + + const options = renderOptions(item); + card.appendChild(options); + + const noteWrap = document.createElement("div"); + noteWrap.className = "item-note"; + const noteLabel = document.createElement("label"); + noteLabel.setAttribute("for", `note-${item.id}`); + noteLabel.textContent = "备注(可选):"; + const textarea = document.createElement("textarea"); + textarea.id = `note-${item.id}`; + textarea.placeholder = "添加补充说明..."; + noteWrap.appendChild(noteLabel); + noteWrap.appendChild(textarea); + card.appendChild(noteWrap); + + return card; +} + +function renderOptions(item) { + const optionsWrap = document.createElement("div"); + optionsWrap.className = "options-list"; + const current = AppState.decisions[item.id]; + + item.options.forEach((option) => { + const label = document.createElement("label"); + label.className = "option-item"; + label.dataset.value = option.value; + label.dataset.recommended = option.value === item.recommend ? "true" : "false"; + + const input = document.createElement("input"); + input.type = "radio"; + input.name = `item-${item.id}`; + input.value = option.value; + if (current === option.value) { + input.checked = true; + label.classList.add("selected"); + } + + const content = document.createElement("div"); + content.className = "option-content"; + + const header = document.createElement("div"); + header.className = "option-header"; + const optLabel = document.createElement("span"); + optLabel.className = "option-label"; + optLabel.textContent = option.label || option.value; + header.appendChild(optLabel); + + if (option.score !== undefined && option.score !== null) { + const score = document.createElement("span"); + score.className = "option-score"; + score.textContent = `评分: ${option.score}`; + header.appendChild(score); + } + + content.appendChild(header); + + const hasPros = Array.isArray(option.pros) && option.pros.length > 0; + const hasCons = Array.isArray(option.cons) && option.cons.length > 0; + if (hasPros || hasCons) { + const details = document.createElement("div"); + details.className = "option-details"; + if (hasPros) { + const pros = document.createElement("div"); + pros.className = "option-pros"; + pros.innerHTML = `优点: ${option.pros.join(",")}`; + details.appendChild(pros); + } + if (hasCons) { + const cons = document.createElement("div"); + cons.className = "option-cons"; + cons.innerHTML = `缺点: ${option.cons.join(",")}`; + details.appendChild(cons); + } + content.appendChild(details); + } + + label.appendChild(input); + label.appendChild(content); + optionsWrap.appendChild(label); + }); + + return optionsWrap; +} + +function bindEvents() { + const container = document.getElementById("items-container"); + container.addEventListener("change", (event) => { + const target = event.target; + if (target && target.type === "radio") { + const itemId = parseInt(target.name.replace("item-", ""), 10); + handleOptionSelect(itemId, target.value); + } + }); + + container.addEventListener("input", (event) => { + const target = event.target; + if (target && target.tagName === "TEXTAREA") { + const itemId = parseInt(target.id.replace("note-", ""), 10); + handleNoteInput(itemId, target.value); + } + }); + + document.getElementById("submit-btn").addEventListener("click", submitDecisions); +} + +function handleOptionSelect(itemId, value) { + AppState.decisions[itemId] = value; + const card = document.querySelector(`.item-card[data-item-id="${itemId}"]`); + if (card) { + const options = card.querySelectorAll(".option-item"); + options.forEach((opt) => { + if (opt.dataset.value === value) { + opt.classList.add("selected"); + } else { + opt.classList.remove("selected"); + } + }); + if (AppState.decisions[itemId]) { + card.classList.add("completed"); + } + } + updateProgress(); + updateSubmitButton(); +} + +function handleNoteInput(itemId, note) { + AppState.notes[itemId] = note; +} + +function updateProgress() { + const total = AppState.items.length; + const completed = Object.keys(AppState.decisions).length; + const text = document.getElementById("progress-text"); + text.textContent = `已完成 ${completed}/${total} 项`; +} + +function canSubmit() { + return ( + AppState.items.length > 0 && + Object.keys(AppState.decisions).length === AppState.items.length && + !AppState.isSubmitting + ); +} + +function updateSubmitButton() { + const button = document.getElementById("submit-btn"); + const allowed = canSubmit(); + button.disabled = !allowed; + button.setAttribute("aria-disabled", allowed ? "false" : "true"); + button.textContent = AppState.isSubmitting ? "提交中..." : "提交决策"; +} + +function buildDecisionData() { + const decisions = AppState.items.map((item) => { + const note = AppState.notes[item.id]; + const trimmed = typeof note === "string" ? note.trim() : ""; + const payload = { id: item.id, chosen: AppState.decisions[item.id] }; + if (trimmed) { + payload.note = trimmed; + } + return payload; + }); + return { decisions }; +} + +async function submitDecisions() { + if (!canSubmit()) { + showError("请先完成所有待定项的选择"); + return; + } + AppState.isSubmitting = true; + updateSubmitButton(); + + try { + const response = await fetch("/api/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildDecisionData()), + }); + + if (!response.ok) { + let detail = "提交失败"; + try { + const error = await response.json(); + detail = error.detail || detail; + } catch (e) { + // ignore + } + throw new Error(detail); + } + + showSuccess(); + } catch (error) { + AppState.isSubmitting = false; + updateSubmitButton(); + showError(`提交失败: ${error.message}`); + } +} + +function showSuccess() { + const overlay = document.getElementById("success-overlay"); + overlay.hidden = false; + AppState.isSubmitting = false; + const button = document.getElementById("submit-btn"); + button.disabled = true; + button.setAttribute("aria-disabled", "true"); +} + +let errorTimer = null; +function showError(message) { + const toast = document.getElementById("error-toast"); + const text = document.getElementById("error-message"); + text.textContent = message; + toast.hidden = false; + if (errorTimer) { + clearTimeout(errorTimer); + } + errorTimer = setTimeout(() => { + toast.hidden = true; + }, 4000); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/aide-program/aide/decide/web/index.html b/aide-program/aide/decide/web/index.html new file mode 100644 index 0000000..10b87d7 --- /dev/null +++ b/aide-program/aide/decide/web/index.html @@ -0,0 +1,49 @@ + + + + + + Aide 待定项确认 + + + +
+
+
+

Aide 待定项确认

+

请根据任务信息完成待定项选择

+
+
+

任务:

+

来源:

+
+
+ +
+ +
+
+ 已完成 0/0 项 +
+ + 请先完成所有待定项的选择 +
+
+ + + + + + + + diff --git a/aide-program/aide/decide/web/style.css b/aide-program/aide/decide/web/style.css new file mode 100644 index 0000000..7bdef8d --- /dev/null +++ b/aide-program/aide/decide/web/style.css @@ -0,0 +1,336 @@ +:root { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-success: #16a34a; + --color-warning: #ca8a04; + --color-error: #dc2626; + --color-text: #1f2937; + --color-text-secondary: #6b7280; + --color-border: #e5e7eb; + --color-background: #f9fafb; + --color-card: #ffffff; + + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-size-sm: 14px; + --font-size-md: 16px; + --font-size-lg: 18px; + --font-size-xl: 24px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: var(--font-family); + background: radial-gradient(circle at 20% 20%, #f1f5f9, #f9fafb 50%); + color: var(--color-text); +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: var(--spacing-xl) var(--spacing-lg); +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + box-shadow: var(--shadow-md); +} + +.title h1 { + margin: 0 0 var(--spacing-xs); + font-size: 28px; +} + +.title .subtitle { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--font-size-md); +} + +.task-info p { + margin: var(--spacing-xs) 0; + color: var(--color-text-secondary); +} + +.items-container { + margin: var(--spacing-xl) 0; + display: grid; + gap: var(--spacing-lg); +} + +.item-card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + box-shadow: var(--shadow-sm); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.item-card.completed { + border-color: var(--color-primary); + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.08); +} + +.item-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-md); +} + +.item-title { + margin: 0; + font-size: var(--font-size-lg); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.item-number { + color: var(--color-primary); + font-weight: 700; +} + +.recommend-badge { + font-size: var(--font-size-sm); + color: var(--color-primary); + background: rgba(37, 99, 235, 0.1); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-md); +} + +.item-context, +.item-location { + margin-top: var(--spacing-sm); + color: var(--color-text-secondary); + line-height: 1.6; +} + +.options-list { + margin-top: var(--spacing-md); + display: grid; + gap: var(--spacing-sm); +} + +.option-item { + display: flex; + align-items: flex-start; + gap: var(--spacing-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--spacing-md); + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.option-item input { + margin-top: 4px; +} + +.option-item.selected { + border-color: var(--color-primary); + background: rgba(37, 99, 235, 0.05); +} + +.option-item[data-recommended="true"] { + border-style: dashed; +} + +.option-content { + width: 100%; +} + +.option-header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + color: var(--color-text); +} + +.option-score { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.option-details { + display: flex; + gap: var(--spacing-lg); + margin-top: var(--spacing-xs); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.option-details strong { + color: var(--color-text); + font-weight: 600; +} + +.item-note { + margin-top: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.item-note label { + font-weight: 600; + color: var(--color-text); +} + +.item-note textarea { + width: 100%; + min-height: 80px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--spacing-sm); + font-size: var(--font-size-md); + resize: vertical; + font-family: var(--font-family); +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-md) var(--spacing-lg); + box-shadow: var(--shadow-sm); +} + +.progress { + color: var(--color-text-secondary); + font-weight: 600; +} + +.submit-btn { + background: var(--color-primary); + color: #fff; + border: none; + border-radius: var(--radius-md); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-size-md); + cursor: pointer; + transition: background-color 0.2s ease, box-shadow 0.2s ease; + min-width: 140px; +} + +.submit-btn:hover:not(:disabled) { + background: var(--color-primary-hover); + box-shadow: var(--shadow-md); +} + +.submit-btn:disabled, +.submit-btn[aria-disabled="true"] { + background: var(--color-border); + color: var(--color-text-secondary); + cursor: not-allowed; + box-shadow: none; +} + +.success-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(2px); +} + +.success-message { + background: var(--color-card); + padding: var(--spacing-xl); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + text-align: center; +} + +.success-message h2 { + margin: 0 0 var(--spacing-sm); + color: var(--color-success); +} + +.error-toast { + position: fixed; + bottom: var(--spacing-lg); + right: var(--spacing-lg); + display: flex; + align-items: center; + gap: var(--spacing-sm); + background: #fff; + border: 1px solid var(--color-error); + color: var(--color-error); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + min-width: 220px; +} + +.error-icon { + font-weight: 700; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 640px) { + .container { + padding: var(--spacing-lg); + } + + .header { + flex-direction: column; + gap: var(--spacing-md); + } + + .option-details { + flex-direction: column; + } + + .footer { + flex-direction: column; + align-items: flex-start; + } + + .submit-btn { + width: 100%; + text-align: center; + } +} diff --git a/aide-program/aide/main.py b/aide-program/aide/main.py index b00f390..9a97e2f 100644 --- a/aide-program/aide/main.py +++ b/aide-program/aide/main.py @@ -9,6 +9,7 @@ from typing import Any from aide.core import output from aide.core.config import ConfigManager +from aide.decide import cmd_decide from aide.env.manager import EnvManager from aide.flow.tracker import FlowTracker @@ -129,6 +130,11 @@ def build_parser() -> argparse.ArgumentParser: flow_parser.set_defaults(func=handle_flow_help) + # aide decide + decide_parser = subparsers.add_parser("decide", help="待定项确认与决策记录") + decide_parser.add_argument("data", help="待定项 JSON 数据或 result") + decide_parser.set_defaults(func=handle_decide) + parser.add_argument("--version", action="version", version="aide dev") return parser @@ -282,6 +288,10 @@ def handle_flow_error(args: argparse.Namespace) -> bool: return tracker.error(args.description) +def handle_decide(args: argparse.Namespace) -> bool: + return cmd_decide(args) + + def _parse_value(raw: str) -> Any: lowered = raw.lower() if lowered in {"true", "false"}: diff --git a/aide-program/docs/formats/config.md b/aide-program/docs/formats/config.md index 81d41e6..79037f6 100644 --- a/aide-program/docs/formats/config.md +++ b/aide-program/docs/formats/config.md @@ -52,6 +52,11 @@ path = "requirements.txt" # flow: 流程配置 [flow] phases = ["task-optimize", "flow-design", "impl", "verify", "docs", "finish"] + +# decide: 待定项确认服务配置 +[decide] +port = 3721 +timeout = 0 ``` --- @@ -156,6 +161,17 @@ manager = "pnpm" - `aide flow` 校验环节跳转合法性 - 定义有效的环节名称 +### 4.5 [decide] 待定项确认配置 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `port` | int | `3721` | Web 服务起始端口,端口被占用时向后探测最多 10 次 | +| `timeout` | int | `0` | 服务超时时间(秒),0 表示不启用超时 | + +**使用场景**: +- `aide decide ''` 读取 `port` 作为起始端口 +- `aide decide ''` 读取 `timeout` 控制服务最长等待时间 + --- ## 五、配置读写接口 diff --git a/statements/1.md b/statements/1.md index 0736ed1..e69de29 100644 --- a/statements/1.md +++ b/statements/1.md @@ -1,8 +0,0 @@ -1. 首先阅读 @statements/optimize.md,还有 @README.md 并根据其指引了解跟aide decide有关的所有信息 -2. 对aide decide开始详细设计,我将在完成设计后将所有设计文档打包交给其他开发人员进行开发, - - 设计文档中要有一个用于指导接手人员的入口文档,其中要说明:需要了解哪些信息、需要做什么事 - -对开发文档完善程度的预期是:可以仅靠整套文档和可能含有的其他附属文件的内容(我将会打包aide-program目录),以此为确定目标和完整指导(aide-program/docs/ 目录下的相关文档),开始目标程序的实际开发工作,直到构建产出程序和通过测试运行(运行aide decide -h 及其一系列命令与参数) - -切记,非常重要:开发文档中不允许给出代码片段、实现代码,最多只能出现类型与数据结构伪代码原型、方法签名、业务逻辑流程的plantuml流程图、API约定 -