Forráskód Böngészése

Merge pull request #97 from ranran-liu/fix/urgency-type-mismatch

fix: coerce string urgency values to float
lingfengQAQ 3 hete
szülő
commit
a113e2e4c3

+ 1 - 1
webnovel-writer/agents/data-agent.md

@@ -107,7 +107,7 @@ hook_strength: "strong"
 - **entity_deltas 子项**:必须用 `entity_type`(不是 `type`),值为 `角色|组织|地点|物品|势力` 等,不是默认填 `"角色"`。`is_protagonist: true` 用于标记主角,主角字段会同步到 `state.protagonist_state`。
 - **accepted_events 通用**:每条必须包含 `event_id`、`chapter`、`event_type`、`subject`、`payload`。`event_id` 用章节内稳定 ID(如 `evt-ch100-001`);`chapter` 写当前章号;`event_type` 用枚举值(`character_state_changed|power_breakthrough|relationship_changed|world_rule_revealed|world_rule_broken|open_loop_created|open_loop_closed|promise_created|promise_paid_off|artifact_obtained`);`subject` 是事件主体的 entity_id(不是中文名)。
 - **character_state_changed.payload**:用 `field`(或 `field_path`)+ `new`(或 `new_state`/`new_value`)+ `old`(或 `previous_state`/`old_value`)。建议直接用 `field` + `new` + `old` 与 state_deltas 保持一致。
-- **open_loop_created.payload**:必须有 `content`(悬念正文),可选 `loop_type`(悬念类型)、`unanswered_question`(核心疑问)、`urgency`、`planted_chapter`、`expected_payoff`/`loop_deadline`。投影器会从 content > unanswered_question > description 取值,不要省略 content。
+- **open_loop_created.payload**:必须有 `content`(悬念正文),可选 `loop_type`(悬念类型)、`unanswered_question`(核心疑问)、`urgency`(**0-100 整数**;惯例:紧急≈100、一般≈60、远期≈20。若误传字符串 `"high"`/`"medium"`/`"low"`,消费端会兜底转换,但**首选数字**)、`planted_chapter`、`expected_payoff`/`loop_deadline`。投影器会从 content > unanswered_question > description 取值,不要省略 content。
 - **world_rule_revealed.payload**:必须有 `rule_content`(或 `rule`、`description`),可选 `rule_category` / `domain`、`scope`。
 - **relationship_changed.payload**:必须有 `to_entity` 和 `relationship_type`(不是 `type`)。
 - **artifact_obtained.payload**:必须有 `artifact_id`、`name`、`owner`(或 `holder`)。

+ 3 - 2
webnovel-writer/scripts/data_modules/memory/writer.py

@@ -9,6 +9,7 @@ import hashlib
 from typing import Any, Dict, List
 
 from ..config import DataModulesConfig, get_config
+from ..urgency_utils import coerce_urgency
 from .schema import MemoryItem
 from .store import ScratchpadManager
 
@@ -238,7 +239,7 @@ class MemoryWriter:
                 field="status",
                 value=content,
                 payload={
-                    "urgency": row.get("urgency"),
+                    "urgency": coerce_urgency(row.get("urgency")),
                     "planted_chapter": row.get("planted_chapter"),
                     "expected_payoff": row.get("expected_payoff"),
                     "status": row.get("status"),
@@ -314,7 +315,7 @@ class MemoryWriter:
                         {
                             "content": content,
                             "status": payload.get("status") or "active",
-                            "urgency": payload.get("urgency") or 0,
+                            "urgency": coerce_urgency(payload.get("urgency")),
                             "planted_chapter": (
                                 payload.get("planted_chapter") or event.get("chapter") or chapter
                             ),

+ 2 - 1
webnovel-writer/scripts/data_modules/memory_contract_adapter.py

@@ -22,6 +22,7 @@ from .memory_contract import (
     TimelineEvent,
 )
 from .story_runtime_sources import load_runtime_sources
+from .urgency_utils import coerce_urgency
 
 logger = logging.getLogger(__name__)
 
@@ -319,7 +320,7 @@ class MemoryContractAdapter:
                     status=item.status,
                     planted_chapter=item.source_chapter,
                     expected_payoff=item.payload.get("expected_payoff", ""),
-                    urgency=float(item.payload.get("urgency", 0.0)),
+                    urgency=coerce_urgency(item.payload.get("urgency")),
                 )
                 for item in items
             ]

