Bläddra i källkod

refactor(api)!: 删除 /api/chat/only

它与 /chat 除了计费流水上的 kind 标签外行为完全相同,而那个标签没有任何
消费方(不聚合、不分账、不影响额度),1.5 前端也从未调用过它。

1.5 为这份重复付过可查的代价:
- 计费 bug:docs/实施记录-B3-P3.md 的 R7-#24/P1-11「/chat vs /chat/only
  标题计费口径不一致」
- 多付一份安全验证:tests/l4_auth_runtime.py 的 L4-1b-c2(thread_id 越权)
  与 L4-8c(413 体积闸)只为在第二个入口上再证一遍同样的阀门
- 多养一个专用探针:tests/probe_b345_dedup_diff.py 的唯一职责是证明两者相同
- B3-45 自己也认定了 95% 重复并抽了公共管线,只是出于兼容留了端点

改动:
- 删路由与 chat_only 这个 kind;kind 收敛为 chat / resume / scheduled
- _run_chat_pipeline 去掉 billing_kind 参数(单调用方传常量是噪音),
  在 StreamRequest 处显式写 kind="chat" 并注释取值集合
- 保留 /chat/resume 与 /chat/interrupt:它们承载不同能力,不是同一能力的别名
- README 删掉该行并加一段说明;AGENTS.md §六 新增「为什么没有 /chat/only」
  记录理由与 1.5 的代价,§四注明这是契约「只做加法」纪律的唯一次例外
- 新增 6 条路由守卫测试(断言真实服务路由面,含对话族路径钉死),
  http_e2e 补 2 条(该端点 404、同族路由未连带丢失)

验证:pytest 149/149 全绿;http_e2e 51/51 全过(含 /chat/only → 404);
实际路由面确认为 /chat、/chat/resume、/chat/interrupt、/chat/history/{id}。

BREAKING CHANGE: 对外 API 面收缩一处。若 1.5 之外确有调用方在使用
/api/chat/only,需改为调用 /api/chat(行为完全一致)。
deep_agent 1 månad sedan
förälder
incheckning
153acc8611
5 ändrade filer med 151 tillägg och 36 borttagningar
  1. 33 0
      AGENTS.md
  2. 5 1
      README.md
  3. 27 35
      api/chat_routes.py
  4. 11 0
      scripts/http_e2e.py
  5. 75 0
      tests/test_core_units.py

+ 33 - 0
AGENTS.md

@@ -220,6 +220,8 @@ main.py ──> api/ ──> agents/ ──> tools/
   手拼 `data: ` 字符串——字段改名漏掉一处,前端就静默渲染失败。
 - **契约只做加法**:新增字段安全(旧前端忽略未知字段),改名或删除不安全
   (静默破坏渲染)。要改已有字段名,先确认 1.5 前端也一起改。
+  **唯一的例外是删掉 `POST /api/chat/only`**(见 §六),那是一次经过评估的、
+  有记录的收缩——不是「删了也无所谓」的先例。
 - **函数式注释写「为什么」不写「是什么」**。行内注释只在代码本身无法表达
   约束时写(顺序要求、副作用、反直觉的取舍)。
 - **同步驱动(pymysql / sqlite3)在 async 路由里必须走 `asyncio.to_thread`**,
@@ -261,6 +263,37 @@ python scripts/selfcheck.py
 
 列在这里是为了让读代码的人知道「这里没写不是因为忘了」。
 
