diff --git a/README.en.md b/README.en.md index 2cd571d..ade3b80 100644 --- a/README.en.md +++ b/README.en.md @@ -131,6 +131,7 @@ The current Web UI already supports: - persona review pages with key-field completion, evidence-gap checks, and secondary-field tuning - creating, editing, selecting, and reusing scene cards, self cards, and opening presets - automatic next-scene recommendation during chat, with in-session scene switching +- automatic next-beat surfacing when a scene matures, including a transition line, follow-up chain, and auto-opening cue - session restore, recent-session resume, group chat continuation, and direct workbench entry into a scene - dialogue context compression that trims persona / relation context around active participants and injects session memory summaries - viewing transcripts, continuing group chat, and deleting recent sessions in the same interface @@ -207,6 +208,7 @@ You can now layer these helpers before or during a session: - self cards: prepare your identity, tone, motive, and in-scene role for `insert` - opening presets: bundle mode, participants, scene card, and self card into a reusable starting setup - automatic scene recommendation: while a session is running, the system can suggest a more suitable next scene card +- transition assist flow: besides recommending the next scene, it can also provide a transition line, a follow-up scene chain, and an auto-opening cue for the next beat ## Usage 🛠️ diff --git a/README.md b/README.md index f42f63b..8ce4bc8 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ python scripts/run_webui.py --reload - 人物校对页,支持关键字段补全、证据不足检查与二级字段微调 - 场景卡、角色卡、开局模板的创建、编辑、选择与复用 - 聊天过程中自动推荐下一幕场景卡,并支持会话内切换场景 +- 拍点成熟时自动浮出下一幕建议,附带转场起句、后续戏路和自动起拍提示 - 会话恢复、最近会话续聊、群聊继续与工作台直接入场 - 对话上下文自动压缩,按活跃角色裁剪人物/关系上下文,并注入会话记忆摘要 - 在同一页面查看 transcript、继续群聊、删除历史会话 @@ -217,6 +218,7 @@ python scripts/run_webui.py --reload - 角色卡:为 `insert` 模式准备你的身份、语气、动机与在场定位 - 开局模板:把入场方式、参与角色、场景卡、角色卡打包成一套可复用开局 - 自动场景推荐:会话进行中,系统会结合当前局势推荐更适合的下一幕 +- 转场辅助链路:除了推荐哪一幕,还会给出转场起句、后续戏路,以及切幕后可直接继续开聊的自动起拍提示 ## 使用方式 🛠️ diff --git a/docs/session-state-v1.md b/docs/session-state-v1.md new file mode 100644 index 0000000..a4a20cf --- /dev/null +++ b/docs/session-state-v1.md @@ -0,0 +1,110 @@ +# Session State V1 + +## Goal + +This branch treats dialogue session state as a first-class runtime model instead of a loose bag of side effects. + +We intentionally optimize for a clean canonical schema rather than backward compatibility. + +## Canonical State + +Each dialogue session owns one canonical `state` object: + +```json +{ + "version": 1, + "scene": { + "location": "", + "time_hint": "", + "atmosphere_summary": "", + "progression_note": "", + "updated_at": "" + }, + "presence": { + "present_participants": [], + "offstage_participants": [], + "updated_at": "" + }, + "progression": { + "should_offer_scene_shift": false, + "scene_shift_reason": "", + "turns_in_current_scene": 0, + "beat_maturity": 0, + "world_tension_summary": "", + "updated_at": "" + }, + "relations": { + "matrix": {}, + "delta": {} + }, + "characters": { + "snapshots": {} + }, + "signals": { + "recent": [], + "by_type": {}, + "updated_at": "" + }, + "memory": { + "summary": {} + } +} +``` + +## Rules + +1. `state` is the only source of truth for session runtime evolution. +2. API payloads may still project convenience views like `scene_progress` or `relation_delta`, but those are derived views, not primary storage. +3. Session payloads may expose derived helpers like `runtime_state_overview` for UI rendering, but these are read-only projections from canonical `state`. +4. `runtime_state_overview` should stay presentation-friendly: short labels, trimmed text, and stable ordering for characters / relations / events. +5. Scene flow is split into three concerns: + - `scene`: where/when/what tone the current beat has + - `presence`: who is currently onstage or offstage + - `progression`: whether the beat is mature enough to shift scenes +6. Relationship updates are split into: + - `relations.matrix`: baseline merged relation graph for session participants + - `relations.delta`: session-local drift caused by this conversation +7. Character-local runtime drift belongs in `characters.snapshots`. +8. Small event cues, transitions, exits, and atmosphere shifts belong in `signals`. +9. Compression summaries belong in `memory.summary`. + +## Implementation Checklist + +### Slice 1: Canonical State Foundation + +- [x] Define the canonical session-state schema +- [x] Centralize session-state creation and normalization +- [x] Project derived `scene_progress` from canonical state +- [x] Project derived `relation_delta`, `character_snapshots`, and `event_signals` +- [x] Move session-store readers to canonical state paths + +### Slice 2: Progression Engine + +- [x] Split time, location, atmosphere, and onstage/offstage decisions into dedicated state updaters +- [x] Track scene maturity explicitly in `progression.beat_maturity` +- [x] Let narration, exits, and returns update canonical presence state directly + +### Slice 3: Session Snapshots + +- [x] Expand character snapshots into stable per-character runtime cards +- [x] Expand relation deltas into stable per-pair interaction drift +- [x] Add explicit session-level world tension / atmosphere summary + +### Slice 4: Prompt Integration + +- [x] Feed canonical state into turn payloads +- [x] Feed canonical state into suggestion payloads +- [x] Feed canonical state into scene-progress generation prompts +- [x] Trim prompt payloads using canonical active-state priority + +### Slice 5: UI Integration + +- [x] Surface canonical presence/time/progression hints in the chat UI +- [x] Surface natural next-scene hints from `progression` +- [x] Surface per-character session drift from `characters.snapshots` + +## Non-Goals + +- Preserving every old session-state shape on disk +- Layering more compatibility shims for low-value legacy paths +- Keeping duplicated state across multiple top-level session fields diff --git a/src/core/session_store.py b/src/core/session_store.py index 4491bc7..834b893 100644 --- a/src/core/session_store.py +++ b/src/core/session_store.py @@ -91,12 +91,12 @@ def compress_context( to_archive = history[:-recent_turns] keep = history[-recent_turns:] state = session.setdefault("state", {}) - memory_summary = dict(state.get("memory_summary", {}) or {}) + memory_summary = dict(dict(state.get("memory", {}) or {}).get("summary", {}) or {}) previous_summary = _normalize_text(memory_summary.get("summary", "")) compressed = self._build_memory_summary(previous_summary, to_archive, summary_limit) key_points = self._extract_key_points(to_archive, limit=8) - state["memory_summary"] = { + state.setdefault("memory", {})["summary"] = { "summary": compressed, "key_points": key_points, "compressed_turns": len(to_archive), @@ -199,8 +199,8 @@ def save_relation_snapshot(self, session: Dict[str, Any]) -> None: "session_id": session_id, "novel_id": session.get("novel_id"), "updated_at": session.get("updated_at"), - "relation_matrix": session.get("state", {}).get("relation_matrix", {}), - "relation_delta": session.get("state", {}).get("relation_delta", {}), + "relation_matrix": dict(dict(session.get("state", {}).get("relations", {}) or {}).get("matrix", {}) or {}), + "relation_delta": dict(dict(session.get("state", {}).get("relations", {}) or {}).get("delta", {}) or {}), } save_markdown_data( self._relation_snapshot_path(session_id), diff --git a/src/skill_support/__init__.py b/src/skill_support/__init__.py index abff2b9..f5feaef 100644 --- a/src/skill_support/__init__.py +++ b/src/skill_support/__init__.py @@ -3,3 +3,18 @@ """Shared helpers for prompt-first skill workflows.""" +from .scene_recommendations import ( + build_scene_opening_message, + build_scene_recommendation_bundle, + normalize_scene_recommendation_context, + recommend_dialogue_scene_cards, + recommend_scene_cards_base, +) + +__all__ = [ + "build_scene_opening_message", + "build_scene_recommendation_bundle", + "normalize_scene_recommendation_context", + "recommend_dialogue_scene_cards", + "recommend_scene_cards_base", +] diff --git a/src/skill_support/scene_recommendations.py b/src/skill_support/scene_recommendations.py new file mode 100644 index 0000000..f66b8af --- /dev/null +++ b/src/skill_support/scene_recommendations.py @@ -0,0 +1,641 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import re +from typing import Any + +_GROUP_SCENE_TOKENS = ("众人", "席间", "满座", "同席", "众目", "围坐", "宴", "厅", "堂", "多人") +_DUO_SCENE_TOKENS = ("二人", "对坐", "独处", "檐下", "私谈", "夜谈", "回廊", "亭中", "单独") +_INSERT_SCENE_TOKENS = ("来客", "访客", "外客", "误入", "新到", "初来", "借住", "入席", "登门") +_PLOT_PUSH_TOKENS = ("试探", "摊牌", "转折", "打断", "逼问", "推", "揭", "撞破", "失手", "变局") +_SCENE_FIELDS = ( + "title", + "time_hint", + "location", + "atmosphere", + "opening_situation", + "public_goal", + "hidden_tension", + "scene_drive", + "expected_rhythm", + "forbidden_topics", +) + + +def build_scene_recommendation_bundle(context: dict[str, Any]) -> dict[str, Any]: + normalized = normalize_scene_recommendation_context(context) + recommended = recommend_dialogue_scene_cards( + cards=list(normalized.get("scene_cards", []) or []), + mode=str(normalized.get("mode", "observe")).strip() or "observe", + participants=list(normalized.get("participants", []) or []), + current_scene=dict(normalized.get("current_scene", {}) or {}), + current_scene_id=str(normalized.get("current_scene_card_id", "")).strip(), + runtime_overview=dict(normalized.get("runtime_state_overview", {}) or {}), + recent_text=str(normalized.get("recent_text", "")).strip(), + ) + top_card: dict[str, Any] = next( + ( + item + for item in list(recommended.get("items", []) or []) + if str(item.get("card_id", "")).strip() == str(recommended.get("recommended_card_id", "")).strip() + ), + {}, + ) + fields = dict(top_card.get("fields", {}) or {}) + recommended["recommended_auto_continue_message"] = build_scene_opening_message( + mode=str(normalized.get("mode", "observe")).strip() or "observe", + participants=list(normalized.get("participants", []) or []), + scene_card=fields, + controlled_character=str(normalized.get("controlled_character", "")).strip(), + self_profile=dict(normalized.get("self_profile", {}) or {}), + ) + return { + "kind": "dialogue_scene_recommendation_bundle", + "payload": recommended, + "host_hint": ( + "The host may directly apply recommended_card_id + recommended_transition_message. " + "If it wants to auto-continue the new beat immediately, it can feed recommended_auto_continue_message " + "back into its dialogue engine as the next opening cue." + ), + } + + +def normalize_scene_recommendation_context(context: dict[str, Any]) -> dict[str, Any]: + mode = str(context.get("mode", "observe")).strip() or "observe" + participants = [str(item).strip() for item in list(context.get("participants", []) or []) if str(item).strip()] + scene_cards = [_normalize_scene_card_entry(item) for item in list(context.get("scene_cards", []) or []) if isinstance(item, dict)] + if not scene_cards: + raise ValueError("Scene recommendation context requires non-empty scene_cards.") + return { + "mode": mode, + "participants": participants, + "scene_cards": scene_cards, + "current_scene_card_id": str(context.get("current_scene_card_id", "")).strip(), + "current_scene": _normalize_scene_fields(dict(context.get("current_scene", {}) or {})), + "runtime_state_overview": dict(context.get("runtime_state_overview", {}) or {}), + "recent_text": str(context.get("recent_text", "")).strip() or _transcript_to_recent_text(context.get("transcript", [])), + "controlled_character": str(context.get("controlled_character", "")).strip(), + "self_profile": dict(context.get("self_profile", {}) or {}), + } + + +def recommend_dialogue_scene_cards( + *, + cards: list[dict[str, Any]], + mode: str, + participants: list[str], + current_scene: dict[str, Any], + current_scene_id: str, + runtime_overview: dict[str, Any] | None = None, + recent_text: str = "", +) -> dict[str, Any]: + normalized_mode = str(mode or "observe").strip() or "observe" + participant_list = [str(item).strip() for item in participants if str(item).strip()] + current_scene_snapshot = _merge_current_scene_snapshot(current_scene, dict(runtime_overview or {})) + base = recommend_scene_cards_base(cards, mode=normalized_mode, participants=participant_list) + reranked_items: list[dict[str, Any]] = [] + for item in list(base.get("items", []) or []): + recommendation = dict(item.get("recommendation", {}) or {}) + score = int(recommendation.get("score", 0) or 0) + reasons = [str(reason).strip() for reason in list(recommendation.get("reasons", []) or []) if str(reason).strip()] + item_card_id = str(item.get("card_id", "")).strip() + fields = dict(item.get("fields", {}) or {}) + + if current_scene_id and item_card_id == current_scene_id: + score -= 5 + reasons.insert(0, "当前已经在这幕里,优先换一拍") + else: + current_location = str(current_scene_snapshot.get("location", "")).strip() + candidate_location = str(fields.get("location", "")).strip() + if current_location and candidate_location and candidate_location != current_location: + score += 1 + reasons.append("地点切换更明显,适合转场") + + overlap = _scene_text_overlap_score(fields, recent_text) + if overlap: + score += overlap + reasons.append("和最近这几句的气口更接") + + state_bonus, state_reasons = _score_scene_card_with_runtime_state( + fields, + runtime_overview=dict(runtime_overview or {}), + current_scene=current_scene_snapshot, + participants=participant_list, + recent_text=recent_text, + ) + score += state_bonus + reasons.extend(state_reasons) + + reranked_items.append( + { + **item, + "recommendation": { + "score": score, + "reasons": reasons[:4] or ["适合承接当前会话"], + }, + } + ) + + reranked_items.sort( + key=lambda item: ( + int(item.get("recommendation", {}).get("score", 0) or 0), + str(item.get("updated_at", "")), + str(item.get("card_id", "")), + ), + reverse=True, + ) + recommended_card_id = str(reranked_items[0].get("card_id", "")).strip() if reranked_items else "" + top_fields = dict(reranked_items[0].get("fields", {}) or {}) if reranked_items else {} + return { + "mode": normalized_mode, + "participants": participant_list, + "current_scene_card_id": str(current_scene_id or "").strip(), + "recommended_card_id": recommended_card_id, + "recommended_transition_message": _build_transition_message_hint( + current_scene=current_scene_snapshot, + next_scene=top_fields, + recent_text=recent_text, + runtime_overview=dict(runtime_overview or {}), + ), + "chain_suggestions": _build_scene_chain_suggestions( + current_scene=current_scene_snapshot, + current_scene_id=str(current_scene_id or "").strip(), + reranked_items=reranked_items, + recent_text=recent_text, + runtime_overview=dict(runtime_overview or {}), + ), + "items": reranked_items, + } + + +def recommend_scene_cards_base( + cards: list[dict[str, Any]], + *, + mode: str, + participants: list[str] | None = None, +) -> dict[str, Any]: + normalized_mode = str(mode or "observe").strip() or "observe" + participant_list = [str(item).strip() for item in (participants or []) if str(item).strip()] + scored_items: list[dict[str, Any]] = [] + for item in cards: + fields = _normalize_scene_fields(dict(item.get("fields", {}) or {})) + score, reasons = _score_scene_card(fields, mode=normalized_mode, participants=participant_list) + scored_items.append( + { + **item, + "fields": fields, + "recommendation": { + "score": score, + "reasons": reasons, + }, + } + ) + scored_items.sort( + key=lambda item: ( + int(item.get("recommendation", {}).get("score", 0) or 0), + str(item.get("updated_at", "")), + str(item.get("card_id", "")), + ), + reverse=True, + ) + return { + "mode": normalized_mode, + "participants": participant_list, + "recommended_card_id": str(scored_items[0].get("card_id", "")).strip() if scored_items else "", + "items": scored_items, + } + + +def build_scene_opening_message( + *, + mode: str, + participants: list[str], + scene_card: dict[str, Any], + controlled_character: str = "", + self_profile: dict[str, Any] | None = None, +) -> str: + normalized_mode = str(mode or "observe").strip() or "observe" + cast = "、".join(str(item).strip() for item in participants if str(item).strip()) or "当前角色" + scene = _normalize_scene_fields(scene_card) + scene_prefix_bits = [bit for bit in (scene.get("title", ""), scene.get("location", ""), scene.get("atmosphere", "")) if bit] + scene_prefix = f"场景设定:{' / '.join(scene_prefix_bits)}。" if scene_prefix_bits else "" + opening_suffix = f" 开场局面是:{scene.get('opening_situation', '')}。" if str(scene.get("opening_situation", "")).strip() else "" + drive_suffix = f" 推进方向优先朝这边走:{scene.get('scene_drive', '')}。" if str(scene.get("scene_drive", "")).strip() else "" + if normalized_mode == "act": + controlled = str(controlled_character or "").strip() or "该角色" + return ( + f"{scene_prefix}请先为 {controlled} 与 {cast} 生成一个自然开场。" + f"{opening_suffix}{drive_suffix}" + "先给 1 条简短的场景提示或旁白,再让其他角色先接出第一轮对话,不要等待用户补充。" + ) + if normalized_mode == "insert": + profile = dict(self_profile or {}) + display_name = str(profile.get("display_name", "")).strip() or "我" + scene_identity = str(profile.get("scene_identity", "")).strip() or str(profile.get("core_identity", "")).strip() + identity_suffix = f",身份是{scene_identity}" if scene_identity else "" + return ( + f"{scene_prefix}请先为 {display_name}{identity_suffix} 与 {cast} 生成一个自然开场。" + f"{opening_suffix}{drive_suffix}" + "先给 1 条简短的场景提示或旁白,再让角色们先开口,对这个进入场景的人作出第一轮反应。" + ) + return ( + f"{scene_prefix}请先为 {cast} 生成一个自然开场。" + f"{opening_suffix}{drive_suffix}" + "先给 1 条简短的场景提示或旁白,再让角色们开始第一轮对话,让场景自己动起来。" + ) + + +def _normalize_scene_card_entry(item: dict[str, Any]) -> dict[str, Any]: + fields = _normalize_scene_fields(dict(item.get("fields", {}) or {})) + preview = dict(item.get("preview", {}) or {}) + if not preview: + preview = { + "title": str(fields.get("title", "")).strip(), + "time_hint": str(fields.get("time_hint", "")).strip(), + "location": str(fields.get("location", "")).strip(), + "atmosphere": str(fields.get("atmosphere", "")).strip(), + "opening_situation": str(fields.get("opening_situation", "")).strip(), + "scene_drive": str(fields.get("scene_drive", "")).strip(), + "expected_rhythm": str(fields.get("expected_rhythm", "")).strip(), + } + return { + "card_id": str(item.get("card_id", "")).strip(), + "fields": fields, + "preview": preview, + "updated_at": str(item.get("updated_at", "")).strip(), + } + + +def _normalize_scene_fields(fields: dict[str, Any]) -> dict[str, str]: + return {field: str(fields.get(field, "") or "").strip() for field in _SCENE_FIELDS} + + +def _score_scene_card(fields: dict[str, Any], *, mode: str, participants: list[str]) -> tuple[int, list[str]]: + normalized = _normalize_scene_fields(fields) + combined_text = "\n".join(str(normalized.get(field, "")).strip() for field in _SCENE_FIELDS) + participant_count = len(participants) + score = 0 + reasons: list[str] = [] + + if normalized["scene_drive"]: + score += 3 + reasons.append("推进方向明确") + if normalized["opening_situation"]: + score += 2 + reasons.append("开场局面具体") + if normalized["atmosphere"]: + score += 1 + reasons.append("气氛落点清楚") + + if participant_count >= 3: + hit = _count_hits(combined_text, _GROUP_SCENE_TOKENS) + if hit: + score += 3 + min(2, hit - 1) + reasons.append("更像多人同席场") + elif participant_count == 2: + hit = _count_hits(combined_text, _DUO_SCENE_TOKENS) + if hit: + score += 3 + min(1, hit - 1) + reasons.append("更适合双人拉扯") + + if mode == "insert": + hit = _count_hits(combined_text, _INSERT_SCENE_TOKENS) + if hit: + score += 4 + min(1, hit - 1) + reasons.append("适合来客/自我入场") + elif mode == "observe": + hit = _count_hits(combined_text, _PLOT_PUSH_TOKENS) + if hit: + score += 3 + min(2, hit - 1) + reasons.append("更利于旁观推动剧情") + elif mode == "act": + duo_hit = _count_hits(combined_text, _DUO_SCENE_TOKENS) + if duo_hit: + score += 2 + reasons.append("留有角色正面接戏空间") + + if normalized["public_goal"]: + score += 1 + if normalized["hidden_tension"]: + score += 1 + if normalized["expected_rhythm"]: + score += 1 + + if not reasons: + reasons.append("信息比较完整,能直接开场") + return score, reasons[:3] + + +def _count_hits(text: str, tokens: tuple[str, ...]) -> int: + compact = str(text or "").strip() + if not compact: + return 0 + return sum(1 for token in tokens if token and token in compact) + + +def _merge_current_scene_snapshot(current_scene: dict[str, Any], runtime_overview: dict[str, Any]) -> dict[str, Any]: + merged = _normalize_scene_fields(current_scene) + if str(runtime_overview.get("location", "")).strip(): + merged["location"] = str(runtime_overview.get("location", "")).strip() + if str(runtime_overview.get("time_hint", "")).strip(): + merged["time_hint"] = str(runtime_overview.get("time_hint", "")).strip() + if str(runtime_overview.get("atmosphere", "")).strip(): + merged["atmosphere"] = str(runtime_overview.get("atmosphere", "")).strip() + return merged + + +def _scene_text_overlap_score(fields: dict[str, Any], recent_text: str) -> int: + compact_recent = str(recent_text or "").strip() + if not compact_recent: + return 0 + phrases: list[str] = [] + for key in ("location", "atmosphere", "opening_situation", "scene_drive", "public_goal", "hidden_tension"): + raw = str(fields.get(key, "") or "").strip() + if not raw: + continue + for part in re.split(r"[,,。;;、::\s]+", raw): + text = part.strip() + if 2 <= len(text) <= 8 and text not in phrases: + phrases.append(text) + overlap = sum(1 for phrase in phrases[:12] if phrase in compact_recent) + return min(3, overlap) + + +def _score_scene_card_with_runtime_state( + fields: dict[str, Any], + *, + runtime_overview: dict[str, Any], + current_scene: dict[str, Any], + participants: list[str], + recent_text: str, +) -> tuple[int, list[str]]: + score = 0 + reasons: list[str] = [] + current_location = str(current_scene.get("location", "")).strip() + candidate_location = str(fields.get("location", "")).strip() + current_time = str(runtime_overview.get("time_hint", "") or current_scene.get("time_hint", "")).strip() + candidate_time = str(fields.get("time_hint", "")).strip() + beat_maturity = max(0, min(100, int(runtime_overview.get("beat_maturity", 0) or 0))) + should_shift = bool(runtime_overview.get("should_offer_scene_shift", False)) + shift_reason = str(runtime_overview.get("scene_shift_reason", "")).strip() + tension = str(runtime_overview.get("tension", "")).strip() + next_hint = str(runtime_overview.get("next_hint", "")).strip() + atmosphere = str(runtime_overview.get("atmosphere", "")).strip() + event_rows = list(runtime_overview.get("event_rows", []) or []) + recent_event = str((event_rows[-1] or {}).get("copy", "")).strip() if event_rows else "" + + if should_shift: + if current_location and candidate_location and candidate_location != current_location: + score += 4 + reasons.append("这一拍已接近收束,更适合换场推进") + elif current_location and candidate_location and candidate_location == current_location: + score -= 2 + reasons.append("这一拍已经该收住了,不必继续原地打转") + elif candidate_location: + score += 1 + reasons.append("当前已经适合往下一拍走") + elif beat_maturity and beat_maturity < 45 and current_location and candidate_location == current_location: + score += 2 + reasons.append("这一拍还没聊满,先在同场景续火更顺") + + if current_time and candidate_time: + if candidate_time == current_time: + score += 1 + reasons.append("时间承接自然") + elif should_shift or beat_maturity >= 55: + score += 2 + reasons.append("时间推进能带出下一拍") + + state_overlap = _state_overlap_score( + fields, + state_texts=[atmosphere, tension, next_hint, recent_event, recent_text], + ) + if state_overlap: + score += state_overlap + reasons.append("能接住本局气氛和悬念") + + if len(participants) >= 3 and candidate_location and any(token in candidate_location for token in ("厅", "堂", "席", "园", "院")): + score += 1 + reasons.append("多人局切到这个场面更容易铺开") + + if shift_reason: + shift_tokens = [part for part in re.split(r"[,,。;;、::\s]+", shift_reason) if 2 <= len(part.strip()) <= 8] + if any(token and token in "\n".join(str(fields.get(key, "")).strip() for key in ("opening_situation", "scene_drive", "hidden_tension")) for token in shift_tokens[:4]): + score += 2 + reasons.append("和当前这拍的收束理由接得上") + + return score, reasons + + +def _state_overlap_score(fields: dict[str, Any], *, state_texts: list[str]) -> int: + compact_state = "\n".join(text.strip() for text in state_texts if str(text).strip()) + if not compact_state: + return 0 + phrases: list[str] = [] + for key in ("atmosphere", "opening_situation", "public_goal", "hidden_tension", "scene_drive"): + raw = str(fields.get(key, "")).strip() + if not raw: + continue + for part in re.split(r"[,,。;;、::\s]+", raw): + text = part.strip() + if 2 <= len(text) <= 8 and text not in phrases: + phrases.append(text) + overlap = sum(1 for phrase in phrases[:14] if phrase in compact_state) + return min(4, overlap) + + +def _build_transition_message_hint( + *, + current_scene: dict[str, Any], + next_scene: dict[str, Any], + recent_text: str, + runtime_overview: dict[str, Any] | None = None, +) -> str: + runtime = dict(runtime_overview or {}) + next_location = str(next_scene.get("location", "")).strip() + next_title = str(next_scene.get("title", "")).strip() + next_opening = str(next_scene.get("opening_situation", "")).strip() + next_atmosphere = str(next_scene.get("atmosphere", "")).strip() + current_location = str(current_scene.get("location", "")).strip() + next_time = str(next_scene.get("time_hint", "")).strip() + current_time = str(runtime.get("time_hint", "") or current_scene.get("time_hint", "")).strip() + shift_reason = str(runtime.get("scene_shift_reason", "")).strip() + tension = str(runtime.get("tension", "")).strip() + should_shift = bool(runtime.get("should_offer_scene_shift", False)) + + if shift_reason and should_shift and current_location and next_location and current_location != next_location: + anchor = next_title or next_location + return f"{shift_reason},场面顺势从{current_location}转到{anchor}。" + + if next_time and current_time and next_time != current_time: + destination = next_location or next_title or "下一幕" + if tension: + return f"带着这股{_trim_transition_text(tension, 18)},时间已经推到{next_time},场面也转进了{destination}。" + return f"这一拍不知不觉拖到了{next_time},场面也顺势转进了{destination}。" + + if next_opening: + first_sentence = re.split(r"[。!?!?]", next_opening, maxsplit=1)[0].strip() + if first_sentence: + if not re.search(r"[。!?!?]$", first_sentence): + first_sentence = f"{first_sentence}。" + return first_sentence + + if current_location and next_location and current_location != next_location: + anchor = next_title or next_location + return f"局面一转,众人从{current_location}挪到{anchor},气氛也跟着变了。" + + compact_recent = str(recent_text or "").strip() + if tension and next_atmosphere: + return f"刚才那股{_trim_transition_text(tension, 18)}还吊着,场面已经慢慢转成了{next_atmosphere}。" + if compact_recent and next_atmosphere: + return f"刚才那股{compact_recent[-12:]}的余波还没散,场面已经转成了{next_atmosphere}。" + + if next_location and next_atmosphere: + return f"这一拍顺势转到{next_location},场面也慢慢收成了{next_atmosphere}。" + if next_location: + return f"这一拍顺势转到{next_location}。" + if next_title: + return f"这一拍顺势转入「{next_title}」。" + return "" + + +def _build_scene_chain_suggestions( + *, + current_scene: dict[str, Any], + current_scene_id: str, + reranked_items: list[dict[str, Any]], + recent_text: str, + runtime_overview: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + candidates = [ + item + for item in reranked_items + if str(item.get("card_id", "")).strip() and str(item.get("card_id", "")).strip() != current_scene_id + ][:5] + chains: list[dict[str, Any]] = [] + for first_index, first in enumerate(candidates): + for second_index, second in enumerate(candidates): + if second_index == first_index: + continue + chains.append( + _build_chain_payload( + current_scene=current_scene, + items=[first, second], + recent_text=recent_text, + runtime_overview=runtime_overview, + ) + ) + for third_index, third in enumerate(candidates): + if third_index in {first_index, second_index}: + continue + chains.append( + _build_chain_payload( + current_scene=current_scene, + items=[first, second, third], + recent_text=recent_text, + runtime_overview=runtime_overview, + ) + ) + chains.sort(key=lambda item: (int(item.get("score", 0) or 0), len(item.get("scenes", []) or [])), reverse=True) + deduped: list[dict[str, Any]] = [] + seen_keys: set[str] = set() + for chain in chains: + scene_ids = [str(scene.get("card_id", "")).strip() for scene in list(chain.get("scenes", []) or [])] + key = "->".join(scene_ids) + if not key or key in seen_keys: + continue + seen_keys.add(key) + deduped.append(chain) + if len(deduped) >= 3: + break + return deduped + + +def _build_chain_payload( + *, + current_scene: dict[str, Any], + items: list[dict[str, Any]], + recent_text: str, + runtime_overview: dict[str, Any] | None = None, +) -> dict[str, Any]: + scenes: list[dict[str, str]] = [] + previous_scene = dict(current_scene or {}) + current_runtime = dict(runtime_overview or {}) + total_score = 0 + locations: list[str] = [] + for index, item in enumerate(items): + fields = dict(item.get("fields", {}) or {}) + score = int(dict(item.get("recommendation", {}) or {}).get("score", 0) or 0) + total_score += max(0, score) * max(1, 4 - index) + location = str(fields.get("location", "")).strip() + if location: + locations.append(location) + scenes.append( + { + "card_id": str(item.get("card_id", "")).strip(), + "title": str(item.get("preview", {}).get("title", "") or fields.get("title", "")).strip(), + "location": location, + "atmosphere": str(fields.get("atmosphere", "")).strip(), + "scene_drive": str(fields.get("scene_drive", "")).strip(), + "transition_message": _build_transition_message_hint( + current_scene=previous_scene, + next_scene=fields, + recent_text=recent_text if index == 0 else str(previous_scene.get("scene_drive", "")).strip(), + runtime_overview=current_runtime if index == 0 else None, + ), + } + ) + previous_scene = fields + current_runtime = {} + if len(set(locations)) >= 2: + total_score += 4 + if _chain_has_progressive_drive(scenes): + total_score += 3 + return { + "chain_id": " -> ".join(scene.get("card_id", "") for scene in scenes), + "score": total_score, + "reason": _build_chain_reason(scenes), + "scenes": scenes, + } + + +def _chain_has_progressive_drive(scenes: list[dict[str, str]]) -> bool: + drives = [str(scene.get("scene_drive", "")).strip() for scene in scenes if str(scene.get("scene_drive", "")).strip()] + if len(drives) < 2: + return False + strong_tokens = ("试探", "转折", "摊牌", "揭", "逼", "变局", "收紧") + return sum(1 for drive in drives if any(token in drive for token in strong_tokens)) >= 2 + + +def _build_chain_reason(scenes: list[dict[str, str]]) -> str: + if not scenes: + return "这条线能顺着往下接。" + locations = [scene.get("location", "") for scene in scenes if scene.get("location", "")] + if len(scenes) >= 3 and len(set(locations)) >= 2: + return "先换场再收紧,后面还有继续推进的余地。" + if len(scenes) >= 2 and len(set(locations)) >= 2: + return "地点会连续变化,戏路层次更明显。" + if _chain_has_progressive_drive(scenes): + return "每一幕的推进方向都比较明确,适合顺着往下压。" + first_title = str(scenes[0].get("title", "")).strip() or "这条线" + return f"可以先接「{first_title}」,后面还有顺势承接的下一拍。" + + +def _trim_transition_text(text: str, limit: int) -> str: + compact = str(text or "").strip() + if len(compact) <= limit: + return compact + return f"{compact[: max(1, limit - 1)]}…" + + +def _transcript_to_recent_text(transcript: Any) -> str: + lines: list[str] = [] + for item in list(transcript or [])[-6:]: + if not isinstance(item, dict): + continue + message = str(item.get("message", "")).strip() + if message: + lines.append(message) + return "\n".join(lines) diff --git a/src/web/api/routes/dialogue.py b/src/web/api/routes/dialogue.py index f4144ad..ec35b3c 100644 --- a/src/web/api/routes/dialogue.py +++ b/src/web/api/routes/dialogue.py @@ -163,6 +163,7 @@ def switch_dialogue_scene_card( scene_card_id=payload.scene_card_id, scene_profile=payload.scene_profile, transition_message=payload.transition_message, + auto_continue=payload.auto_continue, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Session not found.") from exc diff --git a/src/web/api/schemas.py b/src/web/api/schemas.py index 38c9d8e..d130fe6 100644 --- a/src/web/api/schemas.py +++ b/src/web/api/schemas.py @@ -201,6 +201,7 @@ class SwitchDialogueSceneCardRequest(BaseModel): scene_card_id: str = Field(default="") scene_profile: dict[str, str] = Field(default_factory=dict) transition_message: str = Field(default="") + auto_continue: bool = Field(default=False) class BranchDialogueSessionRequest(BaseModel): diff --git a/src/web/chat/__init__.py b/src/web/chat/__init__.py index 24ac840..aedd985 100644 --- a/src/web/chat/__init__.py +++ b/src/web/chat/__init__.py @@ -1,5 +1,6 @@ from .entrypoints import ( + continue_dialogue_scene_opening_payload, create_dialogue_session_payload, reply_dialogue_turn_payload, suggest_dialogue_turn_payload, @@ -31,6 +32,7 @@ "build_dialogue_opening_message", "build_dialogue_scene_progress_messages", "compact_dialogue_suggestion_payload", + "continue_dialogue_scene_opening_payload", "create_dialogue_session_payload", "friendly_dialogue_llm_error", "generate_dialogue_suggestion", diff --git a/src/web/chat/entrypoints.py b/src/web/chat/entrypoints.py index e573ffc..d568b29 100644 --- a/src/web/chat/entrypoints.py +++ b/src/web/chat/entrypoints.py @@ -56,6 +56,47 @@ def create_dialogue_session_payload( return ingested +def continue_dialogue_scene_opening_payload( + *, + run_id: str, + session: dict[str, Any], + manifest: dict[str, Any], + dialogue: Any, + build_dialogue_opening_message: Callable[[dict[str, Any]], str], + load_pending_turn_payload: Callable[[str, str], dict[str, Any]], + generate_dialogue_responses: Callable[[str, dict[str, Any]], list[dict[str, str]]], + friendly_dialogue_llm_error: Callable[[Exception], str], + evolve_relations_from_turn: Callable[[str, dict[str, Any], list[dict[str, str]]], None], + refresh_scene_progress: Callable[[str, dict[str, Any]], dict[str, Any]] | None = None, +) -> dict[str, Any]: + session_id = str(session.get("session_id", "")).strip() + if not session_id: + raise ValueError("Session not found.") + opening_message = build_dialogue_opening_message(session) + dialogue.prepare_turn( + manifest, + session_id=session_id, + message=opening_message, + speaker_override="场景提示", + transcript_message="", + ) + pending_payload = load_pending_turn_payload(run_id, session_id) + try: + responses = generate_dialogue_responses(run_id, pending_payload) + except LLMRequestError as exc: + raise ValueError(friendly_dialogue_llm_error(exc)) from exc + evolve_relations_from_turn(run_id, pending_payload, responses) + ingested = dialogue.ingest_turn_responses( + run_id, + session_id=session_id, + responses=responses, + remember_turn_memory=True, + ) + if callable(refresh_scene_progress): + ingested = refresh_scene_progress(run_id, ingested) + return ingested + + def reply_dialogue_turn_payload( *, run_id: str, diff --git a/src/web/chat/helpers.py b/src/web/chat/helpers.py index 198c98e..3065e6f 100644 --- a/src/web/chat/helpers.py +++ b/src/web/chat/helpers.py @@ -5,43 +5,65 @@ from typing import Any, Callable from src.core.exceptions import LLMRequestError +from src.skill_support.scene_recommendations import build_scene_opening_message + + +def _session_state(session: dict[str, Any]) -> dict[str, Any]: + return dict(session.get("state", {}) or {}) + + +def _canonical_scene_progress(session: dict[str, Any]) -> dict[str, Any]: + state = _session_state(session) + scene = dict(state.get("scene", {}) or {}) + presence = dict(state.get("presence", {}) or {}) + progression = dict(state.get("progression", {}) or {}) + derived = { + "present_participants": list(presence.get("present_participants", []) or []), + "offstage_participants": list(presence.get("offstage_participants", []) or []), + "time_hint": str(scene.get("time_hint", "")).strip(), + "location": str(scene.get("location", "")).strip(), + "atmosphere_summary": str(scene.get("atmosphere_summary", "")).strip(), + "progression_note": str(scene.get("progression_note", "")).strip(), + "should_offer_scene_shift": bool(progression.get("should_offer_scene_shift", False)), + "scene_shift_reason": str(progression.get("scene_shift_reason", "")).strip(), + "turns_in_current_scene": int(progression.get("turns_in_current_scene", 0) or 0), + "beat_maturity": int(progression.get("beat_maturity", 0) or 0), + "world_tension_summary": str(progression.get("world_tension_summary", "")).strip(), + "updated_at": ( + str(progression.get("updated_at", "")).strip() + or str(presence.get("updated_at", "")).strip() + or str(scene.get("updated_at", "")).strip() + ), + } + merged = dict(derived) + merged.update(dict(session.get("scene_progress", {}) or {})) + return {key: value for key, value in merged.items() if value not in ("", [], False, 0, None)} + + +def _canonical_relation_delta(session: dict[str, Any]) -> dict[str, Any]: + state = _session_state(session) + relations = dict(state.get("relations", {}) or {}) + return dict(session.get("relation_delta", {}) or relations.get("delta", {}) or {}) + + +def _canonical_character_snapshots(session: dict[str, Any]) -> dict[str, Any]: + state = _session_state(session) + characters = dict(state.get("characters", {}) or {}) + return dict(session.get("character_snapshots", {}) or characters.get("snapshots", {}) or {}) + + +def _canonical_event_signals(session: dict[str, Any]) -> dict[str, Any]: + state = _session_state(session) + return dict(session.get("event_signals", {}) or state.get("signals", {}) or {}) def build_dialogue_opening_message(session: dict[str, Any]) -> str: - mode = str(session.get("mode", "observe")).strip() or "observe" - participants = [str(item).strip() for item in session.get("participants", []) if str(item).strip()] - cast = "、".join(participants) or "当前角色" - scene_card = dict(session.get("scene_card", {}) or {}) - scene_title = str(scene_card.get("title", "")).strip() - location = str(scene_card.get("location", "")).strip() - atmosphere = str(scene_card.get("atmosphere", "")).strip() - opening = str(scene_card.get("opening_situation", "")).strip() - drive = str(scene_card.get("scene_drive", "")).strip() - scene_prefix_bits = [bit for bit in (scene_title, location, atmosphere) if bit] - scene_prefix = f"场景设定:{' / '.join(scene_prefix_bits)}。" if scene_prefix_bits else "" - opening_suffix = f" 开场局面是:{opening}。" if opening else "" - drive_suffix = f" 推进方向优先朝这边走:{drive}。" if drive else "" - if mode == "act": - controlled = str(session.get("controlled_character", "")).strip() or "该角色" - return ( - f"{scene_prefix}请先为 {controlled} 与 {cast} 生成一个自然开场。" - f"{opening_suffix}{drive_suffix}" - "先给 1 条简短的场景提示或旁白,再让其他角色先接出第一轮对话,不要等待用户补充。" - ) - if mode == "insert": - self_profile = dict(session.get("self_insert", {}) or {}) - display_name = str(self_profile.get("display_name", "")).strip() or "我" - scene_identity = str(self_profile.get("scene_identity", "")).strip() or str(self_profile.get("core_identity", "")).strip() - identity_suffix = f",身份是{scene_identity}" if scene_identity else "" - return ( - f"{scene_prefix}请先为 {display_name}{identity_suffix} 与 {cast} 生成一个自然开场。" - f"{opening_suffix}{drive_suffix}" - "先给 1 条简短的场景提示或旁白,再让角色们先开口,对这个进入场景的人作出第一轮反应。" - ) - return ( - f"{scene_prefix}请先为 {cast} 生成一个自然开场。" - f"{opening_suffix}{drive_suffix}" - "先给 1 条简短的场景提示或旁白,再让角色们开始第一轮对话,让场景自己动起来。" + return build_scene_opening_message( + mode=str(session.get("mode", "observe")).strip() or "observe", + participants=[str(item).strip() for item in session.get("participants", []) if str(item).strip()], + scene_card=dict(session.get("scene_card", {}) or {}), + controlled_character=str(session.get("controlled_character", "")).strip(), + self_profile=dict(session.get("self_insert", {}) or {}), ) @@ -239,7 +261,11 @@ def _compact_memory_context(memory_context: dict[str, Any]) -> dict[str, Any]: "archived_summary": compact_archived, "retrieved_memories": compact_hits, "scene_progress": { - inner_key: _trim_text(str(inner_value).strip(), 100) + inner_key: ( + list(inner_value)[:6] + if isinstance(inner_value, list) + else inner_value + ) for inner_key, inner_value in scene_progress.items() if _has_meaningful_value(inner_value) }, @@ -367,6 +393,7 @@ def build_dialogue_suggestion_llm_messages( relation_excerpt = str(payload.get("relation_context", {}).get("relations_excerpt", "")).strip() history = payload.get("history", []) memory_context = dict(payload.get("memory_context", {}) or {}) + scene_progress = dict(payload.get("scene_progress", {}) or memory_context.get("scene_progress", {}) or {}) instructions = dict(payload.get("instructions", {}) or {}) host_action = dict(payload.get("host_action", {}) or {}) scene_card = dict(payload.get("scene_card", {}) or {}) @@ -386,6 +413,8 @@ def build_dialogue_suggestion_llm_messages( "如果上下文允许多种接法,优先选更符合 user_persona 的那一种,而不是只做一个泛用接话。", "如果 mode=act,就按 controlled character 的 persona profile、speech_style、temperament 和典型说话习惯来写。", "如果 mode=observe,就把这句话写成推动剧情的场景提示:让局势往前走,而不是复述、总结或劝说。", + "如果 scene_progress 显示这一拍已经成熟、适合转场,就优先写成自然的转场推进;如果还没到转场时机,就优先续当前这一拍的动作、情绪或张力。", + "offstage_participants 里的人不要被你无端写回来,除非这句提示本身就在明确推动他们重新入场。", "如果 scene_card 存在,优先服从它给出的地点、气氛、开场局面、明面目标、暗线张力与推进方向。", "只输出一句最终可发送的成品台词,不要解释上下文,不要总结历史,不要提供建议理由,不要写“作为/当前场景/我们可以/你可以/建议/回复:”这类分析话术。", "不要分段,不要项目符号,不要加引号,不要加说话人标签。", @@ -401,6 +430,7 @@ def build_dialogue_suggestion_llm_messages( "speaker": str(input_block.get("speaker", "")).strip(), "seed_text": str(input_block.get("message", "")).strip(), "scene_card": scene_card, + "scene_progress": scene_progress, "memory_context": memory_context, "user_persona": user_persona, "participants": participants, @@ -457,8 +487,8 @@ def build_dialogue_scene_progress_messages(session: dict[str, Any]) -> list[dict "scene_card": dict(session.get("session_card", {}).get("scene_card", {}) or session.get("scene_card", {}) or {}), "session_memory_summary": dict(session.get("session_memory_summary", {}) or {}), "recent_transcript": recent, - "current_scene_progress": dict(session.get("scene_progress", {}) or {}), - "event_signals": dict(session.get("event_signals", {}) or session.get("state", {}).get("event_signals", {}) or {}), + "current_scene_progress": _canonical_scene_progress(session), + "event_signals": _canonical_event_signals(session), } system_prompt = "\n".join( [ @@ -467,10 +497,13 @@ def build_dialogue_scene_progress_messages(session: dict[str, Any]) -> list[dict "offstage_participants 里的人默认不应继续直接开口,除非最近文本明确写到他们回来、进门、现身、重新加入。", "如果最近内容已经从白天聊到傍晚、夜里、深夜等,time_hint 要跟着更新,而不是一直停在原时间。", "如果几个人已经离开原场所进入更私密的新地点,其他未同去角色不要继续被视作同场。", + "atmosphere_summary 用一句很短的话概括当前氛围,比如“安静下来”“暧昧发僵”“雨夜压下来”。", + "beat_maturity 用 0-100 的整数表示这一拍推进到什么程度:刚起势偏低,已经聊出完整一拍则更高。", + "world_tension_summary 用一句话概括当前这局最该继续带着走的张力、冲突或悬念。", "event_signals 里如果出现 scene_transition / cast_enter / cast_exit / atmosphere_shift / time_change / environment_change / beat_complete,要把它们纳入判断。", "should_offer_scene_shift 只在这一幕已经聊出明显一拍、适合自然转场时返回 true。", "只返回 JSON 对象,不要解释。", - "格式:{\"present_participants\":[],\"offstage_participants\":[],\"time_hint\":\"\",\"location\":\"\",\"progression_note\":\"\",\"should_offer_scene_shift\":false,\"scene_shift_reason\":\"\"}", + "格式:{\"present_participants\":[],\"offstage_participants\":[],\"time_hint\":\"\",\"location\":\"\",\"atmosphere_summary\":\"\",\"progression_note\":\"\",\"beat_maturity\":0,\"world_tension_summary\":\"\",\"should_offer_scene_shift\":false,\"scene_shift_reason\":\"\"}", ] ) return [ @@ -500,9 +533,9 @@ def build_dialogue_relation_state_messages( } ) current_state = { - "relation_delta": dict(session.get("state", {}).get("relation_delta", {}) or {}), - "character_snapshots": dict(session.get("state", {}).get("character_snapshots", {}) or {}), - "event_signals": dict(session.get("state", {}).get("event_signals", {}) or {}), + "relation_delta": _canonical_relation_delta(session), + "character_snapshots": _canonical_character_snapshots(session), + "event_signals": _canonical_event_signals(session), } payload = { "participants": [str(item).strip() for item in list(session.get("participants", []) or []) if str(item).strip()], @@ -578,12 +611,20 @@ def clean_names(value: Any) -> list[str]: present = clean_names(parsed.get("present_participants", [])) offstage = [name for name in clean_names(parsed.get("offstage_participants", [])) if name not in present] + try: + beat_maturity = max(0, min(100, int(parsed.get("beat_maturity", 0) or 0))) + except Exception: + beat_maturity = 0 + return { "present_participants": present, "offstage_participants": offstage, "time_hint": _trim_text(str(parsed.get("time_hint", "")).strip(), 40), "location": _trim_text(str(parsed.get("location", "")).strip(), 40), + "atmosphere_summary": _trim_text(str(parsed.get("atmosphere_summary", "")).strip(), 80), "progression_note": _trim_text(str(parsed.get("progression_note", "")).strip(), 120), + "beat_maturity": beat_maturity, + "world_tension_summary": _trim_text(str(parsed.get("world_tension_summary", "")).strip(), 120), "should_offer_scene_shift": bool(parsed.get("should_offer_scene_shift", False)), "scene_shift_reason": _trim_text(str(parsed.get("scene_shift_reason", "")).strip(), 120), } @@ -636,10 +677,16 @@ def pair_key(left: str, right: str) -> str: amount = 0 if amount: normalized[field] = max(-3, min(3, amount)) - for field in ("last_event", "relation_change", "typical_interaction"): + for field in ("last_event", "relation_change", "typical_interaction", "last_actor", "last_target", "updated_at"): value = _trim_text(str(item.get(field, "")).strip(), 120) if value: normalized[field] = value + try: + momentum = int(item.get("momentum", 0) or 0) + except Exception: + momentum = 0 + if momentum: + normalized["momentum"] = max(0, min(10, momentum)) evidence_lines = [ _trim_text(str(line).strip(), 180) for line in list(item.get("evidence_lines", []) or []) diff --git a/src/web/chat/service.py b/src/web/chat/service.py index 561a9bc..0424fea 100644 --- a/src/web/chat/service.py +++ b/src/web/chat/service.py @@ -22,6 +22,7 @@ def _utc_now() -> str: class DialogueService: + SESSION_STATE_VERSION = 1 _SCENE_ENTER_TOKENS = ("进门", "入内", "走进", "转入", "移步", "到了", "回到", "落座", "入座", "上楼", "进屋", "推门而入") _SCENE_EXIT_TOKENS = ("出去", "离开", "退场", "回房", "回家", "出门", "走远", "散去", "下楼", "离席") _ACTION_TOKENS = ("抬头", "低头", "笑", "沉默", "转身", "皱眉", "顿住", "垂眼", "抿唇", "抬眼", "偏头", "停住", "看向") @@ -65,6 +66,229 @@ def __init__( self._memory_store_resolver = memory_store_resolver self._memory_stores: dict[str, MarkdownSessionStore] = {} + @classmethod + def _empty_session_state(cls) -> dict[str, Any]: + return { + "version": cls.SESSION_STATE_VERSION, + "scene": { + "location": "", + "time_hint": "", + "atmosphere_summary": "", + "progression_note": "", + "updated_at": "", + }, + "presence": { + "present_participants": [], + "offstage_participants": [], + "updated_at": "", + }, + "progression": { + "should_offer_scene_shift": False, + "scene_shift_reason": "", + "turns_in_current_scene": 0, + "beat_maturity": 0, + "world_tension_summary": "", + "updated_at": "", + }, + "relations": { + "matrix": {}, + "delta": {}, + }, + "characters": { + "snapshots": {}, + }, + "signals": cls._empty_event_signals_state(), + "memory": { + "summary": {}, + }, + } + + def _ensure_session_state(self, session: dict[str, Any]) -> dict[str, Any]: + state = dict(session.get("state", {}) or {}) + canonical = self._empty_session_state() + canonical["version"] = int(state.get("version", self.SESSION_STATE_VERSION) or self.SESSION_STATE_VERSION) + + scene = dict(state.get("scene", {}) or {}) + scene_legacy = dict(state.get("scene_progress", {}) or {}) + canonical["scene"] = { + **dict(canonical.get("scene", {}) or {}), + **{key: value for key, value in scene.items() if key in {"location", "time_hint", "atmosphere_summary", "progression_note", "updated_at"}}, + **{ + key: value + for key, value in scene_legacy.items() + if key in {"location", "time_hint", "atmosphere_summary", "progression_note", "updated_at"} + }, + } + + presence = dict(state.get("presence", {}) or {}) + canonical["presence"] = { + **dict(canonical.get("presence", {}) or {}), + **{ + "present_participants": list(presence.get("present_participants", []) or scene_legacy.get("present_participants", []) or []), + "offstage_participants": list(presence.get("offstage_participants", []) or scene_legacy.get("offstage_participants", []) or []), + "updated_at": str(presence.get("updated_at", "")).strip() or str(scene_legacy.get("updated_at", "")).strip(), + }, + } + + progression = dict(state.get("progression", {}) or {}) + canonical["progression"] = { + **dict(canonical.get("progression", {}) or {}), + **{ + "should_offer_scene_shift": bool( + progression.get("should_offer_scene_shift", scene_legacy.get("should_offer_scene_shift", False)) + ), + "scene_shift_reason": str( + progression.get("scene_shift_reason", scene_legacy.get("scene_shift_reason", "")) + ).strip(), + "turns_in_current_scene": int( + progression.get("turns_in_current_scene", scene_legacy.get("turns_in_current_scene", 0)) or 0 + ), + "beat_maturity": int( + progression.get("beat_maturity", scene_legacy.get("beat_maturity", 0)) or 0 + ), + "world_tension_summary": str( + progression.get("world_tension_summary", scene_legacy.get("world_tension_summary", "")) + ).strip(), + "updated_at": str(progression.get("updated_at", "")).strip() or str(scene_legacy.get("updated_at", "")).strip(), + }, + } + + relations = dict(state.get("relations", {}) or {}) + canonical["relations"] = { + "matrix": dict(relations.get("matrix", {}) or state.get("relation_matrix", {}) or {}), + "delta": dict(relations.get("delta", {}) or state.get("relation_delta", {}) or {}), + } + characters = dict(state.get("characters", {}) or {}) + canonical["characters"] = { + "snapshots": dict(characters.get("snapshots", {}) or state.get("character_snapshots", {}) or {}), + } + canonical["signals"] = dict(state.get("signals", {}) or state.get("event_signals", {}) or self._empty_event_signals_state()) + memory = dict(state.get("memory", {}) or {}) + canonical["memory"] = { + "summary": dict(memory.get("summary", {}) or state.get("memory_summary", {}) or {}), + } + session["state"] = canonical + return canonical + + def _session_scene_progress(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + scene = dict(state.get("scene", {}) or {}) + presence = dict(state.get("presence", {}) or {}) + progression = dict(state.get("progression", {}) or {}) + return { + "present_participants": list(presence.get("present_participants", []) or []), + "offstage_participants": list(presence.get("offstage_participants", []) or []), + "time_hint": str(scene.get("time_hint", "")).strip(), + "location": str(scene.get("location", "")).strip(), + "atmosphere_summary": str(scene.get("atmosphere_summary", "")).strip(), + "progression_note": str(scene.get("progression_note", "")).strip(), + "should_offer_scene_shift": bool(progression.get("should_offer_scene_shift", False)), + "scene_shift_reason": str(progression.get("scene_shift_reason", "")).strip(), + "turns_in_current_scene": int(progression.get("turns_in_current_scene", 0) or 0), + "beat_maturity": int(progression.get("beat_maturity", 0) or 0), + "world_tension_summary": str(progression.get("world_tension_summary", "")).strip(), + "updated_at": ( + str(progression.get("updated_at", "")).strip() + or str(presence.get("updated_at", "")).strip() + or str(scene.get("updated_at", "")).strip() + ), + } + + def _set_session_scene_progress(self, session: dict[str, Any], scene_progress: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + payload = dict(scene_progress or {}) + updated_at = str(payload.get("updated_at", "")).strip() or _utc_now() + state["scene"] = { + "location": str(payload.get("location", "")).strip(), + "time_hint": str(payload.get("time_hint", "")).strip(), + "atmosphere_summary": str(payload.get("atmosphere_summary", "")).strip(), + "progression_note": str(payload.get("progression_note", "")).strip(), + "updated_at": updated_at, + } + state["presence"] = { + "present_participants": [str(item).strip() for item in list(payload.get("present_participants", []) or []) if str(item).strip()], + "offstage_participants": [str(item).strip() for item in list(payload.get("offstage_participants", []) or []) if str(item).strip()], + "updated_at": updated_at, + } + state["progression"] = { + "should_offer_scene_shift": bool(payload.get("should_offer_scene_shift", False)), + "scene_shift_reason": str(payload.get("scene_shift_reason", "")).strip(), + "turns_in_current_scene": int(payload.get("turns_in_current_scene", 0) or 0), + "beat_maturity": int(payload.get("beat_maturity", 0) or 0), + "world_tension_summary": str(payload.get("world_tension_summary", "")).strip(), + "updated_at": updated_at, + } + self._sync_character_runtime_cards(session, payload, updated_at=updated_at) + + def _session_relation_matrix(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("relations", {}).get("matrix", {}) or {}) + + def _set_session_relation_matrix(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state.setdefault("relations", {})["matrix"] = dict(payload or {}) + + def _session_relation_delta(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("relations", {}).get("delta", {}) or {}) + + def _set_session_relation_delta(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state.setdefault("relations", {})["delta"] = dict(payload or {}) + + def _session_character_snapshots(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("characters", {}).get("snapshots", {}) or {}) + + def _set_session_character_snapshots(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state.setdefault("characters", {})["snapshots"] = dict(payload or {}) + + def _sync_character_runtime_cards( + self, + session: dict[str, Any], + scene_progress: dict[str, Any] | None, + *, + updated_at: str, + ) -> None: + state = self._ensure_session_state(session) + snapshots = dict(state.get("characters", {}).get("snapshots", {}) or {}) + progress = dict(scene_progress or {}) + participants = [str(item).strip() for item in list(session.get("participants", []) or []) if str(item).strip()] + present = { + str(item).strip() + for item in list(progress.get("present_participants", []) or []) + if str(item).strip() + } + location = str(progress.get("location", "")).strip() + time_hint = str(progress.get("time_hint", "")).strip() + for name in participants: + current = dict(snapshots.get(name, {}) or {}) + current["present_state"] = "onstage" if name in present else "offstage" + if location: + current["scene_location"] = location + if time_hint: + current["time_hint"] = time_hint + current["updated_at"] = updated_at + snapshots[name] = current + state.setdefault("characters", {})["snapshots"] = snapshots + + def _session_event_signals(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("signals", {}) or {}) + + def _set_session_event_signals(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state["signals"] = dict(payload or self._empty_event_signals_state()) + + def _session_memory_summary_state(self, session: dict[str, Any]) -> dict[str, Any]: + state = self._ensure_session_state(session) + return dict(state.get("memory", {}).get("summary", {}) or {}) + + def _set_session_memory_summary_state(self, session: dict[str, Any], payload: dict[str, Any] | None) -> None: + state = self._ensure_session_state(session) + state.setdefault("memory", {})["summary"] = dict(payload or {}) + def list_sessions(self, run_id: str) -> list[dict[str, Any]]: root = self._sessions_root(run_id) items: list[dict[str, Any]] = [] @@ -122,17 +346,12 @@ def create_session( "branch_origin": dict(branch_origin or {}), "history": [], "pending_turn": {}, - "state": { - "scene_progress": {}, - "relation_matrix": self._seed_relation_matrix(run_manifest, selected), - "relation_delta": {}, - "character_snapshots": {}, - "event_signals": self._empty_event_signals_state(), - }, + "state": self._empty_session_state(), "created_at": _utc_now(), "updated_at": _utc_now(), "status": "ready", } + self._set_session_relation_matrix(payload, self._seed_relation_matrix(run_manifest, selected)) if dict(scene_profile or {}): initial_summary = self._build_session_memory_summary(run_id, payload, []) payload["scene_history"] = [ @@ -142,7 +361,7 @@ def create_session( memory_summary=initial_summary, ) ] - payload["state"]["scene_progress"] = self._derive_scene_progress_state(payload, []) + self._set_session_scene_progress(payload, self._derive_scene_progress_state(payload, [])) self._write_json(root / "session.json", payload) if carried_memory_summary: session_store = self._resolve_memory_store(run_id) @@ -194,7 +413,7 @@ def update_scene_card( "ts": _utc_now(), } ) - session.setdefault("state", {})["scene_progress"] = self._derive_scene_progress_state(session, self._serialize_transcript(session)) + self._set_session_scene_progress(session, self._derive_scene_progress_state(session, self._serialize_transcript(session))) transcript = self._serialize_transcript(session) memory_summary = self._build_session_memory_summary(run_id, session, transcript) scene_history = list(session.get("scene_history", []) or []) @@ -218,9 +437,12 @@ def update_scene_progress_state( scene_progress: dict[str, Any] | None = None, ) -> dict[str, Any]: session = self._read_json(self._session_file(run_id, session_id)) - session.setdefault("state", {})["scene_progress"] = self._merge_scene_progress_state( + self._set_session_scene_progress( session, - dict(scene_progress or {}), + self._merge_scene_progress_state( + session, + dict(scene_progress or {}), + ), ) session["updated_at"] = _utc_now() self._write_json(self._session_file(run_id, session_id), session) @@ -329,7 +551,13 @@ def build_suggestion_payload( speaker = str(payload.get("input", {}).get("speaker", "")).strip() participants = list(payload.get("input", {}).get("participants", [])) payload["kind"] = "zaomeng_dialogue_suggestion" - payload["user_persona"] = self._build_user_suggestion_persona(mode, session, payload.get("persona_contexts", [])) + scene_progress = dict(payload.get("scene_progress", {}) or {}) + payload["user_persona"] = self._build_user_suggestion_persona( + mode, + session, + payload.get("persona_contexts", []), + scene_progress=scene_progress, + ) payload["instructions"] = { "mode": mode, "generation_goal": "Draft one short, natural, directly sendable next user line that fits the current scene, relationships, and persona voices.", @@ -341,7 +569,12 @@ def build_suggestion_payload( "expected_output": {"suggestion": "一句可直接发送的话"}, "output_rule": "Keep it short, in-scene, directly sendable, and never explanatory.", } - payload["host_prompt_brief"] = self._host_suggestion_prompt_brief(mode, speaker, participants) + payload["host_prompt_brief"] = self._host_suggestion_prompt_brief( + mode, + speaker, + participants, + scene_progress=scene_progress, + ) payload["updated_at"] = _utc_now() return payload @@ -458,8 +691,8 @@ def _build_turn_payload( persona_map = {item["name"]: item for item in character_index} relation_graph = dict(run_manifest.get("artifact_index", {}).get("relation_graph", {}) or {}) full_history = list(session.get("history", [])) - scene_progress = dict(session.get("state", {}).get("scene_progress", {}) or {}) - character_snapshots = dict(session.get("state", {}).get("character_snapshots", {}) or {}) + scene_progress = self._session_scene_progress(session) + character_snapshots = self._session_character_snapshots(session) active_participants = self._resolve_active_participants(participants, full_history, mode, speaker, scene_progress) scene_card = dict(session.get("scene_card", {}) or {}) transcript = self._serialize_transcript(session) @@ -638,9 +871,11 @@ def _scene_progress_rule(scene_progress: dict[str, Any]) -> str: offstage = [str(item).strip() for item in list(state.get("offstage_participants", []) or []) if str(item).strip()] time_hint = str(state.get("time_hint", "")).strip() location = str(state.get("location", "")).strip() + atmosphere = str(state.get("atmosphere_summary", "")).strip() note = str(state.get("progression_note", "")).strip() shift = bool(state.get("should_offer_scene_shift", False)) reason = str(state.get("scene_shift_reason", "")).strip() + beat_maturity = int(state.get("beat_maturity", 0) or 0) bits = [ "Respect scene continuity: keep who is present, who already left, and what time/location the scene has drifted to internally consistent.", @@ -651,6 +886,8 @@ def _scene_progress_rule(scene_progress: dict[str, Any]) -> str: details.append(f"time={time_hint}") if location: details.append(f"location={location}") + if atmosphere: + details.append(f"atmosphere={atmosphere}") bits.append(f"Current scene state: {', '.join(details)}.") if present: bits.append(f"Characters currently in-scene: {', '.join(present)}.") @@ -666,6 +903,11 @@ def _scene_progress_rule(scene_progress: dict[str, Any]) -> str: ) if note: bits.append(f"Latest progression note: {note}.") + if beat_maturity: + bits.append(f"Current beat maturity is {beat_maturity}/100; let replies feel appropriately early, settled, or ready to turn.") + tension = str(state.get("world_tension_summary", "")).strip() + if tension: + bits.append(f"Current world tension to carry forward: {tension}.") if shift: bits.append( f"This beat is mature enough to hint a next scene or transition if it helps momentum. Reason: {reason or 'the current beat already feels complete'}." @@ -693,8 +935,11 @@ def _build_user_suggestion_persona( mode: str, session: dict[str, Any], persona_contexts: list[dict[str, Any]], + *, + scene_progress: dict[str, Any] | None = None, ) -> dict[str, Any]: scene_card = dict(session.get("scene_card", {}) or {}) + state = dict(scene_progress or {}) if mode == "act": controlled = str(session.get("controlled_character", "")).strip() matched = next( @@ -720,20 +965,39 @@ def _build_user_suggestion_persona( "profile": dict(card), "scene_card": scene_card, } + preferred_moves = [ + "introduce a new action", + "add a small interruption", + "surface a hidden tension", + "shift the emotional temperature", + "make someone notice something important", + ] + offstage = [str(item).strip() for item in list(state.get("offstage_participants", []) or []) if str(item).strip()] + if bool(state.get("should_offer_scene_shift", False)): + preferred_moves.extend( + [ + "turn the scene into its next beat naturally", + "advance time or location without sounding abrupt", + ] + ) + elif offstage: + preferred_moves.append("briefly cut to an offstage thread only if the text explicitly motivates it") return { "mode": "observe", "speaker": "User", "source": "observer_hint", - "must_follow": "Write as a scene observer giving a short in-world nudge that actively moves the scene, rather than speaking as a cast member.", + "must_follow": ( + "Write as a scene observer giving a short in-world nudge that actively moves the scene, " + "rather than speaking as a cast member. Respect the current scene progress, presence state, " + "and whether this beat should continue or naturally turn into the next one." + ), "profile": { "goal": "push_plot_forward", - "preferred_moves": [ - "introduce a new action", - "add a small interruption", - "surface a hidden tension", - "shift the emotional temperature", - "make someone notice something important", - ], + "preferred_moves": preferred_moves, + "scene_shift_reason": str(state.get("scene_shift_reason", "")).strip(), + "time_hint": str(state.get("time_hint", "")).strip(), + "location": str(state.get("location", "")).strip(), + "world_tension_summary": str(state.get("world_tension_summary", "")).strip(), }, "scene_card": scene_card, } @@ -762,11 +1026,30 @@ def _host_prompt_brief(mode: str, speaker: str, participants: list[str]) -> str: return f"The user is observing. Let {', '.join(participants)} continue the scene in character and keep the chosen scene moving." @staticmethod - def _host_suggestion_prompt_brief(mode: str, speaker: str, participants: list[str]) -> str: + def _host_suggestion_prompt_brief( + mode: str, + speaker: str, + participants: list[str], + *, + scene_progress: dict[str, Any] | None = None, + ) -> str: + state = dict(scene_progress or {}) if mode == "act": return f"Help the user speak as {speaker} with one believable next line." if mode == "insert": return f"Help the user speak as {speaker} inside the current scene with one natural next line." + shift_reason = str(state.get("scene_shift_reason", "")).strip() + if bool(state.get("should_offer_scene_shift", False)): + return ( + f"Help the user guide {', '.join(participants)} with one short prompt that naturally turns this scene into its next beat. " + f"Current transition pressure: {shift_reason or 'the current beat already feels complete'}." + ) + tension = str(state.get("world_tension_summary", "")).strip() + if tension: + return ( + f"Help the user guide {', '.join(participants)} with one short prompt that clearly pushes the scene forward. " + f"Carry this tension: {tension}." + ) return f"Help the user guide {', '.join(participants)} with one short prompt that clearly pushes the scene into its next beat." @staticmethod @@ -844,27 +1127,91 @@ def clean_names(values: Any) -> list[str]: "offstage_participants": offstage or [name for name in list(base.get("offstage_participants", []) or []) if name not in present], "time_hint": str(incoming.get("time_hint", "")).strip() or str(base.get("time_hint", "")).strip(), "location": str(incoming.get("location", "")).strip() or str(base.get("location", "")).strip(), + "atmosphere_summary": str(incoming.get("atmosphere_summary", "")).strip() or str(base.get("atmosphere_summary", "")).strip(), "progression_note": str(incoming.get("progression_note", "")).strip() or str(base.get("progression_note", "")).strip(), "should_offer_scene_shift": bool(incoming.get("should_offer_scene_shift", base.get("should_offer_scene_shift", False))), "scene_shift_reason": str(incoming.get("scene_shift_reason", "")).strip() or str(base.get("scene_shift_reason", "")).strip(), "turns_in_current_scene": int(base.get("turns_in_current_scene", 0) or 0), + "beat_maturity": int(incoming.get("beat_maturity", base.get("beat_maturity", 0)) or 0), + "world_tension_summary": str(incoming.get("world_tension_summary", "")).strip() or str(base.get("world_tension_summary", "")).strip(), "updated_at": _utc_now(), } + if merged["should_offer_scene_shift"]: + merged["beat_maturity"] = max(75, int(merged.get("beat_maturity", 0) or 0)) return merged def _derive_scene_progress_state(self, session: dict[str, Any], transcript: list[dict[str, Any]]) -> dict[str, Any]: participants = [str(item).strip() for item in list(session.get("participants", []) or []) if str(item).strip()] scene_card = dict(session.get("scene_card", {}) or {}) - prior = dict(session.get("state", {}).get("scene_progress", {}) or {}) + prior = self._session_scene_progress(session) history = list(session.get("history", []) or []) - latest_time_event = self._latest_event_signal(session, "time_change") - latest_scene_event = self._latest_event_signal(session, "scene_transition") - latest_beat_event = self._latest_event_signal(session, "beat_complete") + presence_state = self._derive_presence_state(session, participants=participants, history=history) + scene_frame = self._derive_scene_frame_state(session, transcript=transcript, scene_card=scene_card, prior=prior) + progression_state = self._derive_progression_state( + session, + transcript=transcript, + scene_card=scene_card, + prior=prior, + presence_state=presence_state, + scene_frame=scene_frame, + ) + progression_bits = [] + if scene_frame.get("location"): + progression_bits.append(f"地点:{scene_frame['location']}") + if scene_frame.get("time_hint"): + progression_bits.append(f"时间:{scene_frame['time_hint']}") + if scene_frame.get("atmosphere_summary"): + progression_bits.append(f"氛围:{scene_frame['atmosphere_summary']}") + if presence_state.get("present_participants"): + progression_bits.append(f"在场:{'、'.join(list(presence_state.get('present_participants', []))[:4])}") + if presence_state.get("offstage_participants"): + progression_bits.append(f"离场:{'、'.join(list(presence_state.get('offstage_participants', []))[:3])}") + progression_bits.append(f"成熟度:{int(progression_state.get('beat_maturity', 0) or 0)}") + progression_note = ";".join(bit for bit in progression_bits if bit) + return { + **presence_state, + **scene_frame, + **progression_state, + "progression_note": progression_note, + "updated_at": _utc_now(), + } + + def _derive_presence_state( + self, + session: dict[str, Any], + *, + participants: list[str], + history: list[dict[str, Any]], + ) -> dict[str, Any]: departed = self._infer_departed_participants(participants, history) + latest_exit = self._latest_event_signal(session, "cast_exit") + latest_enter = self._latest_event_signal(session, "cast_enter") + if latest_exit: + actor = str(latest_exit.get("actor", "")).strip() + if actor in participants: + departed.add(actor) + if latest_enter: + actor = str(latest_enter.get("actor", "")).strip() + if actor in participants: + departed.discard(actor) present = [name for name in participants if name not in departed] if not present and participants: present = participants[:1] - offstage = [name for name in participants if name not in present] + return { + "present_participants": present, + "offstage_participants": [name for name in participants if name not in present], + } + + def _derive_scene_frame_state( + self, + session: dict[str, Any], + *, + transcript: list[dict[str, Any]], + scene_card: dict[str, Any], + prior: dict[str, Any], + ) -> dict[str, Any]: + latest_time_event = self._latest_event_signal(session, "time_change") + latest_scene_event = self._latest_event_signal(session, "scene_transition") time_hint = ( str(latest_time_event.get("time_hint", "")).strip() or self._infer_time_hint(transcript) @@ -876,41 +1223,142 @@ def _derive_scene_progress_state(self, session: dict[str, Any], transcript: list or str(prior.get("location", "")).strip() or str(scene_card.get("location", "")).strip() ) + latest_atmosphere_event = self._latest_event_signal(session, "atmosphere_shift") + atmosphere_summary = ( + self._trim_summary_text(str(latest_atmosphere_event.get("cue", "")).strip(), 80) + or self._infer_atmosphere_summary(transcript) + or self._trim_summary_text(str(prior.get("atmosphere_summary", "")).strip(), 80) + or self._trim_summary_text(str(scene_card.get("atmosphere", "")).strip(), 80) + ) + return { + "time_hint": time_hint, + "location": location, + "atmosphere_summary": atmosphere_summary, + } + + def _derive_progression_state( + self, + session: dict[str, Any], + *, + transcript: list[dict[str, Any]], + scene_card: dict[str, Any], + prior: dict[str, Any], + presence_state: dict[str, Any], + scene_frame: dict[str, Any], + ) -> dict[str, Any]: + latest_beat_event = self._latest_event_signal(session, "beat_complete") turns_in_current_scene = self._count_current_scene_turns(session) + beat_maturity = self._estimate_scene_maturity( + turns_in_current_scene=turns_in_current_scene, + transcript=transcript, + scene_card=scene_card, + presence_state=presence_state, + scene_frame=scene_frame, + latest_beat_event=latest_beat_event, + prior=prior, + ) scene_shift_reason = "" should_offer_scene_shift = False - if scene_card and turns_in_current_scene >= 8: + if scene_card and beat_maturity >= 72: should_offer_scene_shift = True scene_shift_reason = "这一幕已经接了好几拍,可以顺势换到下一幕。" if latest_beat_event: should_offer_scene_shift = True scene_shift_reason = str(latest_beat_event.get("cue", "")).strip() or scene_shift_reason initial_time = str(scene_card.get("time_hint", "")).strip() - if time_hint and initial_time and time_hint != initial_time and turns_in_current_scene >= 5: + time_hint = str(scene_frame.get("time_hint", "")).strip() + if time_hint and initial_time and time_hint != initial_time and beat_maturity >= 55: should_offer_scene_shift = True scene_shift_reason = scene_shift_reason or f"时间已经自然推到{time_hint},适合顺势转下一拍。" - progression_bits = [] - if location: - progression_bits.append(f"地点:{location}") - if time_hint: - progression_bits.append(f"时间:{time_hint}") - if present: - progression_bits.append(f"在场:{'、'.join(present[:4])}") - if offstage: - progression_bits.append(f"离场:{'、'.join(offstage[:3])}") - progression_note = ";".join(progression_bits) return { - "present_participants": present, - "offstage_participants": offstage, - "time_hint": time_hint, - "location": location, - "progression_note": progression_note, "should_offer_scene_shift": should_offer_scene_shift, "scene_shift_reason": scene_shift_reason, "turns_in_current_scene": turns_in_current_scene, - "updated_at": _utc_now(), + "beat_maturity": beat_maturity, + "world_tension_summary": self._derive_world_tension_summary(session, transcript=transcript, scene_frame=scene_frame), } + def _estimate_scene_maturity( + self, + *, + turns_in_current_scene: int, + transcript: list[dict[str, Any]], + scene_card: dict[str, Any], + presence_state: dict[str, Any], + scene_frame: dict[str, Any], + latest_beat_event: dict[str, Any], + prior: dict[str, Any], + ) -> int: + score = min(60, max(0, turns_in_current_scene * 10)) + if latest_beat_event: + score += 25 + if str(scene_frame.get("time_hint", "")).strip() and str(scene_frame.get("time_hint", "")).strip() != str(scene_card.get("time_hint", "")).strip(): + score += 10 + if str(scene_frame.get("location", "")).strip() and str(scene_frame.get("location", "")).strip() != str(scene_card.get("location", "")).strip(): + score += 10 + if list(presence_state.get("offstage_participants", []) or []): + score += 6 + if str(scene_frame.get("atmosphere_summary", "")).strip(): + score += 4 + previous_maturity = int(prior.get("beat_maturity", 0) or 0) + if previous_maturity: + score = max(score, min(100, previous_maturity - 8)) + if len(transcript) >= 6: + score += 6 + return max(0, min(100, score)) + + def _infer_atmosphere_summary(self, transcript: list[dict[str, Any]]) -> str: + recent_messages = [ + str(item.get("message", "")).strip() + for item in list(transcript or [])[-8:] + if str(item.get("message", "")).strip() + ] + if not recent_messages: + return "" + joined = " ".join(recent_messages) + for token in self._ATMOSPHERE_TOKENS: + if token in joined: + return self._trim_summary_text(token, 40) + for message in reversed(recent_messages): + trimmed = self._trim_summary_text(message, 40) + if trimmed: + return trimmed + return "" + + def _derive_world_tension_summary( + self, + session: dict[str, Any], + *, + transcript: list[dict[str, Any]], + scene_frame: dict[str, Any], + ) -> str: + latest_atmosphere_event = self._latest_event_signal(session, "atmosphere_shift") + latest_relation_event = self._latest_event_signal(session, "relationship_shift") + latest_scene_event = self._latest_event_signal(session, "scene_transition", "environment_change", "time_change") + for candidate in (latest_atmosphere_event, latest_relation_event, latest_scene_event): + cue = self._trim_summary_text(str((candidate or {}).get("cue", "")).strip(), 88) + if cue: + return cue + relation_delta = self._session_relation_delta(session) + if relation_delta: + pair_key, delta = next(iter(relation_delta.items())) + metrics: list[str] = [] + for field, label in (("trust", "信任"), ("affection", "好感"), ("hostility", "敌意"), ("ambiguity", "摇摆")): + amount = int(dict(delta or {}).get(field, 0) or 0) + if amount: + metrics.append(f"{label}{amount:+d}") + if metrics: + return self._trim_summary_text(f"{pair_key} 当前仍在变化:{'、'.join(metrics)}", 88) + atmosphere = str(scene_frame.get("atmosphere_summary", "")).strip() + if atmosphere: + return self._trim_summary_text(f"这一拍的气氛是:{atmosphere}", 88) + for item in reversed(list(transcript or [])[-8:]): + role = str(item.get("role", "")).strip() + message = self._trim_summary_text(str(item.get("message", "")).strip(), 88) + if role in {"scene", "director"} and message: + return message + return "" + @staticmethod def _infer_time_hint(transcript: list[dict[str, Any]]) -> str: tokens = ( @@ -1075,13 +1523,12 @@ def _seed_relation_matrix(self, run_manifest: dict[str, Any], participants: list return keys def _merged_relation_matrix(self, session: dict[str, Any], participants: list[str]) -> dict[str, Any]: - state = dict(session.get("state", {}) or {}) base = { str(key).strip(): self._normalize_relation_entry(dict(value or {})) - for key, value in dict(state.get("relation_matrix", {}) or {}).items() + for key, value in self._session_relation_matrix(session).items() if str(key).strip() } - deltas = dict(state.get("relation_delta", {}) or {}) + deltas = self._session_relation_delta(session) selected = [str(item).strip() for item in list(participants or []) if str(item).strip()] for index, left in enumerate(selected): for right in selected[index + 1 :]: @@ -1101,10 +1548,15 @@ def _merged_relation_matrix(self, session: dict[str, Any], participants: list[st step = 0 baseline = int(merged.get(field, self._default_relation_entry()[field]) or self._default_relation_entry()[field]) merged[field] = max(0, min(10, baseline + step)) - for field in ("last_event", "relation_change", "typical_interaction"): + for field in ("last_event", "relation_change", "typical_interaction", "last_actor", "last_target", "updated_at"): value = str(delta_payload.get(field, "")).strip() if value: merged[field] = value + if "momentum" in delta_payload: + try: + merged["momentum"] = int(delta_payload.get("momentum", 0) or 0) + except Exception: + pass evidence_lines = list(merged.get("evidence_lines", []) or []) for item in list(delta_payload.get("evidence_lines", []) or []): text = str(item).strip() @@ -1124,7 +1576,7 @@ def _empty_event_signals_state() -> dict[str, Any]: } def _merge_event_signals_state(self, session: dict[str, Any], incoming: list[dict[str, Any]]) -> dict[str, Any]: - current = dict(session.get("state", {}).get("event_signals", {}) or {}) + current = self._session_event_signals(session) recent = [ dict(item or {}) for item in list(current.get("recent", []) or []) @@ -1214,12 +1666,11 @@ def normalize_event(item: dict[str, Any]) -> dict[str, Any]: "updated_at": _utc_now(), } - @staticmethod - def _latest_event_signal(session: dict[str, Any], *kinds: str) -> dict[str, Any]: + def _latest_event_signal(self, session: dict[str, Any], *kinds: str) -> dict[str, Any]: wanted = {str(item).strip() for item in kinds if str(item).strip()} if not wanted: return {} - recent = list(dict(session.get("state", {}).get("event_signals", {}) or session.get("event_signals", {}) or {}).get("recent", []) or []) + recent = list(self._session_event_signals(session).get("recent", []) or []) for item in reversed(recent): event = dict(item or {}) if str(event.get("kind", "")).strip() in wanted: @@ -1233,7 +1684,7 @@ def _build_session_relation_excerpt( participants: list[str], active_participants: list[str], ) -> str: - deltas = dict(session.get("state", {}).get("relation_delta", {}) or {}) + deltas = self._session_relation_delta(session) if not deltas: return "" merged = self._merged_relation_matrix(session, participants) @@ -1267,13 +1718,17 @@ def _build_session_relation_excerpt( last_event = str(delta.get("last_event", "")).strip() if last_event: line = f"{line}\n- last_event: {self._trim_summary_text(last_event, 120)}" + last_actor = str(delta.get("last_actor", "")).strip() + last_target = str(delta.get("last_target", "")).strip() + if last_actor or last_target: + line = f"{line}\n- drift: {self._trim_summary_text(' -> '.join([item for item in (last_actor, last_target) if item]), 80)}" lines.append(line) if len("\n".join(lines)) >= 1200: break return "\n".join(lines).strip() def _build_session_event_excerpt(self, session: dict[str, Any]) -> list[dict[str, Any]]: - event_signals = dict(session.get("state", {}).get("event_signals", {}) or {}) + event_signals = self._session_event_signals(session) recent = list(event_signals.get("recent", []) or []) normalized: list[dict[str, Any]] = [] for item in recent[-8:]: @@ -1408,9 +1863,13 @@ def _persona_snapshot_payload(snapshot: dict[str, Any], *, detailed: bool) -> di "focus": str(snapshot.get("focus", "")).strip(), "last_target": str(snapshot.get("last_target", "")).strip(), "last_message": str(snapshot.get("last_message", "")).strip(), + "present_state": str(snapshot.get("present_state", "")).strip(), + "scene_location": str(snapshot.get("scene_location", "")).strip(), + "time_hint": str(snapshot.get("time_hint", "")).strip(), } if detailed: fields["last_event"] = str(snapshot.get("last_event", "")).strip() + fields["updated_at"] = str(snapshot.get("updated_at", "")).strip() return {key: value for key, value in fields.items() if value} def _build_relation_excerpt( @@ -1502,7 +1961,7 @@ def _build_turn_memory_context( scene_card: dict[str, Any], scene_progress: dict[str, Any] | None = None, ) -> dict[str, Any]: - state_summary = dict(session.get("state", {}).get("memory_summary", {}) or {}) + state_summary = self._session_memory_summary_state(session) archived_summary = { "summary": self._trim_summary_text(str(state_summary.get("summary", "")).strip(), 360), "key_points": [ @@ -1535,6 +1994,7 @@ def _build_turn_memory_context( ], "should_offer_scene_shift": bool(normalized_progress.get("should_offer_scene_shift", False)), "scene_shift_reason": self._trim_summary_text(str(normalized_progress.get("scene_shift_reason", "")).strip(), 120), + "world_tension_summary": self._trim_summary_text(str(normalized_progress.get("world_tension_summary", "")).strip(), 120), } progress_snapshot = { key: value @@ -1543,7 +2003,7 @@ def _build_turn_memory_context( } character_snapshots = { str(name).strip(): self._persona_snapshot_payload(dict(snapshot or {}), detailed=True) - for name, snapshot in dict(session.get("state", {}).get("character_snapshots", {}) or {}).items() + for name, snapshot in self._session_character_snapshots(session).items() if str(name).strip() and self._persona_snapshot_payload(dict(snapshot or {}), detailed=True) } relation_delta = { @@ -1552,7 +2012,7 @@ def _build_turn_memory_context( for key, value in dict(delta or {}).items() if value not in ("", [], 0, None) } - for pair_key, delta in dict(session.get("state", {}).get("relation_delta", {}) or {}).items() + for pair_key, delta in self._session_relation_delta(session).items() if str(pair_key).strip() } relation_delta = {key: value for key, value in relation_delta.items() if value} @@ -1644,10 +2104,10 @@ def _serialize_session(self, run_id: str, payload: dict[str, Any]) -> dict[str, session["mode_display"] = self._mode_display(str(session.get("mode", "")).strip()) transcript = self._serialize_transcript(session) session["transcript"] = transcript - session["scene_progress"] = dict(session.get("state", {}).get("scene_progress", {}) or {}) - session["relation_delta"] = dict(session.get("state", {}).get("relation_delta", {}) or {}) - session["character_snapshots"] = dict(session.get("state", {}).get("character_snapshots", {}) or {}) - session["event_signals"] = dict(session.get("state", {}).get("event_signals", {}) or {}) + session["scene_progress"] = self._session_scene_progress(session) + session["relation_delta"] = self._session_relation_delta(session) + session["character_snapshots"] = self._session_character_snapshots(session) + session["event_signals"] = self._session_event_signals(session) session["relation_matrix"] = self._merged_relation_matrix(session, list(session.get("participants", []) or [])) session["last_entry_preview"] = self._build_last_entry_preview(session) session["session_card"] = self._build_session_card(session) @@ -1655,6 +2115,7 @@ def _serialize_session(self, run_id: str, payload: dict[str, Any]) -> dict[str, session["branch_origin"] = dict(session.get("branch_origin", {}) or {}) session["pending_turn_summary"] = self._build_pending_turn_summary(session) session["session_memory_summary"] = self._build_session_memory_summary(run_id, session, transcript) + session["runtime_state_overview"] = self._build_runtime_state_overview(session) return session def _serialize_transcript(self, session: dict[str, Any]) -> list[dict[str, Any]]: @@ -1763,12 +2224,175 @@ def _build_pending_turn_summary(self, session: dict[str, Any]) -> dict[str, Any] "response_limit_hint": int(pending.get("response_limit_hint", 0) or 0), } + def _build_runtime_state_overview(self, session: dict[str, Any]) -> dict[str, Any]: + scene_progress = self._session_scene_progress(session) + present = [ + str(item).strip() + for item in list(scene_progress.get("present_participants", []) or []) + if str(item).strip() + ] + offstage = [ + str(item).strip() + for item in list(scene_progress.get("offstage_participants", []) or []) + if str(item).strip() + ] + location = str(scene_progress.get("location", "")).strip() + time_hint = str(scene_progress.get("time_hint", "")).strip() + atmosphere = self._trim_summary_text(str(scene_progress.get("atmosphere_summary", "")).strip(), 80) + beat_maturity = max(0, min(100, int(scene_progress.get("beat_maturity", 0) or 0))) + should_offer_scene_shift = bool(scene_progress.get("should_offer_scene_shift", False)) + shift_reason = self._trim_summary_text(str(scene_progress.get("scene_shift_reason", "")).strip(), 120) + tension = self._trim_summary_text(str(scene_progress.get("world_tension_summary", "")).strip(), 120) + + pills: list[dict[str, Any]] = [] + if location: + pills.append({"text": f"地点 · {location}"}) + if time_hint: + pills.append({"text": f"时间 · {time_hint}"}) + if atmosphere: + pills.append({"text": f"氛围 · {atmosphere}"}) + if beat_maturity > 0: + pills.append({"text": f"推进 {beat_maturity}/100"}) + if should_offer_scene_shift: + pills.append({"text": f"可转场 · {shift_reason or '这一拍已经可以顺势转场'}"}) + + character_rows: list[dict[str, Any]] = [] + for name, snapshot in self._session_character_snapshots(session).items(): + normalized_name = str(name).strip() + if not normalized_name: + continue + current = dict(snapshot or {}) + parts: list[str] = [] + present_state = str(current.get("present_state", "")).strip() + if present_state == "onstage": + parts.append("在场") + elif present_state == "offstage": + parts.append("离场") + for key in ("mood", "interaction_state"): + value = str(current.get(key, "")).strip() + if value: + parts.append(value) + focus = str(current.get("focus", "")).strip() + if focus: + parts.append(f"看向 {focus}") + character_rows.append( + { + "title": normalized_name, + "copy": self._trim_summary_text(" · ".join(parts) or "这一拍还没有额外漂移。", 120), + "rank": 0 if present_state == "onstage" else 1, + } + ) + character_rows.sort(key=lambda item: (int(item.get("rank", 9) or 9), str(item.get("title", "")))) + character_rows = [{"title": item["title"], "copy": item["copy"]} for item in character_rows[:4]] + + relation_rows: list[dict[str, Any]] = [] + for pair_key, delta in self._session_relation_delta(session).items(): + normalized_key = str(pair_key).strip() + if not normalized_key: + continue + payload = dict(delta or {}) + metrics: list[str] = [] + momentum = int(payload.get("momentum", 0) or 0) + for field, label in (("trust", "信任"), ("affection", "好感"), ("hostility", "敌意"), ("ambiguity", "摇摆")): + amount = int(payload.get(field, 0) or 0) + if amount: + metrics.append(f"{label}{amount:+d}") + last_event = self._trim_summary_text(str(payload.get("last_event", "")).strip(), 72) + relation_rows.append( + { + "title": normalized_key.replace("_", " · "), + "copy": self._trim_summary_text( + f"{' / '.join(metrics)}{' · ' if metrics and last_event else ''}{last_event}".strip() or "这组关系本局有变化。", + 120, + ), + "rank": max(momentum, len(metrics)), + } + ) + relation_rows.sort(key=lambda item: (-int(item.get("rank", 0) or 0), str(item.get("title", "")))) + relation_rows = [{"title": item["title"], "copy": item["copy"]} for item in relation_rows[:3]] + + event_rows: list[dict[str, str]] = [] + for event in list(self._session_event_signals(session).get("recent", []) or [])[-4:]: + payload = dict(event or {}) + kind = str(payload.get("kind", "")).strip() + cue = self._trim_summary_text(str(payload.get("cue", "")).strip(), 88) + if not kind or not cue: + continue + actor = str(payload.get("actor", "")).strip() + target = str(payload.get("target", "")).strip() + scope = str(payload.get("scope", "")).strip() + title_bits = [self._event_kind_label(kind)] + if actor: + title_bits.append(actor) + if target: + title_bits.append(target) + event_rows.append( + { + "title": " · ".join(title_bits) if title_bits else (scope or "event"), + "copy": cue, + } + ) + + status_bits: list[str] = [] + pill_texts = [str(item.get("text", "")).strip() for item in pills if str(item.get("text", "")).strip()] + if pill_texts: + status_bits.append(" · ".join(pill_texts[:3])) + if present: + status_bits.append(f"在场:{'、'.join(present[:3])}") + if offstage: + status_bits.append(f"离场:{'、'.join(offstage[:2])}") + if tension: + status_bits.append(f"张力:{self._trim_summary_text(tension, 56)}") + status_line = " | ".join(status_bits) + + next_hint = "" + if should_offer_scene_shift: + next_hint = shift_reason or "这一拍已经可以顺势转场。" + elif tension: + next_hint = self._trim_summary_text(tension, 72) + elif event_rows: + next_hint = self._trim_summary_text(str(event_rows[-1].get("copy", "")).strip(), 72) + + return { + "present": present, + "offstage": offstage, + "location": location, + "time_hint": time_hint, + "atmosphere": atmosphere, + "beat_maturity": beat_maturity, + "should_offer_scene_shift": should_offer_scene_shift, + "scene_shift_reason": shift_reason, + "tension": tension, + "pills": pills, + "character_rows": character_rows, + "relation_rows": relation_rows, + "event_rows": event_rows, + "status_line": status_line, + "next_hint": next_hint, + } + + @staticmethod + def _event_kind_label(kind: str) -> str: + mapping = { + "scene_transition": "转场", + "cast_enter": "入场", + "cast_exit": "离场", + "atmosphere_shift": "气氛变化", + "time_change": "时间推进", + "environment_change": "环境变化", + "beat_complete": "一拍收束", + "relationship_shift": "关系变化", + "micro_action": "细微动作", + } + normalized = str(kind or "").strip() + return mapping.get(normalized, normalized or "事件") + def _build_session_memory_summary(self, run_id: str, session: dict[str, Any], transcript: list[dict[str, Any]]) -> dict[str, str]: mode = str(session.get("mode", "observe")).strip() or "observe" mode_display = self._mode_display(mode) participants = [str(item).strip() for item in session.get("participants", []) if str(item).strip()] history = list(session.get("history", []) or []) - scene_progress = dict(session.get("state", {}).get("scene_progress", {}) or session.get("scene_progress", {}) or {}) + scene_progress = self._session_scene_progress(session) present_participants = [ str(item).strip() for item in list(scene_progress.get("present_participants", []) or []) @@ -1840,7 +2464,10 @@ def _build_session_memory_summary(self, run_id: str, session: dict[str, Any], tr perspective = f"{perspective} 当前时间已经推进到「{time_hint}」。" world = "当前局势里的动作与情绪线会在这里提醒你。" - if progression_note: + world_tension_summary = str(scene_progress.get("world_tension_summary", "")).strip() + if world_tension_summary: + world = self._trim_summary_text(world_tension_summary, 88) + elif progression_note: world = self._trim_summary_text(progression_note, 88) for item in reversed(transcript): role = str(item.get("role", "")).strip() @@ -1883,7 +2510,7 @@ def _build_session_memory_summary(self, run_id: str, session: dict[str, Any], tr semantic_hint = str((hits[0] or {}).get("text", "")).strip() if semantic_hint: relation = f"{relation} · 长期记忆:{self._trim_summary_text(semantic_hint, 68)}" - relation_delta = dict(session.get("state", {}).get("relation_delta", {}) or {}) + relation_delta = self._session_relation_delta(session) if relation_delta: delta_bits: list[str] = [] for pair_key, delta in list(relation_delta.items())[:3]: diff --git a/src/web/review/scene_cards.py b/src/web/review/scene_cards.py index d9b872a..063d0ee 100644 --- a/src/web/review/scene_cards.py +++ b/src/web/review/scene_cards.py @@ -7,6 +7,8 @@ from typing import Any, Callable from uuid import uuid4 +from src.skill_support.scene_recommendations import recommend_scene_cards_base + SCENE_CARD_FIELDS = ( "title", @@ -43,12 +45,6 @@ "forbidden_topics": "不想碰的话头", } -_GROUP_SCENE_TOKENS = ("众人", "席间", "满座", "同席", "众目", "围坐", "宴", "厅", "堂", "多人") -_DUO_SCENE_TOKENS = ("二人", "对坐", "独处", "檐下", "私谈", "夜谈", "回廊", "亭中", "单独") -_INSERT_SCENE_TOKENS = ("来客", "访客", "外客", "误入", "新到", "初来", "借住", "入席", "登门") -_PLOT_PUSH_TOKENS = ("试探", "摊牌", "转折", "打断", "逼问", "推", "揭", "撞破", "失手", "变局") - - def blank_scene_card_fields() -> dict[str, str]: return {field: "" for field in SCENE_CARD_FIELDS} @@ -230,39 +226,7 @@ def recommend_scene_cards( mode: str, participants: list[str] | None = None, ) -> dict[str, Any]: - normalized_mode = str(mode or "observe").strip() or "observe" - participant_list = [str(item).strip() for item in (participants or []) if str(item).strip()] - scored_items: list[dict[str, Any]] = [] - for item in cards: - score, reasons = _score_scene_card( - dict(item.get("fields", {}) or {}), - mode=normalized_mode, - participants=participant_list, - ) - scored_items.append( - { - **item, - "recommendation": { - "score": score, - "reasons": reasons, - }, - } - ) - scored_items.sort( - key=lambda item: ( - int(item.get("recommendation", {}).get("score", 0) or 0), - str(item.get("updated_at", "")), - str(item.get("card_id", "")), - ), - reverse=True, - ) - recommended_card_id = str(scored_items[0].get("card_id", "")).strip() if scored_items else "" - return { - "mode": normalized_mode, - "participants": participant_list, - "recommended_card_id": recommended_card_id, - "items": scored_items, - } + return recommend_scene_cards_base(cards, mode=mode, participants=participants) def _extract_json_object(text: str) -> dict[str, Any] | None: @@ -292,74 +256,6 @@ def _load_card_meta(card_dir: Path) -> dict[str, Any]: return json.loads(meta_path.read_text(encoding="utf-8")) -def _score_scene_card( - fields: dict[str, Any], - *, - mode: str, - participants: list[str], -) -> tuple[int, list[str]]: - normalized = normalize_scene_card_fields(fields) - combined_text = "\n".join(str(normalized.get(field, "")).strip() for field in SCENE_CARD_FIELDS) - participant_count = len(participants) - score = 0 - reasons: list[str] = [] - - if normalized["scene_drive"]: - score += 3 - reasons.append("推进方向明确") - if normalized["opening_situation"]: - score += 2 - reasons.append("开场局面具体") - if normalized["atmosphere"]: - score += 1 - reasons.append("气氛落点清楚") - - if participant_count >= 3: - hit = _count_hits(combined_text, _GROUP_SCENE_TOKENS) - if hit: - score += 3 + min(2, hit - 1) - reasons.append("更像多人同席场") - elif participant_count == 2: - hit = _count_hits(combined_text, _DUO_SCENE_TOKENS) - if hit: - score += 3 + min(1, hit - 1) - reasons.append("更适合双人拉扯") - - if mode == "insert": - hit = _count_hits(combined_text, _INSERT_SCENE_TOKENS) - if hit: - score += 4 + min(1, hit - 1) - reasons.append("适合来客/自我入场") - elif mode == "observe": - hit = _count_hits(combined_text, _PLOT_PUSH_TOKENS) - if hit: - score += 3 + min(2, hit - 1) - reasons.append("更利于旁观推动剧情") - elif mode == "act": - duo_hit = _count_hits(combined_text, _DUO_SCENE_TOKENS) - if duo_hit: - score += 2 - reasons.append("留有角色正面接戏空间") - - if normalized["public_goal"]: - score += 1 - if normalized["hidden_tension"]: - score += 1 - if normalized["expected_rhythm"]: - score += 1 - - if not reasons: - reasons.append("信息比较完整,能直接开场") - return score, reasons[:3] - - -def _count_hits(text: str, tokens: tuple[str, ...]) -> int: - compact = str(text or "").strip() - if not compact: - return 0 - return sum(1 for token in tokens if token and token in compact) - - def _load_scene_card_fields( card_dir: Path, *, diff --git a/src/web/service_facades/dialogue.py b/src/web/service_facades/dialogue.py index 20e232f..0add7d7 100644 --- a/src/web/service_facades/dialogue.py +++ b/src/web/service_facades/dialogue.py @@ -10,6 +10,7 @@ build_dialogue_suggestion_llm_messages, build_dialogue_opening_message, compact_dialogue_suggestion_payload, + continue_dialogue_scene_opening_payload, create_dialogue_session_payload, friendly_dialogue_llm_error, generate_dialogue_suggestion, @@ -109,6 +110,7 @@ def switch_dialogue_scene_card( scene_card_id: str = "", scene_profile: dict[str, str] | None = None, transition_message: str = "", + auto_continue: bool = False, ) -> dict[str, Any]: self._ensure_run_exists(run_id) resolved_scene_profile = dict(scene_profile or {}) @@ -122,12 +124,27 @@ def switch_dialogue_scene_card( **resolved_scene_profile, "scene_card_id": str(card.get("card_id", "")).strip(), } - return self.dialogue.update_scene_card( + switched = self.dialogue.update_scene_card( run_id, session_id, scene_profile=resolved_scene_profile, transition_message=transition_message, ) + if not auto_continue: + return switched + manifest = self._require_manifest(run_id) + return continue_dialogue_scene_opening_payload( + run_id=run_id, + session=switched, + manifest=manifest, + dialogue=self.dialogue, + build_dialogue_opening_message=build_dialogue_opening_message, + load_pending_turn_payload=self._load_pending_turn_payload, + generate_dialogue_responses=self._generate_dialogue_responses, + friendly_dialogue_llm_error=friendly_dialogue_llm_error, + evolve_relations_from_turn=self._evolve_relations_from_turn, + refresh_scene_progress=self._refresh_dialogue_scene_progress, + ) def recommend_dialogue_scene_card(self, run_id: str, *, session_id: str) -> dict[str, Any]: return SceneCardServiceMixin.recommend_dialogue_scene_card(self, run_id, session_id=session_id) @@ -297,7 +314,7 @@ def _generate_dialogue_scene_progress(self, run_id: str, session: dict[str, Any] return {} payload = dict(session or {}) - payload["scene_progress"] = dict(payload.get("scene_progress", {}) or payload.get("state", {}).get("scene_progress", {}) or {}) + payload["scene_progress"] = self.dialogue._session_scene_progress(payload) attempts = ( build_dialogue_scene_progress_messages(payload), [ @@ -356,10 +373,9 @@ def _evolve_relations_from_turn( return session_path = self.dialogue._session_file(run_id, session_id) session = self.dialogue._read_json(session_path) - state = dict(session.get("state", {}) or {}) - relation_delta = dict(state.get("relation_delta", {}) or {}) - character_snapshots = dict(state.get("character_snapshots", {}) or {}) - event_signals = dict(state.get("event_signals", {}) or self.dialogue._empty_event_signals_state()) + relation_delta = self.dialogue._session_relation_delta(session) + character_snapshots = self.dialogue._session_character_snapshots(session) + event_signals = self.dialogue._session_event_signals(session) input_block = dict(pending_payload.get("input", {}) or {}) speaker = str(input_block.get("speaker", "")).strip() participants = [str(item).strip() for item in input_block.get("participants", []) if str(item).strip()] @@ -397,10 +413,18 @@ def _evolve_relations_from_turn( continue current[field] = int(current.get(field, 0) or 0) + int(amount) current["last_event"] = message[:220] + current["last_actor"] = responder + current["last_target"] = target evidence_lines = list(current.get("evidence_lines", []) or []) evidence_lines.append(f"{responder}->{target}: {message}"[:220]) current["evidence_lines"] = evidence_lines[-10:] current["updated_at"] = session.get("updated_at", "") + current["momentum"] = max( + abs(int(current.get("trust", 0) or 0)), + abs(int(current.get("affection", 0) or 0)), + abs(int(current.get("hostility", 0) or 0)), + abs(int(current.get("ambiguity", 0) or 0)), + ) relation_delta[key] = current if any(int(current.get(field, 0) or 0) for field in ("trust", "affection", "hostility", "ambiguity")): detected_events.append( @@ -459,9 +483,9 @@ def _evolve_relations_from_turn( detected_events, ) - session.setdefault("state", {})["relation_delta"] = relation_delta - session.setdefault("state", {})["character_snapshots"] = character_snapshots - session.setdefault("state", {})["event_signals"] = event_signals + self.dialogue._set_session_relation_delta(session, relation_delta) + self.dialogue._set_session_character_snapshots(session, character_snapshots) + self.dialogue._set_session_event_signals(session, event_signals) session["updated_at"] = session.get("updated_at") or "" self.dialogue._write_json(session_path, session) store = self.dialogue._resolve_memory_store(run_id) @@ -494,10 +518,8 @@ def _generate_dialogue_relation_state( return {} payload = dict(session or {}) - payload.setdefault("state", {}) - payload["state"] = dict(payload.get("state", {}) or {}) - payload["state"]["relation_delta"] = dict(relation_delta or {}) - payload["state"]["character_snapshots"] = dict(character_snapshots or {}) + self.dialogue._set_session_relation_delta(payload, relation_delta) + self.dialogue._set_session_character_snapshots(payload, character_snapshots) attempts = ( build_dialogue_relation_state_messages(payload, pending_payload, responses), [ @@ -538,10 +560,15 @@ def _merge_relation_delta(base: dict[str, Any], incoming: dict[str, Any]) -> dic current[field] = int(next_value.get(field, 0) or 0) except Exception: pass - for field in ("last_event", "relation_change", "typical_interaction"): + for field in ("last_event", "relation_change", "typical_interaction", "last_actor", "last_target", "updated_at"): text = str(next_value.get(field, "")).strip() if text: current[field] = text + if "momentum" in next_value: + try: + current["momentum"] = int(next_value.get("momentum", 0) or 0) + except Exception: + pass evidence_lines = [ str(item).strip() for item in list(next_value.get("evidence_lines", []) or []) diff --git a/src/web/service_facades/scene_cards.py b/src/web/service_facades/scene_cards.py index 7b625db..ff676b4 100644 --- a/src/web/service_facades/scene_cards.py +++ b/src/web/service_facades/scene_cards.py @@ -1,9 +1,9 @@ from __future__ import annotations from datetime import UTC, datetime -import re from typing import Any +from src.skill_support.scene_recommendations import build_scene_recommendation_bundle from src.web.artifacts import load_profile_source, render_profile_md from src.web.review import ( build_random_scene_card_messages, @@ -82,230 +82,24 @@ def recommend_dialogue_scene_card(self, run_id: str, *, session_id: str) -> dict mode = str(session.get("mode", "") or session.get("session_card", {}).get("mode", "observe")).strip() or "observe" participants = list(session.get("session_card", {}).get("participants", []) or []) current_scene = dict(session.get("session_card", {}).get("scene_card", {}) or {}) + runtime_overview = dict(session.get("runtime_state_overview", {}) or {}) current_scene_id = str(session.get("session_card", {}).get("scene_card_id", "")).strip() recent_text = "\n".join( str(item.get("message", "")).strip() for item in list(session.get("transcript", []) or [])[-6:] if str(item.get("message", "")).strip() ) - payload = recommend_scene_cards(cards, mode=mode, participants=participants) - reranked_items: list[dict[str, Any]] = [] - for item in list(payload.get("items", []) or []): - recommendation = dict(item.get("recommendation", {}) or {}) - score = int(recommendation.get("score", 0) or 0) - reasons = [str(reason).strip() for reason in list(recommendation.get("reasons", []) or []) if str(reason).strip()] - item_card_id = str(item.get("card_id", "")).strip() - fields = dict(item.get("fields", {}) or {}) - - if current_scene_id and item_card_id == current_scene_id: - score -= 5 - reasons.insert(0, "当前已经在这幕里,优先换一拍") - else: - current_location = str(current_scene.get("location", "")).strip() - candidate_location = str(fields.get("location", "")).strip() - if current_location and candidate_location and candidate_location != current_location: - score += 1 - reasons.append("地点切换更明显,适合转场") - - overlap = _scene_text_overlap_score(fields, recent_text) - if overlap: - score += overlap - reasons.append("和最近这几句的气口更接") - - reranked_items.append( - { - **item, - "recommendation": { - "score": score, - "reasons": reasons[:4] or ["适合承接当前会话"], - }, - } - ) - - reranked_items.sort( - key=lambda item: ( - int(item.get("recommendation", {}).get("score", 0) or 0), - str(item.get("updated_at", "")), - str(item.get("card_id", "")), - ), - reverse=True, - ) - recommended_card_id = str(reranked_items[0].get("card_id", "")).strip() if reranked_items else "" - top_fields = dict(reranked_items[0].get("fields", {}) or {}) if reranked_items else {} - chain_suggestions = _build_scene_chain_suggestions( - current_scene=current_scene, - current_scene_id=current_scene_id, - reranked_items=reranked_items, - recent_text=recent_text, - ) - return { - "mode": mode, - "participants": participants, - "current_scene_card_id": current_scene_id, - "recommended_card_id": recommended_card_id, - "recommended_transition_message": _build_transition_message_hint( - current_scene=current_scene, - next_scene=top_fields, - recent_text=recent_text, - ), - "chain_suggestions": chain_suggestions, - "items": reranked_items, - } - - -def _scene_text_overlap_score(fields: dict[str, Any], recent_text: str) -> int: - compact_recent = str(recent_text or "").strip() - if not compact_recent: - return 0 - phrases: list[str] = [] - for key in ("location", "atmosphere", "opening_situation", "scene_drive", "public_goal", "hidden_tension"): - raw = str(fields.get(key, "") or "").strip() - if not raw: - continue - for part in re.split(r"[,,。;;、::\s]+", raw): - text = part.strip() - if 2 <= len(text) <= 8 and text not in phrases: - phrases.append(text) - overlap = sum(1 for phrase in phrases[:12] if phrase in compact_recent) - return min(3, overlap) - - -def _build_transition_message_hint( - *, - current_scene: dict[str, Any], - next_scene: dict[str, Any], - recent_text: str, -) -> str: - next_location = str(next_scene.get("location", "")).strip() - next_title = str(next_scene.get("title", "")).strip() - next_opening = str(next_scene.get("opening_situation", "")).strip() - next_atmosphere = str(next_scene.get("atmosphere", "")).strip() - current_location = str(current_scene.get("location", "")).strip() - - if next_opening: - first_sentence = re.split(r"[。!?!?]", next_opening, maxsplit=1)[0].strip() - if first_sentence: - if not re.search(r"[。!?!?]$", first_sentence): - first_sentence = f"{first_sentence}。" - return first_sentence - - if current_location and next_location and current_location != next_location: - anchor = next_title or next_location - return f"局面一转,众人从{current_location}挪到{anchor},气氛也跟着变了。" - - compact_recent = str(recent_text or "").strip() - if compact_recent and next_atmosphere: - return f"刚才那股{compact_recent[-12:]}的余波还没散,场面已经转成了{next_atmosphere}。" - - if next_location and next_atmosphere: - return f"这一拍顺势转到{next_location},场面也慢慢收成了{next_atmosphere}。" - if next_location: - return f"这一拍顺势转到{next_location}。" - if next_title: - return f"这一拍顺势转入「{next_title}」。" - return "" - - -def _build_scene_chain_suggestions( - *, - current_scene: dict[str, Any], - current_scene_id: str, - reranked_items: list[dict[str, Any]], - recent_text: str, -) -> list[dict[str, Any]]: - candidates = [ - item - for item in reranked_items - if str(item.get("card_id", "")).strip() and str(item.get("card_id", "")).strip() != current_scene_id - ][:5] - chains: list[dict[str, Any]] = [] - for first_index, first in enumerate(candidates): - for second_index, second in enumerate(candidates): - if second_index == first_index: - continue - chain_items = [first, second] - chains.append(_build_chain_payload(current_scene=current_scene, items=chain_items, recent_text=recent_text)) - for third_index, third in enumerate(candidates): - if third_index in {first_index, second_index}: - continue - chains.append(_build_chain_payload(current_scene=current_scene, items=[first, second, third], recent_text=recent_text)) - chains.sort(key=lambda item: (int(item.get("score", 0) or 0), len(item.get("scenes", []) or [])), reverse=True) - deduped: list[dict[str, Any]] = [] - seen_keys: set[str] = set() - for chain in chains: - scene_ids = [str(scene.get("card_id", "")).strip() for scene in list(chain.get("scenes", []) or [])] - key = "->".join(scene_ids) - if not key or key in seen_keys: - continue - seen_keys.add(key) - deduped.append(chain) - if len(deduped) >= 3: - break - return deduped - - -def _build_chain_payload( - *, - current_scene: dict[str, Any], - items: list[dict[str, Any]], - recent_text: str, -) -> dict[str, Any]: - scenes: list[dict[str, str]] = [] - previous_scene = dict(current_scene or {}) - total_score = 0 - locations: list[str] = [] - for index, item in enumerate(items): - fields = dict(item.get("fields", {}) or {}) - score = int(dict(item.get("recommendation", {}) or {}).get("score", 0) or 0) - total_score += max(0, score) * max(1, 4 - index) - location = str(fields.get("location", "")).strip() - if location: - locations.append(location) - scenes.append( + bundle = build_scene_recommendation_bundle( { - "card_id": str(item.get("card_id", "")).strip(), - "title": str(item.get("preview", {}).get("title", "") or fields.get("title", "")).strip(), - "location": location, - "atmosphere": str(fields.get("atmosphere", "")).strip(), - "scene_drive": str(fields.get("scene_drive", "")).strip(), - "transition_message": _build_transition_message_hint( - current_scene=previous_scene, - next_scene=fields, - recent_text=recent_text if index == 0 else str(previous_scene.get("scene_drive", "")).strip(), - ), + "mode": mode, + "participants": participants, + "scene_cards": cards, + "current_scene": current_scene, + "current_scene_card_id": current_scene_id, + "runtime_state_overview": runtime_overview, + "recent_text": recent_text, + "controlled_character": str(session.get("controlled_character", "")).strip(), + "self_profile": dict(session.get("self_insert", {}) or {}), } ) - previous_scene = fields - if len(set(locations)) >= 2: - total_score += 4 - if _chain_has_progressive_drive(scenes): - total_score += 3 - return { - "chain_id": " -> ".join(scene.get("card_id", "") for scene in scenes), - "score": total_score, - "reason": _build_chain_reason(scenes), - "scenes": scenes, - } - - -def _chain_has_progressive_drive(scenes: list[dict[str, str]]) -> bool: - drives = [str(scene.get("scene_drive", "")).strip() for scene in scenes if str(scene.get("scene_drive", "")).strip()] - if len(drives) < 2: - return False - strong_tokens = ("试探", "转折", "摊牌", "揭", "逼", "变局", "收紧") - hit_count = sum(1 for drive in drives if any(token in drive for token in strong_tokens)) - return hit_count >= 2 - - -def _build_chain_reason(scenes: list[dict[str, str]]) -> str: - if not scenes: - return "这条线能顺着往下接。" - locations = [scene.get("location", "") for scene in scenes if scene.get("location", "")] - if len(scenes) >= 3 and len(set(locations)) >= 2: - return "先换场再收紧,后面还有继续推进的余地。" - if len(scenes) >= 2 and len(set(locations)) >= 2: - return "地点会连续变化,戏路层次更明显。" - if _chain_has_progressive_drive(scenes): - return "每一幕的推进方向都比较明确,适合顺着往下压。" - first_title = str(scenes[0].get("title", "")).strip() or "这条线" - return f"可以先接「{first_title}」,后面还有顺势承接的下一拍。" + return dict(bundle.get("payload", {}) or {}) diff --git a/src/web/static/fragments/main-shell.html b/src/web/static/fragments/main-shell.html index f526a4a..d1c90fb 100644 --- a/src/web/static/fragments/main-shell.html +++ b/src/web/static/fragments/main-shell.html @@ -26,16 +26,55 @@ 中途转场 切一张场景卡,再补一句转场提示,就能把这一幕顺手推过去。 + +
+

+
场景回顾 @@ -77,6 +116,10 @@
+
diff --git a/src/web/static/js/composer-vue-island.js b/src/web/static/js/composer-vue-island.js index be1ea41..791016e 100644 --- a/src/web/static/js/composer-vue-island.js +++ b/src/web/static/js/composer-vue-island.js @@ -58,6 +58,7 @@ ); const placeholder = computed(() => String(composer.value.placeholder || "")); const quickReplies = computed(() => (Array.isArray(composer.value.quickReplies) ? composer.value.quickReplies : [])); + const quickHint = computed(() => String(composer.value.quickHint || "").trim()); const disabled = computed(() => Boolean(composer.value.disabled)); const suggestHidden = computed(() => Boolean(composer.value.suggestHidden) || mode.value === "observe"); const suggestDisabled = computed(() => Boolean(composer.value.suggestDisabled)); @@ -114,6 +115,7 @@ draftKind, handleEnter, placeholder, + quickHint, quickReplies, quickReply, send, @@ -140,6 +142,18 @@
+
+

顺手往下推:{{ quickHint }}

+ +
+