diff --git a/application/analyst/services/state_updater.py b/application/analyst/services/state_updater.py index a7560918..1ee696f3 100644 --- a/application/analyst/services/state_updater.py +++ b/application/analyst/services/state_updater.py @@ -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: @@ -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, diff --git a/tests/unit/application/services/test_state_updater_storyline_resolution.py b/tests/unit/application/services/test_state_updater_storyline_resolution.py new file mode 100644 index 00000000..4051e9b8 --- /dev/null +++ b/tests/unit/application/services/test_state_updater_storyline_resolution.py @@ -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: + 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