From a1591aa1df96233587b4d5ee3ecfa5aacf35bb2a Mon Sep 17 00:00:00 2001 From: ws1065 Date: Fri, 8 May 2026 11:23:23 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(bible):=20=E4=BF=AE=E5=A4=8D=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=85=B3=E7=B3=BB=E6=8C=87=E6=95=B0=E5=A2=9E=E9=95=BF?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E6=95=B0=E6=8D=AE=E5=BA=93=E7=88=86=E7=82=B8?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bible_service.add_character() 原先每次调用都会全量加载 Bible、删除所有 子表数据后重新写入,多次调用会导致关系数据按 2^n 倍增。实测 bible_character_relationships 表膨胀至 406 万行(1.4GB),其中每条 关系被复制 131,072 次。 修复方案: - 新增 SqliteBibleRepository.add_character_incremental() 增量写入方法 - BibleService.add_character() 改为增量 INSERT,不再 DELETE + 全量重写 - 更新单元测试适配新逻辑 Co-Authored-By: Claude Opus 4.7 --- application/world/services/bible_service.py | 46 +++++++++++++++---- .../database/sqlite_bible_repository.py | 45 ++++++++++++++++++ .../services/test_bible_service.py | 14 ++++-- 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/application/world/services/bible_service.py b/application/world/services/bible_service.py index 8970beebb..72258bead 100644 --- a/application/world/services/bible_service.py +++ b/application/world/services/bible_service.py @@ -13,7 +13,7 @@ from domain.bible.repositories.bible_repository import BibleRepository from domain.novel.repositories.novel_repository import NovelRepository from domain.novel.repositories.chapter_repository import ChapterRepository -from domain.shared.exceptions import EntityNotFoundError +from domain.shared.exceptions import EntityNotFoundError, InvalidOperationError from application.world.dtos.bible_dto import BibleDTO, CharacterDTO if TYPE_CHECKING: @@ -105,7 +105,7 @@ def add_character( description: str, relationships: list = None ) -> BibleDTO: - """添加人物 + """添加人物(增量写入,不触碰已有角色和关系) Args: novel_id: 小说 ID @@ -124,15 +124,41 @@ def add_character( if bible is None: raise EntityNotFoundError("Bible", f"for novel {novel_id}") - character = Character( - id=CharacterId(character_id), - name=name, - description=description, - relationships=relationships or [], - ) - bible.add_character(character) - self.bible_repository.save(bible) + # 检查角色是否已存在 + if bible.get_character(CharacterId(character_id)) is not None: + raise InvalidOperationError( + f"Character with id '{character_id}' already exists" + ) + # 增量写入:只 INSERT 新角色及其关系,不 DELETE 其他数据 + repo = self.bible_repository + if hasattr(repo, "add_character_incremental"): + rel_dicts = [] + for rel in (relationships or []): + if isinstance(rel, dict): + rel_dicts.append(rel) + elif hasattr(rel, "model_dump"): + rel_dicts.append(rel.model_dump()) + elif hasattr(rel, "dict"): + rel_dicts.append(rel.dict()) + else: + rel_dicts.append({"target": str(rel), "relation": "", "description": ""}) + repo.add_character_incremental( + novel_id, character_id, name, description, rel_dicts, + ) + else: + # fallback:非 SQLite 仓储走原路径 + character = Character( + id=CharacterId(character_id), + name=name, + description=description, + relationships=relationships or [], + ) + bible.add_character(character) + repo.save(bible) + + # 重新加载最新状态返回 + bible = self.bible_repository.get_by_novel_id(NovelId(novel_id)) return BibleDTO.from_domain(bible) def update_character_voice_anchors( diff --git a/infrastructure/persistence/database/sqlite_bible_repository.py b/infrastructure/persistence/database/sqlite_bible_repository.py index 52fffb590..b2c4cdb79 100644 --- a/infrastructure/persistence/database/sqlite_bible_repository.py +++ b/infrastructure/persistence/database/sqlite_bible_repository.py @@ -315,6 +315,51 @@ def exists(self, bible_id: str) -> bool: r = self.db.fetch_one("SELECT 1 AS o FROM bibles WHERE id = ?", (bible_id,)) return r is not None + def add_character_incremental( + self, + novel_id: str, + character_id: str, + name: str, + description: str, + relationships: List[Dict[str, str]], + *, + mental_state: str = "NORMAL", + verbal_tic: str = "", + idle_behavior: str = "", + ) -> None: + """增量添加单个角色及其关系(不触碰其他角色)。""" + now = self._now() + conn = self._conn() + try: + conn.execute( + """ + INSERT OR REPLACE INTO bible_characters ( + id, novel_id, name, description, + mental_state, mental_state_reason, verbal_tic, idle_behavior, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, '', ?, ?, ?, ?) + """, + (character_id, novel_id, name, description, + mental_state, verbal_tic, idle_behavior, now, now), + ) + for i, rel in enumerate(relationships): + rid = f"{character_id}-rel-{i}-{uuid.uuid4().hex[:6]}" + target_name = rel.get("target", "") or "" + relation = rel.get("relation", "") or "" + desc = rel.get("description", "") or "" + conn.execute( + """ + INSERT INTO bible_character_relationships + (id, character_id, target_name, relation, description) + VALUES (?, ?, ?, ?, ?) + """, + (rid, character_id, target_name, relation, desc), + ) + conn.commit() + except Exception: + conn.rollback() + raise + def update_character_anchors( self, novel_id: str, diff --git a/tests/unit/application/services/test_bible_service.py b/tests/unit/application/services/test_bible_service.py index 9813335aa..dac071cda 100644 --- a/tests/unit/application/services/test_bible_service.py +++ b/tests/unit/application/services/test_bible_service.py @@ -40,9 +40,13 @@ def test_create_bible(self, service, mock_repository): def test_add_character(self, service, mock_repository): """测试添加人物""" - # 准备 mock 数据 - bible = Bible(id="bible-1", novel_id=NovelId("novel-1")) - mock_repository.get_by_novel_id.return_value = bible + # 准备 mock 数据:第一次返回空 Bible,第二次返回含角色的 Bible(模拟增量写入后) + bible_empty = Bible(id="bible-1", novel_id=NovelId("novel-1")) + bible_with_char = Bible(id="bible-1", novel_id=NovelId("novel-1")) + bible_with_char.add_character( + Character(id=CharacterId("char-1"), name="主角", description="主角描述") + ) + mock_repository.get_by_novel_id.side_effect = [bible_empty, bible_with_char] bible_dto = service.add_character( novel_id="novel-1", @@ -56,8 +60,8 @@ def test_add_character(self, service, mock_repository): assert bible_dto.characters[0].id == "char-1" assert bible_dto.characters[0].name == "主角" - # 验证调用了 save - mock_repository.save.assert_called_once() + # 验证调用了增量写入 + mock_repository.add_character_incremental.assert_called_once() def test_add_character_bible_not_found(self, service, mock_repository): """测试向不存在的 Bible 添加人物""" From 054b4ce9ea0d158e8e1dbb6c292e8e9b5dd5e06a Mon Sep 17 00:00:00 2001 From: ws1065 Date: Fri, 8 May 2026 12:41:27 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(engine):=20=E4=BD=BF=E7=94=A8=20LLM=20s?= =?UTF-8?q?top=5Freason=20=E6=9B=BF=E4=BB=A3=E6=96=87=E6=9C=AC=E7=8C=9C?= =?UTF-8?q?=E6=B5=8B=E8=BF=9B=E8=A1=8C=E6=88=AA=E6=96=AD=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 _ensure_complete_ending() 通过正则检测内容是否以句号等符号结尾来判断 截断,导致大量误判(对话引号、逗号等正常结尾也会触发续写)。 现在从 LLM API 返回的 finish_reason / stop_reason / finishReason 中提取 真正的截断信号: - "length" / "max_tokens" / "MAX_TOKENS" → 确认截断,发起续写 - "stop" / "end_turn" / "STOP" 等 → 正常结束,跳过续写 - 为空时降级到原有文本匹配逻辑 改动涉及:GenerationResult 增加 stop_reason 字段,各 Provider(OpenAI / Anthropic / Gemini / Mock)提取 stop_reason,BaseProvider 增加 last_stream_stop_reason 用于流式场景。 Co-Authored-By: Claude Opus 4.7 --- .../engine/services/autopilot_daemon.py | 69 ++++++++++++------- domain/ai/services/llm_service.py | 3 +- .../ai/providers/anthropic_provider.py | 26 ++++++- infrastructure/ai/providers/base.py | 1 + .../ai/providers/gemini_provider.py | 40 ++++++++++- infrastructure/ai/providers/mock_provider.py | 5 +- .../ai/providers/openai_provider.py | 30 +++++++- 7 files changed, 143 insertions(+), 31 deletions(-) diff --git a/application/engine/services/autopilot_daemon.py b/application/engine/services/autopilot_daemon.py index 66f5d72b7..504cb10b4 100644 --- a/application/engine/services/autopilot_daemon.py +++ b/application/engine/services/autopilot_daemon.py @@ -659,9 +659,9 @@ async def _handle_writing(self, novel: Novel): # - 最终输出应接近 prompt 目标,略低于原始目标 max_tokens = int(beat.target_words * 1.1) cfg = GenerationConfig(max_tokens=max_tokens, temperature=0.85) - beat_content = await self._stream_llm_with_stop_watch(prompt, cfg, novel=novel) + beat_content, beat_stop_reason = await self._stream_llm_with_stop_watch(prompt, cfg, novel=novel) else: - beat_content = await self._stream_one_beat( + beat_content, beat_stop_reason = await self._stream_one_beat( outline, context, beat_prompt, @@ -672,9 +672,10 @@ async def _handle_writing(self, novel: Novel): ) if beat_content.strip(): - # V8: 截断检测与自动续写(软着陆) + # V9: 截断检测与自动续写(基于 stop_reason) beat_content = await self._ensure_complete_ending( - beat_content, beat, outline, chapter_content, novel + beat_content, beat, outline, chapter_content, novel, + stop_reason=beat_stop_reason, ) chapter_content += ("\n\n" if chapter_content else "") + beat_content await self._upsert_chapter_content(novel, next_chapter_node, chapter_content, status="draft") @@ -711,9 +712,9 @@ async def _handle_writing(self, novel: Novel): voice_anchors=voice_anchors, ) cfg = GenerationConfig(max_tokens=3000, temperature=0.85) - beat_content = await self._stream_llm_with_stop_watch(prompt, cfg, novel=novel) + beat_content, _ = await self._stream_llm_with_stop_watch(prompt, cfg, novel=novel) else: - beat_content = await self._stream_one_beat( + beat_content, _ = await self._stream_one_beat( outline, context, None, None, novel=novel, voice_anchors=voice_anchors ) if not self._is_still_running(novel): @@ -1273,10 +1274,13 @@ async def _score_tension(self, content: str) -> int: async def _stream_llm_with_stop_watch( self, prompt: Prompt, config: GenerationConfig, novel=None - ) -> str: + ) -> tuple[str, str]: """与 workflow 共用同一套 Prompt + LLM;novel 传入时并行轮询 DB 是否已停止。 - + 流式生成时会实时推送增量文字到 streaming_callback(如果设置)。 + + Returns: + (content, stop_reason) 元组,stop_reason 为 LLM API 返回的停止原因。 """ content = "" stop_detected = asyncio.Event() @@ -1301,11 +1305,11 @@ async def _watch_stop_from_db() -> None: if novel is not None and stop_detected.is_set(): break content += chunk - + # 实时推送增量文字到全局流式队列 if novel is not None and chunk: await self._push_streaming_chunk(novel.novel_id.value, chunk) - + if novel is not None and stop_detected.is_set(): break finally: @@ -1320,7 +1324,12 @@ async def _watch_stop_from_db() -> None: if novel is not None: self._merge_autopilot_status_from_db(novel) - return strip_reasoning_artifacts(content) + # 从 provider 读取 stream 的 stop_reason + stop_reason = "" + if hasattr(self.llm_service, "last_stream_stop_reason"): + stop_reason = self.llm_service.last_stream_stop_reason or "" + + return strip_reasoning_artifacts(content), stop_reason async def _push_streaming_chunk(self, novel_id: str, chunk: str): """推送增量文字到全局流式队列,供 SSE 接口消费""" @@ -1334,11 +1343,14 @@ async def _ensure_complete_ending( outline: str, chapter_draft_so_far: str, novel=None, + stop_reason: str = "", ) -> str: - """V8: 截断检测与自动续写(软着陆) + """V9: 截断检测与自动续写(基于 stop_reason + 文本兜底) - 检测内容是否被截断(没有以句号等结束符结尾), - 如果被截断,自动发起续写请求完成收尾。 + 优先使用 LLM API 返回的 stop_reason 判断是否截断: + - stop_reason 为 "length"/"max_tokens"/"MAX_TOKENS" → 确认截断,发起续写 + - stop_reason 为其他值("stop"/"end_turn"/"STOP" 等)→ LLM 正常结束,不续写 + - stop_reason 为空(无法获取时)→ 降级到文本匹配兜底 Args: content: 已生成的内容 @@ -1346,6 +1358,7 @@ async def _ensure_complete_ending( outline: 章节大纲 chapter_draft_so_far: 本章已生成的正文 novel: 小说对象 + stop_reason: LLM API 返回的停止原因 Returns: 完整的内容(可能包含续写部分) @@ -1355,17 +1368,23 @@ async def _ensure_complete_ending( if not content or not content.strip(): return content - # 检测是否以句子结束符结尾 - # 中文句号、英文句号、叹号、问号、引号、省略号 - ending_pattern = r'[。!?…)】》"\'』」]$' stripped = content.rstrip() - if re.search(ending_pattern, stripped): - # 结尾完整,无需续写 - return content - - # 检测是否被截断 - logger.warning(f"[截断检测] 内容未以结束符结尾,可能被截断,发起自动续写") + # 判断是否需要续写 + truncated_by_token = stop_reason in ("length", "max_tokens", "MAX_TOKENS") + + if not truncated_by_token: + if stop_reason: + # LLM 明确报告正常结束,不续写 + return content + # stop_reason 为空(无法获取),降级到文本匹配 + ending_pattern = r'[。!?…)】》"\'』」]$' + if re.search(ending_pattern, stripped): + return content + # 文本也不匹配结束符,可能是截断 + logger.warning(f"[截断检测] stop_reason 未知且内容未以结束符结尾,尝试续写") + else: + logger.warning(f"[截断检测] stop_reason={stop_reason},确认 token 耗尽截断,发起续写") # 构建续写 Prompt continuation_prompt = Prompt( @@ -1387,7 +1406,7 @@ async def _ensure_complete_ending( try: config = GenerationConfig(max_tokens=300, temperature=0.7) - continuation = await self._stream_llm_with_stop_watch( + continuation, _ = await self._stream_llm_with_stop_watch( continuation_prompt, config, novel=novel ) @@ -1412,7 +1431,7 @@ async def _stream_one_beat( novel=None, voice_anchors: str = "", chapter_draft_so_far: str = "", - ) -> str: + ) -> tuple[str, str]: """无 AutoNovelGenerationWorkflow 时的降级:爽文短 Prompt + 流式。""" va = (voice_anchors or "").strip() voice_block = "" diff --git a/domain/ai/services/llm_service.py b/domain/ai/services/llm_service.py index 8d9649437..a214821ba 100644 --- a/domain/ai/services/llm_service.py +++ b/domain/ai/services/llm_service.py @@ -30,9 +30,10 @@ def __post_init__(self): class GenerationResult: """生成结果""" - def __init__(self, content: str, token_usage: TokenUsage): + def __init__(self, content: str, token_usage: TokenUsage, stop_reason: str = ""): self.content = content self.token_usage = token_usage + self.stop_reason = stop_reason # "stop"/"length"(OpenAI), "end_turn"/"max_tokens"(Anthropic), "STOP"/"MAX_TOKENS"(Gemini) self.__post_init__() def __post_init__(self): diff --git a/infrastructure/ai/providers/anthropic_provider.py b/infrastructure/ai/providers/anthropic_provider.py index 8be2b7b38..1e3f18354 100644 --- a/infrastructure/ai/providers/anthropic_provider.py +++ b/infrastructure/ai/providers/anthropic_provider.py @@ -160,7 +160,7 @@ async def generate( output_tokens=response.usage.output_tokens ) - return GenerationResult(content=content, token_usage=token_usage) + return GenerationResult(content=content, token_usage=token_usage, stop_reason=response.stop_reason or "") except RuntimeError: raise @@ -208,6 +208,7 @@ async def stream_generate( logger.debug(f"[Stream] Calling {url}") + self.last_stream_stop_reason = "" try: async with httpx.AsyncClient( timeout=self.settings.timeout_seconds, @@ -234,6 +235,10 @@ async def stream_generate( text_content = self._parse_sse_event(event_text) if text_content: yield text_content + # 从 message_delta 事件提取 stop_reason + stop = self._extract_stop_reason_from_sse(event_text) + if stop: + self.last_stream_stop_reason = stop except Exception as e: logger.error(f"[Stream] Failed: {e}") @@ -266,3 +271,22 @@ def _parse_sse_event(self, event_text: str) -> str: return delta.get("text", "") return "" + + def _extract_stop_reason_from_sse(self, event_text: str) -> str: + """从 message_delta 事件中提取 stop_reason。""" + lines = event_text.strip().split("\n") + data = None + for line in lines: + if line.startswith("data:"): + data = line[5:].strip() + break + if not data: + return "" + try: + parsed = json.loads(data) + except json.JSONDecodeError: + return "" + if parsed.get("type") == "message_delta": + delta = parsed.get("delta", {}) + return delta.get("stop_reason", "") or "" + return "" diff --git a/infrastructure/ai/providers/base.py b/infrastructure/ai/providers/base.py index 17b609240..83e427bdc 100644 --- a/infrastructure/ai/providers/base.py +++ b/infrastructure/ai/providers/base.py @@ -17,3 +17,4 @@ def __init__(self, settings: Settings): settings: AI 配置设置 """ self.settings = settings + self.last_stream_stop_reason: str = "" diff --git a/infrastructure/ai/providers/gemini_provider.py b/infrastructure/ai/providers/gemini_provider.py index 130130b00..a4c4c5d37 100644 --- a/infrastructure/ai/providers/gemini_provider.py +++ b/infrastructure/ai/providers/gemini_provider.py @@ -56,7 +56,16 @@ async def generate(self, prompt: Prompt, config: GenerationConfig) -> Generation input_tokens=int(usage.get('promptTokenCount') or 0), output_tokens=int(usage.get('candidatesTokenCount') or 0), ) - return GenerationResult(content=content, token_usage=token_usage) + + # 提取 finishReason + stop_reason = "" + for candidate in data.get('candidates') or []: + fr = candidate.get('finishReason') + if fr: + stop_reason = fr + break + + return GenerationResult(content=content, token_usage=token_usage, stop_reason=stop_reason) async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> AsyncIterator[str]: model_id = require_resolved_model_id( @@ -69,6 +78,7 @@ async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> Asy url = self._build_url(model_id, 'streamGenerateContent') timeout = httpx.Timeout(self.settings.timeout_seconds) + self.last_stream_stop_reason = "" async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: async with client.stream( 'POST', @@ -86,6 +96,10 @@ async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> Asy text = self._parse_sse_event(event_text) if text: yield text + # 提取 finishReason + fr = self._extract_finish_reason_from_sse(event_text) + if fr: + self.last_stream_stop_reason = fr def _build_url(self, model: str, action: str) -> str: model_name = model.strip() @@ -168,3 +182,27 @@ def _parse_sse_event(self, event_text: str) -> str: if isinstance(payload, dict): return self._extract_text(payload) return '' + + def _extract_finish_reason_from_sse(self, event_text: str) -> str: + """从 SSE 事件中提取 finishReason。""" + data_lines: list[str] = [] + for line in event_text.splitlines(): + if line.startswith('data:'): + data_lines.append(line[5:].strip()) + if not data_lines: + return '' + raw_payload = ''.join(data_lines).strip() + if not raw_payload or raw_payload == '[DONE]': + return '' + try: + payload = json.loads(raw_payload) + except json.JSONDecodeError: + return '' + items = payload if isinstance(payload, list) else [payload] + for item in items: + if isinstance(item, dict): + for candidate in item.get('candidates') or []: + fr = candidate.get('finishReason') + if fr: + return fr + return '' diff --git a/infrastructure/ai/providers/mock_provider.py b/infrastructure/ai/providers/mock_provider.py index b8f47b201..9584f0c40 100644 --- a/infrastructure/ai/providers/mock_provider.py +++ b/infrastructure/ai/providers/mock_provider.py @@ -17,7 +17,7 @@ def __init__(self): No settings or API key needed. """ - pass + self.last_stream_stop_reason: str = "" async def generate( self, @@ -529,7 +529,7 @@ async def generate( output_tokens=len(content) ) - return GenerationResult(content=content, token_usage=token_usage) + return GenerationResult(content=content, token_usage=token_usage, stop_reason="stop") async def stream_generate( self, @@ -546,6 +546,7 @@ async def stream_generate( Mock response chunks """ result = await self.generate(prompt, config) + self.last_stream_stop_reason = result.stop_reason # Simulate streaming by yielding the content in chunks chunk_size = 50 for i in range(0, len(result.content), chunk_size): diff --git a/infrastructure/ai/providers/openai_provider.py b/infrastructure/ai/providers/openai_provider.py index a708a3c4e..8932944f2 100644 --- a/infrastructure/ai/providers/openai_provider.py +++ b/infrastructure/ai/providers/openai_provider.py @@ -99,9 +99,13 @@ async def _generate_via_chat(self, prompt: Prompt, config: GenerationConfig) -> input_tokens = response.usage.prompt_tokens if response.usage else 0 output_tokens = response.usage.completion_tokens if response.usage else 0 + finish_reason = "" + if getattr(response, "choices", None): + finish_reason = getattr(response.choices[0], "finish_reason", "") or "" return GenerationResult( content=content, token_usage=TokenUsage(input_tokens=input_tokens, output_tokens=output_tokens), + stop_reason=finish_reason, ) async def stream_generate( @@ -116,12 +120,19 @@ async def stream_generate( if use_responses: try: # 尝试走 Responses 流式 API + self.last_stream_stop_reason = "" request_kwargs = self._build_responses_request_kwargs(prompt, config, stream=True) stream = await self.async_client.responses.create(**request_kwargs) async for chunk in stream: content = self._extract_text_from_responses_chunk(chunk) if content: yield content + # Responses API: response.completed 事件携带 status + event_type = getattr(chunk, "type", "") + if event_type == "response.completed": + resp = getattr(chunk, "response", None) + if resp: + self.last_stream_stop_reason = getattr(resp, "status", "") or "" return # 正常完成则结束 generator except (openai.NotFoundError, openai.BadRequestError): self.__class__._fallback_to_chat_cache.add(base_url) @@ -135,6 +146,7 @@ async def stream_generate( raise # 降级:走原来的 Chat Completions 流式 API + self.last_stream_stop_reason = "" messages = self._build_messages(prompt) request_kwargs = self._build_chat_request_kwargs(messages, config, stream=True) stream = await self.async_client.chat.completions.create(**request_kwargs) @@ -142,6 +154,11 @@ async def stream_generate( content = self._extract_text_from_stream_chunk(chunk) if content: yield content + # 记录 finish_reason(最后一个非空 chunk 的 choices[0].finish_reason) + if getattr(chunk, "choices", None): + fr = getattr(chunk.choices[0], "finish_reason", None) + if fr: + self.last_stream_stop_reason = fr except Exception as e: logger.error(f"[Stream] Failed: {e}") raise RuntimeError(f"Failed to stream text: {str(e)}") from e @@ -227,9 +244,20 @@ async def _generate_via_responses(self, prompt: Prompt, config: GenerationConfig input_tokens = response.usage.prompt_tokens if response.usage else 0 output_tokens = response.usage.completion_tokens if response.usage else 0 + # Responses API: stop_reason 在 output message 上 + stop_reason = "" + if output: + for item in output: + if getattr(item, "type", "") == "message": + stop_reason = getattr(item, "stop_reason", "") or "" + break + if not stop_reason: + stop_reason = getattr(response, "status", "") or "" + return GenerationResult( content=content, - token_usage=TokenUsage(input_tokens=input_tokens, output_tokens=output_tokens) + token_usage=TokenUsage(input_tokens=input_tokens, output_tokens=output_tokens), + stop_reason=stop_reason, ) @staticmethod From 4366570f147a93cf8b7a70db0aa98ee769b742c7 Mon Sep 17 00:00:00 2001 From: ws1065 Date: Fri, 8 May 2026 12:50:51 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(ai):=20DynamicLLMService=20=E4=BB=A3?= =?UTF-8?q?=E7=90=86=20last=5Fstream=5Fstop=5Freason=20=E5=88=B0=E5=AE=9E?= =?UTF-8?q?=E9=99=85=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DynamicLLMService 每次调用 _resolve_provider() 获取 provider 实例, last_stream_stop_reason 设置在 provider 上,但 daemon 从 DynamicLLMService 上读取。新增 _last_provider 引用和 last_stream_stop_reason 属性,将读取委托给最后一次使用的 provider。 Co-Authored-By: Claude Opus 4.7 --- infrastructure/ai/provider_factory.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/infrastructure/ai/provider_factory.py b/infrastructure/ai/provider_factory.py index ba52ad572..4e7984655 100644 --- a/infrastructure/ai/provider_factory.py +++ b/infrastructure/ai/provider_factory.py @@ -70,10 +70,17 @@ class DynamicLLMService(LLMService): def __init__(self, factory: Optional[LLMProviderFactory] = None): self.factory = factory or LLMProviderFactory() + self._last_provider: Optional[LLMService] = None def _resolve_provider(self) -> LLMService: return self.factory.create_active_provider() + @property + def last_stream_stop_reason(self) -> str: + if self._last_provider and hasattr(self._last_provider, "last_stream_stop_reason"): + return self._last_provider.last_stream_stop_reason # type: ignore[union-attr] + return "" + @staticmethod def _merge_config(config: GenerationConfig, provider: LLMService) -> GenerationConfig: settings = getattr(provider, 'settings', None) @@ -100,11 +107,13 @@ def _merge_config(config: GenerationConfig, provider: LLMService) -> GenerationC async def generate(self, prompt: Prompt, config: GenerationConfig) -> GenerationResult: provider = self._resolve_provider() + self._last_provider = provider effective_config = self._merge_config(config, provider) return await provider.generate(prompt, effective_config) async def stream_generate(self, prompt: Prompt, config: GenerationConfig) -> AsyncIterator[str]: provider = self._resolve_provider() + self._last_provider = provider effective_config = self._merge_config(config, provider) async for chunk in provider.stream_generate(prompt, effective_config): yield chunk From 1ed72b4a98b36ed3bdd7b2fb5132edae68915354 Mon Sep 17 00:00:00 2001 From: ws1065 Date: Fri, 8 May 2026 13:02:45 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(engine):=20=E6=8F=90=E9=AB=98=20beat=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=20max=5Ftokens=20=E5=80=8D=E7=8E=87?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E4=B8=AD=E6=96=87=E6=88=AA=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文 1 字 ≈ 1-2 token,之前 max_tokens = target_words × 1.1 导致模型 在 token 耗尽前就被截断。将倍率从 1.1 提高到 2.0,留足余量。 Co-Authored-By: Claude Opus 4.7 --- application/engine/services/autopilot_daemon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/engine/services/autopilot_daemon.py b/application/engine/services/autopilot_daemon.py index 504cb10b4..d19513ba1 100644 --- a/application/engine/services/autopilot_daemon.py +++ b/application/engine/services/autopilot_daemon.py @@ -655,9 +655,9 @@ async def _handle_writing(self, novel: Novel): ) # 字数控制策略: # - prompt 中要求目标的 75%(在 context_builder 中处理) - # - max_tokens = prompt 目标 × 1.1(硬性上限,超出会被截断) + # - max_tokens = prompt 目标 × 2.0(中文 1 字 ≈ 1-2 token,留足余量) # - 最终输出应接近 prompt 目标,略低于原始目标 - max_tokens = int(beat.target_words * 1.1) + max_tokens = int(beat.target_words * 2.0) cfg = GenerationConfig(max_tokens=max_tokens, temperature=0.85) beat_content, beat_stop_reason = await self._stream_llm_with_stop_watch(prompt, cfg, novel=novel) else: From a3ddbb4fa6ae3df9c7024b4c3115e1b073b25707 Mon Sep 17 00:00:00 2001 From: ws1065 Date: Fri, 8 May 2026 13:07:50 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(engine):=20=E7=94=A8=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=20max=5Ftokens=20=E6=9B=BF=E4=BB=A3=E5=AD=97=E6=95=B0=E4=BC=B0?= =?UTF-8?q?=E7=AE=97=EF=BC=8C=E5=BD=BB=E5=BA=95=E9=81=BF=E5=85=8D=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=88=AA=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不同模型 tokenizer 对中文的编码差异大(1 字 ≈ 1-2.5 token), 用 target_words 估算 max_tokens 总是不准确。改为固定 8192, 让模型自然结束,依赖 stop_reason 判断是否真正截断。 Co-Authored-By: Claude Opus 4.7 --- application/engine/services/autopilot_daemon.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/application/engine/services/autopilot_daemon.py b/application/engine/services/autopilot_daemon.py index d19513ba1..4912f625d 100644 --- a/application/engine/services/autopilot_daemon.py +++ b/application/engine/services/autopilot_daemon.py @@ -655,9 +655,9 @@ async def _handle_writing(self, novel: Novel): ) # 字数控制策略: # - prompt 中要求目标的 75%(在 context_builder 中处理) - # - max_tokens = prompt 目标 × 2.0(中文 1 字 ≈ 1-2 token,留足余量) - # - 最终输出应接近 prompt 目标,略低于原始目标 - max_tokens = int(beat.target_words * 2.0) + # - max_tokens 设为足够大的固定值,让模型自然结束 + # - 依赖 stop_reason 判断是否真正截断,而非用 max_tokens 硬卡字数 + max_tokens = 8192 cfg = GenerationConfig(max_tokens=max_tokens, temperature=0.85) beat_content, beat_stop_reason = await self._stream_llm_with_stop_watch(prompt, cfg, novel=novel) else: @@ -1462,8 +1462,8 @@ async def _stream_one_beat( user_parts.append(f"\n{beat_prompt}") user_parts.append("\n\n开始撰写:") - # 字数控制策略(与主流程一致) - max_tokens = int(beat.target_words * 1.1) if beat else 3000 + # 字数控制策略(与主流程一致):固定上限,依赖 stop_reason 判断截断 + max_tokens = 8192 prompt = Prompt(system=system, user="\n".join(user_parts)) config = GenerationConfig(max_tokens=max_tokens, temperature=0.85) From 773f5cdda724585d795f5dbbbafb2a0e62918dd7 Mon Sep 17 00:00:00 2001 From: ws1065 Date: Fri, 8 May 2026 15:27:05 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat(audit):=20=E6=96=B0=E5=A2=9E=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=E4=BF=AE=E5=A4=8D=E5=8A=9F=E8=83=BD=20=E2=80=94=20?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E7=9F=AD=E7=AB=A0=E8=8A=82=20+=20AI=20?= =?UTF-8?q?=E6=89=A9=E5=86=99=20+=20=E5=89=8D=E5=90=8E=E8=A1=94=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 因数据库爆炸和截断检测误判导致大量章节内容过短,新增章节修复页面: - 新增 ChapterRepairService:扫描短章节、单章扩写、批量扩写 - 扩写时自动读取前后章节内容确保故事连贯 - SSE 流式输出扩写进度,支持批量顺序执行 - 前端新增 ChapterRepair.vue 页面,支持全选、一键审查续写 - 工作台 AI 工具菜单增加"章节修复"入口 Co-Authored-By: Claude Opus 4.7 --- application/audit/dtos/chapter_repair_dto.py | 25 + .../audit/services/chapter_repair_service.py | 274 ++++++++ frontend/src/api/chapterRepair.ts | 214 ++++++ frontend/src/components/stats/StatsTopBar.vue | 8 + frontend/src/router/index.ts | 2 + frontend/src/views/ChapterRepair.vue | 617 ++++++++++++++++++ interfaces/api/dependencies.py | 12 + .../api/v1/audit/chapter_repair_routes.py | 122 ++++ interfaces/main.py | 3 +- 9 files changed, 1276 insertions(+), 1 deletion(-) create mode 100644 application/audit/dtos/chapter_repair_dto.py create mode 100644 application/audit/services/chapter_repair_service.py create mode 100644 frontend/src/api/chapterRepair.ts create mode 100644 frontend/src/views/ChapterRepair.vue create mode 100644 interfaces/api/v1/audit/chapter_repair_routes.py diff --git a/application/audit/dtos/chapter_repair_dto.py b/application/audit/dtos/chapter_repair_dto.py new file mode 100644 index 000000000..d81b4cfd8 --- /dev/null +++ b/application/audit/dtos/chapter_repair_dto.py @@ -0,0 +1,25 @@ +"""章节修复扫描结果 DTO""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class ShortChapterDTO: + """短章节扫描结果项""" + chapter_number: int + title: str + word_count: int + status: str + content_preview: str # 前 200 字 + severity: str # "critical"(<1000) / "warning"(<2500) / "info"( ChapterRepairScanResult: + """扫描字数不足的章节""" + chapters = self._chapter_repo.list_by_novel(NovelId(novel_id)) + short_chapters: list[ShortChapterDTO] = [] + summary = {"critical": 0, "warning": 0, "info": 0} + + for ch in chapters: + wc = ch.word_count.value + if wc >= threshold: + continue + + if wc < _CRITICAL_THRESHOLD: + severity = "critical" + elif wc < _WARNING_THRESHOLD: + severity = "warning" + else: + severity = "info" + + summary[severity] += 1 + content = ch.content or "" + short_chapters.append(ShortChapterDTO( + chapter_number=ch.number, + title=ch.title or f"第{ch.number}章", + word_count=wc, + status=ch.status.value if hasattr(ch.status, "value") else ch.status, + content_preview=content[:200], + severity=severity, + )) + + return ChapterRepairScanResult( + novel_id=novel_id, + threshold=threshold, + total_chapters=len(chapters), + short_chapters=short_chapters, + summary=summary, + ) + + async def expand_chapter( + self, + novel_id: str, + chapter_number: int, + target_words: int = 4000, + ) -> AsyncIterator[dict[str, Any]]: + """扩写单个章节,SSE 事件流""" + yield {"type": "phase", "phase": "loading", "chapter_number": chapter_number} + + # 加载当前章 + current = self._chapter_svc.get_chapter_by_novel_and_number(novel_id, chapter_number) + if not current: + yield {"type": "error", "message": f"章节 {chapter_number} 不存在"} + return + + existing_content = current.content or "" + title = current.title or f"第{chapter_number}章" + outline = self._get_chapter_outline(novel_id, chapter_number, title, existing_content) + + # 加载前后章 + prev_tail = self._get_prev_chapter_tail(novel_id, chapter_number) + next_head = self._get_next_chapter_head(novel_id, chapter_number) + + yield {"type": "phase", "phase": "context", "chapter_number": chapter_number} + + # 构建 prompt + prompt = self._build_expand_prompt( + chapter_number=chapter_number, + title=title, + outline=outline, + existing_content=existing_content, + prev_tail=prev_tail, + next_head=next_head, + target_words=target_words, + ) + + yield {"type": "phase", "phase": "llm", "chapter_number": chapter_number} + + # 流式 LLM 生成 + config = GenerationConfig(max_tokens=min(target_words * 3, 16384), temperature=0.7) + chunks: list[str] = [] + try: + async for piece in self._llm.stream_generate(prompt, config): + chunks.append(piece) + yield {"type": "chunk", "text": piece, "chapter_number": chapter_number} + except Exception as e: + logger.error(f"章节 {chapter_number} 扩写 LLM 失败: {e}") + yield {"type": "error", "message": f"LLM 生成失败: {e}"} + return + + # 拼接并清理 + expanded = strip_reasoning_artifacts("".join(chunks)).strip() + if not expanded: + yield {"type": "error", "message": "LLM 返回空内容"} + return + + yield {"type": "phase", "phase": "saving", "chapter_number": chapter_number} + + # 保存 + try: + chapter_entity = self._chapter_repo.get_by_novel_and_number( + NovelId(novel_id), chapter_number + ) + if chapter_entity: + chapter_entity.update_content(expanded) + self._chapter_repo.save(chapter_entity) + except Exception as e: + logger.error(f"章节 {chapter_number} 保存失败: {e}") + yield {"type": "error", "message": f"保存失败: {e}"} + return + + # 后处理(异步,不阻塞 SSE) + self._schedule_aftermath(novel_id, chapter_number, expanded) + + yield { + "type": "done", + "chapter_number": chapter_number, + "content": expanded, + "word_count": len(expanded), + } + + async def batch_expand_chapters( + self, + novel_id: str, + chapter_numbers: list[int], + target_words: int = 4000, + ) -> AsyncIterator[dict[str, Any]]: + """批量扩写章节(顺序执行,保证前后衔接)""" + total = len(chapter_numbers) + yield { + "type": "session", + "novel_id": novel_id, + "chapters": chapter_numbers, + "total": total, + } + + for i, ch_num in enumerate(chapter_numbers, 1): + yield {"type": "chapter_start", "chapter_number": ch_num, "index": i, "total": total} + async for event in self.expand_chapter(novel_id, ch_num, target_words): + yield event + yield {"type": "chapter_done", "chapter_number": ch_num, "index": i, "total": total} + + yield {"type": "session_done"} + + # ── 内部方法 ── + + def _get_chapter_outline(self, novel_id: str, chapter_number: int, title: str, content: str) -> str: + """获取章节大纲,优先从 DB 读取,否则从内容合成""" + chapter = self._chapter_repo.get_by_novel_and_number(NovelId(novel_id), chapter_number) + if chapter and chapter.outline and chapter.outline.strip(): + return chapter.outline.strip() + # 合成大纲 + preview = content[:200] if content else "" + return f"【{title}】\n{preview}" if preview else f"【{title}】\n(大纲缺失,请根据已有内容和上下文扩写)" + + def _get_prev_chapter_tail(self, novel_id: str, chapter_number: int) -> str: + """获取上一章尾部内容""" + if chapter_number <= 1: + return "" + prev = self._chapter_svc.get_chapter_by_novel_and_number(novel_id, chapter_number - 1) + if not prev or not prev.content: + return "" + content = prev.content.strip() + if len(content) <= _PREV_CHAPTER_TAIL_CHARS: + return f"【第{chapter_number - 1}章末尾】\n{content}" + return f"【第{chapter_number - 1}章末尾】\n{content[-_PREV_CHAPTER_TAIL_CHARS:]}" + + def _get_next_chapter_head(self, novel_id: str, chapter_number: int) -> str: + """获取下一章开头内容""" + nxt = self._chapter_svc.get_chapter_by_novel_and_number(novel_id, chapter_number + 1) + if not nxt or not nxt.content: + return "" + content = nxt.content.strip() + if len(content) <= _NEXT_CHAPTER_HEAD_CHARS: + return f"【第{chapter_number + 1}章开头】\n{content}" + return f"【第{chapter_number + 1}章开头】\n{content[:_NEXT_CHAPTER_HEAD_CHARS]}" + + def _build_expand_prompt( + self, + chapter_number: int, + title: str, + outline: str, + existing_content: str, + prev_tail: str, + next_head: str, + target_words: int, + ) -> Prompt: + """构建扩写 prompt""" + system = ( + "你是一位资深小说编辑,擅长扩写和修复因技术问题被截断的章节。\n\n" + "必须遵守:\n" + "1. 保留所有已有剧情事件、因果顺序、角色关系、伏笔信息。\n" + "2. 基于章节大纲扩写,补充缺失的场景描写、对话、心理活动、环境细节。\n" + "3. 确保与上一章末尾自然衔接,为下一章开头做好铺垫。\n" + "4. 输出只能是扩写后的完整章节正文,不要解释,不要加章节标题。\n" + f"5. 目标字数:{target_words} 字左右。\n" + ) + + user_parts = [f"第 {chapter_number} 章,标题:{title}\n"] + user_parts.append(f"章节大纲:\n{outline}\n") + + if prev_tail: + user_parts.append(f"\n上一章末尾(请确保你的开头自然衔接这段内容):\n{prev_tail}\n") + + user_parts.append(f"\n当前章节正文(需要扩写):\n{existing_content}\n") + + if next_head: + user_parts.append(f"\n下一章开头(请确保你的结尾能自然过渡到这段内容):\n{next_head}\n") + + user_parts.append(f"\n请将上述章节正文扩写至约 {target_words} 字,保持剧情连贯,直接输出正文:") + + return Prompt(system=system, user="\n".join(user_parts)) + + def _schedule_aftermath(self, novel_id: str, chapter_number: int, content: str) -> None: + """异步触发章后管线""" + if not self._aftermath or not content.strip(): + return + + async def _run() -> None: + try: + await self._aftermath.run_after_chapter_saved(novel_id, chapter_number, content) + except Exception as e: + logger.warning(f"章节修复后管线失败 novel={novel_id} ch={chapter_number}: {e}") + + try: + asyncio.create_task(_run()) + except Exception as e: + logger.warning(f"章节修复后管线未调度: {e}") diff --git a/frontend/src/api/chapterRepair.ts b/frontend/src/api/chapterRepair.ts new file mode 100644 index 000000000..0851e5a28 --- /dev/null +++ b/frontend/src/api/chapterRepair.ts @@ -0,0 +1,214 @@ +/** 章节修复 API */ +import { apiClient, resolveHttpUrl } from './config' + +// ── 类型 ── + +export interface ShortChapterDTO { + chapter_number: number + title: string + word_count: number + status: string + content_preview: string + severity: 'critical' | 'warning' | 'info' +} + +export interface ChapterRepairScanResult { + novel_id: string + threshold: number + total_chapters: number + short_chapters: ShortChapterDTO[] + summary: { critical: number; warning: number; info: number } +} + +export type ChapterRepairStreamEvent = + | { type: 'phase'; phase: string; chapter_number?: number } + | { type: 'chunk'; text: string; chapter_number?: number } + | { type: 'done'; content: string; word_count: number; chapter_number: number } + | { type: 'error'; message: string } + | { type: 'session'; novel_id: string; chapters: number[]; total: number } + | { type: 'chapter_start'; chapter_number: number; index: number; total: number } + | { type: 'chapter_done'; chapter_number: number; index: number; total: number } + | { type: 'session_done' } + +// ── REST API ── + +export const chapterRepairApi = { + scanShortChapters: (novelId: string, threshold: number = 4000) => + apiClient.get( + `/novels/${novelId}/chapter-repair/scan?threshold=${threshold}` + ), +} + +// ── SSE 工具 ── + +function parseSseDataLine(line: string): unknown | null { + if (!line.startsWith('data: ')) return null + try { + return JSON.parse(line.slice(6)) as unknown + } catch { + return null + } +} + +// ── SSE 消费者:单章扩写 ── + +export async function consumeExpandChapterStream( + novelId: string, + chapterNumber: number, + targetWords: number, + handlers: { + onEvent?: (ev: ChapterRepairStreamEvent) => void + onPhase?: (phase: string) => void + onChunk?: (text: string) => void + onDone?: (result: { content: string; word_count: number }) => void + onError?: (message: string) => void + signal?: AbortSignal + } +): Promise { + const res = await fetch(resolveHttpUrl(`/api/v1/novels/${novelId}/chapter-repair/expand/${chapterNumber}`), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_words: targetWords }), + signal: handlers.signal, + }) + if (!res.ok || !res.body) { + const t = await res.text().catch(() => '') + handlers.onError?.(t || `HTTP ${res.status}`) + return + } + await _consumeSse(res.body, handlers) +} + +// ── SSE 消费者:批量扩写 ── + +export async function consumeBatchExpandStream( + novelId: string, + chapterNumbers: number[], + targetWords: number, + handlers: { + onEvent?: (ev: ChapterRepairStreamEvent) => void + onPhase?: (phase: string) => void + onChunk?: (text: string, chapterNumber?: number) => void + onChapterStart?: (chapterNumber: number, index: number, total: number) => void + onChapterDone?: (chapterNumber: number, index: number, total: number) => void + onDone?: () => void + onError?: (message: string) => void + signal?: AbortSignal + } +): Promise { + const res = await fetch(resolveHttpUrl(`/api/v1/novels/${novelId}/chapter-repair/batch-expand`), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chapter_numbers: chapterNumbers, target_words: targetWords }), + signal: handlers.signal, + }) + if (!res.ok || !res.body) { + const t = await res.text().catch(() => '') + handlers.onError?.(t || `HTTP ${res.status}`) + return + } + + const reader = res.body.getReader() + const dec = new TextDecoder() + let buf = '' + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += dec.decode(value, { stream: true }) + let sep: number + while ((sep = buf.indexOf('\n\n')) >= 0) { + const block = buf.slice(0, sep) + buf = buf.slice(sep + 2) + for (const line of block.split('\n')) { + const raw = parseSseDataLine(line) + if (!raw || typeof raw !== 'object' || raw === null) continue + const o = raw as Record + const typ = o.type as string + const ev = o as unknown as ChapterRepairStreamEvent + handlers.onEvent?.(ev) + + if (typ === 'phase') { + handlers.onPhase?.(String(o.phase ?? '')) + } else if (typ === 'chunk') { + handlers.onChunk?.(String(o.text ?? ''), o.chapter_number as number | undefined) + } else if (typ === 'chapter_start') { + handlers.onChapterStart?.( + Number(o.chapter_number), Number(o.index), Number(o.total) + ) + } else if (typ === 'chapter_done') { + handlers.onChapterDone?.( + Number(o.chapter_number), Number(o.index), Number(o.total) + ) + } else if (typ === 'session_done') { + handlers.onDone?.() + return + } else if (typ === 'error') { + handlers.onError?.(String(o.message ?? '扩写失败')) + return + } + } + } + } + } catch (e: unknown) { + if (e instanceof Error && e.name === 'AbortError') return + const msg = e instanceof Error ? e.message : '流式连接失败' + handlers.onError?.(msg) + } +} + +// ── 内部:通用 SSE 消费 ── + +async function _consumeSse( + body: ReadableStream, + handlers: { + onEvent?: (ev: ChapterRepairStreamEvent) => void + onPhase?: (phase: string) => void + onChunk?: (text: string) => void + onDone?: (result: { content: string; word_count: number }) => void + onError?: (message: string) => void + } +): Promise { + const reader = body.getReader() + const dec = new TextDecoder() + let buf = '' + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += dec.decode(value, { stream: true }) + let sep: number + while ((sep = buf.indexOf('\n\n')) >= 0) { + const block = buf.slice(0, sep) + buf = buf.slice(sep + 2) + for (const line of block.split('\n')) { + const raw = parseSseDataLine(line) + if (!raw || typeof raw !== 'object' || raw === null) continue + const o = raw as Record + const typ = o.type as string + const ev = o as unknown as ChapterRepairStreamEvent + handlers.onEvent?.(ev) + + if (typ === 'phase') { + handlers.onPhase?.(String(o.phase ?? '')) + } else if (typ === 'chunk') { + handlers.onChunk?.(String(o.text ?? '')) + } else if (typ === 'done') { + handlers.onDone?.({ + content: String(o.content ?? ''), + word_count: Number(o.word_count ?? 0), + }) + return + } else if (typ === 'error') { + handlers.onError?.(String(o.message ?? '扩写失败')) + return + } + } + } + } + } catch (e: unknown) { + if (e instanceof Error && e.name === 'AbortError') return + const msg = e instanceof Error ? e.message : '流式连接失败' + handlers.onError?.(msg) + } +} diff --git a/frontend/src/components/stats/StatsTopBar.vue b/frontend/src/components/stats/StatsTopBar.vue index 5bbf5a286..a2c931627 100644 --- a/frontend/src/components/stats/StatsTopBar.vue +++ b/frontend/src/components/stats/StatsTopBar.vue @@ -85,6 +85,7 @@ import { computed, onMounted, ref } from 'vue' import { NTooltip, NSpin, NDropdown, NButton, useMessage } from 'naive-ui' import { useStatsStore } from '@/stores/statsStore' +import { useRouter, useRoute } from 'vue-router' import { novelApi } from '@/api/novel' import GlobalLLMEntryButton from '@/components/global/GlobalLLMEntryButton.vue' import PromptPlazaEntryButton from '@/components/global/PromptPlazaEntryButton.vue' @@ -98,6 +99,8 @@ defineEmits<{ }>() const message = useMessage() +const router = useRouter() +const route = useRoute() // AI 工具组件引用(用于以编程方式触发各组件内部按钮) const llmRef = ref<{ $el: HTMLElement } | null>(null) @@ -106,6 +109,8 @@ const plazaRef = ref<{ $el: HTMLElement } | null>(null) const aiToolsOptions = [ { label: '⚙️ AI 控制台', key: 'llm' }, { label: '✦ 提示词广场', key: 'plaza' }, + { type: 'divider', key: 'd1' }, + { label: '🔧 章节修复', key: 'repair' }, ] function handleAiToolSelect(key: string) { @@ -113,6 +118,9 @@ function handleAiToolSelect(key: string) { llmRef.value?.$el?.querySelector('button')?.click() } else if (key === 'plaza') { plazaRef.value?.$el?.querySelector('button')?.click() + } else if (key === 'repair') { + const slug = route.params.slug as string + router.push(`/book/${slug}/chapter-repair`) } } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4493c8aa2..2f68b163f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import Home from '../views/Home.vue' import Workbench from '../views/Workbench.vue' import Chapter from '../views/Chapter.vue' +import ChapterRepair from '../views/ChapterRepair.vue' import Cast from '../views/Cast.vue' import CharacterGraph from '../views/CharacterGraph.vue' import LocationGraph from '../views/LocationGraph.vue' @@ -14,6 +15,7 @@ const router = createRouter({ { path: '/book/:slug/workbench', name: 'Workbench', component: Workbench }, { path: '/book/:slug/cast', name: 'Cast', component: Cast }, { path: '/book/:slug/chapter/:id', name: 'Chapter', component: Chapter }, + { path: '/book/:slug/chapter-repair', name: 'ChapterRepair', component: ChapterRepair }, { path: '/book/:slug/characters', name: 'CharacterGraph', component: CharacterGraph }, { path: '/book/:slug/location-graph', name: 'LocationGraph', component: LocationGraph }, { path: '/debug/scheduler', name: 'CharacterSchedulerSimulator', component: CharacterSchedulerSimulator }, diff --git a/frontend/src/views/ChapterRepair.vue b/frontend/src/views/ChapterRepair.vue new file mode 100644 index 000000000..109eae62a --- /dev/null +++ b/frontend/src/views/ChapterRepair.vue @@ -0,0 +1,617 @@ + + + + + diff --git a/interfaces/api/dependencies.py b/interfaces/api/dependencies.py index fc99de648..8f29c3932 100644 --- a/interfaces/api/dependencies.py +++ b/interfaces/api/dependencies.py @@ -337,6 +337,18 @@ def get_chapter_aftermath_pipeline(): ) +def get_chapter_repair_service(): + """章节修复:扫描短章节 + AI 扩写 + 批量修复。""" + from application.audit.services.chapter_repair_service import ChapterRepairService + return ChapterRepairService( + chapter_repository=get_chapter_repository(), + novel_repository=get_novel_repository(), + llm_service=get_llm_service(), + chapter_service=get_chapter_service(), + aftermath_pipeline=get_chapter_aftermath_pipeline(), + ) + + def get_hosted_write_service() -> HostedWriteService: """托管连写:自动大纲 + 多章流式生成 + 可选落库。""" return HostedWriteService( diff --git a/interfaces/api/v1/audit/chapter_repair_routes.py b/interfaces/api/v1/audit/chapter_repair_routes.py new file mode 100644 index 000000000..d005cd243 --- /dev/null +++ b/interfaces/api/v1/audit/chapter_repair_routes.py @@ -0,0 +1,122 @@ +"""章节修复 API 端点""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from application.audit.services.chapter_repair_service import ChapterRepairService + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["chapter-repair"]) + + +# ── 请求模型 ── + + +class ExpandChapterRequest(BaseModel): + target_words: int = Field(default=4000, ge=500, le=20000, description="目标字数") + + +class BatchExpandRequest(BaseModel): + chapter_numbers: list[int] = Field(..., min_length=1, description="章节号列表") + target_words: int = Field(default=4000, ge=500, le=20000, description="目标字数") + + +# ── 依赖注入 ── + + +def _get_service() -> ChapterRepairService: + from interfaces.api.dependencies import get_chapter_repair_service + return get_chapter_repair_service() + + +# ── 端点 ── + + +@router.get("/novels/{novel_id}/chapter-repair/scan") +async def scan_short_chapters( + novel_id: str, + threshold: int = Query(default=4000, ge=100, le=20000, description="字数阈值"), + service: ChapterRepairService = Depends(_get_service), +): + """扫描字数不足的章节""" + result = service.scan_short_chapters(novel_id, threshold) + return { + "novel_id": result.novel_id, + "threshold": result.threshold, + "total_chapters": result.total_chapters, + "short_chapters": [ + { + "chapter_number": ch.chapter_number, + "title": ch.title, + "word_count": ch.word_count, + "status": ch.status, + "content_preview": ch.content_preview, + "severity": ch.severity, + } + for ch in result.short_chapters + ], + "summary": result.summary, + } + + +@router.post("/novels/{novel_id}/chapter-repair/expand/{chapter_number}") +async def expand_chapter( + novel_id: str, + chapter_number: int, + request: ExpandChapterRequest, + service: ChapterRepairService = Depends(_get_service), +): + """SSE 流式扩写单个章节""" + + async def event_gen(): + try: + async for event in service.expand_chapter(novel_id, chapter_number, request.target_words): + yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + except Exception as e: + logger.error(f"扩写 SSE 异常: {e}") + yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n" + + return StreamingResponse( + event_gen(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.post("/novels/{novel_id}/chapter-repair/batch-expand") +async def batch_expand_chapters( + novel_id: str, + request: BatchExpandRequest, + service: ChapterRepairService = Depends(_get_service), +): + """SSE 流式批量扩写章节""" + + async def event_gen(): + try: + async for event in service.batch_expand_chapters( + novel_id, request.chapter_numbers, request.target_words + ): + yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + except Exception as e: + logger.error(f"批量扩写 SSE 异常: {e}") + yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n" + + return StreamingResponse( + event_gen(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/interfaces/main.py b/interfaces/main.py index c1b392eb9..ee5f6fc8d 100644 --- a/interfaces/main.py +++ b/interfaces/main.py @@ -68,7 +68,7 @@ ) # Audit module -from interfaces.api.v1.audit import chapter_review_routes, macro_refactor, chapter_element_routes +from interfaces.api.v1.audit import chapter_review_routes, macro_refactor, chapter_element_routes, chapter_repair_routes # Analyst module from interfaces.api.v1.analyst import voice, narrative_state, foreshadow_ledger @@ -543,6 +543,7 @@ def restart_autopilot_daemon(): app.include_router(chapter_review_routes.router) app.include_router(macro_refactor.router, prefix="/api/v1") app.include_router(chapter_element_routes.router) +app.include_router(chapter_repair_routes.router, prefix="/api/v1") # Analyst module routes app.include_router(voice.router, prefix="/api/v1")