+ 33 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_contract_adapter.py

@@ -171,6 +171,39 @@ class TestGetOpenLoops:
         assert loops[0].content == "萧炎与纳兰嫣然三年之约"
         assert loops[0].urgency == 0.9
 
+    def test_get_open_loops_with_string_urgency_does_not_crash(self, tmp_path):
+        """回归测试:data-agent 输出字符串 urgency 时,整批伏笔不应被吞掉。
+
+        Issue 根因:``get_open_loops`` 内部用 ``float("high")`` 抛
+        ``ValueError``,外层 ``except`` 兜底返回 ``[]``,所有伏笔同时丢失。
+        """
+        cfg = _make_project(tmp_path)
+        from data_modules.memory.schema import MemoryItem
+        from data_modules.memory.store import ScratchpadManager
+
+        store = ScratchpadManager(cfg)
+        # 模拟 LLM 写入的三种典型字符串值,外加一条正常数值
+        for idx, urgency in enumerate(["high", "medium", "low", 75]):
+            store.upsert_item(MemoryItem(
+                id=f"ol-str-{idx}",
+                layer="semantic",
+                category="open_loop",
+                subject=f"loop-{idx}",
+                field="",
+                value=f"伏笔 {idx}",
+                status="active",
+                source_chapter=idx + 1,
+                payload={"urgency": urgency, "expected_payoff": ""},
+            ))
+
+        adapter = MemoryContractAdapter(cfg)
+        loops = adapter.get_open_loops()
+        # 关键:4 条全部返回,而不是因为单条字符串触发 except 后整批失踪
+        assert len(loops) == 4
+        urgencies = sorted(loop.urgency for loop in loops)
+        # high=100, medium=60, low=20, 数值=75 → 排序后应为 [20, 60, 75, 100]
+        assert urgencies == [20.0, 60.0, 75.0, 100.0]
+
 
 class TestGetTimeline:
     def test_get_timeline_empty(self, tmp_path):

+ 116 - 0
webnovel-writer/scripts/data_modules/tests/test_urgency_utils.py

