# novel_generator/chapter.py # -*- coding: utf-8 -*- """ 章节草稿生成及获取历史章节文本、短期摘要等 """ import os import logging from nltk import download from llm_adapters import create_llm_adapter from prompt_definitions import first_chapter_draft_prompt, next_chapter_draft_prompt, summarize_recent_chapters_prompt from chapter_directory_parser import get_chapter_info_from_blueprint from novel_generator.common import invoke_with_cleaning from utils import read_file, clear_file_content, save_string_to_txt from novel_generator.vectorstore_utils import get_relevant_context_from_vector_store def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> list: """ 从目录 chapters_dir 中获取最近 n 章的文本内容,返回文本列表。 """ texts = [] start_chap = max(1, current_chapter_num - n) for c in range(start_chap, current_chapter_num): chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") if os.path.exists(chap_file): text = read_file(chap_file).strip() texts.append(text) else: texts.append("") return texts def summarize_recent_chapters( interface_format: str, api_key: str, base_url: str, model_name: str, temperature: float, max_tokens: int, chapters_text_list: list, timeout: int = 600 ) -> tuple: """ 生成 (short_summary, next_chapter_keywords) 如果解析失败,则返回 (合并文本, "") """ combined_text = "\n".join(chapters_text_list).strip() if not combined_text: return ("", "") llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, temperature=temperature, max_tokens=max_tokens, timeout=timeout ) prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) response_text = invoke_with_cleaning(llm_adapter, prompt) short_summary = "" next_chapter_keywords = "" for line in response_text.splitlines(): line = line.strip() if line.startswith("短期摘要:"): short_summary = line.replace("短期摘要:", "").strip() elif line.startswith("下一章关键字:"): next_chapter_keywords = line.replace("下一章关键字:", "").strip() if not short_summary and not next_chapter_keywords: short_summary = response_text return (short_summary, next_chapter_keywords) def build_chapter_prompt( api_key: str, base_url: str, model_name: str, filepath: str, novel_number: int, word_number: int, temperature: float, user_guidance: str, characters_involved: str, key_items: str, scene_location: str, time_constraint: str, embedding_api_key: str, embedding_url: str, embedding_interface_format: str, embedding_model_name: str, embedding_retrieval_k: int = 2, interface_format: str = "openai", max_tokens: int = 2048, timeout: int = 600 ) -> str: """ 构造当前章节的请求提示词,不调用 LLM,仅返回构造好的提示词字符串。 """ arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) directory_file = os.path.join(filepath, "Novel_directory.txt") blueprint_text = read_file(directory_file) global_summary_file = os.path.join(filepath, "global_summary.txt") global_summary_text = read_file(global_summary_file) character_state_file = os.path.join(filepath, "character_state.txt") character_state_text = read_file(character_state_file) chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) chapter_title = chapter_info["chapter_title"] chapter_role = chapter_info["chapter_role"] chapter_purpose = chapter_info["chapter_purpose"] suspense_level = chapter_info["suspense_level"] foreshadowing = chapter_info["foreshadowing"] plot_twist_level = chapter_info["plot_twist_level"] chapter_summary = chapter_info["chapter_summary"] chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) if novel_number == 1: prompt_text = first_chapter_draft_prompt.format( novel_number=novel_number, word_number=word_number, chapter_title=chapter_title, chapter_role=chapter_role, chapter_purpose=chapter_purpose, suspense_level=suspense_level, foreshadowing=foreshadowing, plot_twist_level=plot_twist_level, chapter_summary=chapter_summary, characters_involved=characters_involved, key_items=key_items, scene_location=scene_location, time_constraint=time_constraint, user_guidance=user_guidance, novel_setting=novel_architecture_text ) else: recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) short_summary, next_chapter_keywords = summarize_recent_chapters( interface_format=interface_format, api_key=api_key, base_url=base_url, model_name=model_name, temperature=temperature, max_tokens=max_tokens, chapters_text_list=recent_3_texts, timeout=timeout ) previous_chapter_excerpt = "" for text_block in reversed(recent_3_texts): if text_block.strip(): if len(text_block) > 1500: previous_chapter_excerpt = text_block[-1500:] else: previous_chapter_excerpt = text_block break from embedding_adapters import create_embedding_adapter # 避免循环依赖 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, embedding_url, embedding_model_name ) retrieval_query = short_summary + " " + next_chapter_keywords relevant_context = get_relevant_context_from_vector_store( embedding_adapter=embedding_adapter, query=retrieval_query, filepath=filepath, k=embedding_retrieval_k ) if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" prompt_text = next_chapter_draft_prompt.format( novel_number=novel_number, word_number=word_number, chapter_title=chapter_title, chapter_role=chapter_role, chapter_purpose=chapter_purpose, suspense_level=suspense_level, foreshadowing=foreshadowing, plot_twist_level=plot_twist_level, chapter_summary=chapter_summary, characters_involved=characters_involved, key_items=key_items, scene_location=scene_location, time_constraint=time_constraint, user_guidance=user_guidance, novel_setting=novel_architecture_text, global_summary=global_summary_text, character_state=character_state_text, context_excerpt=relevant_context, previous_chapter_excerpt=previous_chapter_excerpt ) return prompt_text def generate_chapter_draft( api_key: str, base_url: str, model_name: str, filepath: str, novel_number: int, word_number: int, temperature: float, user_guidance: str, characters_involved: str, key_items: str, scene_location: str, time_constraint: str, embedding_api_key: str, embedding_url: str, embedding_interface_format: str, embedding_model_name: str, embedding_retrieval_k: int = 2, interface_format: str = "openai", max_tokens: int = 2048, timeout: int = 600, custom_prompt_text: str = None # 新增参数,若不为 None,则使用用户编辑后的提示词 ) -> str: """ 根据 novel_number 判断是否为第一章。 - 若是第一章,则使用 first_chapter_draft_prompt - 否则使用 next_chapter_draft_prompt 若 custom_prompt_text 提供,则以此作为提示词进行生成。 最终将生成文本存入 chapters/chapter_{novel_number}.txt。 """ # 构造提示词:若用户提供了编辑后的提示词,则使用之;否则构造默认提示词 if custom_prompt_text is None: prompt_text = build_chapter_prompt( api_key=api_key, base_url=base_url, model_name=model_name, filepath=filepath, novel_number=novel_number, word_number=word_number, temperature=temperature, user_guidance=user_guidance, characters_involved=characters_involved, key_items=key_items, scene_location=scene_location, time_constraint=time_constraint, embedding_api_key=embedding_api_key, embedding_url=embedding_url, embedding_interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, embedding_retrieval_k=embedding_retrieval_k, interface_format=interface_format, max_tokens=max_tokens, timeout=timeout ) else: prompt_text = custom_prompt_text chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, temperature=temperature, max_tokens=max_tokens, timeout=timeout ) chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") return chapter_content