+### 为什么没有 `/chat/only`
+
+1.5 有 `POST /api/chat/only`,2.0 **删掉了**。如果你正想「为了兼容 1.5 把它加
+回来」,先看这段。
+
+它和 `/chat` 的差别只有计费流水上那个 `kind` 标签(`chat` vs `chat_only`),
+**行为完全相同**。而那个标签在 1.5 和 2.0 里都没有任何消费方:没有按渠道聚合
+的接口、没有按渠道拆的额度、没有渠道维度的报表。也就是说它看起来像渠道区分,
+实际不承载信息。
+
+这份重复在 1.5 有可查的代价:
+
+- **出过计费 bug**:`docs/实施记录-B3-P3.md` 的 R7-#24 / P1-11 ——
+  「/chat vs /chat/only 标题计费口径不一致」。两个本该一致的入口漂移了。
+- **多付一份安全验证**:`tests/l4_auth_runtime.py` 里的 `L4-1b-c2`(thread_id
+  越权)与 `L4-8c`(413 体积闸)存在的唯一理由,是在第二个入口上再证一遍同样
+  的阀门。
+- **多养一个专用探针**:`tests/probe_b345_dedup_diff.py` 的唯一职责是证明两者
+  行为相同。
+- **最终被收敛**:B3-45 提交说明写着「/chat vs /chat/only 95% 重复,抽
+  `_run_chat_pipeline`」——1.5 自己也认定它是重复,只是出于兼容留了端点、只删
+  了重复实现。
+- 而 1.5 前端**从头到尾 0 次调用** `/chat/only`。
+
+要重新引入这类入口,正确的顺序是**先把渠道差异做实**(按渠道聚合用量、按渠道
+拆额度、渠道维度报表),再引入入口;不要先立一个行为相同的端点,指望以后有人
+去消费那个标签。
+
+保留的另两个入口 —— `/chat/resume`、`/chat/interrupt` —— 不属于这类:它们承载
+的是**不同能力**(中断恢复、挂起查询),不是同一能力的别名。
+
 1. **没有鉴权**。`/api/chat` 直接取 multipart 的 `user_name`(缺省 `default`),
    客户端可自报身份。计费、会话归属、并发配额全建立在这个值上,所以
    **当前只适合内网或开发环境**。接真实鉴权时替换

+ 5 - 1
README.md

@@ -55,11 +55,15 @@ python main.py
 | 方法 | 路径 | 说明 |
 |---|---|---|
 | POST | `/api/chat` | 主入口,SSE 流式 |
-| POST | `/api/chat/only` | 同管线,仅计费类型不同(`chat_only`) |
 | POST | `/api/chat/resume` | 恢复被中断挂起的对话 |
 | GET | `/api/chat/interrupt` | 查挂起的中断(刷新页面补渲染卡片);有则 200,无则 204 |
 | GET | `/api/chat/history/{session_id}` | 会话历史 |
 
+> **没有 `/api/chat/only`**:1.5 有这个端点,但它与 `/chat` 除了流水上的
+> `kind` 标签外行为完全相同,而那个标签没有任何消费方(不聚合、不分账、
+> 不影响额度),1.5 前端也从未调用过它。2.0 删掉了它——理由与 1.5 为此付过的
+> 代价见 `AGENTS.md` §六。
+
 `POST /api/chat` 请求体是 `multipart/form-data`:
 
 | 字段 | 必填 | 说明 |

+ 27 - 35
api/chat_routes.py

@@ -4,14 +4,24 @@
 (multipart 入参、SSE 帧格式、thread_id ≡ session_id、错误码语义),这样 1.5
 的前端不用改一行就能连上。
 
