Skip to content

feat: 新增仓库材料数量识别任务 - #871

Merged
Windsland52 merged 11 commits into
MAA1999:mainfrom
APPLe-DF:feat/warehouse-inventory
Aug 7, 2026
Merged

feat: 新增仓库材料数量识别任务#871
Windsland52 merged 11 commits into
MAA1999:mainfrom
APPLe-DF:feat/warehouse-inventory

Conversation

@APPLe-DF

@APPLe-DF APPLe-DF commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

功能

新增「仓库材料识别」任务:扫描仓库素材页全部已配模板的材料数量,落盘到 data/combat/warehouse_inventory.json,为未来的计划作战/材料缺口提示等打基础。

实现

  • WarehouseInventoryScan custom action:三段往返扫描(12 屏)+ 扫描前回滚到列表顶部,每个材料读 2-3 次
  • 数量 OCR 防误读:按图标高度分组偏移(h≥80 顶部偏移 / h<80 底部偏移)、横向 ROI 收窄 50% 避开装饰竖线、多读数众数纠错
  • 数据源 data/combat/items.json(46 种材料),只扫有模板的材料——新增材料只需补模板图,无需改代码
  • 输出快照 data/combat/warehouse_inventory.jsonupdated_at / counts / skipped / materials,已 gitignore)
  • 新增 34 个材料图标模板,46/46 全量覆盖
  • pipeline / 任务入口 / schema 注册 / 8 个单元测试 / 中英双语文档

验证

  • 两个账号实机测试:46/46 材料全部识别、零跳过,数量与游戏内核对一致(含 1 位数金羊毛、3 位数苦盐簇 271、4 位数 1919、5 位数 20023)
  • pnpm check:py(ruff + pyright + pytest 67 passed)、pnpm check:schema 通过
  • 已知取舍:放宽横向 ROI 到 70% 实测会把金色装饰条卷入导致误读,故保持 50%

Closes #637

Summary by Sourcery

添加一个仓库材料库存扫描任务,将游戏内仓库材料数量记录到 JSON 快照中,以供未来战斗规划功能使用。

New Features:

  • 引入 WarehouseInventoryScan 自定义动作,根据物品模板扫描仓库材料数量,并持久化结构化的库存快照。
  • 新增 WarehouseInventory 任务条目和流水线定义,将库存扫描集成到现有任务系统中。

Enhancements:

  • 在动作注册表中注册新的 warehouse_inventory 自定义动作模块,并扩展自定义动作 schema 以描述该新任务。

Documentation:

  • 添加双语(EN/ZH)协议文档,说明仓库库存任务、其数据契约以及维护指南。

Tests:

  • 添加单元测试,覆盖物品数据完整性、模板存在性、数量选择逻辑以及 warehouse_inventory 动作的注册。

Chores:

  • 在版本控制中忽略生成的仓库库存快照文件。
Original summary in English

Summary by Sourcery

Add a warehouse materials inventory scanning task that records in-game warehouse material counts to a JSON snapshot for use by future combat planning features.

New Features:

  • Introduce the WarehouseInventoryScan custom action to scan warehouse material quantities based on item templates and persist a structured inventory snapshot.
  • Add a WarehouseInventory task entry and pipeline definition to integrate the inventory scan into the existing task system.

Enhancements:

  • Register the new warehouse_inventory custom action module in the action registry and extend the custom action schema to describe the new task.

Documentation:

  • Add bilingual (EN/ZH) protocol documentation describing the warehouse inventory task, its data contract, and maintenance guidelines.

Tests:

  • Add unit tests covering item data completeness, template presence, count selection logic, and registration of the warehouse_inventory action.

Chores:

  • Ignore the generated warehouse inventory snapshot file in version control.

Summary by CodeRabbit

  • 新功能

    • 新增仓库库存扫描任务,识别配置材料及数量并生成带时间戳的库存快照。
    • 支持 OCR、多次扫描与结果校验,区分未找到、数量无法识别及读数不一致的材料。
    • 将仓库扫描流程整合至任务配置和工作流中。
  • 文档

    • 新增中英文仓库库存协议文档,说明扫描流程、输出格式及模板维护方法。
  • 测试

    • 增加材料筛选、OCR 解析、动作注册及快照写入相关测试。

