diff --git a/.env.example b/.env.example index d5c9ee906..d82ea1080 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,16 @@ EMBEDDING_MODEL_PATH=./.models/bge-small-zh-v1.5 # GPU acceleration for local model (true/false) EMBEDDING_USE_GPU=true +# ── GCP Vertex AI 企业级配置 ── +# [重要] 1. 项目 ID 必须与你的 ADC 凭据关联的项目一致 +# [重要] 2. 建议区域设为 global,以获得预览版模型 (如 3.1 Flash) 的最佳兼容性 +GOOGLE_CLOUD_PROJECT= +GOOGLE_CLOUD_LOCATION=global +GOOGLE_CLOUD_API_KEY= +# 核心认证路径:执行 gcloud auth application-default login 后生成的 JSON 路径 +GOOGLE_APPLICATION_CREDENTIALS= +GOOGLE_GENAI_USE_VERTEXAI=true + # ── Vector Store Configuration ── VECTOR_STORE_TYPE=chromadb diff --git a/.gitignore b/.gitignore index 1f4bff18a..655247c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ .env .env.* !.env.example +README-LOCAL.md +README_local.md # ── Python ── __pycache__/ @@ -92,6 +94,10 @@ data/llm_configs.json # ── 日志 ── logs/ +# ── 本地大模型与助手沙盒 ── +models/ +scratch/ + # ── PyInstaller 打包产物(根目录) ── plotpilot.spec plotpilot.lock @@ -110,6 +116,7 @@ base_library.zip # ── 内嵌 Python(保留 zip 原始包,解压目录不入库) ── /tools/python_embed/ /tools/plotpilot/ +/tools/aitext/ # tools/python-*.zip # ← 保留!Python embed 原始包(~15MB),入库供用户直接用 # ── 临时 / 无关文件 ── diff --git a/application/ai/llm_control_service.py b/application/ai/llm_control_service.py index dee87f7e0..bd4c0afb4 100644 --- a/application/ai/llm_control_service.py +++ b/application/ai/llm_control_service.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -LLMProtocol = Literal['openai', 'anthropic', 'gemini'] +LLMProtocol = Literal['openai', 'anthropic', 'gemini', 'vertex-ai'] class LLMPreset(BaseModel): @@ -257,6 +257,15 @@ def get_presets(self) -> List[LLMPreset]: description='方舟 OpenAI-compatible 接口;模型名以方舟控制台 Endpoint 为准。', tags=['domestic', 'preset'], ), + LLMPreset( + key='vertex-ai-official', + label='Vertex AI / Google Cloud 官方', + protocol='vertex-ai', + default_base_url='', + default_model='gemini-1.5-flash', + description='GCP Vertex AI 企业版接口。需配置 GOOGLE_APPLICATION_CREDENTIALS 或通过 extra_body 传 project_id。', + tags=['official', 'enterprise'], + ), ] def get_preset_map(self) -> Dict[str, LLMPreset]: @@ -524,6 +533,14 @@ def _build_initial_config(self) -> LLMControlConfig: base_url='https://generativelanguage.googleapis.com/v1beta', model='', ), + LLMProfile( + id='vertex-ai-official-default', + name='Vertex AI / GCP', + preset_key='vertex-ai-official', + protocol='vertex-ai', + base_url='', + model='gemini-1.5-flash', + ), ] active_profile_id = profiles[0].id @@ -566,5 +583,14 @@ def _build_initial_config(self) -> LLMControlConfig: 'model': (os.getenv('ARK_MODEL') or '').strip(), }) active_profile_id = profiles[0].id + elif os.getenv('GCP_PROJECT_ID') or os.getenv('GOOGLE_APPLICATION_CREDENTIALS'): + profiles[3] = profiles[3].model_copy(update={ + 'model': (os.getenv('GCP_MODEL') or '').strip() or profiles[3].model, + 'extra_body': { + 'project_id': (os.getenv('GCP_PROJECT_ID') or '').strip(), + 'region': (os.getenv('GCP_REGION') or '').strip() or 'us-central1' + } + }) + active_profile_id = profiles[3].id return LLMControlConfig(version=1, active_profile_id=active_profile_id, profiles=profiles) diff --git a/application/analyst/services/state_extractor.py b/application/analyst/services/state_extractor.py index 882d35408..09db6729a 100644 --- a/application/analyst/services/state_extractor.py +++ b/application/analyst/services/state_extractor.py @@ -8,7 +8,9 @@ chapter_state_payload_to_domain, empty_chapter_state, parse_chapter_state_llm_response, + ChapterStateLlmPayload, ) +from application.ai.structured_json_pipeline import structured_json_generate logger = logging.getLogger(__name__) @@ -37,23 +39,24 @@ async def extract_chapter_state(self, content: str) -> ChapterState: system_prompt, user_prompt = self._build_extraction_prompt(content) prompt = Prompt(system=system_prompt, user=user_prompt) - # 配置 LLM + # 配置 LLM 并强制指定 JSON schema config = GenerationConfig( model=os.getenv("WRITING_MODEL", ""), max_tokens=4096, - temperature=0.3 + temperature=0.3, + response_format=ChapterStateLlmPayload, ) - # 调用 LLM 生成 - result = await self.llm_service.generate(prompt=prompt, config=config) - raw_response = result.content - logger.debug(f"StateExtractor LLM raw response (first 500 chars): {raw_response[:500]}") + payload = await structured_json_generate( + llm=self.llm_service, + prompt=prompt, + config=config, + schema_model=ChapterStateLlmPayload, + ) - payload, errors = parse_chapter_state_llm_response(raw_response) if payload is None: logger.warning( - "StateExtractor: LLM 输出未通过契约校验: %s", - "; ".join(errors) if errors else "unknown", + "StateExtractor: LLM 输出结构化生成失败,返回安全空值。" ) chapter_state = empty_chapter_state() else: diff --git a/application/core/services/novel_service.py b/application/core/services/novel_service.py index 479b5efba..15b5fdc58 100644 --- a/application/core/services/novel_service.py +++ b/application/core/services/novel_service.py @@ -438,3 +438,689 @@ def get_novel_statistics(self, novel_id: str) -> Dict[str, Any]: "stage": novel.stage.value, "last_updated": datetime.now(timezone.utc).isoformat(), } + + def duplicate_novel(self, old_novel_id: str, new_title: str) -> str: + """深拷贝小说及其所有关联的 SQLite 记录""" + import uuid + import shutil + import time + import logging + from pathlib import Path + from infrastructure.persistence.database.connection import get_database + + logger = logging.getLogger(__name__) + + novel = self.novel_repository.get_by_id(NovelId(old_novel_id)) + if not novel: + raise ValueError(f"Novel not found: {old_novel_id}") + + new_novel_id = f"novel-{int(time.time())}" + new_slug = new_novel_id + db = get_database() + now = datetime.now().isoformat() + + from infrastructure.persistence.database.write_dispatch import startup_sqlite_writes_bypass_queue + bypass = startup_sqlite_writes_bypass_queue() + bypass.__enter__() + try: + with db.transaction() as conn: + def safe_execute(sql, params=None): + import sqlite3 + try: + if params is None: + return conn.execute(sql) + return conn.execute(sql, params) + except sqlite3.OperationalError as oe: + if "no such table" in str(oe): + logger.warning(f"Table not found, skipping replication: {oe}") + return None + raise + + def safe_fetchall(sql, params=None): + import sqlite3 + try: + if params is None: + return conn.execute(sql).fetchall() + return conn.execute(sql, params).fetchall() + except sqlite3.OperationalError as oe: + if "no such table" in str(oe): + logger.warning(f"Table not found, skipping query: {oe}") + return [] + raise + + def safe_fetchone(sql, params=None): + import sqlite3 + try: + if params is None: + return conn.execute(sql).fetchone() + return conn.execute(sql, params).fetchone() + except sqlite3.OperationalError as oe: + if "no such table" in str(oe): + logger.warning(f"Table not found, skipping query: {oe}") + return None + raise + # 1. 复制 novels 主表 + conn.execute( + """ + INSERT INTO novels ( + id, title, slug, author, target_chapters, premise, + autopilot_status, auto_approve_mode, current_stage, current_act, current_chapter_in_act, + max_auto_chapters, current_auto_chapters, last_chapter_tension, + consecutive_error_count, current_beat_index, beats_completed, + last_audit_chapter_number, last_audit_similarity, last_audit_drift_alert, + last_audit_narrative_ok, last_audit_at, last_audit_vector_stored, + last_audit_foreshadow_stored, last_audit_triples_extracted, + last_audit_quality_scores, last_audit_issues, target_words_per_chapter, + audit_progress, generation_prefs_json, created_at, updated_at + ) + SELECT ?, ?, ?, author, target_chapters, premise, + 'stopped', auto_approve_mode, current_stage, current_act, current_chapter_in_act, + max_auto_chapters, current_auto_chapters, last_chapter_tension, + 0, current_beat_index, beats_completed, + last_audit_chapter_number, last_audit_similarity, last_audit_drift_alert, + last_audit_narrative_ok, last_audit_at, last_audit_vector_stored, + last_audit_foreshadow_stored, last_audit_triples_extracted, + last_audit_quality_scores, last_audit_issues, target_words_per_chapter, + audit_progress, generation_prefs_json, ?, ? + FROM novels WHERE id = ? + """, + (new_novel_id, new_title, new_slug, now, now, old_novel_id) + ) + + # 2. 复制 chapters 与 beat_sheets + chapter_rows = conn.execute("SELECT * FROM chapters WHERE novel_id = ?", (old_novel_id,)).fetchall() + chapter_id_map = {} + for ch in chapter_rows: + old_ch_id = ch["id"] + new_ch_id = f"chapter-{uuid.uuid4().hex[:12]}" + chapter_id_map[old_ch_id] = new_ch_id + + conn.execute( + """ + INSERT INTO chapters ( + id, novel_id, number, title, content, outline, status, tension_score, word_count, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_ch_id, new_novel_id, ch["number"], ch["title"], ch["content"], ch["outline"], + ch["status"], ch["tension_score"], ch["word_count"] if "word_count" in ch.keys() else 0, now, now + ) + ) + + bs_row = safe_fetchone("SELECT * FROM beat_sheets WHERE chapter_id = ?", (old_ch_id,)) + if bs_row: + new_bs_id = f"bs-{uuid.uuid4().hex[:12]}" + safe_execute( + "INSERT INTO beat_sheets (id, chapter_id, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + (new_bs_id, new_ch_id, bs_row["data"], now, now) + ) + + # 3. 复制大纲树节点 (story_nodes, chapter_elements, chapter_scenes) + node_rows = conn.execute("SELECT * FROM story_nodes WHERE novel_id = ?", (old_novel_id,)).fetchall() + node_id_map = {} + for node in node_rows: + old_node_id = node["id"] + new_node_id = f"node-{uuid.uuid4().hex[:12]}" + node_id_map[old_node_id] = new_node_id + + for node in node_rows: + old_node_id = node["id"] + new_node_id = node_id_map[old_node_id] + old_parent_id = node["parent_id"] + new_parent_id = node_id_map.get(old_parent_id) if old_parent_id else None + + # 映射 POV 角色 ID (如果 POV 角色有在新书中克隆出来) + # 后面在复制 bible_characters 时,我们也会填充 POV 映射 + conn.execute( + """ + INSERT INTO story_nodes ( + id, novel_id, parent_id, node_type, number, title, description, order_index, + planning_status, planning_source, chapter_start, chapter_end, chapter_count, + suggested_chapter_count, content, outline, word_count, status, themes, + key_events, narrative_arc, conflicts, pov_character_id, timeline_start, + timeline_end, metadata, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_node_id, new_novel_id, new_parent_id, node["node_type"], node["number"], node["title"], + node["description"], node["order_index"], node["planning_status"], node["planning_source"], + node["chapter_start"], node["chapter_end"], node["chapter_count"], node["suggested_chapter_count"], + node["content"], node["outline"], node["word_count"], node["status"], node["themes"], + node["key_events"], node["narrative_arc"], node["conflicts"], node["pov_character_id"], + node["timeline_start"], node["timeline_end"], node["metadata"], now, now + ) + ) + + # 复制 chapter_elements + elements = safe_fetchall("SELECT * FROM chapter_elements WHERE chapter_id = ?", (old_node_id,)) + for el in elements: + new_el_id = f"el-{uuid.uuid4().hex[:12]}" + safe_execute( + """ + INSERT INTO chapter_elements ( + id, chapter_id, element_type, element_id, relation_type, importance, appearance_order, notes, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_el_id, new_node_id, el["element_type"], el["element_id"], el["relation_type"], + el["importance"], el["appearance_order"], el["notes"], now + ) + ) + + # 复制 chapter_scenes + scenes = safe_fetchall("SELECT * FROM chapter_scenes WHERE chapter_id = ?", (old_node_id,)) + for sc in scenes: + new_sc_id = f"scene-{uuid.uuid4().hex[:12]}" + safe_execute( + """ + INSERT INTO chapter_scenes ( + id, chapter_id, scene_number, location_id, timeline, summary, purpose, content, word_count, characters, order_index, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_sc_id, new_node_id, sc["scene_number"], sc["location_id"], sc["timeline"], + sc["summary"], sc["purpose"], sc["content"], sc["word_count"], sc["characters"], + sc["order_index"], now, now + ) + ) + + # 4. 复制 Bible 及其子表 + bible_row = safe_fetchone("SELECT * FROM bibles WHERE novel_id = ?", (old_novel_id,)) + char_id_map = {} + loc_id_map = {} + if bible_row: + new_bible_id = f"bible-{uuid.uuid4().hex[:12]}" + safe_execute( + "INSERT INTO bibles (id, novel_id, schema_version, extensions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + (new_bible_id, new_novel_id, bible_row["schema_version"], bible_row["extensions"], now, now) + ) + + # 复制人物 + char_rows = safe_fetchall("SELECT * FROM bible_characters WHERE novel_id = ?", (old_novel_id,)) + for ch in char_rows: + old_char_id = ch["id"] + new_char_id = f"char-{uuid.uuid4().hex[:12]}" + char_id_map[old_char_id] = new_char_id + + safe_execute( + """ + INSERT INTO bible_characters ( + id, novel_id, name, description, mental_state, mental_state_reason, verbal_tic, idle_behavior, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_char_id, new_novel_id, ch["name"], ch["description"], ch["mental_state"], + ch["mental_state_reason"], ch["verbal_tic"], ch["idle_behavior"], now, now + ) + ) + + # 复制人物关系 + rels = safe_fetchall("SELECT * FROM bible_character_relationships WHERE character_id = ?", (old_char_id,)) + for rel in rels: + new_rel_id = f"rel-{uuid.uuid4().hex[:12]}" + safe_execute( + "INSERT INTO bible_character_relationships (id, character_id, target_name, relation, description) VALUES (?, ?, ?, ?, ?)", + (new_rel_id, new_char_id, rel["target_name"], rel["relation"], rel["description"]) + ) + + # 复制世界设定 + safe_execute( + """ + INSERT INTO bible_world_settings (id, novel_id, name, description, setting_type, created_at, updated_at) + SELECT 'ws-' || lower(hex(randomblob(6))), ?, name, description, setting_type, ?, ? + FROM bible_world_settings WHERE novel_id = ? + """, + (new_novel_id, now, now, old_novel_id) + ) + + # 复制地理位置 + loc_rows = safe_fetchall("SELECT * FROM bible_locations WHERE novel_id = ?", (old_novel_id,)) + for loc in loc_rows: + old_loc_id = loc["id"] + new_loc_id = f"loc-{uuid.uuid4().hex[:12]}" + loc_id_map[old_loc_id] = new_loc_id + + for loc in loc_rows: + old_loc_id = loc["id"] + new_loc_id = loc_id_map[old_loc_id] + old_parent_loc_id = loc["parent_id"] + new_parent_loc_id = loc_id_map.get(old_parent_loc_id) if old_parent_loc_id else None + + safe_execute( + """ + INSERT INTO bible_locations (id, novel_id, name, description, location_type, parent_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_loc_id, new_novel_id, loc["name"], loc["description"], loc["location_type"], + new_parent_loc_id, now, now + ) + ) + + # 复制时间线备注 + safe_execute( + """ + INSERT INTO bible_timeline_notes (id, novel_id, event, time_point, description, sort_order, created_at, updated_at) + SELECT 'tln-' || lower(hex(randomblob(6))), ?, event, time_point, description, sort_order, ?, ? + FROM bible_timeline_notes WHERE novel_id = ? + """, + (new_novel_id, now, now, old_novel_id) + ) + + # 复制写作风格备注 + safe_execute( + """ + INSERT INTO bible_style_notes (id, novel_id, category, content, created_at, updated_at) + SELECT 'sn-' || lower(hex(randomblob(6))), ?, category, content, ?, ? + FROM bible_style_notes WHERE novel_id = ? + """, + (new_novel_id, now, now, old_novel_id) + ) + + # 5. 复制三元组及其多对多关联、属性、溯源 + triple_rows = safe_fetchall("SELECT * FROM triples WHERE novel_id = ?", (old_novel_id,)) + triple_id_map = {} + for tr in triple_rows: + old_tr_id = tr["id"] + new_tr_id = f"triple-{uuid.uuid4().hex[:12]}" + triple_id_map[old_tr_id] = new_tr_id + + # 映射 subject_entity_id / object_entity_id 引用 + old_sub_id = tr["subject_entity_id"] + old_obj_id = tr["object_entity_id"] + new_sub_id = char_id_map.get(old_sub_id) or loc_id_map.get(old_sub_id) or old_sub_id + new_obj_id = char_id_map.get(old_obj_id) or loc_id_map.get(old_obj_id) or old_obj_id + + safe_execute( + """ + INSERT INTO triples ( + id, novel_id, subject, predicate, object, chapter_number, note, entity_type, + importance, location_type, description, first_appearance, confidence, source_type, + subject_entity_id, object_entity_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_tr_id, new_novel_id, tr["subject"], tr["predicate"], tr["object"], tr["chapter_number"], + tr["note"], tr["entity_type"], tr["importance"], tr["location_type"], tr["description"], + tr["first_appearance"], tr["confidence"], tr["source_type"], new_sub_id, new_obj_id, now, now + ) + ) + + # 标签 + tags = safe_fetchall("SELECT * FROM triple_tags WHERE triple_id = ?", (old_tr_id,)) + for tag in tags: + safe_execute("INSERT INTO triple_tags (triple_id, tag) VALUES (?, ?)", (new_tr_id, tag["tag"])) + + # 扩展属性 + attrs = safe_fetchall("SELECT * FROM triple_attr WHERE triple_id = ?", (old_tr_id,)) + for attr in attrs: + safe_execute("INSERT INTO triple_attr (triple_id, attr_key, attr_value) VALUES (?, ?, ?)", (new_tr_id, attr["attr_key"], attr["attr_value"])) + + # 溯源 + provs = safe_fetchall("SELECT * FROM triple_provenance WHERE triple_id = ?", (old_tr_id,)) + for prov in provs: + new_prov_id = f"prov-{uuid.uuid4().hex[:12]}" + old_snode_id = prov["story_node_id"] + new_snode_id = node_id_map.get(old_snode_id) if old_snode_id else None + safe_execute( + """ + INSERT INTO triple_provenance (id, triple_id, novel_id, story_node_id, chapter_element_id, rule_id, role, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (new_prov_id, new_tr_id, new_novel_id, new_snode_id, prov["chapter_element_id"], prov["rule_id"], prov["role"], now) + ) + + # 关联更多章节 + more_chaps = safe_fetchall("SELECT * FROM triple_more_chapters WHERE triple_id = ?", (old_tr_id,)) + for mc in more_chaps: + safe_execute( + "INSERT INTO triple_more_chapters (triple_id, novel_id, chapter_number) VALUES (?, ?, ?)", + (new_tr_id, new_novel_id, mc["chapter_number"]) + ) + + # 6. 复制知识库与章节摘要 + k_row = safe_fetchone("SELECT * FROM knowledge WHERE novel_id = ?", (old_novel_id,)) + if k_row: + new_k_id = f"k-{uuid.uuid4().hex[:12]}" + safe_execute( + "INSERT INTO knowledge (id, novel_id, version, premise_lock, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + (new_k_id, new_novel_id, k_row["version"], k_row["premise_lock"], now, now) + ) + + safe_execute( + """ + INSERT INTO chapter_summaries (id, knowledge_id, chapter_number, summary, created_at, updated_at) + SELECT 'sum-' || lower(hex(randomblob(6))), ?, chapter_number, summary, ?, ? + FROM chapter_summaries WHERE knowledge_id = ? + """, + (new_k_id, now, now, k_row["id"]) + ) + + # 7. 复制章节审阅记录 (chapter_reviews) + safe_execute( + """ + INSERT INTO chapter_reviews (novel_id, chapter_number, status, memo, created_at, updated_at) + SELECT ?, chapter_number, status, memo, ?, ? FROM chapter_reviews WHERE novel_id = ? + """, + (new_novel_id, now, now, old_novel_id) + ) + + # 8. 复制文风漂移分数 (chapter_style_scores) + safe_execute( + """ + INSERT INTO chapter_style_scores (score_id, novel_id, chapter_number, adjective_density, avg_sentence_length, sentence_count, similarity_score, computed_at) + SELECT 'style-' || lower(hex(randomblob(6))), ?, chapter_number, adjective_density, avg_sentence_length, sentence_count, similarity_score, ? + FROM chapter_style_scores WHERE novel_id = ? + """, + (new_novel_id, now, old_novel_id) + ) + + # 9. 复制安全护栏快照 (chapter_guardrail_snapshots) + safe_execute( + """ + INSERT INTO chapter_guardrail_snapshots (novel_id, chapter_number, report_json, updated_at) + SELECT ?, chapter_number, report_json, ? FROM chapter_guardrail_snapshots WHERE novel_id = ? + """, + (new_novel_id, now, old_novel_id) + ) + + # 10. 复制故事线 (storylines) 与里程碑 + sl_rows = safe_fetchall("SELECT * FROM storylines WHERE novel_id = ?", (old_novel_id,)) + for sl in sl_rows: + old_sl_id = sl["id"] + new_sl_id = f"sl-{uuid.uuid4().hex[:12]}" + safe_execute( + """ + INSERT INTO storylines (id, novel_id, storyline_type, status, estimated_chapter_start, estimated_chapter_end, current_milestone_index, extensions, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (new_sl_id, new_novel_id, sl["storyline_type"], sl["status"], sl["estimated_chapter_start"], sl["estimated_chapter_end"], sl["current_milestone_index"], sl["extensions"], now, now) + ) + + safe_execute( + """ + INSERT INTO storyline_milestones (id, storyline_id, milestone_order, title, description, target_chapter_start, target_chapter_end, prerequisite_list, milestone_triggers) + SELECT 'ms-' || lower(hex(randomblob(6))), ?, milestone_order, title, description, target_chapter_start, target_chapter_end, prerequisite_list, milestone_triggers + FROM storyline_milestones WHERE storyline_id = ? + """, + (new_sl_id, old_sl_id) + ) + + # 11. 复制情节弧线与剧情点 + pa_rows = safe_fetchall("SELECT * FROM plot_arcs WHERE novel_id = ?", (old_novel_id,)) + for pa in pa_rows: + old_pa_id = pa["id"] + new_pa_id = f"arc-{uuid.uuid4().hex[:12]}" + safe_execute( + "INSERT INTO plot_arcs (id, novel_id, slug, display_name, extensions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (new_pa_id, new_novel_id, pa["slug"], pa["display_name"], pa["extensions"], now, now) + ) + + safe_execute( + """ + INSERT INTO plot_points (id, plot_arc_id, sort_order, chapter_number, point_type, description, tension) + SELECT 'pt-' || lower(hex(randomblob(6))), ?, sort_order, chapter_number, point_type, description, tension + FROM plot_points WHERE plot_arc_id = ? + """, + (new_pa_id, old_pa_id) + ) + + # 12. 复制物理实体基类、事件溯源、文风指纹和伏笔 + safe_execute( + """ + INSERT INTO entity_base (id, novel_id, entity_type, name, core_attributes, created_at) + SELECT 'eb-' || lower(hex(randomblob(6))), ?, entity_type, name, core_attributes, ? + FROM entity_base WHERE novel_id = ? + """, + (new_novel_id, now, old_novel_id) + ) + + safe_execute( + """ + INSERT INTO narrative_events (event_id, novel_id, chapter_number, event_summary, mutations, tags, timestamp_ts) + SELECT 'evt-' || lower(hex(randomblob(6))), ?, chapter_number, event_summary, mutations, tags, ? + FROM narrative_events WHERE novel_id = ? + """, + (new_novel_id, now, old_novel_id) + ) + + safe_execute( + """ + INSERT INTO voice_vault (sample_id, novel_id, chapter_number, scene_type, ai_original, author_refined, diff_analysis, created_at) + SELECT 'vv-' || lower(hex(randomblob(6))), ?, chapter_number, scene_type, ai_original, author_refined, diff_analysis, ? + FROM voice_vault WHERE novel_id = ? + """, + (new_novel_id, now, old_novel_id) + ) + + safe_execute( + """ + INSERT INTO voice_fingerprint (fingerprint_id, novel_id, pov_character_id, adjective_density, avg_sentence_length, sentence_count, sample_count, last_updated) + SELECT 'vfp-' || lower(hex(randomblob(6))), ?, pov_character_id, adjective_density, avg_sentence_length, sentence_count, sample_count, ? + FROM voice_fingerprint WHERE novel_id = ? + """, + (new_novel_id, now, old_novel_id) + ) + + safe_execute( + """ + INSERT INTO novel_foreshadow_registry (novel_id, payload, updated_at) + SELECT ?, payload, ? FROM novel_foreshadow_registry WHERE novel_id = ? + """, + (new_novel_id, now, old_novel_id) + ) + + safe_execute( + """ + INSERT INTO dag_versions (id, novel_id, version, dag_id, name, description, nodes_json, edges_json, fingerprint, created_at, updated_at) + SELECT 'dag-' || lower(hex(randomblob(6))), ?, version, dag_id, name, description, nodes_json, edges_json, fingerprint, ?, ? + FROM dag_versions WHERE novel_id = ? + """, + (new_novel_id, now, now, old_novel_id) + ) + + # 更新新书 POV 角色映射以保证引用的完整性 (若 node 绑定的 pov 角色属于旧书,映射到新克隆出来的角色ID) + for old_char_id, new_char_id in char_id_map.items(): + conn.execute( + "UPDATE story_nodes SET pov_character_id = ? WHERE novel_id = ? AND pov_character_id = ?", + (new_char_id, new_novel_id, old_char_id) + ) + + except Exception as e: + logger.error(f"Failed to duplicate SQLite records for {old_novel_id}: {e}") + raise e + finally: + bypass.__exit__(None, None, None) + + # 13. 复制物理磁盘文件夹下的文件设定 (Bible, Foreshadows JSON 等) + try: + from application.paths import get_db_path + db_path = get_db_path() + db_dir = Path(db_path).parent + old_novel_dir = db_dir / "novels" / old_novel_id + new_novel_dir = db_dir / "novels" / new_novel_id + if old_novel_dir.exists() and old_novel_dir.is_dir(): + shutil.copytree(old_novel_dir, new_novel_dir) + logger.info(f"Duplicated physical novel files directory from {old_novel_dir} to {new_novel_dir}") + except Exception as e: + logger.warning(f"Failed to copy physical directories for novel duplication: {e}") + + logger.info(f"Successfully duplicated novel {old_novel_id} -> {new_novel_id}") + return new_novel_id + + def clear_novel_drafts(self, novel_id: str) -> None: + """选择 1:清空已生成正文(保留大纲)""" + import logging + from infrastructure.persistence.database.connection import get_database + logger = logging.getLogger(__name__) + db = get_database() + + with db.transaction() as conn: + def safe_execute(sql, params=None): + import sqlite3 + try: + if params is None: + return conn.execute(sql) + return conn.execute(sql, params) + except sqlite3.OperationalError as oe: + if "no such table" in str(oe): + logger.warning(f"Table not found, skipping delete: {oe}") + return None + raise + + # 删正文、大纲、评定 + # 删正文、评定、快照 + conn.execute("DELETE FROM chapters WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM chapter_guardrail_snapshots WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM chapter_style_scores WHERE novel_id = ?", (novel_id,)) + + # 清除衍生的僵尸数据 + safe_execute("DELETE FROM chapter_reviews WHERE novel_id = ?", (novel_id,)) + # chapter_summaries is linked by knowledge_id. Get knowledge_id first: + safe_execute("DELETE FROM chapter_summaries WHERE knowledge_id IN (SELECT id FROM knowledge WHERE novel_id = ?)", (novel_id,)) + safe_execute("DELETE FROM beat_sheets WHERE chapter_id IN (SELECT id FROM chapters WHERE novel_id = ?)", (novel_id,)) + + # 重置故事线进度 + safe_execute("UPDATE storylines SET current_milestone_index = 0 WHERE novel_id = ?", (novel_id,)) + + # 将所有 chapter 大纲节点重置为 draft 和零词数(坚决保留 outline 细纲!) + conn.execute( + """ + UPDATE story_nodes + SET status = 'draft', word_count = 0, content = NULL + WHERE novel_id = ? AND node_type = 'chapter' + """, + (novel_id,) + ) + + # 更新 novels 统计 + conn.execute( + """ + UPDATE novels + SET current_chapter_in_act = 0, current_beat_index = 0, beats_completed = 0, + last_audit_chapter_number = NULL, last_audit_similarity = NULL, + last_audit_drift_alert = 0, last_audit_narrative_ok = 1 + WHERE id = ? + """, + (novel_id,) + ) + logger.info(f"Cleared chapter drafts and reset metadata for novel {novel_id}") + + # 同样需要清理向量库中关于此书的所有记忆 + try: + from infrastructure.ai.chromadb_vector_store import ChromaDBVectorStore + import asyncio + vector_store = ChromaDBVectorStore() + try: + loop = asyncio.get_running_loop() + loop.create_task(vector_store.delete(collection="novel_vectors", where={"novel_id": novel_id})) + except RuntimeError: + asyncio.run(vector_store.delete(collection="novel_vectors", where={"novel_id": novel_id})) + logger.info(f"Triggered vector store cleanup for novel drafts {novel_id}") + except Exception as e: + logger.warning(f"Failed to clear vector store for {novel_id}: {e}") + + # 同步多进程缓存 + try: + from application.engine.services.state_bootstrap import bootstrap_novel_state + bootstrap_novel_state(novel_id, force=True) + logger.info(f"Synchronized daemon shared state for novel drafts {novel_id}") + except Exception as e: + logger.warning(f"Failed to sync daemon shared state for {novel_id}: {e}") + + def clear_novel_outline(self, novel_id: str) -> None: + """选择 2:彻底重设(清空正文与大纲树,恢复至规划中)""" + import logging + from infrastructure.persistence.database.connection import get_database + logger = logging.getLogger(__name__) + db = get_database() + + with db.transaction() as conn: + def safe_execute(sql, params=None): + import sqlite3 + try: + if params is None: + return conn.execute(sql) + return conn.execute(sql, params) + except sqlite3.OperationalError as oe: + if "no such table" in str(oe): + logger.warning(f"Table not found, skipping delete: {oe}") + return None + raise + + # 1. 删正文和评定 + conn.execute("DELETE FROM chapters WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM chapter_guardrail_snapshots WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM chapter_style_scores WHERE novel_id = ?", (novel_id,)) + + # 2. 删整个故事结构树 (外键级联删除 elements 和 scenes) + conn.execute("DELETE FROM story_nodes WHERE novel_id = ?", (novel_id,)) + + # 3. 删提取的三元组及关联数据 + safe_execute("DELETE FROM triples WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM triple_provenance WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM triple_more_chapters WHERE novel_id = ?", (novel_id,)) + + # 4. 删大体量故事线、情节弧、事件、DAG版本 + safe_execute("DELETE FROM storylines WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM plot_arcs WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM plot_points WHERE plot_arc_id IN (SELECT id FROM plot_arcs WHERE novel_id = ?)", (novel_id,)) + safe_execute("DELETE FROM narrative_events WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM dag_versions WHERE novel_id = ?", (novel_id,)) + + # 4.5. 删伏笔账本与文风声线库 + safe_execute("DELETE FROM novel_foreshadow_registry WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM voice_vault WHERE novel_id = ?", (novel_id,)) + safe_execute("DELETE FROM voice_fingerprint WHERE novel_id = ?", (novel_id,)) + + # 5. 重设主表状态至规划中 (planning) + conn.execute( + """ + UPDATE novels + SET current_stage = 'planning', autopilot_status = 'stopped', + current_act = 0, current_chapter_in_act = 0, current_beat_index = 0, + beats_completed = 0, last_audit_chapter_number = NULL, last_audit_similarity = NULL, + last_audit_drift_alert = 0, last_audit_narrative_ok = 1, current_auto_chapters = 0 + WHERE id = ? + """, + (novel_id,) + ) + logger.info(f"Fully cleared and reset outline and drafts for novel {novel_id}") + + # 6. 删除向量库中的嵌入记忆,防止 RAG 幽灵上下文 + try: + from infrastructure.ai.chromadb_vector_store import ChromaDBVectorStore + import asyncio + vector_store = ChromaDBVectorStore() + # Since vector store methods are async, we use the event loop + try: + loop = asyncio.get_running_loop() + loop.create_task(vector_store.delete(collection="novel_vectors", where={"novel_id": novel_id})) + except RuntimeError: + asyncio.run(vector_store.delete(collection="novel_vectors", where={"novel_id": novel_id})) + logger.info(f"Triggered vector store cleanup for novel {novel_id}") + except Exception as e: + logger.warning(f"Failed to clear vector store for {novel_id}: {e}") + + # 7. 删除本地磁盘上的物理缓存和导出文件 + try: + import shutil + from pathlib import Path + from application.paths import get_db_path + db_dir = Path(get_db_path()).parent + chapters_dir = db_dir / "novels" / novel_id / "chapters" + if chapters_dir.exists() and chapters_dir.is_dir(): + shutil.rmtree(chapters_dir) + logger.info(f"Removed physical chapters directory for {novel_id}") + except Exception as e: + logger.warning(f"Failed to clear physical directories for {novel_id}: {e}") + + # 8. 同步清除/重载守护进程的多进程共享状态缓存 + try: + from application.engine.services.state_bootstrap import bootstrap_novel_state + # Force a re-bootstrap which will read the newly reset SQLite state and update shared memory + bootstrap_novel_state(novel_id, force=True) + logger.info(f"Synchronized daemon shared state for novel {novel_id}") + except Exception as e: + logger.warning(f"Failed to sync daemon shared state for {novel_id}: {e}") + diff --git a/application/engine/services/beat_middleware.py b/application/engine/services/beat_middleware.py index 65ca1a5ea..13344a9cc 100644 --- a/application/engine/services/beat_middleware.py +++ b/application/engine/services/beat_middleware.py @@ -33,7 +33,7 @@ import logging from dataclasses import dataclass, field -from typing import List, Optional, Protocol, Tuple +from typing import Any, List, Optional, Protocol, Tuple from application.engine.services.context_builder import Beat @@ -109,8 +109,7 @@ def flush_to_repository(self, repository: Any) -> int: return count -# 避免循环导入 -from typing import Any + class BeatMiddleware(Protocol): diff --git a/application/engine/services/query_service.py b/application/engine/services/query_service.py index 153849b51..fb3b89063 100644 --- a/application/engine/services/query_service.py +++ b/application/engine/services/query_service.py @@ -186,7 +186,7 @@ def get_novel_status(self, novel_id: str) -> Optional[NovelStatusResponse]: manuscript_chapters=completed_chapters, progress_pct_manuscript=round(progress_pct, 1), current_chapter_number=current_chapter_number, - needs_review=state.needs_review, + needs_review=(state.current_stage == "paused_for_review"), auto_approve_mode=state.auto_approve_mode, last_chapter_audit=None, # 需要单独存储 audit_progress=None, @@ -232,7 +232,7 @@ def _build_status_from_raw(self, novel_id: str, raw_data: Dict[str, Any]) -> Nov manuscript_chapters=completed_chapters, progress_pct_manuscript=round(progress_pct, 1), current_chapter_number=current_chapter_number, - needs_review=raw_data.get("needs_review", False), + needs_review=(raw_data.get("current_stage") == "paused_for_review"), auto_approve_mode=raw_data.get("auto_approve_mode", False), last_chapter_audit=None, audit_progress=raw_data.get("audit_progress"), diff --git a/application/engine/services/shared_state_repository.py b/application/engine/services/shared_state_repository.py index fb904fb80..bec09361d 100644 --- a/application/engine/services/shared_state_repository.py +++ b/application/engine/services/shared_state_repository.py @@ -119,7 +119,7 @@ class SharedStateRepository: KEY_DAEMON_HEARTBEAT = "_daemon_heartbeat" KEY_ALL_NOVELS = "_all_novels" - def __init__(self, shared_dict: Optional[mp.Manager().dict] = None): + def __init__(self, shared_dict: Any = None): """初始化共享状态仓库 Args: @@ -138,7 +138,7 @@ def _ensure_state(self): logger.warning(f"无法获取共享字典: {e}") return self._state is not None - def set_shared_dict(self, shared_dict: mp.Manager().dict): + def set_shared_dict(self, shared_dict: Any): """设置共享字典(用于子进程注入)""" self._state = shared_dict @@ -493,7 +493,7 @@ def set_audit_progress(self, novel_id: str, progress: Dict[str, Any]): _shared_state_repository: Optional[SharedStateRepository] = None -def init_shared_state_repository(shared_dict: mp.Manager().dict) -> SharedStateRepository: +def init_shared_state_repository(shared_dict: Any) -> SharedStateRepository: """初始化共享状态仓库(主进程调用)""" global _shared_state_repository _shared_state_repository = SharedStateRepository(shared_dict) @@ -509,7 +509,7 @@ def get_shared_state_repository() -> SharedStateRepository: return _shared_state_repository -def inject_shared_dict(shared_dict: mp.Manager().dict): +def inject_shared_dict(shared_dict: Any): """注入共享字典(子进程调用)""" global _shared_state_repository if _shared_state_repository is None: diff --git a/application/workflows/auto_novel_generation_workflow.py b/application/workflows/auto_novel_generation_workflow.py index 507d68a94..f82486cb1 100644 --- a/application/workflows/auto_novel_generation_workflow.py +++ b/application/workflows/auto_novel_generation_workflow.py @@ -1527,9 +1527,10 @@ def _build_prompt( if "人名硬约束" not in system_message: system_message = system_message.rstrip() + ( - "\n\n【人名硬约束】上下文人物设定(Bible)中的姓名为唯一正典。" + "\n\n【人名硬约束】上下文人物设定(Bible)与三元组(Triples)中的姓名为唯一正典。" + "正文中出现的角色名称必须与三元组和 Bible 中的角色名称完全一致,不得使用任何变体、旧稿占位名或混淆的名字。" "若本章大纲、故事线摘要或节拍说明中出现不同的人名(含旧稿占位名)," - "正文必须以 Bible 为准统一使用 Bible 姓名,不得继续使用大纲里的占位名。\n" + "正文必须以 Bible/三元组 为准统一姓名,绝不能出现偏离或创造新的不一致姓名。\n" ) user_message = _safe_format(user_template, {"outline": outline, "beat_section": ""}) diff --git a/application/world/services/auto_bible_generator.py b/application/world/services/auto_bible_generator.py index c24b86ede..5446ca972 100644 --- a/application/world/services/auto_bible_generator.py +++ b/application/world/services/auto_bible_generator.py @@ -440,11 +440,11 @@ def _prepare_locations_for_save(self, novel_id: str, locations: list[Dict[str, A prepared.append( { "location_id": location_id, - "name": loc_data["name"], - "description": loc_data["description"], - "location_type": loc_data.get("type", "场景"), - "connections": loc_data.get("connections", []), - "raw_parent_id": loc_data.get("parent_id"), + "name": loc_data.get("name") or loc_data.get("地点名") or loc_data.get("名称") or loc_data.get("id") or f"地点 {idx+1}", + "description": loc_data.get("description") or loc_data.get("简介") or loc_data.get("描述") or loc_data.get("function") or "暂无描述", + "location_type": loc_data.get("type") or loc_data.get("location_type") or loc_data.get("类型") or "场景", + "connections": loc_data.get("connections") or loc_data.get("连接") or [], + "raw_parent_id": loc_data.get("parent_id") or loc_data.get("父级ID"), } ) @@ -593,6 +593,7 @@ async def generate_and_save( # 基于已有世界观生成人物 existing_worldbuilding = self._load_worldbuilding(novel_id) + wb_summary = json.dumps(existing_worldbuilding, ensure_ascii=False) bible_data = await self._generate_characters(premise, target_chapters, existing_worldbuilding) chars_payload = bible_data.get("characters") or [] if not chars_payload: @@ -613,12 +614,16 @@ async def generate_and_save( used_char_ids.add(character_id) try: + char_name = char_data.get("name") or char_data.get("角色名") or char_data.get("姓名") or char_data.get("Name") or "未命名角色" + char_role = char_data.get("role") or char_data.get("身份") or char_data.get("定位") or char_data.get("Role") or "配角" + char_desc = char_data.get("description") or char_data.get("简介") or char_data.get("描述") or char_data.get("Background") or "暂无背景描述" + self.bible_service.add_character( novel_id=novel_id, character_id=character_id, - name=char_data["name"], - description=f"{char_data['role']} - {char_data['description']}", - relationships=char_data.get("relationships", []) + name=char_name, + description=f"{char_role} - {char_desc}", + relationships=char_data.get("relationships") or char_data.get("关系") or [] ) character_ids.append((character_id, char_data)) logger.info(f"Character saved: {character_id}") @@ -829,11 +834,15 @@ async def _save_to_bible(self, novel_id: str, bible_data: Dict[str, Any]) -> Non used_character_ids.add(character_id) try: + char_name = char_data.get("name") or char_data.get("角色名") or char_data.get("姓名") or char_data.get("Name") or "未命名角色" + char_role = char_data.get("role") or char_data.get("身份") or char_data.get("定位") or char_data.get("Role") or "角色" + char_desc = char_data.get("description") or char_data.get("简介") or char_data.get("描述") or char_data.get("Background") or "暂无描述" + self.bible_service.add_character( novel_id=novel_id, character_id=character_id, - name=char_data["name"], - description=f"{char_data['role']} - {char_data['description']}" + name=char_name, + description=f"{char_role} - {char_desc}" ) logger.info(f"Character saved: {character_id}") except Exception as e: @@ -1203,7 +1212,7 @@ async def _generate_single_dimension( 请生成世界观的「{dim_label}」维度。{context_block} -请严格按照以下JSON格式输出,字段名不要修改,可以被Python json.loads函数解析。只给出JSON,不作解释,不作答: +请按照以下json格式进行输出,可以被Python json.loads函数解析。只给出JSON,不作解释,不作答: ```json {{ {fields_desc} @@ -1539,11 +1548,19 @@ async def _generate_characters(self, premise: str, target_chapters: int, worldbu {wb_summary} 请基于这个世界观生成主要人物。 +**注意:每个角色必须包含 "name", "role", "description" 字段,且内容不能为空。** 请按照以下json格式进行输出,可以被Python json.loads函数解析。只给出JSON,不作解释,不作答: ```json {{ - "characters": [] + "characters": [ + {{ + "name": "角色姓名", + "role": "主角/配角/对手/导师", + "description": "性格背景、目标动机、外貌特征详述", + "relationships": [] + }} + ] }} ```""" + _BIBLE_CHARACTERS_NAMING_USER_SUFFIX @@ -1574,11 +1591,23 @@ async def _stream_generate_characters( {wb_summary} 请基于这个世界观生成主要人物。 - -请按照以下json格式进行输出,可以被Python json.loads函数解析。只给出JSON,不作解释,不作答: +请严格按照以下 JSON 格式输出,字段名必须为英文: ```json {{ - "characters": [] + "characters": [ + {{ + "name": "Character Name", + "role": "Role (Protag/Antag/etc)", + "description": "Character background and personality", + "relationships": [ + {{ + "target": "Target Name", + "relation": "Relation type", + "description": "Relation details" + }} + ] + }} + ] }} ```""" + _BIBLE_CHARACTERS_NAMING_USER_SUFFIX prompt = Prompt(system=system_prompt, user=user_prompt) @@ -1698,11 +1727,25 @@ async def _stream_generate_locations( {char_summary} 请基于世界观和人物生成完整地图。 - -请按照以下json格式进行输出,可以被Python json.loads函数解析。只给出JSON,不作解释,不作答: +请严格按照以下 JSON 格式输出,字段名必须为英文: ```json {{ - "locations": [] + "locations": [ + {{ + "id": "loc_001", + "name": "Location Name", + "type": "City/Building/Wilderness", + "description": "Detailed description", + "parent_id": null, + "connections": [ + {{ + "target": "Target Name", + "relation": "connection type", + "description": "connection details" + }} + ] + }} + ] }} ```""" prompt = Prompt(system=system_prompt, user=user_prompt) @@ -1823,7 +1866,11 @@ async def _generate_character_triples(self, novel_id: str, character_ids: list): logger.info(f"Generating character relationship triples for {novel_id}") # 创建人物名称到ID的映射 - name_to_id = {char_data["name"]: char_id for char_id, char_data in character_ids} + # 构建名字到 ID 的映射,处理缺失 name 的情况 + name_to_id = {} + for char_id, char_data in character_ids: + name = char_data.get("name") or char_data.get("id") or "未命名角色" + name_to_id[name] = char_id id_to_char = {cid: data for cid, data in character_ids} for char_id, char_data in character_ids: @@ -1841,8 +1888,9 @@ async def _generate_character_triples(self, novel_id: str, character_ids: list): # 简单的名称匹配 for other_id, other_data in character_ids: - if other_id != char_id and other_data["name"] in rel: - target_name = other_data["name"] + other_name = other_data.get("name") + if other_id != char_id and other_name and other_name in rel: + target_name = other_name break # 提取关系类型 @@ -1882,7 +1930,7 @@ async def _generate_character_triples(self, novel_id: str, character_ids: list): source_type=SourceType.BIBLE_GENERATED, description=description, attributes={ - "subject_label": char_data["name"], + "subject_label": char_data.get("name") or char_data.get("id") or "未命名角色", "object_label": target_name, "subject_importance": subj_imp, "object_importance": obj_imp, @@ -1892,7 +1940,7 @@ async def _generate_character_triples(self, novel_id: str, character_ids: list): ) try: await self.triple_repository.save(triple) - logger.info(f"Created triple: {char_data['name']} -{predicate}-> {target_name}") + logger.info(f"Created triple: {char_data.get('name', '未命名角色')} -{predicate}-> {target_name}") except Exception as e: logger.error(f"Failed to save triple: {e}") @@ -1901,7 +1949,10 @@ async def _generate_location_triples(self, novel_id: str, location_ids: list): logger.info(f"Generating location connection triples for {novel_id}") # 创建地点名称到ID的映射 - name_to_id = {loc_data["name"]: loc_id for loc_id, loc_data in location_ids} + name_to_id = {} + for loc_id, loc_data in location_ids: + lname = loc_data.get("name") or loc_data.get("id") or "未命名地点" + name_to_id[lname] = loc_id id_to_loc = {lid: data for lid, data in location_ids} for loc_id, loc_data in location_ids: @@ -1964,7 +2015,7 @@ async def _generate_location_triples(self, novel_id: str, location_ids: list): source_type=SourceType.BIBLE_GENERATED, description=description, attributes={ - "subject_label": loc_data["name"], + "subject_label": loc_data.get("name") or loc_data.get("id") or "未命名地点", "object_label": target_name, "subject_importance": subj_imp, "subject_location_type": subj_lt, @@ -1976,7 +2027,7 @@ async def _generate_location_triples(self, novel_id: str, location_ids: list): ) try: await self.triple_repository.save(triple) - logger.info(f"Created triple: {loc_data['name']} -{predicate}-> {target_name}") + logger.info(f"Created triple: {loc_data.get('name', '未命名地点')} -{predicate}-> {target_name}") except Exception as e: logger.error(f"Failed to save triple: {e}") diff --git a/application/world/services/auto_knowledge_generator.py b/application/world/services/auto_knowledge_generator.py index d66d7f92d..8284451dc 100644 --- a/application/world/services/auto_knowledge_generator.py +++ b/application/world/services/auto_knowledge_generator.py @@ -7,7 +7,9 @@ build_initial_knowledge_system_prompt, parse_initial_knowledge_llm_response, to_knowledge_service_update_dict, + LlmInitialKnowledgePayload, ) +from application.ai.structured_json_pipeline import structured_json_generate from application.world.services.knowledge_service import KnowledgeService logger = logging.getLogger(__name__) @@ -77,13 +79,16 @@ async def _generate_knowledge_data(self, title: str, bible_summary: str) -> Dict config = GenerationConfig(max_tokens=2048, temperature=0.4) - result = await self.llm_service.generate(prompt, config) + payload = await structured_json_generate( + llm=self.llm_service, + prompt=prompt, + config=config, + schema_model=LlmInitialKnowledgePayload, + ) - payload, errors = parse_initial_knowledge_llm_response(result.content) if payload is None: logger.warning( - "AutoKnowledgeGenerator: LLM 输出未通过契约校验: %s", - "; ".join(errors) if errors else "unknown", + "AutoKnowledgeGenerator: LLM 输出结构化生成失败,返回安全空值。" ) return { "version": 1, diff --git a/application/world/services/bible_service.py b/application/world/services/bible_service.py index ea4bf96a1..50cf039b7 100644 --- a/application/world/services/bible_service.py +++ b/application/world/services/bible_service.py @@ -394,6 +394,7 @@ def update_bible( # 记录改名前的 id→name 映射,用于 save 后同步刷新 story_nodes prev_name_by_id = {c.character_id.value: c.name for c in bible.characters} prev_chars = {c.character_id.value: c for c in bible.characters} + prev_loc_name_by_id = {l.id: l.name for l in bible.locations} # 清空现有数据 bible._characters = [] @@ -535,6 +536,13 @@ def update_bible( self.bible_repository.save(bible) self._sync_location_triples(novel_id, bible) + new_char_ids = {c.id for c in characters} + new_loc_ids = {l.id for l in locations} + deleted_char_ids = set(prev_name_by_id.keys()) - new_char_ids + deleted_loc_ids = set(prev_loc_name_by_id.keys()) - new_loc_ids + + self._cleanup_orphaned_triples(novel_id, deleted_char_ids, deleted_loc_ids, prev_name_by_id, prev_loc_name_by_id) + # 批量刷新结构节点里的旧人名(改名后大纲仍用旧名会导致生成时出现旧名) self._propagate_character_renames(novel_id, prev_name_by_id, characters) @@ -556,7 +564,6 @@ def _propagate_character_renames( """ import logging _log = logging.getLogger(__name__) - try: from application.paths import get_db_path from infrastructure.persistence.database.story_node_repository import StoryNodeRepository @@ -567,21 +574,129 @@ def _propagate_character_renames( new_name = (getattr(char_data, "name", None) or "").strip() old_name = (prev_name_by_id.get(cid) or "").strip() if old_name and new_name and old_name != new_name: - renames.append((old_name, new_name)) + renames.append((cid, old_name, new_name)) if not renames: return repo = StoryNodeRepository(str(get_db_path())) - for old_name, new_name in renames: + from infrastructure.persistence.database.connection import get_database + conn = get_database(str(get_db_path())).get_connection() + cursor = conn.cursor() + + for cid, old_name, new_name in renames: affected = repo.bulk_replace_text_sync(novel_id, old_name, new_name) if affected: _log.info( "story_nodes 人名替换:novel=%s %s → %s,影响 %d 行", novel_id, old_name, new_name, affected, ) + + # 1. Update triples where subject_entity_id matches cid + cursor.execute( + "UPDATE triples SET subject = ? WHERE novel_id = ? AND subject_entity_id = ?", + (new_name, novel_id, cid) + ) + affected_triples_sub_id = cursor.rowcount + + # 2. Update triples where object_entity_id matches cid + cursor.execute( + "UPDATE triples SET object = ? WHERE novel_id = ? AND object_entity_id = ?", + (new_name, novel_id, cid) + ) + affected_triples_obj_id = cursor.rowcount + + # 3. Update triples where subject matches old_name and entity_id is null/empty + cursor.execute( + "UPDATE triples SET subject = ? WHERE novel_id = ? AND subject = ? AND (subject_entity_id IS NULL OR subject_entity_id = '')", + (new_name, novel_id, old_name) + ) + affected_triples_sub_name = cursor.rowcount + + # 4. Update triples where object matches old_name and entity_id is null/empty + cursor.execute( + "UPDATE triples SET object = ? WHERE novel_id = ? AND object = ? AND (object_entity_id IS NULL OR object_entity_id = '')", + (new_name, novel_id, old_name) + ) + affected_triples_obj_name = cursor.rowcount + + total_affected_triples = ( + affected_triples_sub_id + affected_triples_obj_id + + affected_triples_sub_name + affected_triples_obj_name + ) + + if total_affected_triples > 0: + _log.info( + "triples 人名替换:novel=%s %s → %s,影响 %d 行三元组", + novel_id, old_name, new_name, total_affected_triples, + ) + conn.commit() + except Exception as exc: + import logging as _logging + _logging.getLogger(__name__).warning( + "story_nodes / triples 人名替换失败(不影响主流程): %s", exc + ) + + def _cleanup_orphaned_triples( + self, + novel_id: str, + deleted_char_ids: set, + deleted_loc_ids: set, + prev_name_by_id: dict, + prev_loc_name_by_id: dict, + ) -> None: + """删除已经被废弃/删除的角色和地点的所有知识图谱三元组连线。""" + if not deleted_char_ids and not deleted_loc_ids: + return + + import logging + _log = logging.getLogger(__name__) + try: + from application.paths import get_db_path + from infrastructure.persistence.database.connection import get_database + conn = get_database(str(get_db_path())).get_connection() + cursor = conn.cursor() + + total_deleted = 0 + + # 删除相关的 Character Triples + for cid in deleted_char_ids: + old_name = (prev_name_by_id.get(cid) or "").strip() + cursor.execute( + "DELETE FROM triples WHERE novel_id = ? AND (subject_entity_id = ? OR object_entity_id = ?)", + (novel_id, cid, cid) + ) + total_deleted += cursor.rowcount + if old_name: + cursor.execute( + "DELETE FROM triples WHERE novel_id = ? AND (subject = ? OR object = ?) AND (subject_entity_id IS NULL OR subject_entity_id = '') AND (object_entity_id IS NULL OR object_entity_id = '')", + (novel_id, old_name, old_name) + ) + total_deleted += cursor.rowcount + + # 删除相关的 Location Triples + for lid in deleted_loc_ids: + old_name = (prev_loc_name_by_id.get(lid) or "").strip() + cursor.execute( + "DELETE FROM triples WHERE novel_id = ? AND (subject_entity_id = ? OR object_entity_id = ?)", + (novel_id, lid, lid) + ) + total_deleted += cursor.rowcount + if old_name: + cursor.execute( + "DELETE FROM triples WHERE novel_id = ? AND (subject = ? OR object = ?) AND (subject_entity_id IS NULL OR subject_entity_id = '') AND (object_entity_id IS NULL OR object_entity_id = '')", + (novel_id, old_name, old_name) + ) + total_deleted += cursor.rowcount + + if total_deleted > 0: + _log.info( + "triples 清理:novel=%s 删除了 %d 个被废弃实体,共清理 %d 行孤立三元组", + novel_id, len(deleted_char_ids) + len(deleted_loc_ids), total_deleted + ) + conn.commit() except Exception as exc: import logging as _logging _logging.getLogger(__name__).warning( - "story_nodes 人名替换失败(不影响主流程): %s", exc + "清理废弃角色的孤立三元组失败(不影响主流程): %s", exc ) diff --git a/cli.py b/cli.py index 370edb3f6..ed202428f 100644 --- a/cli.py +++ b/cli.py @@ -21,7 +21,6 @@ def main(args=None): if parsed_args.command == 'serve': import uvicorn - from .interfaces.main import app _port = parsed_args.port _host = parsed_args.host @@ -71,7 +70,7 @@ def _port_in_use(p): print(f"[PlotPilot] 警告:端口 {_port} 仍被占用,启动可能失败") uvicorn.run( - app, + "interfaces.main:app", host=_host, port=_port, reload=parsed_args.reload diff --git a/frontend/index.html b/frontend/index.html index 947afe94b..fb31b6c0b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -22,6 +22,11 @@ href="https://fonts.loli.net/css2?family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" /> + + PlotPilot · 墨枢 | 作者的领航员 diff --git a/frontend/src/components/stats/StatsSidebar.vue b/frontend/src/components/stats/StatsSidebar.vue index 9a6221ddf..a3940980b 100644 --- a/frontend/src/components/stats/StatsSidebar.vue +++ b/frontend/src/components/stats/StatsSidebar.vue @@ -136,6 +136,15 @@ + @@ -176,6 +185,7 @@ import PromptPlazaEntryButton from '@/components/global/PromptPlazaEntryButton.v const emit = defineEmits<{ (e: 'create-book'): void (e: 'refresh-list'): void + (e: 'open-settings'): void (e: 'collapsed-change', collapsed: boolean): void }>() @@ -615,6 +625,9 @@ const updateTimeText = computed(() => formatTime(lastUpdateTime.value)) border-color: color-mix(in srgb, var(--color-brand, #4f46e5) 52%, transparent); } +.action-btn.action-settings { + grid-column: 1 / -1; +} .action-btn:hover { filter: none; transform: none; diff --git a/frontend/src/components/workbench/ChapterList.vue b/frontend/src/components/workbench/ChapterList.vue index 1df06f839..da1793818 100644 --- a/frontend/src/components/workbench/ChapterList.vue +++ b/frontend/src/components/workbench/ChapterList.vue @@ -1,12 +1,22 @@