n1mem 0.1.3__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.
- n1mem-0.1.3/PKG-INFO +91 -0
- n1mem-0.1.3/README.md +134 -0
- n1mem-0.1.3/README.n1mem.md +74 -0
- n1mem-0.1.3/n1mem/__init__.py +21 -0
- n1mem-0.1.3/n1mem/client.py +244 -0
- n1mem-0.1.3/n1mem.egg-info/PKG-INFO +91 -0
- n1mem-0.1.3/n1mem.egg-info/SOURCES.txt +14 -0
- n1mem-0.1.3/n1mem.egg-info/dependency_links.txt +1 -0
- n1mem-0.1.3/n1mem.egg-info/requires.txt +3 -0
- n1mem-0.1.3/n1mem.egg-info/top_level.txt +1 -0
- n1mem-0.1.3/pyproject.toml +33 -0
- n1mem-0.1.3/setup.cfg +4 -0
- n1mem-0.1.3/tests/test_n1mem_cloud.py +115 -0
- n1mem-0.1.3/tests/test_sdk.py +75 -0
- n1mem-0.1.3/tests/test_w10_e2e.py +205 -0
- n1mem-0.1.3/tests/test_w92_sdk.py +164 -0
n1mem-0.1.3/PKG-INFO
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: n1mem
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: N1Mem memory API client (BYOK, zero hard dependencies)
|
|
5
|
+
Author: N1Mem (powered by T1Mem engine)
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://www.n1mem.com
|
|
8
|
+
Keywords: memory,llm,rag,agent,n1mem,t1mem
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Provides-Extra: async
|
|
16
|
+
Requires-Dist: httpx>=0.24; extra == "async"
|
|
17
|
+
|
|
18
|
+
# n1mem · N1Mem 记忆 API Python SDK
|
|
19
|
+
|
|
20
|
+
> powered by T1Mem engine · BYOK(自带 Key)· 同步零依赖
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install n1mem # 同步版,零硬依赖
|
|
26
|
+
pip install n1mem[async] # 需要异步客户端时(依赖 httpx)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 10 行代码跑通
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from n1mem import N1Mem
|
|
33
|
+
|
|
34
|
+
m = N1Mem(api_key="tk_xxx") # 或设环境变量 N1MEM_API_KEY 后不传参
|
|
35
|
+
|
|
36
|
+
print(m.health()) # 服务健康 + provider 可用性
|
|
37
|
+
m.ingest("我今天换了新工位,在 3 楼靠窗")
|
|
38
|
+
print(m.recall("我坐哪儿"))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
| 方法 | 对应端点 | 说明 |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| 方法 | 对应端点 | 说明 |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `health()` | `GET /health` | 健康与 provider 状态,无需鉴权 |
|
|
48
|
+
| `metrics()` | `GET /metrics` | Prometheus 文本指标 |
|
|
49
|
+
| `ingest(text, purpose="recall")` | `POST /v1/ingest` | 写入一段记忆(持久化 + 建向量,写入即可召回) |
|
|
50
|
+
| `recall(prompt, purpose="recall")` | `POST /v1/recall` | 按提示召回;返回 `answer`(已基于命中记忆接地)与 `retrieved`(命中明文) |
|
|
51
|
+
| `ask / update / forget / list` | — | **尚未提供**,调用会明确抛 `NotImplementedError` |
|
|
52
|
+
|
|
53
|
+
异步版 `AsyncN1Mem` 接口完全一致:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
async with AsyncN1Mem(api_key="tk_xxx") as m:
|
|
57
|
+
await m.ingest("…")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 错误处理
|
|
61
|
+
|
|
62
|
+
上游 4xx/5xx 统一抛 `N1MemError`,带 `status` / `message` / `endpoint`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from n1mem import N1MemError
|
|
66
|
+
try:
|
|
67
|
+
m.ingest("x")
|
|
68
|
+
except N1MemError as e:
|
|
69
|
+
print(e.status, e.endpoint, e.message) # 401 /v1/ingest {"detail":"invalid api key"}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 当前能力边界
|
|
73
|
+
|
|
74
|
+
- `ingest()` 会把内容持久化到 N1Mem 存储层并生成向量,返回 `{stored, embedded, memory_id}`;写入后即可被召回。
|
|
75
|
+
- `recall()` 走关键词 + 向量混合检索(hybrid),返回 `{answer, retrieved, recall_mode, ...}`。其中 `answer` 已基于命中的记忆做接地生成;若未命中任何记忆,会诚实返回未命中。
|
|
76
|
+
- `ask / update / forget / list` 尚未提供,SDK 会明确抛 `NotImplementedError`,而不是静默返回空。
|
|
77
|
+
|
|
78
|
+
> 历史勘误:v0.1.2 及更早 README 写「Phase 0 不持久化、recall 取不回」,该描述已随 C-2 存储层和召回接地修复而过时,请勿再引用。
|
|
79
|
+
|
|
80
|
+
## 与 `t1mem_sdk` 的区别
|
|
81
|
+
|
|
82
|
+
本目录有两个包,**不要混用**:
|
|
83
|
+
|
|
84
|
+
| 包 | 对接对象 | 用途 |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `n1mem` | 线上 C-2 API(`https://api.n1mem.com`) | **对外发布**,本 README 描述的对象 |
|
|
87
|
+
| `t1mem_sdk` | 本地 t1mem-core API(`http://127.0.0.1:8080`) | 内部使用,接口为 `/memories`、`/sessions`、`/stats` |
|
|
88
|
+
|
|
89
|
+
## 相关:MCP Server
|
|
90
|
+
|
|
91
|
+
想让 Claude / Cursor 直连,见 `src/t1mem-core/mcp_server/`(C-3)。
|
n1mem-0.1.3/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# t1mem-sdk-py · v0.3.0 发布说明
|
|
2
|
+
|
|
3
|
+
> ⚠ 本目录现有**两个包**,请勿混用:
|
|
4
|
+
> - **`t1mem_sdk`**(本文件描述)—— **内部** SDK,对接本地 t1mem-core API
|
|
5
|
+
> (`http://127.0.0.1:8080`,端点为 `/memories`、`/sessions`、`/stats`)
|
|
6
|
+
> - **`n1mem`** —— **对外** SDK,对接线上 C-2 API(`https://api.n1mem.com`),
|
|
7
|
+
> 文档见 **[README.n1mem.md](README.n1mem.md)**
|
|
8
|
+
|
|
9
|
+
> 发布日期:2026-06-20 · W9.2 阶段 · 负责人:祝融·Vulcan
|
|
10
|
+
|
|
11
|
+
## 🎯 概览
|
|
12
|
+
|
|
13
|
+
T1Mem Python SDK v0.3.0 — 零依赖同步客户端,10 分钟接入 T1Mem Core API。
|
|
14
|
+
|
|
15
|
+
| 指标 | v0.2.0 | v0.3.0 | 提升 |
|
|
16
|
+
|------|--------|--------|------|
|
|
17
|
+
| API 方法数 | 9 | **11** | +22% |
|
|
18
|
+
| 测试用例 | 6 | **24** | +300% |
|
|
19
|
+
| 测试覆盖 | 端到端 6 类 | **端到端 24 类 + SDK ↔ TS e2e** | 完整 |
|
|
20
|
+
| 鉴权支持 | api_key 参数 | **api_key 参数 + 自动 Bearer** | OK |
|
|
21
|
+
| 错误类型 | T1MemError(status, body) | **同左 + 详细断言** | 增强 |
|
|
22
|
+
| 外部依赖 | 0(纯 urllib) | **0(纯 urllib)** | 保持 |
|
|
23
|
+
|
|
24
|
+
## 📦 安装
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install t1mem-sdk
|
|
28
|
+
# 或
|
|
29
|
+
pip install git+https://gitee.com/suyupeng/t1mem-sdk-py.git@v0.3.0
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 🚀 5 分钟上手
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from t1mem_sdk import T1MemClient
|
|
36
|
+
|
|
37
|
+
# 1. 创建客户端
|
|
38
|
+
client = T1MemClient(
|
|
39
|
+
base_url="http://127.0.0.1:8080",
|
|
40
|
+
api_key="tk_xxx", # dev 模式可省略
|
|
41
|
+
timeout=10.0,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# 2. 写入一条 memory
|
|
45
|
+
m = client.ingest(
|
|
46
|
+
session_id="user-123",
|
|
47
|
+
role="user",
|
|
48
|
+
content="用户偏好:深色模式 + 中文",
|
|
49
|
+
tags=["preference", "ui"],
|
|
50
|
+
metadata={"source": "onboarding"},
|
|
51
|
+
)
|
|
52
|
+
print(f"saved: {m.id}")
|
|
53
|
+
|
|
54
|
+
# 3. 批量写入
|
|
55
|
+
items = [
|
|
56
|
+
{"session_id": "user-123", "role": "user", "content": "msg-1"},
|
|
57
|
+
{"session_id": "user-123", "role": "assistant", "content": "msg-2"},
|
|
58
|
+
]
|
|
59
|
+
results = client.ingest_batch(items)
|
|
60
|
+
|
|
61
|
+
# 4. 列出 session
|
|
62
|
+
for m in client.list_session("user-123", limit=50):
|
|
63
|
+
print(f"[{m.role}] {m.content}")
|
|
64
|
+
|
|
65
|
+
# 5. 检索(text/vec/hybrid 三种模式)
|
|
66
|
+
hits = client.search("user-123", "深色模式", mode="hybrid", limit=5)
|
|
67
|
+
for h in hits:
|
|
68
|
+
print(f"hit: {h.id} - {h.content}")
|
|
69
|
+
|
|
70
|
+
# 6. 单条查询
|
|
71
|
+
m = client.get(m.id)
|
|
72
|
+
|
|
73
|
+
# 7. 统计
|
|
74
|
+
print(client.stats())
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 📚 API 完整列表
|
|
78
|
+
|
|
79
|
+
| 方法 | 用途 | 返回 |
|
|
80
|
+
|------|------|------|
|
|
81
|
+
| `health()` | 健康检查 | `dict` |
|
|
82
|
+
| `whoami()` | 当前 API key 信息 | `dict` |
|
|
83
|
+
| `stats()` | 系统统计 | `Stats` |
|
|
84
|
+
| `ingest(sid, role, content, **)` | 写入单条 | `Memory` |
|
|
85
|
+
| `ingest_batch(items)` | 批量写入 | `list[Memory]` |
|
|
86
|
+
| `get(memory_id)` | 单条查询 | `Memory` |
|
|
87
|
+
| `list_session(sid, limit, offset)` | 列出 session | `list[Memory]` |
|
|
88
|
+
| `search(sid, query, limit, *, mode)` | 检索(text/vec/hybrid) | `list[Memory]` |
|
|
89
|
+
|
|
90
|
+
## 🧪 测试矩阵(24/24 全过)
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
tests/test_sdk.py · 6 项 · 原 SDK 集成
|
|
94
|
+
tests/test_w92_sdk.py · 12 项 · W9.2 增强
|
|
95
|
+
tests/test_w10_e2e.py · 6 项 · 跨 SDK e2e
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**关键场景**:
|
|
99
|
+
- ✅ 4 种 role(user/assistant/system/tool)全过
|
|
100
|
+
- ✅ 中文 + emoji + 特殊字符 round-trip
|
|
101
|
+
- ✅ 批量 50 条 < 5s
|
|
102
|
+
- ✅ 跨 SDK 互通(Py 写 → TS 读 ID 一致)
|
|
103
|
+
- ✅ 跨 SDK 检索一致(同 query ID 集合相同)
|
|
104
|
+
- ✅ 鉴权/错误(401/404/422 抛 T1MemError)
|
|
105
|
+
|
|
106
|
+
## 🔄 W9.2 关键变更
|
|
107
|
+
|
|
108
|
+
### 新增
|
|
109
|
+
- `Memory.org_id` 字段(`v0.2.0` 缺)
|
|
110
|
+
- `search(..., *, mode="hybrid")` 参数(`text`/`vec`/`hybrid`)
|
|
111
|
+
- `test_w92_sdk.py` · 12 项 SDK 单元测试
|
|
112
|
+
- `test_w10_e2e.py` · 6 项跨 SDK 集成测试
|
|
113
|
+
|
|
114
|
+
### 改进
|
|
115
|
+
- 错误信息更详细(T1MemError.code + T1MemError.body)
|
|
116
|
+
- `list_session` 支持 `offset` 分页
|
|
117
|
+
- `ingest` 支持 `metadata` 合并 `tags`(自动去重)
|
|
118
|
+
|
|
119
|
+
## 🚧 已知限制
|
|
120
|
+
|
|
121
|
+
- 同步客户端(无 async/await)— 后续可加 aiohttp 版本
|
|
122
|
+
- 不支持流式响应(write/read 都是完整 JSON)
|
|
123
|
+
- retry/backoff 需调用方自行处理
|
|
124
|
+
- 批量上限 1000 条/次(受 API 限制)
|
|
125
|
+
|
|
126
|
+
## 📅 下一步
|
|
127
|
+
|
|
128
|
+
- W10.1:TypeScript SDK 同等增强(仓颉·Cangjie 已在做)
|
|
129
|
+
- W10.2:发布到 Gitee 私有仓(`t1mem-sdk-py`)
|
|
130
|
+
- W11:Phase 2 闸门验收(Lead)
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
Made with ❤️ by 祝融·Vulcan (T1Mem AI 团队 · backend)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# n1mem · N1Mem 记忆 API Python SDK
|
|
2
|
+
|
|
3
|
+
> powered by T1Mem engine · BYOK(自带 Key)· 同步零依赖
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install n1mem # 同步版,零硬依赖
|
|
9
|
+
pip install n1mem[async] # 需要异步客户端时(依赖 httpx)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 10 行代码跑通
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from n1mem import N1Mem
|
|
16
|
+
|
|
17
|
+
m = N1Mem(api_key="tk_xxx") # 或设环境变量 N1MEM_API_KEY 后不传参
|
|
18
|
+
|
|
19
|
+
print(m.health()) # 服务健康 + provider 可用性
|
|
20
|
+
m.ingest("我今天换了新工位,在 3 楼靠窗")
|
|
21
|
+
print(m.recall("我坐哪儿"))
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## API
|
|
25
|
+
|
|
26
|
+
| 方法 | 对应端点 | 说明 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| 方法 | 对应端点 | 说明 |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `health()` | `GET /health` | 健康与 provider 状态,无需鉴权 |
|
|
31
|
+
| `metrics()` | `GET /metrics` | Prometheus 文本指标 |
|
|
32
|
+
| `ingest(text, purpose="recall")` | `POST /v1/ingest` | 写入一段记忆(持久化 + 建向量,写入即可召回) |
|
|
33
|
+
| `recall(prompt, purpose="recall")` | `POST /v1/recall` | 按提示召回;返回 `answer`(已基于命中记忆接地)与 `retrieved`(命中明文) |
|
|
34
|
+
| `ask / update / forget / list` | — | **尚未提供**,调用会明确抛 `NotImplementedError` |
|
|
35
|
+
|
|
36
|
+
异步版 `AsyncN1Mem` 接口完全一致:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
async with AsyncN1Mem(api_key="tk_xxx") as m:
|
|
40
|
+
await m.ingest("…")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## 错误处理
|
|
44
|
+
|
|
45
|
+
上游 4xx/5xx 统一抛 `N1MemError`,带 `status` / `message` / `endpoint`:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from n1mem import N1MemError
|
|
49
|
+
try:
|
|
50
|
+
m.ingest("x")
|
|
51
|
+
except N1MemError as e:
|
|
52
|
+
print(e.status, e.endpoint, e.message) # 401 /v1/ingest {"detail":"invalid api key"}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 当前能力边界
|
|
56
|
+
|
|
57
|
+
- `ingest()` 会把内容持久化到 N1Mem 存储层并生成向量,返回 `{stored, embedded, memory_id}`;写入后即可被召回。
|
|
58
|
+
- `recall()` 走关键词 + 向量混合检索(hybrid),返回 `{answer, retrieved, recall_mode, ...}`。其中 `answer` 已基于命中的记忆做接地生成;若未命中任何记忆,会诚实返回未命中。
|
|
59
|
+
- `ask / update / forget / list` 尚未提供,SDK 会明确抛 `NotImplementedError`,而不是静默返回空。
|
|
60
|
+
|
|
61
|
+
> 历史勘误:v0.1.2 及更早 README 写「Phase 0 不持久化、recall 取不回」,该描述已随 C-2 存储层和召回接地修复而过时,请勿再引用。
|
|
62
|
+
|
|
63
|
+
## 与 `t1mem_sdk` 的区别
|
|
64
|
+
|
|
65
|
+
本目录有两个包,**不要混用**:
|
|
66
|
+
|
|
67
|
+
| 包 | 对接对象 | 用途 |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `n1mem` | 线上 C-2 API(`https://api.n1mem.com`) | **对外发布**,本 README 描述的对象 |
|
|
70
|
+
| `t1mem_sdk` | 本地 t1mem-core API(`http://127.0.0.1:8080`) | 内部使用,接口为 `/memories`、`/sessions`、`/stats` |
|
|
71
|
+
|
|
72
|
+
## 相关:MCP Server
|
|
73
|
+
|
|
74
|
+
想让 Claude / Cursor 直连,见 `src/t1mem-core/mcp_server/`(C-3)。
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""N1Mem —— 记忆 API 的 Python SDK(BYOK,零硬依赖)。
|
|
2
|
+
|
|
3
|
+
快速开始::
|
|
4
|
+
|
|
5
|
+
from n1mem import N1Mem
|
|
6
|
+
m = N1Mem(api_key="tk_xxx")
|
|
7
|
+
m.ingest("我今天换了新工位,在 3 楼靠窗")
|
|
8
|
+
m.recall("我坐哪儿")
|
|
9
|
+
|
|
10
|
+
⚠ 当前 C-2 处于 Phase 0:写入尚未落真实存储层,召回不到刚写入的内容。
|
|
11
|
+
存储层(Phase 1)接入后本说明会更新。
|
|
12
|
+
"""
|
|
13
|
+
from .client import (
|
|
14
|
+
DEFAULT_BASE_URL,
|
|
15
|
+
AsyncN1Mem,
|
|
16
|
+
N1Mem,
|
|
17
|
+
N1MemError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.2"
|
|
21
|
+
__all__ = ["N1Mem", "AsyncN1Mem", "N1MemError", "DEFAULT_BASE_URL", "__version__"]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""N1Mem 对外 Python SDK —— 对接线上 C-2 API。
|
|
2
|
+
|
|
3
|
+
设计原则:
|
|
4
|
+
* **零硬依赖**:同步客户端只用标准库 urllib,`pip install n1mem` 不拖任何包。
|
|
5
|
+
* **异步可选**:`pip install n1mem[async]` 才装 httpx;不装也能用同步版。
|
|
6
|
+
* **BYOK**:api_key 由调用方提供,SDK 不存储、不上传任何凭据。
|
|
7
|
+
* **诚实**:线上暂未提供的能力(ask/update/forget/list)**明确抛 NotImplementedError**,
|
|
8
|
+
绝不伪装成"已支持"。这是本项目「可信第一」定位的底线。
|
|
9
|
+
|
|
10
|
+
C-2 当前已提供:/health、/metrics、/v1/ingest(持久化 + 向量化)、/v1/recall
|
|
11
|
+
(hybrid 检索 + 基于命中记忆接地的 answer)。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json as _json
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
__all__ = ["N1Mem", "AsyncN1Mem", "N1MemError", "DEFAULT_BASE_URL"]
|
|
22
|
+
|
|
23
|
+
# 默认指向官方品牌域名 api.n1mem.com(已绑定到 live C-2 公网端点:阿里云 FC 香港,免备案)。
|
|
24
|
+
# 该域名是项目自有的稳定入口,优于 FC 自动生成的默认域名(重建函数可能变更)。
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.n1mem.com"
|
|
26
|
+
|
|
27
|
+
# 线上 C-2 当前具备的业务端点(Phase 0)
|
|
28
|
+
_SUPPORTED = ("/health", "/metrics", "/v1/ingest", "/v1/recall")
|
|
29
|
+
|
|
30
|
+
# 规划中、线上尚未提供的能力(C-2 Phase 1 待接入)
|
|
31
|
+
_NOT_READY = {
|
|
32
|
+
"ask": "需 C-2 提供 /v1/ask(规划中)",
|
|
33
|
+
"update": "需 C-2 提供 /v1/memories/{id} 更新(规划中,且依赖存储层)",
|
|
34
|
+
"forget": "需 C-2 提供 /v1/memories/{id} 删除(规划中,且依赖存储层)",
|
|
35
|
+
"list": "需 C-2 提供 /v1/memories 列表(规划中,且依赖存储层)",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class N1MemError(Exception):
|
|
40
|
+
"""API 返回错误。"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, status: int, message: str, endpoint: str = ""):
|
|
43
|
+
super().__init__(f"[{status}] {message}" + (f" ({endpoint})" if endpoint else ""))
|
|
44
|
+
self.status = status
|
|
45
|
+
self.message = message
|
|
46
|
+
self.endpoint = endpoint
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Base:
|
|
50
|
+
def __init__(self, api_key: str | None = None,
|
|
51
|
+
base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0):
|
|
52
|
+
self.api_key = api_key
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
|
|
56
|
+
# ---- 内部:请求构造 ----
|
|
57
|
+
def _url(self, path: str, params: dict | None = None) -> str:
|
|
58
|
+
u = self.base_url + path
|
|
59
|
+
if params:
|
|
60
|
+
u += "?" + urllib.parse.urlencode(params)
|
|
61
|
+
return u
|
|
62
|
+
|
|
63
|
+
def _headers(self, with_body: bool = False) -> dict:
|
|
64
|
+
h = {"Accept": "application/json"}
|
|
65
|
+
if with_body:
|
|
66
|
+
h["Content-Type"] = "application/json"
|
|
67
|
+
if self.api_key:
|
|
68
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
69
|
+
return h
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _raise_from(exc: urllib.error.HTTPError, path: str):
|
|
73
|
+
try:
|
|
74
|
+
body = exc.read().decode("utf-8", "replace")
|
|
75
|
+
except Exception:
|
|
76
|
+
body = ""
|
|
77
|
+
raise N1MemError(exc.code, body or exc.reason, path) from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class N1Mem(_Base):
|
|
81
|
+
"""同步客户端。
|
|
82
|
+
|
|
83
|
+
用法::
|
|
84
|
+
|
|
85
|
+
from n1mem import N1Mem
|
|
86
|
+
m = N1Mem(api_key="tk_xxx") # 或环境变量 N1MEM_API_KEY
|
|
87
|
+
m.health()
|
|
88
|
+
m.ingest("我今天换了新工位")
|
|
89
|
+
m.recall("我坐哪儿")
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, api_key: str | None = None, **kw):
|
|
93
|
+
import os
|
|
94
|
+
super().__init__(api_key or os.environ.get("N1MEM_API_KEY"), **kw)
|
|
95
|
+
|
|
96
|
+
# ---- 低层 ----
|
|
97
|
+
def _request(self, method: str, path: str, body: dict | None = None,
|
|
98
|
+
params: dict | None = None) -> Any:
|
|
99
|
+
data = _json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
|
100
|
+
req = urllib.request.Request(
|
|
101
|
+
self._url(path, params), data=data, method=method,
|
|
102
|
+
headers=self._headers(with_body=data is not None))
|
|
103
|
+
try:
|
|
104
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
105
|
+
raw = r.read().decode("utf-8", "replace")
|
|
106
|
+
return _json.loads(raw) if raw else {}
|
|
107
|
+
except urllib.error.HTTPError as e:
|
|
108
|
+
self._raise_from(e, path)
|
|
109
|
+
except urllib.error.URLError as e:
|
|
110
|
+
raise N1MemError(0, f"connection error: {e.reason}", path) from e
|
|
111
|
+
|
|
112
|
+
# ---- 已支持 ----
|
|
113
|
+
def health(self) -> dict:
|
|
114
|
+
"""服务健康与 provider 可用性。无需鉴权。"""
|
|
115
|
+
return self._request("GET", "/health")
|
|
116
|
+
|
|
117
|
+
def metrics(self) -> str:
|
|
118
|
+
"""Prometheus 文本指标。无需鉴权。"""
|
|
119
|
+
req = urllib.request.Request(self._url("/metrics"),
|
|
120
|
+
headers=self._headers())
|
|
121
|
+
try:
|
|
122
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
123
|
+
return r.read().decode("utf-8", "replace")
|
|
124
|
+
except urllib.error.HTTPError as e:
|
|
125
|
+
self._raise_from(e, "/metrics")
|
|
126
|
+
except urllib.error.URLError as e:
|
|
127
|
+
raise N1MemError(0, f"connection error: {e.reason}", "/metrics") from e
|
|
128
|
+
|
|
129
|
+
def ingest(self, text: str, *, purpose: str = "recall") -> dict:
|
|
130
|
+
"""写入一段记忆。
|
|
131
|
+
|
|
132
|
+
内容会持久化到 N1Mem 存储层并生成向量,返回 `{stored, embedded, memory_id}`。
|
|
133
|
+
写入后即可被 `recall()` 召回。
|
|
134
|
+
"""
|
|
135
|
+
return self._request("POST", "/v1/ingest", {"text": text, "purpose": purpose})
|
|
136
|
+
|
|
137
|
+
def recall(self, prompt: str, *, purpose: str = "recall", mode: str | None = None) -> dict:
|
|
138
|
+
"""按提示召回记忆。
|
|
139
|
+
|
|
140
|
+
返回体含 `answer`(已基于命中记忆接地的生成回答)、`retrieved`(命中的明文记忆
|
|
141
|
+
列表),以及 `recall_mode`(keyword|semantic|hybrid)等路由信息。
|
|
142
|
+
|
|
143
|
+
mode=None 时用服务端默认(hybrid:关键词 + 向量 RRF 融合,可召回改写问法);
|
|
144
|
+
显式传 "keyword" 则回到纯关键词口径(用于回归对比)。
|
|
145
|
+
"""
|
|
146
|
+
body = {"prompt": prompt, "purpose": purpose}
|
|
147
|
+
if mode:
|
|
148
|
+
body["mode"] = mode
|
|
149
|
+
return self._request("POST", "/v1/recall", body)
|
|
150
|
+
|
|
151
|
+
# ---- 规划中(明确报错,不伪装)----
|
|
152
|
+
def ask(self, *a, **kw):
|
|
153
|
+
raise NotImplementedError(_NOT_READY["ask"])
|
|
154
|
+
|
|
155
|
+
def update(self, *a, **kw):
|
|
156
|
+
raise NotImplementedError(_NOT_READY["update"])
|
|
157
|
+
|
|
158
|
+
def forget(self, *a, **kw):
|
|
159
|
+
raise NotImplementedError(_NOT_READY["forget"])
|
|
160
|
+
|
|
161
|
+
def list(self, *a, **kw):
|
|
162
|
+
raise NotImplementedError(_NOT_READY["list"])
|
|
163
|
+
|
|
164
|
+
def __repr__(self) -> str:
|
|
165
|
+
key = (self.api_key[:6] + "…") if self.api_key else "None"
|
|
166
|
+
return f"N1Mem(base_url={self.base_url!r}, api_key={key!r})"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AsyncN1Mem(_Base):
|
|
170
|
+
"""异步客户端(需 `pip install n1mem[async]`)。
|
|
171
|
+
|
|
172
|
+
用法::
|
|
173
|
+
|
|
174
|
+
async with AsyncN1Mem(api_key="tk_xxx") as m:
|
|
175
|
+
await m.ingest("…")
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, api_key: str | None = None, **kw):
|
|
179
|
+
import os
|
|
180
|
+
super().__init__(api_key or os.environ.get("N1MEM_API_KEY"), **kw)
|
|
181
|
+
try:
|
|
182
|
+
import httpx # noqa: F401
|
|
183
|
+
except ImportError as e: # pragma: no cover
|
|
184
|
+
raise ImportError(
|
|
185
|
+
"AsyncN1Mem 需要 httpx:`pip install n1mem[async]`") from e
|
|
186
|
+
self._client = None
|
|
187
|
+
|
|
188
|
+
async def __aenter__(self):
|
|
189
|
+
import httpx
|
|
190
|
+
self._client = httpx.AsyncClient(
|
|
191
|
+
base_url=self.base_url, timeout=self.timeout,
|
|
192
|
+
headers=self._headers(with_body=True))
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
async def __aexit__(self, *exc):
|
|
196
|
+
if self._client:
|
|
197
|
+
await self._client.aclose()
|
|
198
|
+
self._client = None
|
|
199
|
+
|
|
200
|
+
async def _arequest(self, method: str, path: str,
|
|
201
|
+
body: dict | None = None) -> Any:
|
|
202
|
+
import httpx
|
|
203
|
+
own = self._client is None
|
|
204
|
+
if own:
|
|
205
|
+
await self.__aenter__()
|
|
206
|
+
try:
|
|
207
|
+
r = await self._client.request(method, path, json=body)
|
|
208
|
+
if r.status_code >= 400:
|
|
209
|
+
raise N1MemError(r.status_code, r.text[:400], path)
|
|
210
|
+
ct = r.headers.get("content-type", "")
|
|
211
|
+
return r.json() if "json" in ct else r.text
|
|
212
|
+
finally:
|
|
213
|
+
if own:
|
|
214
|
+
await self.__aexit__()
|
|
215
|
+
|
|
216
|
+
async def health(self) -> dict:
|
|
217
|
+
return await self._arequest("GET", "/health")
|
|
218
|
+
|
|
219
|
+
async def metrics(self) -> str:
|
|
220
|
+
return await self._arequest("GET", "/metrics")
|
|
221
|
+
|
|
222
|
+
async def ingest(self, text: str, *, purpose: str = "recall") -> dict:
|
|
223
|
+
return await self._arequest("POST", "/v1/ingest",
|
|
224
|
+
{"text": text, "purpose": purpose})
|
|
225
|
+
|
|
226
|
+
async def recall(self, prompt: str, *, purpose: str = "recall",
|
|
227
|
+
mode: str | None = None) -> dict:
|
|
228
|
+
body = {"prompt": prompt, "purpose": purpose}
|
|
229
|
+
if mode:
|
|
230
|
+
body["mode"] = mode
|
|
231
|
+
return await self._arequest("POST", "/v1/recall", body)
|
|
232
|
+
|
|
233
|
+
# ---- 规划中(明确报错,不伪装;与同步版口径一致)----
|
|
234
|
+
async def ask(self, *a, **kw):
|
|
235
|
+
raise NotImplementedError(_NOT_READY["ask"])
|
|
236
|
+
|
|
237
|
+
async def update(self, *a, **kw):
|
|
238
|
+
raise NotImplementedError(_NOT_READY["update"])
|
|
239
|
+
|
|
240
|
+
async def forget(self, *a, **kw):
|
|
241
|
+
raise NotImplementedError(_NOT_READY["forget"])
|
|
242
|
+
|
|
243
|
+
async def list(self, *a, **kw):
|
|
244
|
+
raise NotImplementedError(_NOT_READY["list"])
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: n1mem
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: N1Mem memory API client (BYOK, zero hard dependencies)
|
|
5
|
+
Author: N1Mem (powered by T1Mem engine)
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://www.n1mem.com
|
|
8
|
+
Keywords: memory,llm,rag,agent,n1mem,t1mem
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Provides-Extra: async
|
|
16
|
+
Requires-Dist: httpx>=0.24; extra == "async"
|
|
17
|
+
|
|
18
|
+
# n1mem · N1Mem 记忆 API Python SDK
|
|
19
|
+
|
|
20
|
+
> powered by T1Mem engine · BYOK(自带 Key)· 同步零依赖
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install n1mem # 同步版,零硬依赖
|
|
26
|
+
pip install n1mem[async] # 需要异步客户端时(依赖 httpx)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 10 行代码跑通
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from n1mem import N1Mem
|
|
33
|
+
|
|
34
|
+
m = N1Mem(api_key="tk_xxx") # 或设环境变量 N1MEM_API_KEY 后不传参
|
|
35
|
+
|
|
36
|
+
print(m.health()) # 服务健康 + provider 可用性
|
|
37
|
+
m.ingest("我今天换了新工位,在 3 楼靠窗")
|
|
38
|
+
print(m.recall("我坐哪儿"))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
| 方法 | 对应端点 | 说明 |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| 方法 | 对应端点 | 说明 |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `health()` | `GET /health` | 健康与 provider 状态,无需鉴权 |
|
|
48
|
+
| `metrics()` | `GET /metrics` | Prometheus 文本指标 |
|
|
49
|
+
| `ingest(text, purpose="recall")` | `POST /v1/ingest` | 写入一段记忆(持久化 + 建向量,写入即可召回) |
|
|
50
|
+
| `recall(prompt, purpose="recall")` | `POST /v1/recall` | 按提示召回;返回 `answer`(已基于命中记忆接地)与 `retrieved`(命中明文) |
|
|
51
|
+
| `ask / update / forget / list` | — | **尚未提供**,调用会明确抛 `NotImplementedError` |
|
|
52
|
+
|
|
53
|
+
异步版 `AsyncN1Mem` 接口完全一致:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
async with AsyncN1Mem(api_key="tk_xxx") as m:
|
|
57
|
+
await m.ingest("…")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 错误处理
|
|
61
|
+
|
|
62
|
+
上游 4xx/5xx 统一抛 `N1MemError`,带 `status` / `message` / `endpoint`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from n1mem import N1MemError
|
|
66
|
+
try:
|
|
67
|
+
m.ingest("x")
|
|
68
|
+
except N1MemError as e:
|
|
69
|
+
print(e.status, e.endpoint, e.message) # 401 /v1/ingest {"detail":"invalid api key"}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 当前能力边界
|
|
73
|
+
|
|
74
|
+
- `ingest()` 会把内容持久化到 N1Mem 存储层并生成向量,返回 `{stored, embedded, memory_id}`;写入后即可被召回。
|
|
75
|
+
- `recall()` 走关键词 + 向量混合检索(hybrid),返回 `{answer, retrieved, recall_mode, ...}`。其中 `answer` 已基于命中的记忆做接地生成;若未命中任何记忆,会诚实返回未命中。
|
|
76
|
+
- `ask / update / forget / list` 尚未提供,SDK 会明确抛 `NotImplementedError`,而不是静默返回空。
|
|
77
|
+
|
|
78
|
+
> 历史勘误:v0.1.2 及更早 README 写「Phase 0 不持久化、recall 取不回」,该描述已随 C-2 存储层和召回接地修复而过时,请勿再引用。
|
|
79
|
+
|
|
80
|
+
## 与 `t1mem_sdk` 的区别
|
|
81
|
+
|
|
82
|
+
本目录有两个包,**不要混用**:
|
|
83
|
+
|
|
84
|
+
| 包 | 对接对象 | 用途 |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `n1mem` | 线上 C-2 API(`https://api.n1mem.com`) | **对外发布**,本 README 描述的对象 |
|
|
87
|
+
| `t1mem_sdk` | 本地 t1mem-core API(`http://127.0.0.1:8080`) | 内部使用,接口为 `/memories`、`/sessions`、`/stats` |
|
|
88
|
+
|
|
89
|
+
## 相关:MCP Server
|
|
90
|
+
|
|
91
|
+
想让 Claude / Cursor 直连,见 `src/t1mem-core/mcp_server/`(C-3)。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
README.n1mem.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
n1mem/__init__.py
|
|
5
|
+
n1mem/client.py
|
|
6
|
+
n1mem.egg-info/PKG-INFO
|
|
7
|
+
n1mem.egg-info/SOURCES.txt
|
|
8
|
+
n1mem.egg-info/dependency_links.txt
|
|
9
|
+
n1mem.egg-info/requires.txt
|
|
10
|
+
n1mem.egg-info/top_level.txt
|
|
11
|
+
tests/test_n1mem_cloud.py
|
|
12
|
+
tests/test_sdk.py
|
|
13
|
+
tests/test_w10_e2e.py
|
|
14
|
+
tests/test_w92_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
n1mem
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "n1mem"
|
|
7
|
+
version = "0.1.3"
|
|
8
|
+
description = "N1Mem memory API client (BYOK, zero hard dependencies)"
|
|
9
|
+
readme = "README.n1mem.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "N1Mem (powered by T1Mem engine)" }]
|
|
13
|
+
keywords = ["memory", "llm", "rag", "agent", "n1mem", "t1mem"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
async = ["httpx>=0.24"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://www.n1mem.com"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
include = ["n1mem*"]
|
|
30
|
+
|
|
31
|
+
# 说明:本文件只打包对外的 `n1mem` 包。
|
|
32
|
+
# 同目录下的 `t1mem_sdk/` 是**内部** SDK(对接本地 t1mem-core API),
|
|
33
|
+
# 接口与线上 C-2 不同套,不对外发布,故在此排除。
|
n1mem-0.1.3/setup.cfg
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""n1mem 对外 SDK 单测(全部 mock,零网络、零 API 花费)。
|
|
2
|
+
|
|
3
|
+
覆盖:
|
|
4
|
+
1. 默认端点与鉴权头正确
|
|
5
|
+
2. ingest / recall 请求体字段与线上 C-2 契约一致
|
|
6
|
+
3. 上游 401 / 429 正确转成 N1MemError
|
|
7
|
+
4. 未就绪能力(ask/update/forget/list)明确 NotImplementedError —— 不伪装支持
|
|
8
|
+
5. 无 api_key 时不带 Authorization 头
|
|
9
|
+
"""
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import urllib.error
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from n1mem import DEFAULT_BASE_URL, N1Mem, N1MemError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Resp(io.BytesIO):
|
|
20
|
+
def __init__(self, payload, status=200):
|
|
21
|
+
raw = payload if isinstance(payload, str) else json.dumps(payload)
|
|
22
|
+
super().__init__(raw.encode("utf-8"))
|
|
23
|
+
self.status = status
|
|
24
|
+
|
|
25
|
+
def __enter__(self):
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __exit__(self, *a):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _capture(monkeypatch):
|
|
33
|
+
"""拦截 urlopen,返回 (记录的请求, 预设响应设置器)。"""
|
|
34
|
+
box = {}
|
|
35
|
+
|
|
36
|
+
def fake_urlopen(req, timeout=None, **kw):
|
|
37
|
+
box["req"] = req
|
|
38
|
+
box["timeout"] = timeout
|
|
39
|
+
return box.get("resp", _Resp({"ok": True}))
|
|
40
|
+
|
|
41
|
+
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
42
|
+
return box
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_default_base_url_is_production():
|
|
46
|
+
m = N1Mem(api_key="tk_x")
|
|
47
|
+
assert m.base_url == DEFAULT_BASE_URL == "https://api.n1mem.com"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_auth_header_present(monkeypatch):
|
|
51
|
+
box = _capture(monkeypatch)
|
|
52
|
+
N1Mem(api_key="tk_abc").health()
|
|
53
|
+
assert box["req"].get_header("Authorization") == "Bearer tk_abc"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_no_key_no_auth_header(monkeypatch):
|
|
57
|
+
box = _capture(monkeypatch)
|
|
58
|
+
N1Mem(api_key=None).health()
|
|
59
|
+
assert box["req"].get_header("Authorization") is None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_ingest_body_matches_api_contract(monkeypatch):
|
|
63
|
+
box = _capture(monkeypatch)
|
|
64
|
+
N1Mem(api_key="tk").ingest("我今天换了新工位")
|
|
65
|
+
req = box["req"]
|
|
66
|
+
assert req.get_method() == "POST"
|
|
67
|
+
assert req.full_url == DEFAULT_BASE_URL + "/v1/ingest"
|
|
68
|
+
body = json.loads(req.data.decode("utf-8"))
|
|
69
|
+
assert body["text"] == "我今天换了新工位"
|
|
70
|
+
assert body["purpose"] == "recall"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_recall_body_matches_api_contract(monkeypatch):
|
|
74
|
+
box = _capture(monkeypatch)
|
|
75
|
+
N1Mem(api_key="tk").recall("我坐哪儿")
|
|
76
|
+
req = box["req"]
|
|
77
|
+
assert req.full_url == DEFAULT_BASE_URL + "/v1/recall"
|
|
78
|
+
body = json.loads(req.data.decode("utf-8"))
|
|
79
|
+
assert body["prompt"] == "我坐哪儿"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_health_returns_parsed_json(monkeypatch):
|
|
83
|
+
box = _capture(monkeypatch)
|
|
84
|
+
box["resp"] = _Resp({"status": "ok", "region": "cn-hongkong"})
|
|
85
|
+
out = N1Mem(api_key="tk").health()
|
|
86
|
+
assert out["status"] == "ok"
|
|
87
|
+
assert out["region"] == "cn-hongkong"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize("code", [401, 429])
|
|
91
|
+
def test_http_error_becomes_n1mem_error(code, monkeypatch):
|
|
92
|
+
def boom(req, timeout=None, **kw):
|
|
93
|
+
raise urllib.error.HTTPError(
|
|
94
|
+
DEFAULT_BASE_URL + "/v1/ingest", code, "err", {},
|
|
95
|
+
io.BytesIO(b'{"detail":"nope"}'))
|
|
96
|
+
|
|
97
|
+
monkeypatch.setattr("urllib.request.urlopen", boom)
|
|
98
|
+
with pytest.raises(N1MemError) as ei:
|
|
99
|
+
N1Mem(api_key="tk").ingest("x")
|
|
100
|
+
assert ei.value.status == code
|
|
101
|
+
assert "nope" in ei.value.message
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.parametrize("name", ["ask", "update", "forget", "list"])
|
|
105
|
+
def test_not_ready_capabilities_raise_clearly(name):
|
|
106
|
+
"""关键:未就绪的能力必须明确报错,绝不能静默伪装成可用。"""
|
|
107
|
+
with pytest.raises(NotImplementedError) as ei:
|
|
108
|
+
getattr(N1Mem(api_key="tk"), name)("x")
|
|
109
|
+
assert "C-2" in str(ei.value) or "规划中" in str(ei.value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_repr_hides_key():
|
|
113
|
+
r = repr(N1Mem(api_key="tk_secret_value_123"))
|
|
114
|
+
assert "secret_value_123" not in r
|
|
115
|
+
assert "tk_sec" in r
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""T1Mem SDK tests (integration with running server)."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
# Ensure SDK is on path
|
|
9
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
10
|
+
sys.path.insert(0, ROOT)
|
|
11
|
+
|
|
12
|
+
from t1mem_sdk import T1MemClient, T1MemError # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BASE_URL = os.environ.get("T1MEM_URL", "http://127.0.0.1:8080")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def client():
|
|
20
|
+
return T1MemClient(BASE_URL)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def session_id():
|
|
25
|
+
return f"test-{uuid.uuid4().hex[:8]}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_health(client):
|
|
29
|
+
h = client.health()
|
|
30
|
+
assert h["status"] == "ok"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_ingest_and_get(client, session_id):
|
|
34
|
+
m = client.ingest(session_id, "user", "SDK test message", tags=["pytest"])
|
|
35
|
+
assert m.session_id == session_id
|
|
36
|
+
assert m.content == "SDK test message"
|
|
37
|
+
assert "pytest" in m.metadata.get("tags", [])
|
|
38
|
+
|
|
39
|
+
fetched = client.get(m.id)
|
|
40
|
+
assert fetched.id == m.id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_list_and_search(client, session_id):
|
|
44
|
+
client.ingest(session_id, "user", "alpha")
|
|
45
|
+
client.ingest(session_id, "assistant", "beta")
|
|
46
|
+
client.ingest(session_id, "user", "T1Mem rocks")
|
|
47
|
+
|
|
48
|
+
items = client.list_session(session_id)
|
|
49
|
+
assert len(items) == 3
|
|
50
|
+
|
|
51
|
+
hits = client.search(session_id, "T1Mem")
|
|
52
|
+
assert len(hits) == 1
|
|
53
|
+
assert "T1Mem" in hits[0].content
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_stats(client):
|
|
57
|
+
s = client.stats()
|
|
58
|
+
assert s.total >= 0
|
|
59
|
+
assert s.last_24h >= 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_404_raises(client):
|
|
63
|
+
with pytest.raises(T1MemError) as exc:
|
|
64
|
+
client.get("nonexistent-" + uuid.uuid4().hex)
|
|
65
|
+
assert exc.value.status == 404
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_batch_ingest(client, session_id):
|
|
69
|
+
items = [
|
|
70
|
+
{"session_id": session_id, "role": "user", "content": f"msg-{i}"}
|
|
71
|
+
for i in range(5)
|
|
72
|
+
]
|
|
73
|
+
results = client.ingest_batch(items)
|
|
74
|
+
assert len(results) == 5
|
|
75
|
+
assert all(r.session_id == session_id for r in results)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""W10: SDK e2e 测试 — Python SDK ↔ TypeScript SDK 数据互通
|
|
2
|
+
|
|
3
|
+
场景:
|
|
4
|
+
1. Python SDK 写入 → TypeScript SDK 读取(验证序列化一致)
|
|
5
|
+
2. TypeScript SDK 写入 → Python SDK 读取
|
|
6
|
+
3. 两端 search 行为一致
|
|
7
|
+
4. 跨 SDK 鉴权一致性
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
20
|
+
sys.path.insert(0, ROOT)
|
|
21
|
+
|
|
22
|
+
from t1mem_sdk import T1MemClient, T1MemError # noqa: E402
|
|
23
|
+
|
|
24
|
+
BASE_URL = os.environ.get("T1MEM_URL", "http://127.0.0.1:8080")
|
|
25
|
+
TS_SDK_DIR = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))).parent / "t1mem-sdk-ts"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def py_client():
|
|
30
|
+
return T1MemClient(BASE_URL)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def shared_session():
|
|
35
|
+
"""e2e 共用 session_id"""
|
|
36
|
+
return f"e2e-{uuid.uuid4().hex[:8]}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _run_ts(args: list[str], env_extra: dict | None = None) -> tuple[int, str, str]:
|
|
40
|
+
"""跑一段 TS SDK 测试脚本,返回 (rc, stdout, stderr)"""
|
|
41
|
+
env = os.environ.copy()
|
|
42
|
+
env["T1MEM_URL"] = BASE_URL
|
|
43
|
+
if env_extra:
|
|
44
|
+
env.update(env_extra)
|
|
45
|
+
# 用绝对路径避开 Windows subprocess 找不到 npx
|
|
46
|
+
npx_bin = r"C:\Users\Administrator\.workbuddy\binaries\node\versions\22.22.2\npx.cmd"
|
|
47
|
+
r = subprocess.run(
|
|
48
|
+
[npx_bin, "tsx"] + args,
|
|
49
|
+
cwd=str(TS_SDK_DIR),
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=60,
|
|
53
|
+
env=env,
|
|
54
|
+
shell=True,
|
|
55
|
+
)
|
|
56
|
+
return r.returncode, r.stdout, r.stderr
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_w10_01_py_ingest_ts_read(py_client, shared_session):
|
|
60
|
+
"""W10.1: Python 写入 → TS 读取(通过临时 TS 脚本调 list)"""
|
|
61
|
+
py_client.ingest(shared_session, "user", "Python wrote this: T1Mem rocks")
|
|
62
|
+
py_client.ingest(shared_session, "assistant", "TS SDK should see this")
|
|
63
|
+
time.sleep(0.3)
|
|
64
|
+
|
|
65
|
+
# 让 TS 跑一个临时脚本去 list 这个 session
|
|
66
|
+
script = f"""
|
|
67
|
+
import {{ T1MemClient }} from "../src/index.ts";
|
|
68
|
+
const c = new T1MemClient({{ baseUrl: process.env.T1MEM_URL }});
|
|
69
|
+
const list = await c.listSession("{shared_session}", {{ limit: 10 }});
|
|
70
|
+
console.log(JSON.stringify({{ count: list.length, items: list.map(m => ({{ content: m.content, role: m.role, orgId: m.orgId }})) }}));
|
|
71
|
+
"""
|
|
72
|
+
script_path = TS_SDK_DIR / "tests" / "_tmp_e2e_list.ts"
|
|
73
|
+
script_path.write_text(script)
|
|
74
|
+
try:
|
|
75
|
+
rc, out, err = _run_ts(["tests/_tmp_e2e_list.ts"])
|
|
76
|
+
assert rc == 0, f"TS script failed: {err}"
|
|
77
|
+
data = json.loads(out.strip().splitlines()[-1])
|
|
78
|
+
assert data["count"] == 2
|
|
79
|
+
assert data["items"][0]["orgId"] == "default"
|
|
80
|
+
# 至少 1 条含 T1Mem(顺序可能因 timestamp 翻转)
|
|
81
|
+
assert any("T1Mem" in it["content"] for it in data["items"])
|
|
82
|
+
finally:
|
|
83
|
+
if script_path.exists():
|
|
84
|
+
script_path.unlink()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_w10_02_ts_ingest_py_read(py_client, shared_session):
|
|
88
|
+
"""W10.2: TS 写入 → Python 读取"""
|
|
89
|
+
script = f"""
|
|
90
|
+
import {{ T1MemClient }} from "../src/index.ts";
|
|
91
|
+
const c = new T1MemClient({{ baseUrl: process.env.T1MEM_URL }});
|
|
92
|
+
const m1 = await c.ingest({{ sessionId: "{shared_session}", role: "user", content: "TS wrote: T1Mem Python SDK", tags: ["from-ts"] }});
|
|
93
|
+
const m2 = await c.ingest({{ sessionId: "{shared_session}", role: "assistant", content: "Py SDK should see this" }});
|
|
94
|
+
console.log(JSON.stringify({{ ids: [m1.id, m2.id], tags: m1.metadata?.tags }}));
|
|
95
|
+
"""
|
|
96
|
+
script_path = TS_SDK_DIR / "tests" / "_tmp_e2e_ingest.ts"
|
|
97
|
+
script_path.write_text(script)
|
|
98
|
+
try:
|
|
99
|
+
rc, out, err = _run_ts(["tests/_tmp_e2e_ingest.ts"])
|
|
100
|
+
assert rc == 0, f"TS script failed: {err}"
|
|
101
|
+
data = json.loads(out.strip().splitlines()[-1])
|
|
102
|
+
ts_ids = set(data["ids"])
|
|
103
|
+
assert "from-ts" in (data.get("tags") or [])
|
|
104
|
+
finally:
|
|
105
|
+
if script_path.exists():
|
|
106
|
+
script_path.unlink()
|
|
107
|
+
|
|
108
|
+
time.sleep(0.3)
|
|
109
|
+
# Python 读
|
|
110
|
+
items = py_client.list_session(shared_session)
|
|
111
|
+
assert len(items) == 2
|
|
112
|
+
py_ids = {m.id for m in items}
|
|
113
|
+
# IDs 必须完全一致
|
|
114
|
+
assert py_ids == ts_ids, f"ID mismatch: py={py_ids}, ts={ts_ids}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_w10_03_search_consistency(py_client, shared_session):
|
|
118
|
+
"""W10.3: Python 和 TS 搜同一关键词,结果 ID 集合一致"""
|
|
119
|
+
# Py 写 3 条
|
|
120
|
+
py_client.ingest(shared_session, "user", "T1Mem 提供 Python 和 TypeScript SDK")
|
|
121
|
+
py_client.ingest(shared_session, "assistant", "T1Mem 是 Top-1 AI 记忆中间件")
|
|
122
|
+
py_client.ingest(shared_session, "user", "普通的文档")
|
|
123
|
+
time.sleep(0.5)
|
|
124
|
+
|
|
125
|
+
# Py 搜
|
|
126
|
+
py_hits = py_client.search(shared_session, "T1Mem", limit=10)
|
|
127
|
+
py_ids = sorted(m.id for m in py_hits)
|
|
128
|
+
|
|
129
|
+
# TS 搜
|
|
130
|
+
script = f"""
|
|
131
|
+
import {{ T1MemClient }} from "../src/index.ts";
|
|
132
|
+
const c = new T1MemClient({{ baseUrl: process.env.T1MEM_URL }});
|
|
133
|
+
const hits = await c.search("{shared_session}", "T1Mem", {{ limit: 10 }});
|
|
134
|
+
console.log(JSON.stringify(hits.map(h => h.id).sort()));
|
|
135
|
+
"""
|
|
136
|
+
script_path = TS_SDK_DIR / "tests" / "_tmp_e2e_search.ts"
|
|
137
|
+
script_path.write_text(script)
|
|
138
|
+
try:
|
|
139
|
+
rc, out, err = _run_ts(["tests/_tmp_e2e_search.ts"])
|
|
140
|
+
assert rc == 0, f"TS script failed: {err}"
|
|
141
|
+
ts_ids = sorted(json.loads(out.strip().splitlines()[-1]))
|
|
142
|
+
# 两个 SDK 搜到的 ID 集合必须一致
|
|
143
|
+
assert py_ids == ts_ids, f"Py={py_ids}, TS={ts_ids}"
|
|
144
|
+
finally:
|
|
145
|
+
if script_path.exists():
|
|
146
|
+
script_path.unlink()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_w10_04_chinese_round_trip(py_client, shared_session):
|
|
150
|
+
"""W10.4: 中文 + emoji 跨 SDK 互通"""
|
|
151
|
+
content = "T1Mem 测试中文 + emoji 🎉 + 特殊字符 !@#$%"
|
|
152
|
+
py_client.ingest(shared_session, "user", content)
|
|
153
|
+
time.sleep(0.3)
|
|
154
|
+
|
|
155
|
+
# Py 读
|
|
156
|
+
items = py_client.list_session(shared_session)
|
|
157
|
+
assert len(items) == 1
|
|
158
|
+
assert items[0].content == content
|
|
159
|
+
|
|
160
|
+
# TS 读
|
|
161
|
+
script = f"""
|
|
162
|
+
import {{ T1MemClient }} from "../src/index.ts";
|
|
163
|
+
const c = new T1MemClient({{ baseUrl: process.env.T1MEM_URL }});
|
|
164
|
+
const items = await c.listSession("{shared_session}", {{ limit: 5 }});
|
|
165
|
+
console.log(JSON.stringify({{ count: items.length, content: items[0]?.content }}));
|
|
166
|
+
"""
|
|
167
|
+
script_path = TS_SDK_DIR / "tests" / "_tmp_e2e_cn.ts"
|
|
168
|
+
script_path.write_text(script)
|
|
169
|
+
try:
|
|
170
|
+
rc, out, err = _run_ts(["tests/_tmp_e2e_cn.ts"])
|
|
171
|
+
assert rc == 0, f"TS script failed: {err}"
|
|
172
|
+
data = json.loads(out.strip().splitlines()[-1])
|
|
173
|
+
assert data["count"] == 1
|
|
174
|
+
assert data["content"] == content
|
|
175
|
+
finally:
|
|
176
|
+
if script_path.exists():
|
|
177
|
+
script_path.unlink()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_w10_05_batch_50_items(py_client, shared_session):
|
|
181
|
+
"""W10.5: 批量 50 条性能(< 5s)"""
|
|
182
|
+
items = [
|
|
183
|
+
{"session_id": shared_session, "role": "user", "content": f"batch-e2e-{i:03d}"}
|
|
184
|
+
for i in range(50)
|
|
185
|
+
]
|
|
186
|
+
t0 = time.time()
|
|
187
|
+
results = py_client.ingest_batch(items)
|
|
188
|
+
elapsed = time.time() - t0
|
|
189
|
+
assert len(results) == 50
|
|
190
|
+
assert elapsed < 5.0, f"batch took {elapsed:.2f}s, expected <5s"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_w10_06_ts_test_files_collect(shared_session):
|
|
194
|
+
"""W10.6: 验证 TS SDK 自带 3 个测试文件全过(ingest/recall/search)"""
|
|
195
|
+
rc, out, err = _run_ts([
|
|
196
|
+
"--test", "tests/ingest.test.mjs",
|
|
197
|
+
"--test", "tests/recall.test.mjs",
|
|
198
|
+
"--test", "tests/search.test.mjs",
|
|
199
|
+
])
|
|
200
|
+
# summary 末尾
|
|
201
|
+
lines = out.strip().splitlines()
|
|
202
|
+
last_summary = [l for l in lines if l.startswith("# ")]
|
|
203
|
+
summary_text = "\n".join(last_summary)
|
|
204
|
+
assert "# pass" in summary_text
|
|
205
|
+
assert "# fail 0" in summary_text, f"TS tests failed:\n{out[-2000:]}"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""W9.2: Python SDK 增强测试(祝融主)
|
|
2
|
+
|
|
3
|
+
覆盖:tags / metadata / 4 种 role / 错误处理 / 中文 / batch
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
13
|
+
sys.path.insert(0, ROOT)
|
|
14
|
+
|
|
15
|
+
from t1mem_sdk import T1MemClient, T1MemError # noqa: E402
|
|
16
|
+
|
|
17
|
+
BASE_URL = os.environ.get("T1MEM_URL", "http://127.0.0.1:8080")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def client():
|
|
22
|
+
return T1MemClient(BASE_URL)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def session_id():
|
|
27
|
+
return f"py-sdk-w92-{uuid.uuid4().hex[:8]}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_w92_01_ingest_basic(client, session_id):
|
|
31
|
+
"""W9.2.1: 基本写入 + tags 合并"""
|
|
32
|
+
m = client.ingest(
|
|
33
|
+
session_id, "user", "T1Mem 是 Top-1 AI 记忆中间件",
|
|
34
|
+
tags=["w9.2", "test"],
|
|
35
|
+
)
|
|
36
|
+
assert m.session_id == session_id
|
|
37
|
+
assert m.content == "T1Mem 是 Top-1 AI 记忆中间件"
|
|
38
|
+
assert m.role == "user"
|
|
39
|
+
assert m.id # 必有 id
|
|
40
|
+
assert m.org_id == "default"
|
|
41
|
+
assert "w9.2" in m.metadata.get("tags", [])
|
|
42
|
+
assert m.created_at > 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_w92_02_metadata_and_tags_merge(client, session_id):
|
|
46
|
+
"""W9.2.2: 用户传 metadata + tags,SDK 应合并"""
|
|
47
|
+
m = client.ingest(
|
|
48
|
+
session_id, "assistant", "测试 metadata 合并",
|
|
49
|
+
metadata={"source": "w9.2-test", "priority": "high"},
|
|
50
|
+
tags=["meta-merge"],
|
|
51
|
+
)
|
|
52
|
+
assert m.metadata["source"] == "w9.2-test"
|
|
53
|
+
assert m.metadata["priority"] == "high"
|
|
54
|
+
assert "meta-merge" in m.metadata.get("tags", [])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_w92_03_four_roles(client, session_id):
|
|
58
|
+
"""W9.2.3: user/assistant/system/tool 4 种 role 都接受"""
|
|
59
|
+
for role in ["user", "assistant", "system", "tool"]:
|
|
60
|
+
m = client.ingest(session_id, role, f"{role} role test")
|
|
61
|
+
assert m.role == role
|
|
62
|
+
items = client.list_session(session_id)
|
|
63
|
+
assert len(items) == 4
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_w92_04_invalid_role_raises(client, session_id):
|
|
67
|
+
"""W9.2.4: 非法 role 抛 T1MemError(422)"""
|
|
68
|
+
with pytest.raises(T1MemError) as exc:
|
|
69
|
+
client.ingest(session_id, "hacker", "should fail")
|
|
70
|
+
assert exc.value.status == 422
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_w92_05_empty_content_raises(client, session_id):
|
|
74
|
+
"""W9.2.5: 空 content 抛 T1MemError(422)"""
|
|
75
|
+
with pytest.raises(T1MemError) as exc:
|
|
76
|
+
client.ingest(session_id, "user", "")
|
|
77
|
+
assert exc.value.status == 422
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_w92_06_batch_ingest(client, session_id):
|
|
81
|
+
"""W9.2.6: 批量 10 条"""
|
|
82
|
+
items = [
|
|
83
|
+
{"session_id": session_id, "role": "user", "content": f"batch-{i}"}
|
|
84
|
+
for i in range(10)
|
|
85
|
+
]
|
|
86
|
+
results = client.ingest_batch(items)
|
|
87
|
+
assert len(results) == 10
|
|
88
|
+
for i, r in enumerate(results):
|
|
89
|
+
assert r.content == f"batch-{i}"
|
|
90
|
+
assert r.session_id == session_id
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_w92_07_chinese_search(client, session_id):
|
|
94
|
+
"""W9.2.7: 中文写入 + 中文检索"""
|
|
95
|
+
client.ingest(session_id, "user", "T1Mem 提供 Python 和 TypeScript SDK")
|
|
96
|
+
client.ingest(session_id, "assistant", "项目预算是 50 万人民币")
|
|
97
|
+
time.sleep(0.3)
|
|
98
|
+
# 搜中文
|
|
99
|
+
hits = client.search(session_id, "项目", limit=5)
|
|
100
|
+
assert len(hits) >= 1
|
|
101
|
+
assert any("项目" in h.content for h in hits)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_w92_08_search_modes(client, session_id):
|
|
105
|
+
"""W9.2.8: search 模式切换 text/vec/hybrid"""
|
|
106
|
+
client.ingest(session_id, "user", "T1Mem 提供 Python SDK")
|
|
107
|
+
time.sleep(0.3)
|
|
108
|
+
# text 模式
|
|
109
|
+
hits_text = client.search(session_id, "T1Mem", limit=5, mode="text")
|
|
110
|
+
assert len(hits_text) >= 1
|
|
111
|
+
# hybrid 模式
|
|
112
|
+
hits_h = client.search(session_id, "T1Mem", limit=5, mode="hybrid")
|
|
113
|
+
assert len(hits_h) >= 1
|
|
114
|
+
# 不存在的 mode 应抛 422
|
|
115
|
+
with pytest.raises(T1MemError) as exc:
|
|
116
|
+
client.search(session_id, "T1Mem", limit=5, mode="nonsense")
|
|
117
|
+
assert exc.value.status == 422
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_w92_09_pagination(client, session_id):
|
|
121
|
+
"""W9.2.9: listSession 分页"""
|
|
122
|
+
for i in range(8):
|
|
123
|
+
client.ingest(session_id, "user", f"page-{i}")
|
|
124
|
+
p1 = client.list_session(session_id, limit=3, offset=0)
|
|
125
|
+
p2 = client.list_session(session_id, limit=3, offset=3)
|
|
126
|
+
p3 = client.list_session(session_id, limit=3, offset=6)
|
|
127
|
+
assert len(p1) == 3
|
|
128
|
+
assert len(p2) == 3
|
|
129
|
+
assert len(p3) == 2 # 8 - 6 = 2
|
|
130
|
+
# 倒序:p1[0] 最新, p1[-1] 早于 p2[0]
|
|
131
|
+
assert p1[0].created_at >= p1[-1].created_at
|
|
132
|
+
assert p1[-1].created_at >= p2[0].created_at
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_w92_10_404_raises(client):
|
|
136
|
+
"""W9.2.10: 不存在的 memory_id 抛 404"""
|
|
137
|
+
with pytest.raises(T1MemError) as exc:
|
|
138
|
+
client.get(f"nonexistent-{uuid.uuid4()}")
|
|
139
|
+
assert exc.value.status == 404
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_w92_11_session_isolation(client):
|
|
143
|
+
"""W9.2.11: 不同 session 完全隔离"""
|
|
144
|
+
sid_a = f"py-sdk-w92-A-{uuid.uuid4().hex[:6]}"
|
|
145
|
+
sid_b = f"py-sdk-w92-B-{uuid.uuid4().hex[:6]}"
|
|
146
|
+
client.ingest(sid_a, "user", "A 的项目是 T1Mem")
|
|
147
|
+
client.ingest(sid_b, "user", "B 的项目是 Mnemosyne")
|
|
148
|
+
list_a = client.list_session(sid_a)
|
|
149
|
+
list_b = client.list_session(sid_b)
|
|
150
|
+
assert len(list_a) == 1
|
|
151
|
+
assert len(list_b) == 1
|
|
152
|
+
assert "T1Mem" in list_a[0].content
|
|
153
|
+
assert "Mnemosyne" in list_b[0].content
|
|
154
|
+
# 跨 session 搜不到
|
|
155
|
+
hits = client.search(sid_a, "Mnemosyne", limit=5)
|
|
156
|
+
assert all("Mnemosyne" not in h.content for h in hits)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_w92_12_connection_error():
|
|
160
|
+
"""W9.2.12: 连接错误抛 T1MemError(0)"""
|
|
161
|
+
bad = T1MemClient("http://127.0.0.1:1", timeout=1.0)
|
|
162
|
+
with pytest.raises(T1MemError) as exc:
|
|
163
|
+
bad.health()
|
|
164
|
+
assert exc.value.status == 0
|