@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""urgency_utils.coerce_urgency 单元测试。"""
+from __future__ import annotations
+
+import math
+import sys
+from pathlib import Path
+
+import pytest
+
+# 确保 scripts/ 在 sys.path 中
+_scripts_dir = str(Path(__file__).resolve().parent.parent.parent)
+if _scripts_dir not in sys.path:
+    sys.path.insert(0, _scripts_dir)
+
+from data_modules.urgency_utils import coerce_urgency
+
+
+class TestCoerceUrgencyNumeric:
+    """数值输入直接通过。"""
+
+    def test_int_input(self):
+        assert coerce_urgency(80) == 80.0
+
+    def test_float_input(self):
+        assert coerce_urgency(0.9) == pytest.approx(0.9)
+
+    def test_zero(self):
+        assert coerce_urgency(0) == 0.0
+
+    def test_large_number_not_clamped(self):
+        # 当前实现不夹紧上界,仅做规范化;如果将来需要 clamp,再单独加。
+        assert coerce_urgency(150) == 150.0
+
+    def test_negative_number_preserved(self):
+        # 负数语义虽然不常用,但 coerce 不应该静默修改它。
+        assert coerce_urgency(-5) == -5.0
+
+
+class TestCoerceUrgencyString:
+    """字符串输入被映射或解析。"""
+
+    @pytest.mark.parametrize(
+        "label,expected",
+        [
+            ("high", 100.0),
+            ("HIGH", 100.0),
+            ("  High  ", 100.0),
+            ("medium", 60.0),
+            ("Medium", 60.0),
+            ("low", 20.0),
+        ],
+    )
+    def test_natural_language_label(self, label, expected):
+        assert coerce_urgency(label) == expected
+
+    @pytest.mark.parametrize(
+        "numeric_str,expected",
+        [
+            ("100", 100.0),
+            ("3.14", 3.14),
+            ("1e2", 100.0),
+            (" 42 ", 42.0),
+        ],
+    )
+    def test_numeric_string(self, numeric_str, expected):
+        assert coerce_urgency(numeric_str) == pytest.approx(expected)
+
+    def test_unknown_label_returns_default(self):
+        # "critical"/"urgent" 等未在映射表里 → 走 default
+        assert coerce_urgency("critical") == 0.0
+        assert coerce_urgency("critical", default=50.0) == 50.0
+
+    def test_empty_string_returns_default(self):
+        assert coerce_urgency("") == 0.0
+        assert coerce_urgency("   ") == 0.0
+
+    def test_invalid_string_does_not_raise(self):
+        """关键:旧版本会 raise,新版本必须安全返回 default。"""
+        try:
+            result = coerce_urgency("not-a-number")
+        except Exception as e:  # pragma: no cover - regression guard
+            pytest.fail(f"coerce_urgency should not raise on invalid string: {e}")
+        assert result == 0.0
+
+
+class TestCoerceUrgencyOther:
+    """其他类型/边界情况。"""
+
+    def test_none(self):
+        assert coerce_urgency(None) == 0.0
+
+    def test_none_with_custom_default(self):
+        assert coerce_urgency(None, default=42.0) == 42.0
+
+    def test_bool_returns_default(self):
+        # bool 是 int 子类,但语义不是 urgency;显式返回 default。
+        assert coerce_urgency(True) == 0.0
+        assert coerce_urgency(False) == 0.0
+
+    def test_list_returns_default(self):
+        assert coerce_urgency([100]) == 0.0
+
+    def test_dict_returns_default(self):
+        assert coerce_urgency({"urgency": 100}) == 0.0
+
+
+class TestRegressionOriginalBug:
+    """复现 Issue:data-agent 输出字符串 urgency 导致下游 ValueError。"""
+
+    def test_high_no_longer_raises(self):
+        """旧代码 float("high") 抛 ValueError,新代码必须正常返回。"""
+        result = coerce_urgency("high")
+        assert math.isfinite(result)
+        assert result > 0

+ 58 - 0
webnovel-writer/scripts/data_modules/urgency_utils.py

@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""urgency 字段类型规范化工具。
+
+LLM 生成 commit 时可能把 ``urgency`` 写成自然语言字符串
+(``"high"``/``"medium"``/``"low"``),而消费端(``MemoryContractAdapter``、
+``status_reporter`` 等)一律按 0-100 数值处理。本模块提供统一入口,
+避免 ``float("high")`` 这类 ``ValueError`` 散落各处导致 ``get_open_loops()``
+之类调用因 ``except`` 兜底而静默返回空列表。
+
+约定的字符串→数值映射与 ``DataModulesConfig.foreshadowing_urgency_score_*``
+对齐:``high≈100``、``medium≈60``、``low≈20``。
+"""
+from __future__ import annotations
+
+from typing import Any
+
+
+# 字符串→数值映射;与 config.foreshadowing_urgency_score_* 对齐。
+# 留作模块常量是为了让调用方在需要时也能直接引用语义。
+_URGENCY_STRING_MAP = {
+    "high": 100.0,
+    "medium": 60.0,
+    "low": 20.0,
+}
+
+
+def coerce_urgency(value: Any, default: float = 0.0) -> float:
+    """把任意输入规范化为 0-100 浮点数。
+
+    支持的输入:
+
+    - ``int`` / ``float``:原样转 ``float``。
+    - 数字字面量字符串(如 ``"100"``、``"3.14"``、``"1e2"``):``float()`` 解析后返回。
+    - 自然语言字符串(``"high"``/``"medium"``/``"low"``,大小写、空白无关):
+      映射为预设数值。
+    - ``None``、空字符串、其他无法解析的值:返回 ``default``。
+
+    设计目标:让消费端不会因为单条字段类型异常而整体崩溃。
+    """
+    if value is None:
+        return default
+    if isinstance(value, bool):
+        # bool 是 int 的子类,单独排除避免 True→1.0 / False→0.0 这种语义噪声。
+        return default
+    if isinstance(value, (int, float)):
+        return float(value)
+    if isinstance(value, str):
+        s = value.strip().lower()
+        if not s:
+            return default
+        if s in _URGENCY_STRING_MAP:
+            return _URGENCY_STRING_MAP[s]
+        try:
+            return float(s)
+        except ValueError:
+            return default
+    return default