-与 1.5 的差异只有两处,都在实现层而非契约层:
-
-1. **不做鉴权**:1.5 用 ``Depends(get_current_user)`` 校验 ``x-access-token``
-   并解析出用户;2.0 直接取 multipart 的可选字段 ``user_name``(缺省
-   ``default``)。风险是客户端可自报身份,仅适用于内网/开发期——上线前
-   把 ``_resolve_user`` 换成真实的 ``Depends`` 即可,其余代码不用动。
-2. **没有意图识别**:1.5 曾经按意图分流到不同的 agent,后来也收敛成了单一
-   dialog agent。2.0 直接就是单一 agent。
+与 1.5 的差异有三处:
+
+1. **不做鉴权**(实现层):1.5 用 ``Depends(get_current_user)`` 校验
+   ``x-access-token`` 并解析出用户;2.0 直接取 multipart 的可选字段
+   ``user_name``(缺省 ``default``)。风险是客户端可自报身份,仅适用于
+   内网/开发期——上线前把 ``_resolve_user`` 换成真实的 ``Depends`` 即可,
+   其余代码不用动。
+2. **没有意图识别**(实现层):1.5 曾经按意图分流到不同的 agent,后来也
+   收敛成了单一 dialog agent。2.0 直接就是单一 agent。
+3. **删掉了 ``POST /api/chat/only``**(契约层,唯一一处收缩):1.5 的这个
+   端点与 ``/chat`` 除了流水上的 ``kind`` 标签外**行为完全相同**,而那个标签
+   没有任何消费方(不聚合、不分账、不影响额度)。1.5 自己也为这份重复付过
+   代价——见 AGENTS.md §六「为什么没有 /chat/only」。1.5 前端从未调用过它。
+   若将来确有按渠道分账的需求,应当**先把聚合/额度按渠道拆开**,再引入入口,
+   而不是先立一个行为相同的端点。
+
+   保留 ``/chat`` 之外的另两个入口 ``/chat/resume`` 与 ``/chat/interrupt``:
+   它们承载的是**不同能力**(中断恢复、挂起查询),不是同一能力的别名。
 """
 
 from __future__ import annotations
@@ -288,30 +298,6 @@ async def chat(
         mode=mode,
         file=file,
         user_name=_resolve_user(user_name),
-        billing_kind="chat",
-        trace_id=_trace_id_of(request),
-    )
-
-
-@router.post("/chat/only")
-async def chat_only(
-    message: str = Form(...),
-    session_id: str | None = Form(default=None),
-    thread_id: str | None = Form(default=None),
-    mode: str | None = Form(default=None),
-    file: UploadFile | None = File(default=None),
-    user_name: str | None = Form(default=None),
-    request: Request = None,
-):
-    """纯对话入口。管线与 /chat 完全相同,只有计费类型不同(便于分账)。"""
-    return await _run_chat_pipeline(
-        message=message,
-        session_id=session_id,
-        thread_id=thread_id,
-        mode=mode,
-        file=file,
-        user_name=_resolve_user(user_name),
-        billing_kind="chat_only",
         trace_id=_trace_id_of(request),
     )
 
@@ -323,10 +309,9 @@ async def _run_chat_pipeline(
     mode: str | None,
     file: UploadFile | None,
     user_name: str,
-    billing_kind: str,
     trace_id: str = "",
 ):
-    """/chat 与 /chat/only 的公共管线。
+    """对话入口的公共管线。
 
     步骤顺序与原由:
     0. 额度预检 —— 在开任何资源之前 fail-closed 拦掉超限用户
@@ -336,6 +321,10 @@ async def _run_chat_pipeline(
     4. 上传落盘 —— 存进 agent 可读的沙箱
     5. 消息拼装 —— 文件说明 + 引用 + 技能 + 偏好 + 模式指令
     6. 并发闸 + SSE 装配
+
+    不再是「多入口的公共管线」:``/chat/only`` 已删除(理由见模块 docstring),
+    现在只有 ``/chat`` 走这里。保留这层函数是因为它把管线步骤与 FastAPI 的
+    参数声明分开了——将来再加同族入口时不必把它拆回来。
     """
     started_at = db_conn.now()
     usage_day = db_conn.day_key(started_at)
