lingguang 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lingguang-0.1.0/.github/workflows/publish.yml +51 -0
- lingguang-0.1.0/.gitignore +30 -0
- lingguang-0.1.0/ENGINEERING-PLAN.md +615 -0
- lingguang-0.1.0/LICENSE +21 -0
- lingguang-0.1.0/PKG-INFO +421 -0
- lingguang-0.1.0/README.md +387 -0
- lingguang-0.1.0/USAGE.md +346 -0
- lingguang-0.1.0/docs/reverse-notes.md +294 -0
- lingguang-0.1.0/pyproject.toml +60 -0
- lingguang-0.1.0/scripts/make_fixtures.py +132 -0
- lingguang-0.1.0/src/lingguang/__init__.py +3 -0
- lingguang-0.1.0/src/lingguang/cli/__init__.py +5 -0
- lingguang-0.1.0/src/lingguang/cli/main.py +377 -0
- lingguang-0.1.0/src/lingguang/core/__init__.py +49 -0
- lingguang-0.1.0/src/lingguang/core/auth.py +198 -0
- lingguang-0.1.0/src/lingguang/core/client.py +260 -0
- lingguang-0.1.0/src/lingguang/core/errors.py +49 -0
- lingguang-0.1.0/src/lingguang/core/models.py +134 -0
- lingguang-0.1.0/src/lingguang/core/parser.py +286 -0
- lingguang-0.1.0/src/lingguang/core/service.py +338 -0
- lingguang-0.1.0/src/lingguang/core/sink.py +36 -0
- lingguang-0.1.0/src/lingguang/mcp/__init__.py +18 -0
- lingguang-0.1.0/src/lingguang/mcp/server.py +153 -0
- lingguang-0.1.0/src/lingguang/mcp/tools.py +266 -0
- lingguang-0.1.0/tests/fixtures/answer_billing.cot.json +1 -0
- lingguang-0.1.0/tests/fixtures/answer_billing.raw.html +145 -0
- lingguang-0.1.0/tests/fixtures/answer_multi_tool.cot.json +1 -0
- lingguang-0.1.0/tests/fixtures/answer_multi_tool.raw.html +145 -0
- lingguang-0.1.0/tests/fixtures/answer_plain.cot.json +1 -0
- lingguang-0.1.0/tests/fixtures/answer_plain.raw.html +145 -0
- lingguang-0.1.0/tests/fixtures/answer_unknown_event.cot.json +1 -0
- lingguang-0.1.0/tests/fixtures/answer_unknown_event.raw.html +1 -0
- lingguang-0.1.0/tests/test_contract.py +102 -0
- lingguang-0.1.0/tests/test_mcp_protocol.py +148 -0
- lingguang-0.1.0/tests/test_parser.py +234 -0
- lingguang-0.1.0/tests/test_service.py +281 -0
- lingguang-0.1.0/uv.lock +1243 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# 推一个形如 v0.1.0 的 tag 就自动发布到 PyPI。
|
|
4
|
+
# 用 Trusted Publishing,不需要任何 API token。
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags:
|
|
9
|
+
- "v*"
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
name: 跑测试
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: astral-sh/setup-uv@v5
|
|
19
|
+
- run: uv sync --extra dev
|
|
20
|
+
- run: uv run pytest -q
|
|
21
|
+
|
|
22
|
+
build:
|
|
23
|
+
name: 构建分发包
|
|
24
|
+
needs: test
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: astral-sh/setup-uv@v5
|
|
29
|
+
- run: uv build
|
|
30
|
+
- uses: actions/upload-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
|
|
35
|
+
publish:
|
|
36
|
+
name: 发布到 PyPI
|
|
37
|
+
needs: build
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
environment:
|
|
40
|
+
name: pypi
|
|
41
|
+
url: https://pypi.org/p/lingguang
|
|
42
|
+
permissions:
|
|
43
|
+
# Trusted Publishing 必需
|
|
44
|
+
id-token: write
|
|
45
|
+
steps:
|
|
46
|
+
- uses: actions/download-artifact@v4
|
|
47
|
+
with:
|
|
48
|
+
name: dist
|
|
49
|
+
path: dist/
|
|
50
|
+
- name: 上传到 PyPI
|
|
51
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 凭据与本地状态,绝不入库
|
|
2
|
+
cookie.txt
|
|
3
|
+
*.cookie
|
|
4
|
+
credentials.json
|
|
5
|
+
.pending.jsonl
|
|
6
|
+
|
|
7
|
+
# Python
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
*.egg-info/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
|
|
16
|
+
# 工具
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
htmlcov/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
|
|
22
|
+
# 编辑器
|
|
23
|
+
.vscode/
|
|
24
|
+
.idea/
|
|
25
|
+
*.swp
|
|
26
|
+
|
|
27
|
+
# 测试与调试产物
|
|
28
|
+
.playwright-mcp/
|
|
29
|
+
*.local.json
|
|
30
|
+
failed/
|
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
# 灵光 MCP / CLI 工具工程方案
|
|
2
|
+
|
|
3
|
+
基于 2026-10-28 全部实测数据。所有设计决策都标注了对应的实测依据。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 零、使用场景(这是所有设计的出发点)
|
|
8
|
+
|
|
9
|
+
**核心前提:支付宝账单、钉钉这两件事,没有独立的开放接口可以用。**
|
|
10
|
+
|
|
11
|
+
支付宝不给个人用户开放账单 API,钉钉这边也需要企业管理员开数据访问权限,
|
|
12
|
+
而且灵光连接时已经踩过这个坑(组织未开放 CLI 数据访问)。
|
|
13
|
+
|
|
14
|
+
所以灵光的价值是**唯一入口**:它已经拿到了支付宝授权,已经把账单查出来,
|
|
15
|
+
已经连上了钉钉。这些能力只能通过灵光这个 Agent 间接使用。
|
|
16
|
+
|
|
17
|
+
**由此推出的三条铁律:**
|
|
18
|
+
|
|
19
|
+
1. 工具的价值在"拿到灵光算好的结论",不在"自己算"。
|
|
20
|
+
自己算没有数据源,这是死路。
|
|
21
|
+
2. 工具必须是 Agent 的**遥控器和录音机**,不是替代品。
|
|
22
|
+
它能做的只是:替我发问、替我取结果、替我把轨迹记下来。
|
|
23
|
+
3. 稳定性优先级最高。这是唯一入口,它挂了就没有替代方案。
|
|
24
|
+
所以解析器要有契约测试,错误要分类清楚。
|
|
25
|
+
|
|
26
|
+
**典型使用场景:**
|
|
27
|
+
|
|
28
|
+
- "这个月吃饭花了多少" -> 发给灵光 -> 取回结论
|
|
29
|
+
- "9 月账单里有哪些大额支出" -> 发问 -> 拿结论和工具轨迹
|
|
30
|
+
- 把历史问答导出存档,防止 App 侧数据丢失
|
|
31
|
+
- 批量问一系列问题,把结果汇总成表格
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 一、这不是单纯的 API 包装
|
|
36
|
+
|
|
37
|
+
逆向结论表明,灵光本质上是"移动端授权的 Agent 宿主",网页或 HTTP 只是遥控器。
|
|
38
|
+
这套工具的真实价值不在"调用大模型",而在三件事:
|
|
39
|
+
|
|
40
|
+
1. **账单/钉钉等工具的真实执行轨迹**。Agent 每一步调了什么子程序,
|
|
41
|
+
标题都写在事件流里,这是其他 LLM 给不了的结构化信息。
|
|
42
|
+
2. **拿到结构化后的结论**,不用自己写 SQL 聚合(也没有原始数据可写 SQL)。
|
|
43
|
+
3. **持久归档**。会话本来只能在 App 里翻,导出后可以做时序分析。
|
|
44
|
+
|
|
45
|
+
所以工具定位应该是 **"Agent 执行记录与结论的采集器"**,顺带提供提问能力。
|
|
46
|
+
如果定位成"又一个聊天客户端",投入产出比很低。
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 二、实测约束清单(设计必须先满足这些)
|
|
51
|
+
|
|
52
|
+
| # | 约束 | 实测证据 | 设计影响 |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| 1 | sceneCode 分两套 | 历史/删除用 `LEOPARD`,提问用 `WEB` | 按接口分派,不统一常量 |
|
|
55
|
+
| 2 | **可新建会话** | 客户端生成 UUID 作为 sessionId 即可,服务端直接采用 | `ask` 支持自动建会话 |
|
|
56
|
+
| 3 | sessionId 空值不可用 | 空串 / 省略字段均 500 | 只能用合法 UUID |
|
|
57
|
+
| 4 | 跨场景数据不隔离 | `sceneCode=WEB` 与 `LEOPARD` 返回同一份完整列表 | 不必分场景拉取再合并 |
|
|
58
|
+
| 5 | pageSize 上限 30 | 31 返回 `SYSTEM_ERROR` | 必须分页 |
|
|
59
|
+
| 6 | SSE 不能用 iter_lines | 多字节被截断出现 `\x89` | 按字节切 `\n\n` 分帧 |
|
|
60
|
+
| 7 | 正文藏在 artifact | `AgentEventRenderer.render` 事件流 | 需专用解析器 |
|
|
61
|
+
| 8 | 提问返回是增量帧 | 正文要回查才齐 | 提交与取结果分离 |
|
|
62
|
+
| 9 | **轮次不会被半截查出** | 任务跑完前历史接口查不到该轮;完成后一次性 `status=VALID`,正文完整 | `get_answer` 只需判断"有没有",无需判断"完不完整" |
|
|
63
|
+
| 10 | 删除幂等 | 不存在 id 也返回 SUCCESS | 不用处理 404 |
|
|
64
|
+
| 11 | 直连可用 | 310ms,无需代理 | 部署简单 |
|
|
65
|
+
| 12 | 分页游标 | `latestTime` + `hasMore` | best-effort,不保证无遗漏 |
|
|
66
|
+
|
|
67
|
+
### 约束 2 的实测细节(最重要的一条修正)
|
|
68
|
+
|
|
69
|
+
之前误判为"无法新建会话",因为传 `null`、空串、省略字段全都 500。
|
|
70
|
+
实际网页端的做法是**客户端自己生成 UUID 当 sessionId**:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
sessionId="" -> 500
|
|
74
|
+
sessionId 省略 -> 500
|
|
75
|
+
sessionId=<新UUID> -> 200,会话真的建出来
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
验证:生成 `93a11295-eff7-4efa-938f-b79cc591c278` 发问,
|
|
79
|
+
随后会话列表里出现该 id,标题自动取回复首句"收到"。
|
|
80
|
+
|
|
81
|
+
**影响**:`ask` 的 `session_id` 参数改为可选,不传就自动建新会话。
|
|
82
|
+
|
|
83
|
+
### 约束 4 的实测细节
|
|
84
|
+
|
|
85
|
+
传 `sceneCode=WEB` 和 `sceneCode=LEOPARD` 查询会话列表,
|
|
86
|
+
返回的是同一份数据(6 条完全相同)。说明场景字段用于标识来源,
|
|
87
|
+
不做数据隔离。所以 `list_sessions` 固定传一个值即可拿到全部。
|
|
88
|
+
|
|
89
|
+
注意:App 发起的会话 `sceneCode` 是 `LEOPARD`,网页发起的是 `WEB`,
|
|
90
|
+
这个字段要原样保留,用于追溯来源。
|
|
91
|
+
|
|
92
|
+
### 约束 9 的实测细节(决定 get_answer 能否正确收尾)
|
|
93
|
+
|
|
94
|
+
新建会话 `863db287` 发出"帮我看看这个月吃饭花了多少钱",然后轮询历史接口:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
t=3s 查到 0 条(任务进行中,历史接口里没有这一轮)
|
|
98
|
+
t=4s 查到 1 条 status=VALID len=22641(已完成,正文完整)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
结论:**服务端是原子的,任务完成前不写入历史,写入时就是完整状态。**
|
|
102
|
+
不存在"正文被截断但 status 为 VALID"的情况。
|
|
103
|
+
|
|
104
|
+
这大大简化了 `get_answer`:只需判断"这一轮出现了没有",
|
|
105
|
+
不需要猜"内容够不够完整"。出现即可信。
|
|
106
|
+
|
|
107
|
+
仍保留一层防御:如果查到的 `text` 长度异常短(<50 字符)
|
|
108
|
+
且该轮带工具轨迹,标记 `warnings`,交给人判断。
|
|
109
|
+
|
|
110
|
+
### 约束 12 的实测细节
|
|
111
|
+
|
|
112
|
+
`latestTime` 是时间戳游标。同一毫秒内创建多个会话可能漏读,
|
|
113
|
+
且翻页过程中若有新会话写入,结果会漂移。
|
|
114
|
+
|
|
115
|
+
所以游标是 **best-effort**。需要严格全量时走 `export` 全量拉取,
|
|
116
|
+
或者按 session id 去重,不要依赖游标保证无遗漏。
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 三、核心架构决策
|
|
121
|
+
|
|
122
|
+
### 决策一:MCP 与 CLI 共用同一个 core
|
|
123
|
+
|
|
124
|
+
不要写两遍。CLI 是 core 的薄壳,MCP 也是 core 的薄壳。
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
lingguang/
|
|
128
|
+
core/
|
|
129
|
+
client.py # HTTP 传输层
|
|
130
|
+
models.py # 数据模型
|
|
131
|
+
parser.py # artifact / cot 双通道解析
|
|
132
|
+
service.py # 业务编排,MCP 与 CLI 都调它
|
|
133
|
+
errors.py # 四级异常分类
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
关键点:`service.py` 的每个方法都应能在 CLI 里单独调。
|
|
137
|
+
如果某个能力只能在 MCP 里用,说明分层错了。
|
|
138
|
+
|
|
139
|
+
### 决策二:传输层用 STDIO,不用 HTTP
|
|
140
|
+
|
|
141
|
+
MCP 规范最新版 **2026-07-28**。Streamable HTTP 在该版本移除了 GET 流端点和
|
|
142
|
+
协议级 session,已经是无状态的,但本地场景仍应选 stdio。
|
|
143
|
+
|
|
144
|
+
依据:
|
|
145
|
+
- 规范原文 "Clients SHOULD support stdio whenever possible"
|
|
146
|
+
- 单客户端、单机、无远程需求,HTTP 只会增加负担
|
|
147
|
+
- Streamable HTTP 强制要求校验 `Origin` 头防 DNS 重绑定、做鉴权、
|
|
148
|
+
本地建议只绑 127.0.0.1,这些都是额外工作
|
|
149
|
+
|
|
150
|
+
stdio 的唯一约束:**stdout 只能写合法 MCP 消息,所有日志必须走 stderr**。
|
|
151
|
+
|
|
152
|
+
实现上最省事的做法:在 `mcp/server.py` 启动时立刻把 `sys.stdout`
|
|
153
|
+
重定向到 `sys.stderr`,最后再把真正的 stdout 句柄交给 MCP 传输层。
|
|
154
|
+
这样任何第三方库顺手 `print` 都不会破坏协议帧。
|
|
155
|
+
|
|
156
|
+
### 决策三:提问必须异步化
|
|
157
|
+
|
|
158
|
+
账单分析跑 20 到 30 秒。MCP 工具同步阻塞会撞各种超时,
|
|
159
|
+
调用方也没法中途取消。拆成两段:
|
|
160
|
+
|
|
161
|
+
- `ask` 提交提问,立即返回 `session_id` 和 `query_id`
|
|
162
|
+
- `get_answer` 按 `query_id` 查结果,带 `wait_seconds` 做短暂轮询
|
|
163
|
+
|
|
164
|
+
CLI 侧提供 `--wait` 参数做阻塞封装,内部还是这两段。
|
|
165
|
+
|
|
166
|
+
**不在服务端起后台线程。** SSE 读完或超时就断开,"异步"由调用方这一侧承担。
|
|
167
|
+
MCP server 保持无状态、无线程管理负担。
|
|
168
|
+
|
|
169
|
+
### 决策三之补:ask 必须可恢复
|
|
170
|
+
|
|
171
|
+
这是评审指出最痛的问题。`ask` 不重试是对的(会重复下单),
|
|
172
|
+
但 SSE 断流时调用方会失去对这次提问的控制。如果不设计恢复路径,
|
|
173
|
+
agent 很可能重发一次,导致账单被查询两遍。
|
|
174
|
+
|
|
175
|
+
已有的天然幂等键:**`session_id` 和 `query_id` 都是我方生成的 UUID**。
|
|
176
|
+
|
|
177
|
+
设计:
|
|
178
|
+
|
|
179
|
+
1. `ask` 提交前,把 `(session_id, query_id, query, submitted_at)`
|
|
180
|
+
追加写入一份 pending journal,位置 `~/.cache/lingguang/pending.jsonl`。
|
|
181
|
+
这份文件只记录**尚未确认完成的提问**,不存历史数据,不涉及数据同步问题。
|
|
182
|
+
2. SSE 中途断流**不抛 TransportError**。返回
|
|
183
|
+
`{status: "dispatched_unknown", session_id, query_id}`,
|
|
184
|
+
告诉调用方"已发出,结果未知"。
|
|
185
|
+
3. `get_answer` 查不到 query_id 时,退化为按 session 拉全量,
|
|
186
|
+
按 query 文本 + 时间窗口模糊匹配,认得出来就当作成功。
|
|
187
|
+
4. 确认为终态后从 journal 移除。
|
|
188
|
+
|
|
189
|
+
工具描述里必须写清:"拿到 dispatched_unknown 时,
|
|
190
|
+
先调 get_answer 确认结果,不要重复 ask。"
|
|
191
|
+
|
|
192
|
+
### 决策四:正文解析做双通道
|
|
193
|
+
|
|
194
|
+
**通道 A:artifact 解析**
|
|
195
|
+
从 `templateData.text` 正则抓 `AgentEventRenderer.render({...})`,
|
|
196
|
+
取 `turn/finalResponse/delta` 的 `delta` 拼接,剥掉标签得到正文。
|
|
197
|
+
纯 HTTP 可完整还原,不需要浏览器。
|
|
198
|
+
|
|
199
|
+
**通道 B:cot 解析**
|
|
200
|
+
从 `templateData.cot` 两层 JSON 解析,取过程话术 `item/agentMessage/delta`
|
|
201
|
+
和工具标题 `item/tool/start` 的 `display.title`。
|
|
202
|
+
|
|
203
|
+
A 拿最终结论,B 拿执行轨迹。两个都要暴露。
|
|
204
|
+
|
|
205
|
+
**解析器必须对未知事件宽容。** `AgentEventRenderer` 是事件流不是固定模板,
|
|
206
|
+
上游新增 method 是常态。规则:
|
|
207
|
+
|
|
208
|
+
- 未知 method **忽略并计数**,不抛异常
|
|
209
|
+
- 只有当"已知 method 全部缺失"时才抛 ContractError
|
|
210
|
+
- 事件流里出现未知方法时,结果附 `warnings: ["unknown events: item/xxx"]`
|
|
211
|
+
|
|
212
|
+
否则上游每加一个字段,整个工具就全挂,与稳定性优先相悖。
|
|
213
|
+
|
|
214
|
+
### 决策四之补:断言分级,防止静默降级
|
|
215
|
+
|
|
216
|
+
ContractError 只能拦住"结构变了"。有一类更隐蔽的情况它拦不住:
|
|
217
|
+
**结构没变,但关键内容空了。** 比如 `cot` 还在,
|
|
218
|
+
但 `item/tool/start` 不再出现,工具轨迹就静默变成空数组。
|
|
219
|
+
|
|
220
|
+
而工具轨迹恰好是本工具的核心卖点之一。
|
|
221
|
+
|
|
222
|
+
所以解析结果分三档:
|
|
223
|
+
|
|
224
|
+
| 情况 | 处理 |
|
|
225
|
+
|---|---|
|
|
226
|
+
| 必填结构缺失 | 抛 ContractError,附原始片段 |
|
|
227
|
+
| 结构在但语义可疑 | 正常返回,加 `warnings` |
|
|
228
|
+
| 一切正常 | `warnings: []` |
|
|
229
|
+
|
|
230
|
+
具体的可疑判定:
|
|
231
|
+
- `cot` 存在但零个工具事件,且正文提到"查""读"等动作词
|
|
232
|
+
- 正文长度 < 50 字符但有工具轨迹
|
|
233
|
+
- 出现未知事件类型
|
|
234
|
+
|
|
235
|
+
`warnings` 通过 MCP 的 `structuredContent` 返回,成本很低,
|
|
236
|
+
能防止 agent 把残缺结论当完整结论用。
|
|
237
|
+
|
|
238
|
+
### 决策五:不加 SQLite,但留 MessageSink 接口
|
|
239
|
+
|
|
240
|
+
用户明确要求不加本地库,理由是容易不同步。这个判断成立。
|
|
241
|
+
|
|
242
|
+
设计上用一个接口隔离。接口要**幂等**,否则将来实现 SQLite 时
|
|
243
|
+
`get_transcript` 和 `get_answer` 会重复写入同一条:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
class MessageSink(Protocol):
|
|
247
|
+
def upsert_turn(
|
|
248
|
+
self,
|
|
249
|
+
session_id: str,
|
|
250
|
+
turn: Turn,
|
|
251
|
+
source: Literal["transcript", "answer"],
|
|
252
|
+
) -> None: ...
|
|
253
|
+
|
|
254
|
+
class NullSink:
|
|
255
|
+
def upsert_turn(self, session_id, turn, source): pass
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
去重依据是 `(session_id, turn.query_id)`。`Turn` 必须带稳定的 `query_id`。
|
|
259
|
+
这样将来加 FTS5 全文检索,实现这个接口即可,上层零改动。
|
|
260
|
+
|
|
261
|
+
### 决策六:cookie 管理要有事前预判
|
|
262
|
+
|
|
263
|
+
cookie 是唯一入口的钥匙,失效时工具完全不可用。
|
|
264
|
+
|
|
265
|
+
存放位置 `~/.config/lingguang/credentials.json`,Windows 下用 DPAPI 加密,
|
|
266
|
+
或者退一步至少 0600 权限。**明文 ctoken 被同步盘扫走等于泄露支付宝授权登录态。**
|
|
267
|
+
|
|
268
|
+
记录三个时间戳:
|
|
269
|
+
|
|
270
|
+
```json
|
|
271
|
+
{
|
|
272
|
+
"cookie": "...", // 加密存储
|
|
273
|
+
"imported_at": "...",
|
|
274
|
+
"last_ok_at": "...", // 每次请求成功刷新
|
|
275
|
+
"last_error": "...", // 最近一次失败原因
|
|
276
|
+
"last_error_at": "..."
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`auth_status` 返回"已导入 N 天,上次成功 M 小时前",是事前预判而非事后诊断。
|
|
281
|
+
|
|
282
|
+
`auth import` 要支持两种输入:整段 cookie 字符串、curl 命令原文。
|
|
283
|
+
导入后立即调一次 `loginCheck` 验证,失败立刻报错,不要等下次用才炸。
|
|
284
|
+
|
|
285
|
+
**错误归类规则**:曾经成功过但目前连续失败,
|
|
286
|
+
优先判为 `AuthError` 而非 `ContractError`,避免误导排查方向。
|
|
287
|
+
|
|
288
|
+
### 决策七:限流,按人级低频设计
|
|
289
|
+
|
|
290
|
+
这个接口是面向浏览器前端的,蚂蚁侧有 jshield / apdid 风控。
|
|
291
|
+
高频轮询会让账号被标记,而那会连带影响用户在 App 里正常使用灵光。
|
|
292
|
+
|
|
293
|
+
规则:
|
|
294
|
+
|
|
295
|
+
- `get_answer` 轮询首次 3 秒,之后指数退避到 5 至 8 秒
|
|
296
|
+
- 用户级串行,不做并发批量
|
|
297
|
+
- CLI 的批量场景显式串行并加间隔
|
|
298
|
+
- README 里写清"本工具设计为低频人级调用,不要拿来做爬虫"
|
|
299
|
+
|
|
300
|
+
### 决策八:解析失败时数据仍可取
|
|
301
|
+
|
|
302
|
+
这是"唯一入口"场景最重要的兜底。
|
|
303
|
+
|
|
304
|
+
即使解析器全挂,历史接口返回的原始 JSON 本身仍有归档价值。
|
|
305
|
+
必须保证这条路径存在:
|
|
306
|
+
|
|
307
|
+
- `export --format raw` 输出未经解析的原始响应
|
|
308
|
+
- `ContractError` 触发时把原始响应体写到 `~/.cache/lingguang/failed/`
|
|
309
|
+
- `doctor` 把"上游版本变化且 fixture 跑不通"标为 failed,
|
|
310
|
+
`serve` 在这种情况下拒绝启动并说明原因
|
|
311
|
+
|
|
312
|
+
**静默返回错误答案比拒绝服务更糟。**
|
|
313
|
+
|
|
314
|
+
### 决策九:APASS 高危确认目前不可测,但必须预留
|
|
315
|
+
|
|
316
|
+
灵光 2.0 于 2026-10-27 发布,接入蚂蚁 APASS,官方说法是
|
|
317
|
+
"高危操作需用户确认"。但实测三条线索都指向**该能力在网页端尚未实现**:
|
|
318
|
+
|
|
319
|
+
1. 网页端 21 个脚本里搜 `apass`、`apAuth`、`riskControl`、`高危`、
|
|
320
|
+
`二次确认`、`approval`,零命中
|
|
321
|
+
2. 两个会话的完整 cot 里 method 全集只有九种,全是常规 Agent 生命周期,
|
|
322
|
+
没有 confirm / approval / interrupt / user_action 类事件
|
|
323
|
+
3. `item/tool/start` 的 params 只有 `itemId`、`groupId`、`display.title`,
|
|
324
|
+
没有 `riskLevel` 或 `requiresConfirm` 字段
|
|
325
|
+
|
|
326
|
+
判断:功能太新,还没落地到网页端,或者只在 App 原生层实现。
|
|
327
|
+
|
|
328
|
+
**但这对封装工具是真实风险**:如果未来某次查询触发了确认,
|
|
329
|
+
而 HTTP 层拿不到那个信号,`get_answer` 会永远停在 pending,
|
|
330
|
+
调用方误以为任务卡死。
|
|
331
|
+
|
|
332
|
+
所以 `get_answer` 的超时必须分两种情况:
|
|
333
|
+
|
|
334
|
+
```
|
|
335
|
+
超时,且该轮从未出现过 -> task_timeout
|
|
336
|
+
可能任务失败或未受理
|
|
337
|
+
|
|
338
|
+
超时,但该轮出现过、无正文 -> awaiting_confirmation
|
|
339
|
+
可能等待用户在 App 内人工确认
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
第二种要明确提示"这次提问可能需要你在灵光 App 里手动确认"。
|
|
343
|
+
|
|
344
|
+
另外预留一个钩子:决策四的解析器对未知事件宽容,
|
|
345
|
+
一旦上游开始推送确认类事件,会被自动识别进 `warnings`,
|
|
346
|
+
不会静默丢弃。这条设计顺便把未来兼容性接上了。
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## 四、MCP 工具设计:6 个
|
|
351
|
+
|
|
352
|
+
按最新规范(2026-07-28):
|
|
353
|
+
- `inputSchema` 必须是非 null 的合法 JSON Schema
|
|
354
|
+
- 无参数时用 `{"type":"object","additionalProperties":false}`
|
|
355
|
+
- 提供了 `outputSchema` 就必须返回符合它的 `structuredContent`
|
|
356
|
+
- 返回结构化数据时**同时**给 TextContent 文本块做兼容
|
|
357
|
+
- `annotations` 默认不可信,不依赖它做安全判断
|
|
358
|
+
|
|
359
|
+
### 1. `list_sessions`
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
参数: limit (int, 默认 30, 上限 30), cursor (str|null), scene (str, 默认 LEOPARD)
|
|
363
|
+
返回: { sessions: [{id, summary, scene, created_at, updated_at}], next_cursor }
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
`latestTime` 做游标,`hasMore` 为 false 时 `next_cursor` 返回 null。
|
|
367
|
+
游标由调用方持有,服务端不记状态。
|
|
368
|
+
|
|
369
|
+
### 2. `get_transcript`
|
|
370
|
+
|
|
371
|
+
```
|
|
372
|
+
参数: session_id (必填), limit (默认 30), keyword (可选)
|
|
373
|
+
返回: { turns: [{role, text, tools: [str], summary, query_id, created_at}] }
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`keyword` 在内存过滤,不额外打接口。
|
|
377
|
+
`tools` 是字符串数组,扁平结构方便模型直接读。
|
|
378
|
+
调用时顺手把结果写入 sink(默认 NullSink 无动作)。
|
|
379
|
+
|
|
380
|
+
### 3. `ask`
|
|
381
|
+
|
|
382
|
+
```
|
|
383
|
+
参数: query (必填), session_id (可选)
|
|
384
|
+
返回: { session_id, query_id, status: submitted|dispatched_unknown }
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**`session_id` 不传时自动新建**:客户端生成 UUID,
|
|
388
|
+
服务端直接采用,返回新建的 id。
|
|
389
|
+
|
|
390
|
+
描述里必须写明两件事:
|
|
391
|
+
- "立即返回,需配合 get_answer 取结果"
|
|
392
|
+
- "拿到 dispatched_unknown 时先查 get_answer,不要重复 ask"
|
|
393
|
+
|
|
394
|
+
### 4. `get_answer`
|
|
395
|
+
|
|
396
|
+
```
|
|
397
|
+
参数: query_id (必填), session_id (可选), wait_seconds (默认 0, 上限 60)
|
|
398
|
+
返回: { status: pending|dispatched_unknown|done, text, tools: [str], warnings: [str] }
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
`status` 三态:
|
|
402
|
+
- `pending`:还没出现,继续等
|
|
403
|
+
- `dispatched_unknown`:SSE 断流,结果未知,需要再查
|
|
404
|
+
- `done`:拿到结果
|
|
405
|
+
|
|
406
|
+
`session_id` 可选,因为 `query_id` 已全局唯一。
|
|
407
|
+
只有走模糊匹配恢复时才需要 `session_id`。
|
|
408
|
+
|
|
409
|
+
`wait_seconds=0` 立即查一次。`>0` 轮询到出结果或超时,超时明确报错。
|
|
410
|
+
轮询间隔首次 3 秒,之后指数退避到 5 至 8 秒。
|
|
411
|
+
|
|
412
|
+
如果客户端支持 progress,轮询期间每 5 秒发一次 progress,
|
|
413
|
+
避免客户端显示卡死。
|
|
414
|
+
|
|
415
|
+
### 5. `auth_status`
|
|
416
|
+
|
|
417
|
+
```
|
|
418
|
+
参数: 无
|
|
419
|
+
返回: { valid, session_count, scene, hint }
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
让模型能自己诊断登录态。cookie 失效时直接告诉用户重新导入,
|
|
423
|
+
不要让人猜为什么失败。
|
|
424
|
+
|
|
425
|
+
### 6. `delete_session`
|
|
426
|
+
|
|
427
|
+
```
|
|
428
|
+
参数: session_id (必填), confirm (bool, 必填)
|
|
429
|
+
返回: { deleted: bool, session_id }
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
破坏性操作。CLI 和 MCP 统一提供,不做两套。
|
|
433
|
+
|
|
434
|
+
安全设计:
|
|
435
|
+
- `confirm` 必须显式为 `true`,否则直接报错不做任何事
|
|
436
|
+
- 工具 `annotations` 标 `destructiveHint: true`、`idempotentHint: true`
|
|
437
|
+
- 描述里写清"删除后不可恢复,删除前先用 get_transcript 确认目标"
|
|
438
|
+
- 接口本身幂等,删不存在的 id 也返回成功,不用担心重复调用
|
|
439
|
+
|
|
440
|
+
`confirm` 参数是对模型的一道护栏,让它必须意识到这是破坏性动作。
|
|
441
|
+
真正的最后防线是工具描述,不是参数。
|
|
442
|
+
|
|
443
|
+
### 不做的事
|
|
444
|
+
|
|
445
|
+
- **不做 sync**。无状态设计下不需要
|
|
446
|
+
- **不做 search_archive**。没有本地库
|
|
447
|
+
|
|
448
|
+
CLI 与 MCP 能力对齐,唯一差别是 CLI 多了 `doctor`、`export`、`auth import` 这类运维命令。
|
|
449
|
+
`delete` 两边都有,签名一致。
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## 五、CLI 设计
|
|
454
|
+
|
|
455
|
+
命令树对齐 MCP,多出运维命令:
|
|
456
|
+
|
|
457
|
+
```
|
|
458
|
+
lingguang
|
|
459
|
+
auth status | import <file> | clear
|
|
460
|
+
sessions list [--limit N] | show <id> [--keyword KW]
|
|
461
|
+
ask "问题" [--session ID] [--wait] [--timeout S] [--json]
|
|
462
|
+
answer <query-id> [--session ID] [--wait S] [--json]
|
|
463
|
+
export <session> --format md|json|jsonl|raw -o FILE
|
|
464
|
+
delete <session-id> --yes
|
|
465
|
+
serve # 启动 MCP stdio server
|
|
466
|
+
doctor # 环境自检
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`answer` 是 `get_answer` 的 CLI 对应物,不叫 `get-answer` 是为了少打字。
|
|
470
|
+
|
|
471
|
+
两个原则:
|
|
472
|
+
|
|
473
|
+
**输出要可管道**。默认人类可读,`--json` 输出机器可读。
|
|
474
|
+
|
|
475
|
+
**`doctor` 不能省**。检查项:cookie 是否存在可解析、登录态是否有效、
|
|
476
|
+
接口连通性、解析器能否跑通 fixture。这类逆向工具最怕静默失败。
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## 六、健壮性设计
|
|
481
|
+
|
|
482
|
+
### 错误分类
|
|
483
|
+
|
|
484
|
+
```python
|
|
485
|
+
class LingguangError(Exception): ...
|
|
486
|
+
class AuthError(LingguangError): # cookie 失效,提示重新导入
|
|
487
|
+
class RateLimitError(LingguangError): # 退避重试
|
|
488
|
+
class ContractError(LingguangError): # 接口结构变了,带原始样本
|
|
489
|
+
class TransportError(LingguangError): # 网络问题,重试
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**`ContractError` 是这类工具的生命线。** 所有解析器在结构不符时抛它,
|
|
493
|
+
带原始样本片段。接口一改立刻定位,不会静默返回空数据。
|
|
494
|
+
|
|
495
|
+
### 重试策略
|
|
496
|
+
|
|
497
|
+
只对幂等操作和传输错误重试。
|
|
498
|
+
提问接口**不重试**(会重复发送)。删除接口幂等可以重试。
|
|
499
|
+
|
|
500
|
+
### 版本探测
|
|
501
|
+
|
|
502
|
+
每次启动记录 `appInfo.version`,变了就在 stderr 提示
|
|
503
|
+
"上游版本变化,注意解析兼容性"。当前是 `v1.0.56`。
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
## 七、测试策略
|
|
508
|
+
|
|
509
|
+
**第一层:解析器单测**
|
|
510
|
+
用真实 artifact HTML 当 fixture。
|
|
511
|
+
|
|
512
|
+
fixture 处理原则:**尽量原样保留,只做字面替换**。
|
|
513
|
+
标签结构、类名、delta 的分段方式本身就携带上游版本信息,
|
|
514
|
+
顺手重排缩进或换行会让 fixture 失去回归价值。
|
|
515
|
+
|
|
516
|
+
把"脱敏后的原始字节"和"解析后的期望值"分开存:
|
|
517
|
+
- `fixtures/answer_billing.raw.html`:只替换金额、商户名为占位符
|
|
518
|
+
- `fixtures/answer_billing.expected.json`:期望的正文和工具标题
|
|
519
|
+
|
|
520
|
+
覆盖五种情况:纯文本回复、长账单报告、带工具轨迹、空轨迹、含未知事件类型。
|
|
521
|
+
|
|
522
|
+
**第二层:契约测试**
|
|
523
|
+
打真实接口,只断言结构不断言内容。需要 cookie,`--live` 标记,默认跳过。
|
|
524
|
+
|
|
525
|
+
**第三层:新建会话回归**
|
|
526
|
+
专门测 `ask` 不带 session_id 能否建出会话。这是关键能力,容易因接口变动失效。
|
|
527
|
+
|
|
528
|
+
**第三层必须不污染真实数据**:测试提问加 `[e2e-test]` 前缀,
|
|
529
|
+
测试结束自动调删除接口清理。删除接口幂等,成本很低。
|
|
530
|
+
|
|
531
|
+
**第四层:断流恢复**
|
|
532
|
+
模拟 SSE 中途断开,断言返回 `dispatched_unknown` 而非异常,
|
|
533
|
+
且 `get_answer` 能通过 journal 恢复出结果。
|
|
534
|
+
|
|
535
|
+
---
|
|
536
|
+
|
|
537
|
+
## 八、实施顺序与提交点
|
|
538
|
+
|
|
539
|
+
每一步单独 commit。
|
|
540
|
+
|
|
541
|
+
1. **项目骨架**:`pyproject.toml`(uv)、`.gitignore`、README -> `chore: 初始化项目骨架`
|
|
542
|
+
2. **解析器 + fixture 测试**(最关键)-> `feat: artifact 与 cot 双通道解析器`
|
|
543
|
+
3. **传输层 + 业务层**:`client.py`、`service.py`、`sink.py` -> `feat: HTTP 客户端与业务编排`
|
|
544
|
+
4. **认证与错误分类** -> `feat: 认证管理与错误分类`
|
|
545
|
+
5. **CLI** -> `feat: CLI 命令集`
|
|
546
|
+
6. **MCP server** -> `feat: MCP stdio server`
|
|
547
|
+
7. **文档**:`docs/reverse-notes.md` 记录全部接口细节 -> `docs: 接口逆向记录`
|
|
548
|
+
|
|
549
|
+
先做解析器和 core,MCP 是最薄的一层放最后。
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## 九、明确不做的事
|
|
554
|
+
|
|
555
|
+
- **不做自动登录**。支付宝/钉钉扫码无法绕过,cookie 手动导入一次
|
|
556
|
+
- **不做对话内多轮编排**。Agent 自己会多轮调工具,外面再包是重复劳动
|
|
557
|
+
- **不做写入类操作**(发消息、改配置)。工具只读不写
|
|
558
|
+
- **不做 Web UI**
|
|
559
|
+
- **不做 SQLite**。留 MessageSink 接口,将来需要再加
|
|
560
|
+
|
|
561
|
+
---
|
|
562
|
+
|
|
563
|
+
## 十、目录布局
|
|
564
|
+
|
|
565
|
+
```
|
|
566
|
+
lingguang-mcp/
|
|
567
|
+
pyproject.toml
|
|
568
|
+
README.md
|
|
569
|
+
.gitignore
|
|
570
|
+
src/lingguang/
|
|
571
|
+
__init__.py
|
|
572
|
+
core/ # 纯业务,cli 与 mcp 只能通过这里访问
|
|
573
|
+
errors.py
|
|
574
|
+
models.py
|
|
575
|
+
parser.py
|
|
576
|
+
client.py
|
|
577
|
+
service.py
|
|
578
|
+
sink.py
|
|
579
|
+
auth.py
|
|
580
|
+
cli/ (main.py commands.py)
|
|
581
|
+
mcp/ (server.py tools.py)
|
|
582
|
+
tests/
|
|
583
|
+
fixtures/ (脱敏后的真实样本 + 期望值)
|
|
584
|
+
test_parser.py
|
|
585
|
+
test_contract.py
|
|
586
|
+
docs/
|
|
587
|
+
reverse-notes.md
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**分层约束写进 README**:`cli/` 和 `mcp/` 只准 import `core` 下的
|
|
591
|
+
`service`、`models`、`errors`,不准直接碰 `client` 和 `parser`。
|
|
592
|
+
这样维护者能一眼看出哪些是稳定接口。
|
|
593
|
+
|
|
594
|
+
`docs/reverse-notes.md` 单独维护,记录每个接口的发现时间、
|
|
595
|
+
请求体样例、已知坑。半年后回看,这份文档比代码更有价值。
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
## 十一、接口速查
|
|
600
|
+
|
|
601
|
+
```
|
|
602
|
+
POST /chat/stream_chat 提问(SSE),sceneCode=WEB
|
|
603
|
+
POST /chat/queryHistoryChatSession.json 会话列表,sceneCode=LEOPARD
|
|
604
|
+
POST /chat/queryHistoryChatContent.json 对话全文,pageSize<=30
|
|
605
|
+
POST /chat/deleteChatSession.json 删除会话,幂等
|
|
606
|
+
GET /channel/getAuthToken.json WS 通道凭据
|
|
607
|
+
POST /user/loginCheck.json 登录态
|
|
608
|
+
POST /user/queryUserInfo.json 用户信息
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
鉴权:cookie 里的 `ctoken`。关键 cookie 有 `ctoken`、
|
|
612
|
+
`prod_LINGGUANGJSESSIONID`、`spanner`。
|
|
613
|
+
|
|
614
|
+
跨站请求时注意 CORS 只放行 `https://www.lingguang.com`,
|
|
615
|
+
服务端调用要带 `Origin` 和 `Referer` 头。
|