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
109 changes: 90 additions & 19 deletions application/analyst/services/state_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,28 +280,38 @@ def update_from_chapter(
storyline_name = advanced_data.get("storyline_name")
progress_summary = advanced_data.get("progress_summary", "")

storyline = None
if storyline_id:
# 通过 ID 查找
storyline = self.storyline_repository.get_by_id(storyline_id)
if storyline:
storyline.update_progress(chapter_number, progress_summary)
self.storyline_repository.save(storyline)
logger.debug(f"Updated storyline {storyline_id}: {progress_summary[:50]}")
else:
logger.warning(f"Storyline {storyline_id} not found")
if not storyline:
logger.warning(f"Storyline {storyline_id} not found by ID, trying name fallback")

if storyline is None and storyline_name:
storyline = self._resolve_storyline_by_name(novel_id_obj, storyline_name)

if storyline is not None:
storyline.update_progress(chapter_number, progress_summary)
self.storyline_repository.save(storyline)
logger.debug(f"Updated storyline '{storyline.name or storyline.id}': {progress_summary[:50]}")
elif storyline_name:
# 通过名称查找(需要遍历)
storylines = self.storyline_repository.get_by_novel_id(novel_id_obj)
found = False
for sl in storylines:
if sl.name == storyline_name:
sl.update_progress(chapter_number, progress_summary)
self.storyline_repository.save(sl)
logger.debug(f"Updated storyline '{storyline_name}': {progress_summary[:50]}")
found = True
break
if not found:
logger.warning(f"Storyline '{storyline_name}' not found")
# 自动创建缺失的故事线
logger.warning(
"Storyline '%s' not found, auto-creating", storyline_name
)
auto_storyline = Storyline(
id=str(uuid.uuid4()),
novel_id=novel_id_obj,
storyline_type=StorylineType.GROWTH,
status=StorylineStatus.ACTIVE,
estimated_chapter_start=chapter_number,
estimated_chapter_end=chapter_number + 50,
name=storyline_name,
description=progress_summary[:200] if progress_summary else "",
last_active_chapter=chapter_number,
progress_summary=progress_summary,
)
self.storyline_repository.save(auto_storyline)
logger.info("Auto-created storyline '%s' (id=%s)", storyline_name, auto_storyline.id)

# 创建新故事线
for new_data in chapter_state.new_storylines:
Expand Down Expand Up @@ -340,6 +350,67 @@ def update_from_chapter(
if chapter_state.has_new_characters() and self.db_connection:
self._write_chapter_elements(novel_id, chapter_number, chapter_state.new_characters)

def _resolve_storyline_by_name(
self,
novel_id: NovelId,
target_name: str,
) -> Optional[Storyline]:
"""通过名称模糊匹配故事线。

查找策略(按优先级):
1. 精确匹配 name 字段
2. 归一化后精确匹配(去除空格、大小写)
3. 子串包含匹配(唯一匹配时返回)
4. 无法确定时返回 None

Args:
novel_id: 小说 ID
target_name: LLM 提取的故事线名称

Returns:
匹配到的 Storyline 或 None
"""
storylines = self.storyline_repository.get_by_novel_id(novel_id)
if not storylines:
return None

normalized_target = _normalize_text(target_name)

# 1. 精确匹配
for sl in storylines:
if sl.name == target_name:
return sl

# 2. 归一化后精确匹配
for sl in storylines:
if _normalize_text(sl.name) == normalized_target:
return sl

# 3. 子串包含匹配(必须唯一)
substring_matches: list[Storyline] = []
for sl in storylines:
sl_norm = _normalize_text(sl.name)
if not sl_norm:
continue
if normalized_target in sl_norm or sl_norm in normalized_target:
substring_matches.append(sl)

if len(substring_matches) == 1:
logger.info(
"Storyline '%s' fuzzy-matched to '%s' (id=%s)",
target_name,
substring_matches[0].name,
substring_matches[0].id,
)
return substring_matches[0]
if len(substring_matches) > 1:
logger.warning(
"Ambiguous storyline resolution for %r, candidates=%s",
target_name,
[s.name for s in substring_matches],
)
return None

def _resolve_foreshadowing_id(
self,
registry: ForeshadowingRegistry,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Test storyline name resolution and auto-creation logic in StateUpdater."""
import pytest
from unittest.mock import Mock
from application.analyst.services.state_updater import StateUpdater
from domain.novel.value_objects.novel_id import NovelId
from domain.novel.entities.storyline import Storyline
from domain.novel.value_objects.storyline_type import StorylineType
from domain.novel.value_objects.storyline_status import StorylineStatus
from domain.novel.repositories.storyline_repository import StorylineRepository
from domain.bible.repositories.bible_repository import BibleRepository
from domain.novel.repositories.foreshadowing_repository import ForeshadowingRepository


class TestStorylineResolution:
"""Test _resolve_storyline_by_name fuzzy matching logic."""

def setup_method(self):
"""Set up test fixtures."""
self.storyline_repository = Mock(spec=StorylineRepository)
self.updater = StateUpdater(
bible_repository=Mock(spec=BibleRepository),
foreshadowing_repository=Mock(spec=ForeshadowingRepository),
storyline_repository=self.storyline_repository,
)
self.novel_id = NovelId("novel-test")

def _make_storyline(self, id: str, name: str) -> Storyline:

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Rename parameter to avoid shadowing Python builtin.

The parameter name id shadows the Python builtin function. Consider using a more specific name like storyline_id or sl_id.

🛠️ Proposed fix
-    def _make_storyline(self, id: str, name: str) -> Storyline:
+    def _make_storyline(self, storyline_id: str, name: str) -> Storyline:
         return Storyline(
-            id=id,
+            id=storyline_id,
             novel_id=self.novel_id,
             storyline_type=StorylineType.GROWTH,
             status=StorylineStatus.ACTIVE,
             estimated_chapter_start=1,
             estimated_chapter_end=50,
             name=name,
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _make_storyline(self, id: str, name: str) -> Storyline:
def _make_storyline(self, storyline_id: str, name: str) -> Storyline:
return Storyline(
id=storyline_id,
novel_id=self.novel_id,
storyline_type=StorylineType.GROWTH,
status=StorylineStatus.ACTIVE,
estimated_chapter_start=1,
estimated_chapter_end=50,
name=name,
)
🧰 Tools
🪛 Ruff (0.15.15)

[error] 27-27: Function argument id is shadowing a Python builtin

(A002)

🤖 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/unit/application/services/test_state_updater_storyline_resolution.py`
at line 27, The test helper _make_storyline currently uses a parameter named id
which shadows the Python builtin; rename that parameter to a more specific
identifier (e.g., storyline_id or sl_id) in the _make_storyline function
signature and update all uses within the function and its callers to use the new
name so the builtin is not shadowed (refer to symbol _make_storyline to locate
and change the parameter and its references).

Source: Linters/SAST tools

return Storyline(
id=id,
novel_id=self.novel_id,
storyline_type=StorylineType.GROWTH,
status=StorylineStatus.ACTIVE,
estimated_chapter_start=1,
estimated_chapter_end=50,
name=name,
)

def test_exact_match(self):
"""Exact name match should return the storyline."""
sl = self._make_storyline("sl-1", "盲眼老人的阵盘")
self.storyline_repository.get_by_novel_id.return_value = [sl]

result = self.updater._resolve_storyline_by_name(self.novel_id, "盲眼老人的阵盘")
assert result is not None
assert result.id == "sl-1"

def test_normalized_match(self):
"""Normalized match (case/whitespace insensitive) should work."""
sl = self._make_storyline("sl-2", "收割教廷阴谋")
self.storyline_repository.get_by_novel_id.return_value = [sl]

# Different whitespace/case
result = self.updater._resolve_storyline_by_name(self.novel_id, " 收割教廷阴谋 ")
assert result is not None
assert result.id == "sl-2"

def test_substring_match_unique(self):
"""Unique substring match should return the storyline."""
sl = self._make_storyline("sl-3", "碎片之谜")
self.storyline_repository.get_by_novel_id.return_value = [sl]

# LLM might extract a partial name
result = self.updater._resolve_storyline_by_name(self.novel_id, "碎片")
assert result is not None
assert result.id == "sl-3"

def test_substring_match_ambiguous_returns_none(self):
"""Ambiguous substring match should return None."""
sl1 = self._make_storyline("sl-4", "碎片之谜")
sl2 = self._make_storyline("sl-5", "碎片的秘密")
self.storyline_repository.get_by_novel_id.return_value = [sl1, sl2]

result = self.updater._resolve_storyline_by_name(self.novel_id, "碎片")
assert result is None

def test_no_match_returns_none(self):
"""No match should return None."""
sl = self._make_storyline("sl-6", "主线剧情")
self.storyline_repository.get_by_novel_id.return_value = [sl]

result = self.updater._resolve_storyline_by_name(self.novel_id, "完全无关的名称")
assert result is None

def test_empty_storyline_list_returns_none(self):
"""Empty storyline list should return None."""
self.storyline_repository.get_by_novel_id.return_value = []

result = self.updater._resolve_storyline_by_name(self.novel_id, "任何名称")
assert result is None