@@ -391,7 +380,10 @@ async def _run_chat_pipeline(
                 config=graph_config,
                 message=final_message,
                 user_name=user_name,
-                kind=billing_kind,
+                # 计费渠道标签。取值集合:chat(本入口)| resume(中断恢复)
+                # | scheduled(定时任务)。没有 chat_only——那个入口已删除,
+                # 因为它的标签不承载任何行为差异(见模块 docstring 差异 3)。
+                kind="chat",
                 model=config.MODEL,
                 original_user_message=ref["original"] or message,
                 trace_id=trace_id,

+ 11 - 0
scripts/http_e2e.py

@@ -122,6 +122,17 @@ def test_404_and_errors() -> None:
         r = client.get(f"{BASE}/api/chat/interrupt", params={"session_id": "not-exist-id", "user_name": USER})
         check("中断查询 不存在的会话 → 404", r.status_code == 404, str(r.status_code))
 
+        # 2.0 刻意删除了 /chat/only(见 AGENTS.md §六),这里确认它真的没了。
+        # 用 405/404 都能接受:路由不存在时 FastAPI 返回 404;若有人只删了
+        # POST 处理器却留下路径,则表现为 405——两种都算「端点已下线」。
+        r = client.post(f"{BASE}/api/chat/only", data={"message": "hi", "user_name": USER})
+        check("已删除的 /chat/only 不可用(404/405)", r.status_code in (404, 405), str(r.status_code))
+
+        # 同族路由不能被连带删掉
+        r = client.get(f"{BASE}/api/chat/interrupt", params={"session_id": "x", "user_name": USER})
+        check("同族路由 /chat/interrupt 仍在(非 404 说明路由存在)",
+              r.status_code in (200, 204, 404), str(r.status_code))
+
 
 def test_interrupt_loop() -> None:
     """plan 模式 → agent 调 request_plan_approval → interrupt → resume。"""

+ 75 - 0
tests/test_core_units.py

@@ -831,6 +831,81 @@ class TestStores:
         assert db_conn.health()["status"] == "ok"
 
 
+# ============================================================
+# 路由面(把「删掉 /chat/only」这个决定变成可执行的守卫)
+# ============================================================
+
+
+class TestRouting:
+    """断言**实际对外服务**的路由面。
+
+    这类断言的价值不在于「测试代码」,而在于把架构决定固化下来:
+    没有它,将来有人「为了兼容 1.5」把 /chat/only 加回来时,只有 code review
+    能拦住;有了它,CI 会拦。
+
+    刻意断言 ``main.app.openapi()["paths"]`` 而不是 ``api.router.routes``:
+    后者在本版 FastAPI 里是 ``_IncludedRouter`` 包装(不含 ``.path``),
+    而且真正要守住的是**服务出去的**那套路径。顺带还能验证路由定义合法——
+    schema 生成失败会在这里报出来。
+    """
+
+    def _paths(self) -> set[str]:
+        from main import app
+
+        return set(app.openapi()["paths"])
+
+    def test_chat_entry_exists(self):
+        assert "/api/chat" in self._paths()
+
+    def test_chat_only_is_gone(self):
+        """2.0 刻意删除 /chat/only:它与 /chat 行为完全相同,唯一的 kind 标签
+        无人消费,且 1.5 为此付过计费 bug 与重复验证的代价。见 AGENTS.md §六。"""
+        assert "/api/chat/only" not in self._paths()
+
+    def test_interrupt_endpoints_kept(self):
+        """/chat/resume 与 /chat/interrupt 承载的是不同能力,不是同一能力的
+        别名,所以保留——别把它们和 /chat/only 一起删。"""
+        paths = self._paths()
+        assert "/api/chat/resume" in paths
+        assert "/api/chat/interrupt" in paths
+
+    def test_detail_routes_not_lost_when_removing(self):
+        """删入口时最容易连带弄丢同族路由,这里把对话族钉死。"""
+        chat_paths = {p for p in self._paths() if p.startswith("/api/chat")}
+        assert chat_paths == {
+            "/api/chat",
+            "/api/chat/resume",
+            "/api/chat/interrupt",
+            "/api/chat/history/{session_id}",
+        }, chat_paths
+
+    def test_chat_only_route_not_defined_in_source(self):
+        """源码里不得再出现该路由的装饰器。
+
+        只在装饰器形态上断言,不搜 ``chat_only`` 这个词——它在注释与文档里
+        正当地出现(用来解释为什么没有它),搜词会把说明文字也判成违规。
+        """
+        import inspect
+
+        from api import chat_routes
+
+        src = inspect.getsource(chat_routes)
+        assert '@router.post("/chat/only")' not in src
+        assert '"chat_only"' not in src
+
+    def test_kind_values_are_meaningful(self):
+        """kind 只剩三个有意义取值:chat / resume / scheduled。"""
+        import inspect
+
+        from api import chat_routes
+        from scheduler import runner
+
+        chat_src = inspect.getsource(chat_routes)
+        assert 'kind="chat"' in chat_src
+        assert 'kind="resume"' in chat_src
+        assert 'kind="scheduled"' in inspect.getsource(runner)
+
+
 # ============================================================
 # 模型工厂
 # ============================================================