Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

本项目版本号见根目录 `VERSION` 文件,Docker 镜像 tag 与之对应(`p0luz/ombre-brain:<VERSION>`)。

## 4.0.1

### 修复 / Fixed

- Markdown 导入收到模型的空正文、无 choices 或仅思考内容时,不再显示正常完成;
对应分块计为失败,并在任务状态中显示空提取响应诊断(#134)。模型明确返回
合法 JSON 空数组 `[]` 时,继续正常完成且不创建记忆;其他分块已保存的记忆不受影响。

## 4.0.0

> testing 分支。主版本 +1:新增 Claude Code 自动召回整条链路(新的 HTTP 端点、新的模块库、备份格式多一个成员)。
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4.0.0
4.0.1
1 change: 1 addition & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ python tools/check_buckets.py --json
| 语义检索不可用 | 关键词/BM25 仍可读,返回明确降级提示 | 修复 provider 后等待 outbox 清空 |
| Obsidian 修改后结果旧 | 等待外部变更轮询周期 | 检查 `storage.external_change_poll_seconds`,再看系统诊断的外部变更计数 |
| ZIP 上传被拒绝 | 本地 vault 未写入 | 按错误修复损坏、路径穿越、重复项或清单不一致,重新导出 |
| Markdown 导入提示模型返回空提取响应 | 该分块没有创建记忆,计入失败;其他成功分块保留 | 查看任务的 `errors` / `diagnostics`,检查模型输出模式、输出上限与接口格式;模型明确返回合法 `[]` 则仍是正常零提取,不属于此错误 |
| SQLite quick_check 失败 | Markdown 真源通常仍在 | 先备份 Markdown,移走损坏的派生库,再重建向量;不要删除 Markdown |
| 记忆库里出现 `embeddings.db.corrupt-<时间戳>` 或 `dehydration_cache.db.corrupt-<时间戳>` | 对应派生库启动时打不开,已被自动隔离并重建;Markdown 未受影响 | 确认服务已正常启动、向量在后台补齐后,这些隔离文件可以删除。留着只为取证,不会被读取 |
| outbox 长时间不下降 | 记忆正文仍安全 | 查看熔断状态、最近错误、Key/模型/维度和 provider 连通性 |
Expand Down
2 changes: 1 addition & 1 deletion src/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4.0.0
4.0.1
11 changes: 9 additions & 2 deletions src/import_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@
_TEXT_HASH_CHUNK_CHARS = 1024 * 1024

_IMPORT_ERROR_RULES = (
(
"empty_extraction",
re.compile(r"extraction returned empty response", re.I),
"模型返回了空的记忆提取响应",
"检查模型是否只返回思考内容、是否达到输出上限,以及接口格式是否匹配。",
),
(
"model_not_found",
re.compile(r"model does not exist|model[_ ]?not[_ ]?found|模型不存在", re.I),
Expand Down Expand Up @@ -1370,8 +1376,9 @@ async def _extract_memories(self, chunk_content: str) -> list[dict]:
temperature=_EXTRACT_TEMPERATURE,
)

if not raw.strip():
return []
# 空正文不是明确的 JSON [];必须走失败路径,不能伪装成正常零提取。
if raw is None or not raw.strip():
raise ValueError("LLM extraction returned empty response")

return self._parse_extraction(raw)

Expand Down
195 changes: 195 additions & 0 deletions tests/test_import_memory_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
import hashlib
import json
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest

import import_memory as import_memory_module
from dehydrator import Dehydrator
from import_memory import (
ImportEngine,
ImportState,
Expand Down Expand Up @@ -112,6 +116,197 @@ async def update(self, bucket_id, **_kw):
return True


class RawResponseDehydrator(FakeDehydrator):
"""逐次返回指定模型正文,不在测试替身中预先处理空结果。"""

def __init__(self, *responses):
super().__init__()
self.responses = iter(responses)

async def _chat(self, prompt, content, max_tokens=0, temperature=0.0):
self.prompts.append(prompt)
self.chat_calls.append(content)
return next(self.responses)


@pytest.mark.asyncio
@pytest.mark.parametrize("raw", ["", " \t\n ", None])
async def test_empty_extraction_response_fails_and_persists_error(tmp_path, raw):
bucket_mgr = FakeBucketManager()
engine = ImportEngine(
{"buckets_dir": str(tmp_path), "human": "用户"},
bucket_mgr,
RawResponseDehydrator(raw),
)

result = await engine.start(
"Human: 周三我参加了北桥读书会。\nAssistant: 我记下了。",
filename="empty-response.md",
)

assert result["status"] == "error"
assert result["processed"] == result["total_chunks"] == 1
assert result["api_calls"] == result["chunks_failed"] == 1
assert result["memories_created"] == result["memories_skipped"] == 0
assert len(result["errors"]) == 1
assert "empty response" in result["errors"][0]
assert engine.get_status()["diagnostics"][0]["code"] == "empty_extraction"
assert bucket_mgr.created == []
assert engine.is_running is False
assert engine.active_job_id == ""

persisted = ImportState(str(tmp_path))
assert persisted.load() is True
assert persisted.to_dict()["status"] == "error"
assert persisted.to_dict()["errors"] == result["errors"]


@pytest.mark.asyncio
@pytest.mark.parametrize("raw", [
"[]",
" \n [] \n ",
"```json\n[]\n```",
"没有值得记忆的信息。\n[]",
])
async def test_explicit_empty_extraction_array_still_completes(tmp_path, raw):
bucket_mgr = FakeBucketManager()
engine = ImportEngine(
{"buckets_dir": str(tmp_path), "human": "用户"},
bucket_mgr,
RawResponseDehydrator(raw),
)

result = await engine.start(
"Human: 你好。\nAssistant: 你好。",
filename="no-memory.md",
)

assert result["status"] == "completed"
assert result["processed"] == result["api_calls"] == 1
assert result["chunks_failed"] == result["memories_created"] == 0
assert result["errors"] == engine.get_status()["diagnostics"] == []
assert bucket_mgr.created == []


@pytest.mark.asyncio
@pytest.mark.parametrize("response_case", [
"no_choices", "null_content", "empty_content", "whitespace_content",
"reasoning_only", "length_empty", "explicit_empty_array",
])
async def test_real_dehydrator_empty_response_reaches_import_failure(
tmp_path, monkeypatch, caplog, response_case,
):
dehydrator = Dehydrator({
"buckets_dir": str(tmp_path),
"dehydration": {
"api_key": "test-key",
"api_format": "openai_compat",
"base_url": "https://provider.invalid/v1",
"model": "test-extraction",
},
})
content = {
"no_choices": None,
"null_content": None,
"empty_content": "",
"whitespace_content": " \t\n ",
"reasoning_only": None,
"length_empty": "",
"explicit_empty_array": "[]",
}[response_case]
response = SimpleNamespace(choices=[] if response_case == "no_choices" else [
SimpleNamespace(
message=SimpleNamespace(
content=content,
reasoning_content="合成思考" if response_case == "reasoning_only" else None,
),
finish_reason="length" if response_case == "length_empty" else "stop",
),
])
create = AsyncMock(return_value=response)
monkeypatch.setattr(dehydrator.client.chat.completions, "create", create)
bucket_mgr = FakeBucketManager()
engine = ImportEngine(
{"buckets_dir": str(tmp_path), "human": "用户"},
bucket_mgr,
dehydrator,
)

try:
result = await engine.start(
"Human: 隐私哨兵-S134-不应进入日志。\nAssistant: 我记下了。",
filename="provider-response.md",
)
finally:
await dehydrator.client.close()
dehydrator._cache_conn.close()

assert create.await_count == 1
assert result["api_calls"] == 1
assert bucket_mgr.created == []
if response_case == "explicit_empty_array":
assert result["status"] == "completed"
assert result["errors"] == []
assert result["chunks_failed"] == 0
else:
assert result["status"] == "error"
assert result["chunks_failed"] == 1
assert "empty response" in result["errors"][0]
assert engine.get_status()["diagnostics"][0]["code"] == "empty_extraction"
assert "隐私哨兵-S134" not in caplog.text
assert "隐私哨兵-S134" not in json.dumps(result, ensure_ascii=False)


@pytest.mark.asyncio
@pytest.mark.parametrize("empty_first", [False, True])
async def test_empty_response_marks_partial_without_losing_other_chunk(
tmp_path, monkeypatch, empty_first,
):
chunks = [
{"content": "[用户] 第一件事", "timestamp_start": "", "timestamp_end": ""},
{"content": "[用户] 第二件事", "timestamp_start": "", "timestamp_end": ""},
]
monkeypatch.setattr(
import_memory_module, "chunk_turns", lambda *_args, **_kwargs: chunks,
)
valid = json.dumps([{"name": "读书会", "content": "周三参加了读书会。"}])
responses = ("", valid) if empty_first else (valid, "")
bucket_mgr = FakeBucketManager()
engine = ImportEngine(
{"buckets_dir": str(tmp_path), "human": "用户"},
bucket_mgr,
RawResponseDehydrator(*responses),
)

result = await engine.start("Human: 两件事", filename="partial.md")

assert result["status"] == "partial"
assert result["processed"] == result["api_calls"] == 2
assert result["chunks_failed"] == result["memories_created"] == 1
assert len(result["errors"]) == 1
assert [item["content"] for item in bucket_mgr.created] == ["周三参加了读书会。"]


@pytest.mark.asyncio
async def test_empty_response_failure_releases_job_for_explicit_empty_array(tmp_path):
engine = ImportEngine(
{"buckets_dir": str(tmp_path), "human": "用户"},
FakeBucketManager(),
RawResponseDehydrator("", "[]"),
)
raw = "Human: 你好。\nAssistant: 你好。"

failed = await engine.start(raw, filename="first.md")
assert failed["status"] == "error"
assert engine.is_running is False
completed = await engine.start(raw, filename="second.md")

assert completed["status"] == "completed"
assert completed["job_id"] != failed["job_id"]
assert completed["errors"] == engine.get_status()["diagnostics"] == []
assert completed["chunks_failed"] == 0


@pytest.mark.asyncio
async def test_structured_json_import_is_deterministic_and_skips_llm(tmp_path):
bucket_mgr = FakeBucketManager()
Expand Down
49 changes: 49 additions & 0 deletions tests/test_import_start_reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,55 @@ async def wait_loop():
await asyncio.wait_for(wait_loop(), timeout=2)


@pytest.mark.asyncio
@pytest.mark.parametrize(
("raw_response", "expected_status", "expected_failed"),
[("", "error", 1), ("[]", "completed", 0)],
)
async def test_upload_status_distinguishes_empty_response_from_empty_array(
tmp_path, monkeypatch, raw_response, expected_status, expected_failed,
):
class ResponseDehydrator:
api_available = True
import_max_tokens = 4096

async def _chat(self, *_args, **_kwargs):
return raw_response

engine = ImportEngine(
{"buckets_dir": str(tmp_path), "human": "用户"},
object(),
ResponseDehydrator(),
)
monkeypatch.setattr(import_api.sh, "_require_auth", lambda _request: None)
monkeypatch.setattr(import_api.sh, "import_engine", engine, raising=False)
mcp = FakeMCP()
import_api.register(mcp)
request = BodyRequest(
"Human: 周三参加读书会。\nAssistant: 我记下了。",
"response-boundary.md",
)

started = await mcp.routes[("POST", "/api/import/upload")](request)
assert started.status_code == 200
started_payload = _payload(started)
assert started_payload["status"] == "started"
await _wait_until_finished(engine)
response = await mcp.routes[("GET", "/api/import/status")](request)
status = _payload(response)

assert response.status_code == 200
assert status["job_id"] == started_payload["job_id"]
assert status["status"] == expected_status
assert status["chunks_failed"] == expected_failed
assert status["memories_created"] == 0
if expected_failed:
assert len(status["errors"]) == 1
assert status["diagnostics"][0]["code"] == "empty_extraction"
else:
assert status["errors"] == status["diagnostics"] == []


@pytest.mark.asyncio
async def test_import_engine_concurrent_starts_have_one_owner(tmp_path):
dehydrator = BlockingDehydrator()
Expand Down
10 changes: 5 additions & 5 deletions update_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
},
{
"path": "src/VERSION",
"sha256": "b4116d6e880009dc1440ddab7ec054bcea529aea394ec5bab7943b415a359281",
"size": 5
"sha256": "cd6321af37482f49efc326524a9fcd7fc86902a48da8d4bbd3dc718d290c3fea",
"size": 6
},
{
"path": "src/bm25_index.py",
Expand Down Expand Up @@ -77,8 +77,8 @@
},
{
"path": "src/import_memory.py",
"sha256": "9f89c8ef32b09f0d6ee3274bba1984b8d383ee2ae338ccd4e56dd04171ab5f55",
"size": 60702
"sha256": "2b09b75f1ef38ce8cc8c429f157856646007c8097d51ea9b470763aca5db7ee8",
"size": 61140
},
{
"path": "src/migrate_engine.py",
Expand Down Expand Up @@ -972,5 +972,5 @@
}
],
"rollout_strategy": "single-node",
"version": "4.0.0"
"version": "4.0.1"
}
Loading