Files
hunjing/backend/app/application/big_cycle.py
T
2026-08-21 16:00:13 +08:00

603 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""大周天:长期演化循环(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