"""大周天:长期演化循环(architecture §3.2;v1-scope §3.3)。 双触发:记忆阈值 + 每轮对话后检查。循环阶段化执行(自我评估 → 演化策略 → 候选生成),候选生成调神识模型产出统一 ``evolution_candidates``,按 ``level`` 半自动应用——人格特质/程序记忆等已成熟下游立即派生,L4 核心变更留人工审核 (见 [大周天演化候选落地设计] ../../docs/domain/core-domains.md)。 单进程 asyncio,多 worker 需任务队列。 """ import asyncio from dataclasses import asdict from datetime import UTC from sqlalchemy.ext.asyncio import AsyncSession from app.ai_provider import ProviderManager, ProviderManagerFactory from app.core.config import settings from app.core.errors import AppError from app.core.ids import new_goal_id from app.core.logging import logger from app.core.time import utc_now from app.db.session import AsyncSessionLocal from app.models.big_cycle import BigCycle from app.models.candidates import MemoryCandidate from app.models.drive import CuriosityQuestion, ExploratorySubgoal from app.models.evolution import EvolutionCandidate from app.models.governance import Goal from app.models.memory import Memory from app.models.world import CharacterWorldEntity from app.modules.big_cycle.extract import parse_evolution_candidates from app.modules.big_cycle.prompts import ( EvaluationSnapshot, build_evolution_extraction_messages, ) from app.modules.drive.extract import parse_curiosities from app.modules.drive.prompts import build_drive_extraction_messages from app.modules.evolution.policies import should_auto_apply from app.modules.extraction.extract import parse_extraction from app.modules.extraction.prompts import build_extraction_messages from app.modules.runtime.events import Event from app.repositories import big_cycle as big_cycle_repo from app.repositories import characters as character_repo from app.repositories import drive as drive_repo from app.repositories import evolution as evolution_repo from app.repositories import memory as memory_repo from app.repositories import runtime as runtime_repo # 新增:活跃 run 检查 from app.repositories import world as world_repo from app.repositories.json_utils import dumps from app.services import evolution as evolution_service from app.services.audit import record_audit_event from app.services.event_store import DbEventStore from app.services.personality import build_personality_summary _running_big_cycle_tasks: set[asyncio.Task[None]] = set() _RECENT_MEMORY_LIMIT = 10 # Token 预算估算(各阶段大致消耗,用于超预算时跳过非关键阶段) _ESTIMATED_EVALUATION_COST = 4000 _ESTIMATED_CANDIDATE_COST = 16000 _ESTIMATED_MEMORY_COST = 8000 _provider_manager: ProviderManager | None = None def _get_provider_manager() -> ProviderManager: global _provider_manager if _provider_manager is None: _provider_manager = ProviderManagerFactory.create() return _provider_manager def _get_event_store() -> DbEventStore: """创建临时 EventStore 实例用于推送大周天事件(自管 session 工厂)。""" return DbEventStore(AsyncSessionLocal) async def _emit_big_cycle_event( event_type: str, character_id: str, cycle_id: str, *, payload: dict | None = None, ) -> None: """推送大周天事件到 EventStore(自管 Session,失败静默)。""" try: store = _get_event_store() await store.append( Event( type=event_type, source="big_cycle", visibility="public", character_id=character_id, payload={"cycle_id": cycle_id, **(payload or {})}, ) ) except Exception: # noqa: BLE001 logger.warning(f"推送大周天事件失败: {event_type}", cycle_id=cycle_id) async def drain_pending_big_cycles() -> None: """等待所有进行中的大周天循环完成(测试断言 / 优雅停机用)。""" if _running_big_cycle_tasks: await asyncio.gather(*_running_big_cycle_tasks, return_exceptions=True) async def maybe_trigger_big_cycle(character_id: str, trigger: str = "session_turn") -> str | None: """检查触发条件,满足则创建 cycle 记录并启动后台循环;返回 cycle_id 或 None。""" if not settings.enable_big_cycle: return None async with AsyncSessionLocal() as session: # 任务中断防护:如果有活跃运行中的 run,跳过大周天避免干扰对话 if await runtime_repo.has_active_runs(session, character_id): logger.debug(f"大周天跳过:{character_id} 有活跃 run") return None memory_count = await character_repo.count_memories(session, character_id) if memory_count < settings.big_cycle_memory_threshold: return None last = await big_cycle_repo.get_last_completed(session, character_id) if last is not None and last.completed_at is not None: last_completed = last.completed_at if last_completed.tzinfo is None: last_completed = last_completed.replace(tzinfo=UTC) if ( utc_now() - last_completed ).total_seconds() < settings.big_cycle_min_interval_seconds: return None cycle = BigCycle( character_id=character_id, trigger=trigger, status="pending", memory_count_at_trigger=memory_count, ) await big_cycle_repo.add_cycle(session, cycle) await session.commit() cycle_id = cycle.id start_big_cycle(cycle_id, character_id) return cycle_id def start_big_cycle(cycle_id: str, character_id: str) -> None: task = asyncio.create_task(_run_big_cycle(cycle_id, character_id)) _running_big_cycle_tasks.add(task) task.add_done_callback(_running_big_cycle_tasks.discard) async def _phase_goal_progress(session: AsyncSession, cycle: BigCycle) -> None: """大周天目标进度更新:根据演化结果增加关联目标进度。 当前为简单版本:每完成一轮大周天,character 的活跃 task/session 目标进度 +0.1。 后续可改为 LLM 评估更精确的进度。 """ from app.repositories import governance as governance_repo active_goals = await governance_repo.list_goals_by_character( session, cycle.character_id, status_filter="active" ) for goal in active_goals: if goal.level in ("task", "session") and goal.progress < 1.0: new_progress = min(1.0, goal.progress + 0.1) await governance_repo.update_goal_progress(session, goal.id, new_progress) if new_progress >= 1.0 and goal.level == "task": goal.status = "completed" from app.services.governance import emit_goal_event await emit_goal_event( goal, "completed", extra_payload={"trigger": "big_cycle"}, ) def _score_autonomy(subgoal) -> float: """按 information_gain / interruption_cost 比值排序。""" gain = getattr(subgoal, "expected_information_gain", None) or 0.0 cost = getattr(subgoal, "interruption_cost", None) or 1.0 return gain / max(cost, 0.1) async def _phase_autonomous_exploration( session: AsyncSession, cycle: BigCycle, ) -> None: """自主探索执行:将最高优先级的已批准次生目标标记为待叩心枢执行。 选择 expected_information_gain / interruption_cost 比值最高的已批准次生目标, 标记为 auto_execute_pending,由叩心枢的下一个 tick 拾取。 每次大周天最多标记 2 个。 """ from app.repositories import drive as drive_repo approved = await drive_repo.list_approved_subgoals( session, cycle.character_id, limit=5, ) if not approved: return sorted_subgoals = sorted(approved, key=_score_autonomy, reverse=True) to_execute = sorted_subgoals[:2] for sub in to_execute: sub.status = "auto_execute_pending" await session.flush() logger.info( f"大周天自主探索标记 cycle={cycle.id} " f"subgoals={len(to_execute)}/{len(approved)}" ) async def _run_big_cycle(cycle_id: str, character_id: str) -> None: """大周天主循环:阶段化执行,超预算时跳过非关键阶段。""" budget_spent = 0 await _emit_big_cycle_event("big_cycle.started", character_id, cycle_id) async with AsyncSessionLocal() as session: try: cycle = await big_cycle_repo.get_cycle(session, cycle_id) if cycle is None: return cycle.status = "running" cycle.started_at = utc_now() await session.commit() # 阶段 1:自我评估(关键阶段,始终执行) snapshot = await _phase_evaluation(session, cycle) budget_spent += _ESTIMATED_EVALUATION_COST await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "evaluation", "status": "completed"}) # 阶段 2:演化策略(轻量,始终执行) await _phase_evolution(session, cycle) # 预算检查:超过 token 预算则跳过候选/记忆/探索阶段 if budget_spent >= settings.big_cycle_token_budget: logger.warning(f"大周天 token 预算耗尽 cycle={cycle_id} spent={budget_spent}") cycle.summary = "token 预算耗尽,跳过后续非关键阶段" await session.commit() await _emit_big_cycle_event( "big_cycle.phase", character_id, cycle_id, payload={"phase": "candidate", "status": "skipped", "reason": "budget_exhausted"}, ) await _emit_big_cycle_event( "big_cycle.phase", character_id, cycle_id, payload={"phase": "memory", "status": "skipped", "reason": "budget_exhausted"}, ) else: # 阶段 3:候选生成 await _phase_candidates(session, cycle, snapshot) budget_spent += _ESTIMATED_CANDIDATE_COST await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "candidate", "status": "completed"}) # 阶段 4:记忆/世界抽取 await _phase_memory(session, cycle, snapshot) budget_spent += _ESTIMATED_MEMORY_COST await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "memory", "status": "completed"}) # 阶段 5:目标进度更新 await _phase_goal_progress(session, cycle) # 阶段 6:自主探索执行(轻量,始终执行) await _phase_autonomous_exploration(session, cycle) cycle.status = "completed" cycle.phase = "done" cycle.completed_at = utc_now() await record_audit_event( session, action="big_cycle.completed", character_id=character_id, actor="system", decision="auto", input_summary=(cycle.summary or "大周天循环完成")[:200], resource_type="big_cycle", resource_id=cycle.id, ) await session.commit() await _emit_big_cycle_event( "big_cycle.completed", character_id, cycle_id, payload={"summary": cycle.summary}, ) except Exception as exc: await _fail_cycle(session, cycle_id, exc) await _emit_big_cycle_event( "big_cycle.failed", character_id, cycle_id, payload={"error": str(exc)[:200]}, ) async def _phase_evaluation(session: AsyncSession, cycle: BigCycle) -> EvaluationSnapshot: """自我评估:汇总记忆/人格/世界态势,写入 cycle 快照供候选生成消费。""" cycle.phase = "evaluation" await session.commit() character = await character_repo.get_character(session, cycle.character_id) if character is None: raise AppError("not_found", "人物不存在。") memory_count = await character_repo.count_memories(session, cycle.character_id) recent, _ = await memory_repo.list_memories( session, cycle.character_id, page=1, page_size=_RECENT_MEMORY_LIMIT ) personality = await build_personality_summary(session, character) _, world_count = await world_repo.list_entities( session, cycle.character_id, page=1, page_size=1 ) snapshot = EvaluationSnapshot( character_id=cycle.character_id, memory_count=memory_count, recent_memories=[(m.title, m.content) for m in recent], personality_summary=personality.text or "", world_entity_count=world_count, conclusion=_build_conclusion(memory_count, world_count, personality.text), evidence_ids=[m.id for m in recent], ) # 人格权重向量:查询最近大周天的累积漂移,注入态势快照供 LLM 感知 from app.repositories import evolution as evolution_repo recent_candidates, _ = await evolution_repo.list_candidates( session, cycle.character_id, status=None, page=1, page_size=100, ) if recent_candidates: recent_drift = sum(c.drift_score or 0.0 for c in recent_candidates) drift_budget_remaining = max(0.0, settings.big_cycle_max_drift - recent_drift) snapshot.drift_budget_remaining = drift_budget_remaining snapshot.cumulative_drift = recent_drift cycle.evaluation_snapshot_json = dumps(asdict(snapshot)) await session.commit() logger.info(f"大周天自我评估 cycle={cycle.id} memory={memory_count} world={world_count}") await _generate_drive(session, cycle, snapshot) return snapshot async def _generate_drive( session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot ) -> None: """内驱力生成(C 子阶段):基于态势生成好奇问题与探索性次生目标。""" system, user = build_drive_extraction_messages(snapshot) raw = await _call_model(system, user, cycle.character_id) drafts = parse_curiosities(raw)[: settings.big_cycle_max_candidates] curiosities = [ CuriosityQuestion( character_id=cycle.character_id, cycle_id=cycle.id, question=draft.question, source=draft.source, related_entities_json=dumps(draft.related_entities), expected_value=draft.expected_value, urgency=draft.urgency, risk_level=draft.risk_level, ) for draft in drafts ] if curiosities: await drive_repo.add_curiosities(session, curiosities) await session.flush() subgoals: list[ExploratorySubgoal] = [] for curiosity, draft in zip(curiosities, drafts, strict=True): for sub in draft.subgoals: subgoals.append( ExploratorySubgoal( character_id=cycle.character_id, cycle_id=cycle.id, curiosity_question_id=curiosity.id, title=sub.title, description=sub.description, expected_information_gain=sub.expected_information_gain, interruption_cost=sub.interruption_cost, risk_level=sub.risk_level, status="candidate", ) ) if subgoals: await drive_repo.add_subgoals(session, subgoals) await session.flush() # 自动通过低风险次生目标(L0-L2)→ 创建 Goal auto_approve_levels = {"L0", "L1", "L2"} auto_count = 0 for sub in subgoals: if sub.risk_level in auto_approve_levels: sub.status = "approved" goal = Goal( id=new_goal_id(), character_id=cycle.character_id, title=sub.title, description=sub.description or "由次生目标自动生成", level="task", type="exploration", status="active", ) session.add(goal) auto_count += 1 await session.flush() if auto_count: logger.info(f"大周天自动通过 {auto_count}/{len(subgoals)} 个低风险次生目标") logger.info( f"大周天内驱力生成 cycle={cycle.id} curiosities={len(curiosities)} subgoals={len(subgoals)}" ) await _emit_big_cycle_event( "big_cycle.drive_generated", cycle.character_id, cycle.id, payload={ "curiosity_count": len(curiosities), "subgoal_count": len(subgoals), "auto_approved_subgoals": auto_count, }, ) async def _phase_evolution(session: AsyncSession, cycle: BigCycle) -> None: """演化策略阶段(轻量):演化策略融合进候选生成 prompt,此处仅标记阶段。""" cycle.phase = "evolution" await session.commit() logger.info(f"大周天演化策略 cycle={cycle.id}") async def _phase_candidates( session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot ) -> None: """候选生成:调神识模型产出演化候选,按 level 半自动应用。""" cycle.phase = "candidate" await session.commit() system, user = build_evolution_extraction_messages(snapshot) raw = await _call_model(system, user, cycle.character_id) drafts = parse_evolution_candidates(raw)[: settings.big_cycle_max_candidates] candidates = [ EvolutionCandidate( character_id=cycle.character_id, cycle_id=cycle.id, type=draft.type, source=draft.source, level=draft.level, scope=draft.scope, title=draft.title, proposal=draft.proposal, delta_json=dumps(draft.delta), evidence_ids_json=dumps(_resolve_evidence(snapshot, draft.evidence)), confidence=draft.confidence, benefit_score=draft.benefit_score, risk_score=draft.risk_score, drift_score=draft.drift_score, auto_apply_allowed=draft.auto_apply_allowed, status="pending", ) for draft in drafts ] await evolution_repo.add_candidates(session, candidates) await session.flush() # 反漂移检测:高漂移候选不自动应用 + 累积漂移跟踪 cumulative_drift = 0.0 drift_blocked = 0 for candidate in candidates: if candidate.drift_score >= settings.big_cycle_drift_threshold: # 高漂移候选强制标记为 pending(人工审核),不自动应用 candidate.auto_apply_allowed = False candidate.status = "pending" candidate.error_message = f"漂移 {candidate.drift_score} >= 阈值 {settings.big_cycle_drift_threshold},需人工审核" drift_blocked += 1 cumulative_drift += candidate.drift_score # 累积漂移超过上限 → 全部候选降为 pending if cumulative_drift >= settings.big_cycle_max_drift: logger.warning(f"累积漂移 {cumulative_drift:.2f} >= 上限 {settings.big_cycle_max_drift},全部候选降为人工审核") for candidate in candidates: if candidate.status != "applied": candidate.auto_apply_allowed = False candidate.status = "pending" candidate.error_message = f"累积漂移 {cumulative_drift:.2f} 超限,暂停自动演化" for candidate in candidates: if should_auto_apply(candidate): target_id = await evolution_service.apply_evolution_candidate(session, candidate) if target_id is not None: candidate.status = "applied" candidate.resulting_target_id = target_id else: candidate.error_message = "该类型派生待后续阶段,已降级为人工审核" applied = sum(1 for c in candidates if c.status == "applied") pending = sum(1 for c in candidates if c.status == "pending") cycle.summary = ( f"大周天生成 {len(candidates)} 个演化候选," f"{applied} 个已自动应用,{pending} 个待审核" f"{f'({drift_blocked} 个因漂移拦截)' if drift_blocked else ''}" f"{f',累积漂移 {cumulative_drift:.2f}' if cumulative_drift > 0 else ''}" ) if drift_blocked: await _emit_big_cycle_event( "big_cycle.drift_blocked", cycle.character_id, cycle.id, payload={ "drift_blocked": drift_blocked, "cumulative_drift": round(cumulative_drift, 2), "threshold": settings.big_cycle_drift_threshold, }, ) await session.commit() logger.info(f"大周天候选生成 cycle={cycle.id} count={len(candidates)} applied={applied}") async def _phase_memory( session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot ) -> None: """记忆/世界抽取(D 子阶段):从态势抽取记忆候选与世界实体候选。""" cycle.phase = "memory" await session.commit() system, user = build_extraction_messages(snapshot) raw = await _call_model(system, user, cycle.character_id) memory_drafts, entity_drafts = parse_extraction(raw) memory_drafts = memory_drafts[: settings.big_cycle_max_candidates] entity_drafts = entity_drafts[: settings.big_cycle_max_candidates] memory_cands = [ MemoryCandidate( character_id=cycle.character_id, cycle_id=cycle.id, type=draft.type, title=draft.title, content=draft.content, confidence=draft.confidence, importance=draft.importance, sensitivity=draft.sensitivity, reason=draft.reason, status="pending", ) for draft in memory_drafts ] entities = [ CharacterWorldEntity( character_id=cycle.character_id, type=draft.type, name=draft.name, attributes_json=dumps(draft.attributes), state_type=draft.state_type, confidence=draft.confidence, status="pending", ) for draft in entity_drafts ] if memory_cands: session.add_all(memory_cands) if entities: session.add_all(entities) await session.flush() # 自动落地高置信度(≥0.7)低敏感度的记忆候选到 memories 表 auto_memories = 0 for cand in memory_cands: if cand.confidence >= 0.7 and cand.sensitivity in ("low", "none"): mem = Memory( character_id=cycle.character_id, type=cand.type, title=cand.title, content=cand.content, source="big_cycle", importance=cand.importance, sensitivity=cand.sensitivity, confidence=cand.confidence, ) session.add(mem) cand.status = "applied" auto_memories += 1 # 自动落地高置信度世界实体候选 auto_entities = 0 for ent in entities: if ent.confidence >= 0.7: ent.status = "active" auto_entities += 1 await session.flush() if auto_memories or auto_entities: logger.info( f"大周天记忆/世界自动落地 cycle={cycle.id} " f"memories={auto_memories} entities={auto_entities}" ) logger.info( f"大周天记忆/世界抽取 cycle={cycle.id} " f"memories={len(memory_cands)} entities={len(entities)}" ) def _resolve_evidence(snapshot: EvaluationSnapshot, indices: list[int]) -> list[str]: return [snapshot.evidence_ids[i] for i in indices if 0 <= i < len(snapshot.evidence_ids)] def _build_conclusion(memory_count: int, world_count: int, personality_text: str | None) -> str: parts = [f"记忆 {memory_count} 条", f"世界实体 {world_count} 个"] parts.append("已具备人格基线" if personality_text else "尚无人格基线") return ";".join(parts) async def _call_model(system: str, user: str, _character_id: str) -> str: manager = _get_provider_manager() messages = manager.to_message_list([system], [("user", user)]) response = await manager.chat(messages, model_hint=settings.big_cycle_model) return response.content async def _fail_cycle(session: AsyncSession, cycle_id: str, exc: Exception) -> None: logger.error(f"大周天循环失败 cycle={cycle_id}: {exc}") try: cycle = await big_cycle_repo.get_cycle(session, cycle_id) if cycle is not None: cycle.status = "failed" cycle.error_code = "big_cycle_failed" cycle.error_message = str(exc)[:500] cycle.completed_at = utc_now() await session.commit() except Exception: # noqa: BLE001 — 失败处理本身不能再抛 pass