- WarehouseInventoryScan custom action:扫描仓库素材页全部已配模板材料数量
- 三段往返扫描 + 回顶逻辑,多偏移 OCR + 众数纠错,防装饰条误读
- 输出 data/combat/warehouse_inventory.json 快照(gitignore)
- 新增 34 个材料图标模板,46/46 全量覆盖
- 新增 pipeline、任务入口、schema 注册、8 个单元测试
- 新增中英双语文档 docs/*/protocol/warehouse-inventory.md
Comment thread agent/custom/action/warehouse_inventory.py Outdated

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 4 个问题,并留下了一些整体反馈:

  • WarehouseInventoryScan 中的扫描逻辑依赖多个硬编码的魔法数字(滑动坐标、屏幕高度 718、图标高度阈值 80、偏移量 82/85/90/95 等);建议将它们提取为具名常量或一个小的配置结构,以便之后调整 UI 时更容易、更不容易出错。
  • 数量解析逻辑(对 \d+ 的正则和 max(groups, key=len))在 _recognize_itemtest_count_parsing_takes_longest_digit_group 之间重复;建议把这部分逻辑抽到 action 模块中的一个小的辅助函数里,这样测试就能直接覆盖同一套实现,而不是重新实现一遍。
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- `WarehouseInventoryScan` 中的扫描逻辑依赖多个硬编码的魔法数字(滑动坐标、屏幕高度 718、图标高度阈值 80、偏移量 82/85/90/95 等);建议将它们提取为具名常量或一个小的配置结构,以便之后调整 UI 时更容易、更不容易出错。
- 数量解析逻辑(对 `\d+` 的正则和 `max(groups, key=len)`)在 `_recognize_item``test_count_parsing_takes_longest_digit_group` 之间重复;建议把这部分逻辑抽到 action 模块中的一个小的辅助函数里,这样测试就能直接覆盖同一套实现,而不是重新实现一遍。

## Individual Comments

### Comment 1
<location path="agent/custom/action/warehouse_inventory.py" line_range="227-231" />
<code_context>
+        # 多个偏移读数取众数(出现最多的),无众数时取第一位
+        from collections import Counter
+
+        counter = Counter(candidates)
+        most_common = counter.most_common()
+        if len(most_common) > 1 and most_common[0][1] > most_common[1][1]:
+            return True, most_common[0][0]
+        return True, candidates[0]
</code_context>
<issue_to_address>
**suggestion:** 计数候选值的最终决策依赖偏移量顺序,可以考虑让它更稳定一些。

当多个 `candidates` 具有相同频率时,回退到 `candidates[0]` 会让结果取决于偏移量的顺序(即哪个偏移最先产生非空的 OCR 结果),而不是取决于候选值本身。建议使用与 `_best_count` 一致的、确定性的“平局决胜”策略,比如选择数字位数最长的值(再按数值大小比较),或者对这些并列的 `candidates` 再调用一次 `_best_count`,从而避免对偏移顺序或轻微 OCR 波动过于敏感。
</issue_to_address>

### Comment 2
<location path="tests/test_warehouse_inventory.py" line_range="5" />
<code_context>
+import re
+from pathlib import Path
+
+from agent.custom.action.warehouse_inventory import WarehouseInventoryScan
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** 缺少覆盖主 `run()` 流程及其成功/失败分支的测试

当前测试只覆盖了一些配置假设和辅助函数,但从未调用 `WarehouseInventoryScan.run()`,导致关键分支没有经过验证。请新增调用 `run()` 的测试,并使用一个模拟的 `Context`/控制器以及一小份合成的材料数据集来覆盖以下场景:
- 当 items.json 无法读取/解析时,`success=False`
- 当找不到任何模板时,`success=False`
- 当没有产生任何计数结果时,`success=False`
- 对可读/不可读/缺失材料时,`counts``skipped` 的正确划分
- 输出文件的创建以及其 JSON 结构是否符合预期(包括 `counts``skipped``materials`、timestamp 字段),并且键是排序的
这样可以验证整体的“串线”和控制流程,而不仅仅是辅助工具函数。

Suggested implementation:

```python
import json
import re
from pathlib import Path
from typing import Any, Dict, List

import pytest

from agent.custom.action.warehouse_inventory import WarehouseInventoryScan


def test_items_json_contains_all_rarity_groups() -> None:
    """items.json 材料表包含金/黄/紫/蓝/绿五档稀有度。"""
    with open("data/combat/items.json", encoding="utf-8") as f:
        items = json.load(f)
    assert set(items.keys()) == {"gold", "yellow", "purple", "blue", "green"}
    total = sum(len(v) for v in items.values())
    assert total >= 46


class _DummyContext:
    """Minimal context object for exercising WarehouseInventoryScan.run()."""

    def __init__(self, workdir: Path) -> None:
        self.workdir = workdir
        self.logs: List[str] = []

    # Common logging APIs used by actions; no-op implementations for tests.
    def info(self, msg: str) -> None:  # pragma: no cover - trivial
        self.logs.append(f"INFO: {msg}")

    def warning(self, msg: str) -> None:  # pragma: no cover - trivial
        self.logs.append(f"WARNING: {msg}")

    def error(self, msg: str) -> None:  # pragma: no cover - trivial
        self.logs.append(f"ERROR: {msg}")


def _configure_scan_paths(
    scan: WarehouseInventoryScan, tmp_path: Path
) -> Dict[str, Path]:
    """Point the scan instance at test-local paths if it supports them.

    Returns the effective paths so tests can easily write fixtures.
    """
    items_path = tmp_path / "items.json"
    templates_dir = tmp_path / "templates"
    output_path = tmp_path / "warehouse_inventory.json"

    # Prefer instance attributes if they exist; fall back to class-level constants.
    if hasattr(scan, "items_path"):
        scan.items_path = items_path  # type: ignore[attr-defined]
    elif hasattr(scan, "ITEMS_PATH"):
        scan.ITEMS_PATH = items_path  # type: ignore[attr-defined]

    if hasattr(scan, "templates_dir"):
        scan.templates_dir = templates_dir  # type: ignore[attr-defined]
    elif hasattr(scan, "TEMPLATES_DIR"):
        scan.TEMPLATES_DIR = templates_dir  # type: ignore[attr-defined]

    if hasattr(scan, "output_path"):
        scan.output_path = output_path  # type: ignore[attr-defined]
    elif hasattr(scan, "OUTPUT_PATH"):
        scan.OUTPUT_PATH = output_path  # type: ignore[attr-defined]

    return {
        "items_path": items_path,
        "templates_dir": templates_dir,
        "output_path": output_path,
    }


def test_run_fails_when_items_json_unreadable(tmp_path: Path) -> None:
    """run() 返回 success=False 当 items.json 无法读取/解析时。"""
    ctx = _DummyContext(tmp_path)
    scan = WarehouseInventoryScan()
    paths = _configure_scan_paths(scan, tmp_path)

    # 写入一个非法 JSON 文件,触发解析失败分支。
    paths["items_path"].write_text("{not valid json", encoding="utf-8")

    result: Dict[str, Any] = scan.run(ctx)  # type: ignore[call-arg]
    assert isinstance(result, dict)
    assert result.get("success") is False


def test_run_fails_when_no_templates_found(tmp_path: Path) -> None:
    """run() 返回 success=False 当没有任何模板文件时。"""
    ctx = _DummyContext(tmp_path)
    scan = WarehouseInventoryScan()
    paths = _configure_scan_paths(scan, tmp_path)

    # 合法 items.json,但不创建任何模板目录/文件。
    items = {
        "gold": [{"name": "Gold Item"}],
        "yellow": [],
        "purple": [],
        "blue": [],
        "green": [],
    }
    paths["items_path"].write_text(json.dumps(items, ensure_ascii=False), encoding="utf-8")

    result: Dict[str, Any] = scan.run(ctx)  # type: ignore[call-arg]
    assert isinstance(result, dict)
    assert result.get("success") is False


def test_run_fails_when_no_counts_produced(tmp_path: Path) -> None:
    """run() 返回 success=False 当没有任何计数结果时。"""
    ctx = _DummyContext(tmp_path)
    scan = WarehouseInventoryScan()
    paths = _configure_scan_paths(scan, tmp_path)

    # items.json 中定义材料,但模板设计为全部无法匹配/统计。
    items = {
        "gold": [{"name": "Unmatched Gold"}],
        "yellow": [],
        "purple": [],
        "blue": [],
        "green": [],
    }
    paths["items_path"].write_text(json.dumps(items, ensure_ascii=False), encoding="utf-8")

    templates_dir = paths["templates_dir"]
    templates_dir.mkdir(parents=True, exist_ok=True)
    # 示例模板文件内容:根据具体实现可以是 JSON/YAML;这里写入一个简单占位。
    (templates_dir / "template_unmatched.txt").write_text("no matching items", encoding="utf-8")

    result: Dict[str, Any] = scan.run(ctx)  # type: ignore[call-arg]
    assert isinstance(result, dict)
    assert result.get("success") is False


def test_run_separates_counts_and_skipped_and_writes_output(tmp_path: Path) -> None:
    """run() 正确区分 counts/skipped 并写出预期 JSON 输出文件。"""
    ctx = _DummyContext(tmp_path)
    scan = WarehouseInventoryScan()
    paths = _configure_scan_paths(scan, tmp_path)

    # 构造一个小型数据集:部分可读、部分应被跳过。
    items = {
        "gold": [{"id": 1, "name": "Readable Gold"}],
        "yellow": [{"id": 2, "name": "Skipped Yellow"}],
        "purple": [],
        "blue": [],
        "green": [],
    }
    paths["items_path"].write_text(json.dumps(items, ensure_ascii=False), encoding="utf-8")

    templates_dir = paths["templates_dir"]
    templates_dir.mkdir(parents=True, exist_ok=True)

    # 简单模板文件名用于在实现中通过正则/字符串匹配。
    # 使用 re 避免导入未使用。
    template_name = "template_readable.txt"
    assert re.match(r"template_.*\.txt", template_name)
    (templates_dir / template_name).write_text("Readable Gold", encoding="utf-8")

    result: Dict[str, Any] = scan.run(ctx)  # type: ignore[call-arg]
    assert isinstance(result, dict)
    assert result.get("success") is True

    # 验证输出文件存在且 JSON 结构包含期望字段。
    output_path = paths["output_path"]
    assert output_path.exists()

    output = json.loads(output_path.read_text(encoding="utf-8"))
    # 至少包含 counts, skipped, materials, timestamp 这些字段。
    assert set(output.keys()) >= {"counts", "skipped", "materials", "timestamp"}

    # counts 与 skipped 应该是列表/字典一类的可迭代结构。
    counts = output["counts"]
    skipped = output["skipped"]
    assert isinstance(counts, (list, dict))
    assert isinstance(skipped, (list, dict))

    # 简单检查 counts 与 skipped 之间有区分(至少一侧非空)。
    assert bool(counts) or bool(skipped)

    # materials 应该反映输入材料表。
    materials = output["materials"]
    assert isinstance(materials, dict)
    assert "gold" in materials

    # timestamp 为字符串(具体格式交由实现决定)。
    assert isinstance(output["timestamp"], str)

    # 额外检查输出 JSON 的键顺序是有序的(实现通常使用 sort_keys=True)。
    # 这里通过重新 dump 再比较键列表验证。
    loaded_keys = list(output.keys())
    sorted_keys = sorted(loaded_keys)
    assert loaded_keys == sorted_keys

```

这些测试假设:

1. `WarehouseInventoryScan` 暴露 `items_path` / `templates_dir` / `output_path` 实例属性,或对应的类常量 `ITEMS_PATH` / `TEMPLATES_DIR` / `OUTPUT_PATH`,并在 `run()` 中使用这些路径。
2. `run(context)` 返回一个 `dict`,包含 `success` 布尔字段。
3. `run()` 在成功时会写出一个 JSON 文件,包含至少 `counts`, `skipped`, `materials`, `timestamp` 几个字段,并使用 `sort_keys=True`(或生成按键名排序的字典)写入。

如果当前实现与上述假设不同,请根据实际 API 做以下调整:

- 若路径配置方式不同(例如通过配置对象或 `context` 提供),将 `_configure_scan_paths()` 改为设置实际使用的配置字段,并在各测试中使用相同的辅助函数。
-`run()` 返回值结构不同(例如返回一个自定义结果对象),将断言中对 `result["success"]` 的访问改为对应的属性/字段。
- 若输出文件名或位置不同,更新 `_configure_scan_paths()` 返回的 `output_path``test_run_separates_counts_and_skipped_and_writes_output` 中的路径断言,使其匹配当前实现。
- 若实现未保证 JSON 键排序,请在生产代码中使用 `json.dump(..., sort_keys=True)`,或相应方式确保键按字典序排序,以满足测试中对键顺序的断言。
</issue_to_address>

### Comment 3
<location path="tests/test_warehouse_inventory.py" line_range="42-51" />
<code_context>
+    assert not missing, f"缺少模板的材料: {missing}"
+
+
+def test_count_parsing_takes_longest_digit_group() -> None:
+    """数量解析取最长数字组,忽略噪声。"""
+    # 模拟 ocr_text 返回值(带噪声的情况)
+    cases = [
+        ("123", 123),
+        ("1,234", 1234),
+        ("12 个噪声 3456", 3456),
+        ("", None),
+        ("x", None),
+    ]
+    for text, expected in cases:
+        groups = re.findall(r"\d+", text.replace(",", ""))
+        if not groups:
+            assert expected is None
+        else:
+            assert int(max(groups, key=len)) == expected
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** 数量解析的测试复用了逻辑,而不是直接覆盖 `_recognize_item` 或一个共享的辅助函数

`test_count_parsing_takes_longest_digit_group` 通过 `re.findall`/`max(groups, key=len)` 重新实现了解析逻辑,而不是调用生产代码,这样即使它和 `_recognize_item` 出现差异也不会导致测试失败。

建议:
- 把解析逻辑抽取到一个辅助函数(比如 `_parse_count_text(text: str) -> int | None`),让 `_recognize_item` 和测试都使用这个函数,或
- 重构 `_recognize_item`,使其可以在单元测试中通过一个伪造的 `context`/`img` 注入可控的 `ocr_text`。

这样可以让测试始终和真实实现保持一致。

Suggested implementation:

```python
from agent.custom.action.warehouse_inventory import WarehouseInventoryScan, _parse_count_text

```

```python
    missing = [item_id for group in items.values() for item_id in group if not scan._has_template(item_id)]
    assert not missing, f"缺少模板的材料: {missing}"


def test_count_parsing_takes_longest_digit_group() -> None:
    """数量解析取最长数字组,忽略噪声。"""
    # 模拟 ocr_text 返回值(带噪声的情况)
    cases = [
        ("123", 123),
        ("1,234", 1234),
        ("12 个噪声 3456", 3456),
        ("", None),
        ("x", None),
    ]
    for text, expected in cases:
        assert _parse_count_text(text) == expected


from agent.custom.action.warehouse_inventory import WarehouseInventoryScan, _parse_count_text

```

要完整实现这个建议,还需要更新 `agent/custom/action/warehouse_inventory.py`1. 将现有数量解析逻辑抽取为一个共享辅助函数:

```python
# module-level helper
def _parse_count_text(text: str) -> int | None:
    """
    Parse OCR text for an item count.

    Strategy:
    - Remove commas
    - Find all digit groups
    - Use the longest digit group as the count
    - Return None if no digits found
    """
    text = text.replace(",", "")
    groups = re.findall(r"\d+", text)
    if not groups:
        return None
    return int(max(groups, key=len))
```

2. 重构 `_recognize_item`(或相关方法),使其使用该辅助函数而不是内联正则逻辑,例如:

```python
count = _parse_count_text(ocr_text)  # or whatever variable holds the OCR string
```

3. 确保在 `warehouse_inventory.py` 中已导入 `re`,如果没有则补充。

这些改动可以让测试和生产实现保持对齐,同时避免重复实现解析逻辑。
</issue_to_address>

### Comment 4
<location path="tests/test_warehouse_inventory.py" line_range="75-79" />
<code_context>
+    assert scan._best_count([73, 3]) == 73
+
+
+def test_action_registered_in_action_modules() -> None:
+    """warehouse_inventory 模块在 ACTION_MODULES 注册表中。"""
+    from agent.custom.action import ACTION_MODULES
+
+    assert "warehouse_inventory" in ACTION_MODULES
</code_context>
<issue_to_address>
**suggestion (testing):** 建议同时断言已注册的动作名称和类型,以完整验证“串线”是否正确

目前测试只检查模块名是否在 `ACTION_MODULES` 中,并未确认导入该模块之后是否真的将 `WarehouseInventoryScan` 按预期动作名注册了。建议扩展测试,引入 `AgentServer`(或负责注册的模块),并断言注册表中存在名为 "WarehouseInventoryScan" 的条目且其值就是 `WarehouseInventoryScan` 本身,从而完整验证动作注册 wiring。

Suggested implementation:

```python
def test_action_registered_in_action_modules() -> None:
    """warehouse_inventory 模块在 ACTION_MODULES 注册表中,并且 WarehouseInventoryScan 正确注册。"""
    # 导入 ACTION_MODULES 以确保模块注册
    from agent.custom.action import ACTION_MODULES
    # 导入 AgentServer(或等效入口),以触发所有默认 action wiring
    from agent.server import AgentServer  # noqa: F401

    # 模块级注册:warehouse_inventory 模块应该在 ACTION_MODULES 中
    assert "warehouse_inventory" in ACTION_MODULES

    # 断言 WarehouseInventoryScan 已按预期名称注册到动作注册表中
    # 注意:下面的访问路径和注册表名称需要与实际实现保持一致,
    # 例如 ACTION_REGISTRY、REGISTERED_ACTIONS 等。
    from agent.custom.action.warehouse_inventory import WarehouseInventoryScan

    # 以下示例假设存在一个全局动作注册表 ACTION_REGISTRY,
    # 并且键是动作名称,值是动作类。
    from agent.custom.action import ACTION_REGISTRY  # type: ignore[attr-defined]

    assert "WarehouseInventoryScan" in ACTION_REGISTRY
    assert ACTION_REGISTRY["WarehouseInventoryScan"] is WarehouseInventoryScan

```

1.`from agent.server import AgentServer` 调整为实际触发 action 注册的入口模块路径(如果不叫 `agent.server.AgentServer`,请改为正确的模块和类)。
2.`ACTION_REGISTRY` 替换为代码库中实际使用的动作注册表名称和导入路径(例如 `ACTIONS`, `REGISTERED_ACTIONS`,或 `ACTION_MODULES["warehouse_inventory"].actions` 等)。
3. 如果动作名称并非 `"WarehouseInventoryScan"`(比如使用 snake_case 或其他别名),请将 `"WarehouseInventoryScan"` 更新为真实的注册键。
4. 如果注册表结构不同(例如值是工厂函数或包含元数据的对象),请调整断言以匹配真实结构(例如 `ACTION_REGISTRY["WarehouseInventoryScan"].cls is WarehouseInventoryScan`)。
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Original comment in English

Hey - I've found 4 issues, and left some high level feedback:

  • The scan logic in WarehouseInventoryScan relies on several hardcoded magic numbers (swipe coordinates, screen height 718, icon height threshold 80, offsets 82/85/90/95, etc.); consider extracting these into named constants or a small config structure to make future UI adjustments easier and less error-prone.
  • The count-parsing logic (regex on \d+ and max(groups, key=len)) is duplicated between _recognize_item and test_count_parsing_takes_longest_digit_group; consider factoring this into a small helper function in the action module so tests exercise the same implementation instead of reimplementing it.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The scan logic in `WarehouseInventoryScan` relies on several hardcoded magic numbers (swipe coordinates, screen height 718, icon height threshold 80, offsets 82/85/90/95, etc.); consider extracting these into named constants or a small config structure to make future UI adjustments easier and less error-prone.
- The count-parsing logic (regex on `\d+` and `max(groups, key=len)`) is duplicated between `_recognize_item` and `test_count_parsing_takes_longest_digit_group`; consider factoring this into a small helper function in the action module so tests exercise the same implementation instead of reimplementing it.

## Individual Comments

### Comment 1
<location path="agent/custom/action/warehouse_inventory.py" line_range="227-231" />
<code_context>
+        # 多个偏移读数取众数(出现最多的),无众数时取第一位
+        from collections import Counter
+
+        counter = Counter(candidates)
+        most_common = counter.most_common()
+        if len(most_common) > 1 and most_common[0][1] > most_common[1][1]:
+            return True, most_common[0][0]
+        return True, candidates[0]
</code_context>
<issue_to_address>
**suggestion:** Tie-breaking in count candidates is dependent on offset order and could be made more stable.

When multiple `candidates` share the same frequency, the fallback to `candidates[0]` makes the result depend on offset order (whichever offset produced the first non-empty OCR result) rather than on the values themselves. Consider a deterministic tie-break that’s consistent with `_best_count`, e.g. choosing the value with the longest digit length (then largest numeric value) or reusing `_best_count` on the tied `candidates`, to avoid sensitivity to offset ordering or minor OCR changes.
</issue_to_address>

### Comment 2
<location path="tests/test_warehouse_inventory.py" line_range="5" />
<code_context>
+import re
+from pathlib import Path
+
+from agent.custom.action.warehouse_inventory import WarehouseInventoryScan
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Missing tests that exercise the main `run()` flow and its success/failure branches

Current tests cover config assumptions and helpers, but never invoke `WarehouseInventoryScan.run()`, leaving key branches unverified. Please add tests that call `run()` with a mocked `Context`/controller and a small synthetic items dataset to cover:
- `success=False` when items.json cannot be read/parsed
- `success=False` when no templates are found
- `success=False` when no counts are produced
- Correct separation of `counts` vs `skipped` for readable/unreadable/missing items
- Creation of the output file with the expected JSON schema (including `counts`, `skipped`, `materials`, timestamp) and sorted keys
This will validate the overall wiring and control flow, not just helper utilities.

Suggested implementation:

```python
import json
import re
from pathlib import Path
from typing import Any, Dict, List

import pytest

from agent.custom.action.warehouse_inventory import WarehouseInventoryScan


def test_items_json_contains_all_rarity_groups() -> None:
    """items.json 材料表包含金/黄/紫/蓝/绿五档稀有度。"""
    with open("data/combat/items.json", encoding="utf-8") as f:
        items = json.load(f)
    assert set(items.keys()) == {"gold", "yellow", "purple", "blue", "green"}
    total = sum(len(v) for v in items.values())
    assert total >= 46


class _DummyContext:
    """Minimal context object for exercising WarehouseInventoryScan.run()."""

    def __init__(self, workdir: Path) -> None:
        self.workdir = workdir
        self.logs: List[str] = []

    # Common logging APIs used by actions; no-op implementations for tests.
    def info(self, msg: str) -> None:  # pragma: no cover - trivial
        self.logs.append(f"INFO: {msg}")

    def warning(self, msg: str) -> None:  # pragma: no cover - trivial
        self.logs.append(f"WARNING: {msg}")

    def error(self, msg: str) -> None:  # pragma: no cover - trivial
        self.logs.append(f"ERROR: {msg}")


def _configure_scan_paths(
    scan: WarehouseInventoryScan, tmp_path: Path
) -> Dict[str, Path]:
    """Point the scan instance at test-local paths if it supports them.

    Returns the effective paths so tests can easily write fixtures.
    """
    items_path = tmp_path / "items.json"
    templates_dir = tmp_path / "templates"
    output_path = tmp_path / "warehouse_inventory.json"

    # Prefer instance attributes if they exist; fall back to class-level constants.
    if hasattr(scan, "items_path"):
        scan.items_path = items_path  # type: ignore[attr-defined]
    elif hasattr(scan, "ITEMS_PATH"):
        scan.ITEMS_PATH = items_path  # type: ignore[attr-defined]

    if hasattr(scan, "templates_dir"):
        scan.templates_dir = templates_dir  # type: ignore[attr-defined]
    elif hasattr(scan, "TEMPLATES_DIR"):
        scan.TEMPLATES_DIR = templates_dir  # type: ignore[attr-defined]

    if hasattr(scan, "output_path"):
        scan.output_path = output_path  # type: ignore[attr-defined]
    elif hasattr(scan, "OUTPUT_PATH"):
        scan.OUTPUT_PATH = output_path  # type: ignore[attr-defined]

    return {
        "items_path": items_path,
        "templates_dir": templates_dir,
        "output_path": output_path,
    }


def test_run_fails_when_items_json_unreadable(tmp_path: Path) -> None:
    """run() 返回 success=False 当 items.json 无法读取/解析时。"""
    ctx = _DummyContext(tmp_path)
    scan = WarehouseInventoryScan()
    paths = _configure_scan_paths(scan, tmp_path)

    # 写入一个非法 JSON 文件,触发解析失败分支。
    paths["items_path"].write_text("{not valid json", encoding="utf-8")

    result: Dict[str, Any] = scan.run(ctx)  # type: ignore[call-arg]
    assert isinstance(result, dict)
    assert result.get("success") is False


def test_run_fails_when_no_templates_found(tmp_path: Path) -> None:
    """run() 返回 success=False 当没有任何模板文件时。"""
    ctx = _DummyContext(tmp_path)
    scan = WarehouseInventoryScan()
    paths = _configure_scan_paths(scan, tmp_path)

    # 合法 items.json,但不创建任何模板目录/文件。
    items = {
        "gold": [{"name": "Gold Item"}],
        "yellow": [],
        "purple": [],
        "blue": [],
        "green": [],
    }
    paths["items_path"].write_text(json.dumps(items, ensure_ascii=False), encoding="utf-8")

    result: Dict[str, Any] = scan.run(ctx)  # type: ignore[call-arg]
    assert isinstance(result, dict)
    assert result.get("success") is False


def test_run_fails_when_no_counts_produced(tmp_path: Path) -> None:
    """run() 返回 success=False 当没有任何计数结果时。"""
    ctx = _DummyContext(tmp_path)
    scan = WarehouseInventoryScan()
    paths = _configure_scan_paths(scan, tmp_path)

    # items.json 中定义材料,但模板设计为全部无法匹配/统计。
    items = {
        "gold": [{"name": "Unmatched Gold"}],
        "yellow": [],
        "purple": [],
        "blue": [],
        "green": [],
    }
    paths["items_path"].write_text(json.dumps(items, ensure_ascii=False), encoding="utf-8")

    templates_dir = paths["templates_dir"]
    templates_dir.mkdir(parents=True, exist_ok=True)
    # 示例模板文件内容:根据具体实现可以是 JSON/YAML;这里写入一个简单占位。
    (templates_dir / "template_unmatched.txt").write_text("no matching items", encoding="utf-8")

    result: Dict[str, Any] = scan.run(ctx)  # type: ignore[call-arg]
    assert isinstance(result, dict)
    assert result.get("success") is False


def test_run_separates_counts_and_skipped_and_writes_output(tmp_path: Path) -> None:
    """run() 正确区分 counts/skipped 并写出预期 JSON 输出文件。"""
    ctx = _DummyContext(tmp_path)
    scan = WarehouseInventoryScan()
    paths = _configure_scan_paths(scan, tmp_path)

    # 构造一个小型数据集:部分可读、部分应被跳过。
    items = {
        "gold": [{"id": 1, "name": "Readable Gold"}],
        "yellow": [{"id": 2, "name": "Skipped Yellow"}],
        "purple": [],
        "blue": [],
        "green": [],
    }
    paths["items_path"].write_text(json.dumps(items, ensure_ascii=False), encoding="utf-8")

    templates_dir = paths["templates_dir"]
    templates_dir.mkdir(parents=True, exist_ok=True)

    # 简单模板文件名用于在实现中通过正则/字符串匹配。
    # 使用 re 避免导入未使用。
    template_name = "template_readable.txt"
    assert re.match(r"template_.*\.txt", template_name)
    (templates_dir / template_name).write_text("Readable Gold", encoding="utf-8")

    result: Dict[str, Any] = scan.run(ctx)  # type: ignore[call-arg]
    assert isinstance(result, dict)
    assert result.get("success") is True

    # 验证输出文件存在且 JSON 结构包含期望字段。
    output_path = paths["output_path"]
    assert output_path.exists()

    output = json.loads(output_path.read_text(encoding="utf-8"))
    # 至少包含 counts, skipped, materials, timestamp 这些字段。
    assert set(output.keys()) >= {"counts", "skipped", "materials", "timestamp"}

    # counts 与 skipped 应该是列表/字典一类的可迭代结构。
    counts = output["counts"]
    skipped = output["skipped"]
    assert isinstance(counts, (list, dict))
    assert isinstance(skipped, (list, dict))

    # 简单检查 counts 与 skipped 之间有区分(至少一侧非空)。
    assert bool(counts) or bool(skipped)

    # materials 应该反映输入材料表。
    materials = output["materials"]
    assert isinstance(materials, dict)
    assert "gold" in materials

    # timestamp 为字符串(具体格式交由实现决定)。
    assert isinstance(output["timestamp"], str)

    # 额外检查输出 JSON 的键顺序是有序的(实现通常使用 sort_keys=True)。
    # 这里通过重新 dump 再比较键列表验证。
    loaded_keys = list(output.keys())
    sorted_keys = sorted(loaded_keys)
    assert loaded_keys == sorted_keys

```

这些测试假设:

1. `WarehouseInventoryScan` 暴露 `items_path` / `templates_dir` / `output_path` 实例属性,或对应的类常量 `ITEMS_PATH` / `TEMPLATES_DIR` / `OUTPUT_PATH`,并在 `run()` 中使用这些路径。
2. `run(context)` 返回一个 `dict`,包含 `success` 布尔字段。
3. `run()` 在成功时会写出一个 JSON 文件,包含至少 `counts`, `skipped`, `materials`, `timestamp` 几个字段,并使用 `sort_keys=True`(或生成按键名排序的字典)写入。

如果当前实现与上述假设不同,请根据实际 API 做以下调整:

- 若路径配置方式不同(例如通过配置对象或 `context` 提供),将 `_configure_scan_paths()` 改为设置实际使用的配置字段,并在各测试中使用相同的辅助函数。
-`run()` 返回值结构不同(例如返回一个自定义结果对象),将断言中对 `result["success"]` 的访问改为对应的属性/字段。
- 若输出文件名或位置不同,更新 `_configure_scan_paths()` 返回的 `output_path``test_run_separates_counts_and_skipped_and_writes_output` 中的路径断言,使其匹配当前实现。
- 若实现未保证 JSON 键排序,请在生产代码中使用 `json.dump(..., sort_keys=True)`,或相应方式确保键按字典序排序,以满足测试中对键顺序的断言。
</issue_to_address>

### Comment 3
<location path="tests/test_warehouse_inventory.py" line_range="42-51" />
<code_context>
+    assert not missing, f"缺少模板的材料: {missing}"
+
+
+def test_count_parsing_takes_longest_digit_group() -> None:
+    """数量解析取最长数字组,忽略噪声。"""
+    # 模拟 ocr_text 返回值(带噪声的情况)
+    cases = [
+        ("123", 123),
+        ("1,234", 1234),
+        ("12 个噪声 3456", 3456),
+        ("", None),
+        ("x", None),
+    ]
+    for text, expected in cases:
+        groups = re.findall(r"\d+", text.replace(",", ""))
+        if not groups:
+            assert expected is None
+        else:
+            assert int(max(groups, key=len)) == expected
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** The count-parsing test duplicates logic instead of directly exercising `_recognize_item` or a shared helper

`test_count_parsing_takes_longest_digit_group` reimplements the parsing logic with `re.findall`/`max(groups, key=len)` instead of using the production code, so it can diverge from `_recognize_item` without failing.

Consider either:
- Extracting the parsing into a helper (e.g. `_parse_count_text(text: str) -> int | None`) used by both `_recognize_item` and the test, or
- Refactoring `_recognize_item` so it can be unit-tested with a fake `context`/`img` that provides controlled `ocr_text`.

This keeps the test aligned with the real implementation.

Suggested implementation:

```python
from agent.custom.action.warehouse_inventory import WarehouseInventoryScan, _parse_count_text

```

```python
    missing = [item_id for group in items.values() for item_id in group if not scan._has_template(item_id)]
    assert not missing, f"缺少模板的材料: {missing}"


def test_count_parsing_takes_longest_digit_group() -> None:
    """数量解析取最长数字组,忽略噪声。"""
    # 模拟 ocr_text 返回值(带噪声的情况)
    cases = [
        ("123", 123),
        ("1,234", 1234),
        ("12 个噪声 3456", 3456),
        ("", None),
        ("x", None),
    ]
    for text, expected in cases:
        assert _parse_count_text(text) == expected


from agent.custom.action.warehouse_inventory import WarehouseInventoryScan, _parse_count_text

```

To fully implement the suggestion, you also need to update `agent/custom/action/warehouse_inventory.py`:

1. Extract the existing count-parsing logic into a shared helper:

```python
# module-level helper
def _parse_count_text(text: str) -> int | None:
    """
    Parse OCR text for an item count.

    Strategy:
    - Remove commas
    - Find all digit groups
    - Use the longest digit group as the count
    - Return None if no digits found
    """
    text = text.replace(",", "")
    groups = re.findall(r"\d+", text)
    if not groups:
        return None
    return int(max(groups, key=len))
```

2. Refactor `_recognize_item` (or the relevant method) to use this helper instead of inline regex logic, e.g.:

```python
count = _parse_count_text(ocr_text)  # or whatever variable holds the OCR string
```

3. Ensure `re` is imported in `warehouse_inventory.py` if not already present.

These changes keep the test aligned with the production implementation while avoiding duplication of parsing logic.
</issue_to_address>

### Comment 4
<location path="tests/test_warehouse_inventory.py" line_range="75-79" />
<code_context>
+    assert scan._best_count([73, 3]) == 73
+
+
+def test_action_registered_in_action_modules() -> None:
+    """warehouse_inventory 模块在 ACTION_MODULES 注册表中。"""
+    from agent.custom.action import ACTION_MODULES
+
+    assert "warehouse_inventory" in ACTION_MODULES
</code_context>
<issue_to_address>
**suggestion (testing):** Consider also asserting the registered action name and type to fully prove wiring

Right now this only checks that the module name is in `ACTION_MODULES`. It doesn’t confirm that importing the module actually registers `WarehouseInventoryScan` under the expected action name. Please extend the test to import `AgentServer` (or the responsible module) and assert that the registry contains an entry for "WarehouseInventoryScan" and that it points to `WarehouseInventoryScan` itself, so we fully verify the wiring.

Suggested implementation:

```python
def test_action_registered_in_action_modules() -> None:
    """warehouse_inventory 模块在 ACTION_MODULES 注册表中,并且 WarehouseInventoryScan 正确注册。"""
    # 导入 ACTION_MODULES 以确保模块注册
    from agent.custom.action import ACTION_MODULES
    # 导入 AgentServer(或等效入口),以触发所有默认 action wiring
    from agent.server import AgentServer  # noqa: F401

    # 模块级注册:warehouse_inventory 模块应该在 ACTION_MODULES 中
    assert "warehouse_inventory" in ACTION_MODULES

    # 断言 WarehouseInventoryScan 已按预期名称注册到动作注册表中
    # 注意:下面的访问路径和注册表名称需要与实际实现保持一致,
    # 例如 ACTION_REGISTRY、REGISTERED_ACTIONS 等。
    from agent.custom.action.warehouse_inventory import WarehouseInventoryScan

    # 以下示例假设存在一个全局动作注册表 ACTION_REGISTRY,
    # 并且键是动作名称,值是动作类。
    from agent.custom.action import ACTION_REGISTRY  # type: ignore[attr-defined]

    assert "WarehouseInventoryScan" in ACTION_REGISTRY
    assert ACTION_REGISTRY["WarehouseInventoryScan"] is WarehouseInventoryScan

```

1.`from agent.server import AgentServer` 调整为实际触发 action 注册的入口模块路径(如果不叫 `agent.server.AgentServer`,请改为正确的模块和类)。
2.`ACTION_REGISTRY` 替换为代码库中实际使用的动作注册表名称和导入路径(例如 `ACTIONS`, `REGISTERED_ACTIONS`,或 `ACTION_MODULES["warehouse_inventory"].actions` 等)。
3. 如果动作名称并非 `"WarehouseInventoryScan"`(比如使用 snake_case 或其他别名),请将 `"WarehouseInventoryScan"` 更新为真实的注册键。
4. 如果注册表结构不同(例如值是工厂函数或包含元数据的对象),请调整断言以匹配真实结构(例如 `ACTION_REGISTRY["WarehouseInventoryScan"].cls is WarehouseInventoryScan`)。
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread agent/custom/action/warehouse_inventory.py Outdated
Comment thread tests/test_warehouse_inventory.py Outdated
Comment thread tests/test_warehouse_inventory.py
Comment thread tests/test_warehouse_inventory.py
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

新增 WarehouseInventoryScan 动作和 WarehouseInventory 每日任务。流程进入仓库后执行多页 OCR 扫描,汇总材料数量,并原子写入 JSON 库存快照。

Changes

仓库库存功能

Layer / File(s) Summary
扫描契约与任务接入
tools/schema/custom.action.schema.json, agent/custom/action/__init__.py, tasks/WarehouseInventory.json, interface.json, resource/base/pipeline/warehouse_inventory.json
注册 WarehouseInventoryScan,扩展动作 schema,并接入每日任务和仓库状态机。
仓库扫描与快照生成
agent/custom/action/warehouse_inventory.py
按仓库模板筛选材料,执行三段往返扫描,通过 OCR 和数量选择规则识别数量,并原子写入库存快照。
扫描验证与协议说明
tests/test_warehouse_inventory.py, .gitignore, docs/en_us/protocol/warehouse-inventory.md, docs/zh_cn/protocol/warehouse-inventory.md
测试材料配置、失败处理和快照写入,并补充中英文扫描协议及运行文件规则。

Estimated code review effort: 4 (复杂) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WarehouseInventoryTask
  participant WarehouseInventoryPipeline
  participant WarehouseInventoryScan
  participant WarehouseUI
  participant OCR
  participant InventoryJSON
  WarehouseInventoryTask->>WarehouseInventoryPipeline: 启动每日任务
  WarehouseInventoryPipeline->>WarehouseUI: 进入并验证仓库
  WarehouseInventoryPipeline->>WarehouseInventoryScan: 执行扫描动作
  WarehouseInventoryScan->>WarehouseUI: 执行往返滚动
  WarehouseInventoryScan->>OCR: 识别材料数量
  OCR-->>WarehouseInventoryScan: 返回多次读数
  WarehouseInventoryScan->>InventoryJSON: 原子写入库存快照
  WarehouseInventoryPipeline->>WarehouseUI: 退出仓库并验证返回主界面
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题准确概括了新增仓库材料数量识别任务这一主要变更,内容简洁且明确。
Linked Issues check ✅ Passed PR 实现了 Issue #637 要求的仓库材料识别功能,并为计划作战提供库存数据基础。
Out of Scope Changes check ✅ Passed 代码、任务配置、流水线、Schema、文档和测试均直接支持 Issue #637 的仓库材料识别目标。
Docstring Coverage ✅ Passed Docstring coverage is 92.31% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@agent/custom/action/warehouse_inventory.py`:
- Around line 110-112: The fallback branch in the inventory scan must not assign
counts[item_id] = 0 when _recognize_item() reports found=False. Mark unmatched
materials in skipped or fail the scan when coverage is incomplete, while
preserving zero only for confirmed readings; add a regression test covering a
material with no readings.
- Around line 133-136: Update the snapshot-writing block in the warehouse
inventory action to serialize JSON into a temporary file within the output
directory, then atomically replace the path in _OUTPUT_PATH only after writing
succeeds. Preserve the existing formatting and encoding, and ensure
temporary-file handling does not leave partial data at the live snapshot path.

In `@docs/en_us/protocol/warehouse-inventory.md`:
- Around line 28-36: Label both warehouse inventory flow-diagram fences as text:
update docs/en_us/protocol/warehouse-inventory.md lines 28-36 and
docs/zh_cn/protocol/warehouse-inventory.md lines 27-35 by changing each opening
fence to a text-labelled fence, with no other content changes.
- Around line 42-45: The material-selection documentation incorrectly states
that adding a template alone is sufficient. Update the listed English site in
docs/en_us/protocol/warehouse-inventory.md lines 42-45 and the corresponding
Chinese site in docs/zh_cn/protocol/warehouse-inventory.md lines 41-43 to
require both a data/combat/items.json entry and the warehouse template image;
clarify that IDs are enumerated from the data file before template validation.
- Around line 47-52: Update docs/en_us/protocol/warehouse-inventory.md lines
47-52 and docs/zh_cn/protocol/warehouse-inventory.md lines 45-49 to document the
fixed 12-page scan schedule: 4 pages down, 4 pages up, then 4 pages down. Remove
the 6→6→6 wording and all claims that scanning stops early.
- Around line 101-103: Update the persisted output semantics at
docs/en_us/protocol/warehouse-inventory.md lines 101-103 and
docs/zh_cn/protocol/warehouse-inventory.md lines 92-94: state that selected
materials missing from the warehouse are recorded as counts[id] = 0, while
materials whose icons are found but whose OCR fails are omitted from counts and
listed in skipped.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 12aae362-c4f6-41d2-8c8c-a2eae3fd9828

📥 Commits

Reviewing files that changed from the base of the PR and between 0218b5e and 72c6628.

⛔ Files ignored due to path filters (34)
  • resource/base/image/Warehouse/Item-110101.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110102.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110104.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110201.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110202.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110204.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110301.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110302.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110304.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110401.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110402.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110404.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110501.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110502.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110504.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110602.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110604.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110702.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110704.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110802.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110804.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110902.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-110904.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111001.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111003.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111004.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111005.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111006.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111007.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111008.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111009.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111013.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111102.png is excluded by !**/*.png
  • resource/base/image/Warehouse/Item-111104.png is excluded by !**/*.png
📒 Files selected for processing (10)
  • .gitignore
  • agent/custom/action/__init__.py
  • agent/custom/action/warehouse_inventory.py
  • docs/en_us/protocol/warehouse-inventory.md
  • docs/zh_cn/protocol/warehouse-inventory.md
  • interface.json
  • resource/base/pipeline/warehouse_inventory.json
  • tasks/WarehouseInventory.json
  • tests/test_warehouse_inventory.py
  • tools/schema/custom.action.schema.json

Comment thread agent/custom/action/warehouse_inventory.py
Comment thread agent/custom/action/warehouse_inventory.py Outdated
Comment thread docs/en_us/protocol/warehouse-inventory.md Outdated
Comment thread docs/en_us/protocol/warehouse-inventory.md Outdated
Comment thread docs/en_us/protocol/warehouse-inventory.md Outdated
Comment thread docs/en_us/protocol/warehouse-inventory.md Outdated
- 提取 _write_snapshot():同目录临时文件 + os.replace 原子替换
- 失败时清理临时文件并原样抛出,避免留下损坏的部分 JSON
- 新增 test_write_snapshot_atomic / test_write_snapshot_failure_cleans_tmp
- 新增材料需同时满足 items.json 条目与模板图(原误写只需模板)
- 扫描计划改为固定 12 屏 4/4/4、无提前退出(与代码一致)
- 输出语义:未找到记录 counts[id]=0;从未读到进 skipped 且不入 counts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_warehouse_inventory.py (1)

97-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

验证替换失败时保留已有快照。

当前测试只验证目标文件原本不存在的情况。先写入一个已有快照,再让 os.replace() 抛出异常,并断言目标文件内容保持不变。这样可以验证原子写入的核心恢复语义。

建议的测试修改
 def test_write_snapshot_failure_cleans_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
     scan = WarehouseInventoryScan.__new__(WarehouseInventoryScan)
     scan._OUTPUT_PATH = str(tmp_path / "out" / "snapshot.json")
+    target = tmp_path / "out" / "snapshot.json"
+    target.parent.mkdir(parents=True)
+    target.write_text('{"counts":{"old":1}}', encoding="utf-8")
 
     def boom(src: str, dst: str) -> None:
         raise OSError("disk full")
 
     monkeypatch.setattr("agent.custom.action.warehouse_inventory.os.replace", boom)
 
     with pytest.raises(OSError):
         scan._write_snapshot({"counts": {}})
 
-    assert not (tmp_path / "out" / "snapshot.json").exists()
+    assert json.loads(target.read_text(encoding="utf-8")) == {"counts": {"old": 1}}
     assert list((tmp_path / "out").glob("*.tmp")) == []
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_warehouse_inventory.py` around lines 97 - 111, Extend
test_write_snapshot_failure_cleans_tmp to create an existing snapshot at the
target path before invoking _write_snapshot, then force os.replace to raise and
assert the target file’s original contents remain unchanged. Preserve the
existing assertions that temporary files are cleaned up and the replacement
error is propagated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_warehouse_inventory.py`:
- Around line 97-111: Extend test_write_snapshot_failure_cleans_tmp to create an
existing snapshot at the target path before invoking _write_snapshot, then force
os.replace to raise and assert the target file’s original contents remain
unchanged. Preserve the existing assertions that temporary files are cleaned up
and the replacement error is propagated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f74cc31a-bb0e-4a37-8efb-fa064efb86ed

📥 Commits

Reviewing files that changed from the base of the PR and between 72c6628 and 744bcb6.

📒 Files selected for processing (4)
  • agent/custom/action/warehouse_inventory.py
  • docs/en_us/protocol/warehouse-inventory.md
  • docs/zh_cn/protocol/warehouse-inventory.md
  • tests/test_warehouse_inventory.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
agent/custom/action/warehouse_inventory.py (1)

109-114: 🗄️ Data Integrity & Integration | 🟠 Major

不要把未观测到的材料写入为 0

_recognize_item() 返回 found=False 时,unreadable 不会更新。第 113-114 行随后写入 counts[item_id] = 0,第 125-127 行再把这个未经确认的值写入快照。模板漏检会被后续计划作战逻辑当成真实缺货。

请将该 ID 加入 skipped,或在覆盖不完整时终止扫描。只有 OCR 明确读到 0 时才写入 0。这是上一轮评审中相同问题的重复报告,当前代码仍保留该路径。

建议修复
             else:
-                logger.warning(f"仓库中未找到材料 {with_template[item_id]['name']}({item_id}),按 0 计")
-                counts[item_id] = 0
+                logger.warning(
+                    f"仓库中未找到材料 {with_template[item_id]['name']}({item_id}),无法确认数量,跳过"
+                )
+                skipped.append(item_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/custom/action/warehouse_inventory.py` around lines 109 - 114, Update
the unrecognized-item branch in the inventory scan so an item absent from the
warehouse is added to skipped (or the scan is terminated when coverage is
incomplete) instead of writing counts[item_id] = 0. Preserve zero counts only
for items whose OCR result explicitly reports zero, and ensure the
snapshot-writing path cannot persist unconfirmed values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@agent/custom/action/warehouse_inventory.py`:
- Around line 109-114: Update the unrecognized-item branch in the inventory scan
so an item absent from the warehouse is added to skipped (or the scan is
terminated when coverage is incomplete) instead of writing counts[item_id] = 0.
Preserve zero counts only for items whose OCR result explicitly reports zero,
and ensure the snapshot-writing path cannot persist unconfirmed values.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d08ce8ca-b833-4b56-bb0f-89fa8e7e368f

📥 Commits

Reviewing files that changed from the base of the PR and between 744bcb6 and d0b195d.

📒 Files selected for processing (1)
  • agent/custom/action/warehouse_inventory.py

APPLe-DF and others added 3 commits August 6, 2026 18:01
- 魔法数字提取为具名常量(滑动坐标/ROI 偏移/图标高度阈值)
- 数量解析抽为模块级 parse_count_from_text(),测试覆盖同一实现
- 多偏移平局时改用 _best_count 聚合,消除对偏移顺序的依赖
- _best_count 无众数时取最小值(装饰条并入是最常见的读大误读)
- 新增 run() 主流程测试(失败路径 + counts/skipped 划分 + 输出结构)
@Windsland52
Windsland52 merged commit de3c3d1 into MAA1999:main Aug 7, 2026
4 checks passed
@APPLe-DF
APPLe-DF deleted the feat/warehouse-inventory branch August 7, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

仓库材料识别

2 participants