"""离线维护引擎 — 6 步维护流程

由 reflect() Hook 在 Claude Code 退出时启动独立进程运行。
每步独立 try-except，单步失败不影响其他步骤。

降级矩阵：
- Ollama + chat + embed → 完整维护（Step 1-6）
- Ollama + embed only  → Step 1,2,5(模板),6
- Ollama 不可用        → Step 1,5(模板),6
- 全部失败（DB 不可写）→ 静默退出
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import re
import time

from ..config import Config, load_config
from ..embed import Embedder
from ..resilience import ServiceCooldown
from .db_cog import CogDatabase
from .realtime import RealtimeUpdater
from .rules import (
    looks_project_specific,
    match_edge_rule,
    match_triple_rule,
    sanitize_fact_for_prompt,
    scope_overlap,
)

logger = logging.getLogger(__name__)

# 每个维护步骤的独立超时（秒），防止单步耗尽全局 150s 预算
_STEP_TIMEOUT = 60


class MaintenanceEngine:
    """离线维护引擎 — 6 步维护流程"""

    def __init__(
        self,
        db_cog: CogDatabase,
        config: Config,
        embedder: Embedder | None = None,
    ):
        self.db_cog = db_cog
        self.config = config
        self.embedder = embedder
        self._chat_available: bool | None = None
        self._chat_cooldown = ServiceCooldown(
            "ollama_chat", base_cooldown=30.0, max_cooldown=120.0,
        )
        self._chat_client = None  # lazy httpx.AsyncClient
        self._realtime_active = False  # run() 设置，影响 _step_infer_relations

    async def run(self, collection: str, session_id: str | None = None) -> dict:
        """执行完整维护流程，返回各步骤结果摘要

        如果 realtime marker 存在，跳过已实时化的 L0 步骤（decay/extract_tactics/gc），
        保留 L1/L2 步骤（consolidate/episode/models/brief）和 promote 兜底。
        """
        t0 = time.perf_counter()
        results: dict[str, str | int] = {}

        realtime_active = RealtimeUpdater.check_marker(collection)
        self._realtime_active = realtime_active
        if realtime_active:
            logger.info("realtime marker 存在，跳过 L0 步骤")
            results["realtime_active"] = True

        # 构建步骤列表 — realtime_active 时跳过已实时化的 L0 步骤
        steps: list[tuple[str, ...]] = []
        # session_review 放最前面，产出的 fact 可被后续步骤处理
        steps.append(("session_review", self._step_session_review))
        steps.append(("extract_worldview", self._step_extract_worldview))
        if not realtime_active:
            steps.append(("decay", self._step_decay))
        # RC4 修复：backfill 作为独立步骤，不嵌套在 consolidate 内
        steps.append(("backfill_embeddings", self._step_backfill_embeddings))
        steps.append(("consolidate", self._step_consolidate))
        steps.append(("infer_relations", self._step_infer_relations))
        steps.append(("update_models", self._step_update_models))
        if not realtime_active:
            steps.append(("extract_tactics", self._step_extract_tactics))
        # promote_universal 保留作为兜底（realtime 只做 per-access 检查）
        steps.append(("promote_universal", self._step_promote_universal))
        steps.append(("extract_patterns", self._step_extract_patterns))
        steps.append(("evolve_neighbors", self._step_evolve_neighbors))
        steps.append(("backfill_file_refs", self._step_backfill_file_refs))
        steps.append(("detect_stale", self._step_detect_stale))
        steps.append(("update_centroids", self._step_update_centroids))
        # #260: awakening_briefs 已废弃，awaken 改用 session_record 结构化注入
        # generate_brief 步骤跳过，保留函数供回滚
        # steps.append(("generate_brief", self._step_generate_brief))
        if not realtime_active:
            steps.append(("gc", self._step_gc))

        # RC2 修复：每步独立超时，防止单步（如 extract_worldview）耗尽全局 150s
        for name, step_fn in steps:
            try:
                result = await asyncio.wait_for(
                    step_fn(collection, session_id),
                    timeout=_STEP_TIMEOUT,
                )
                results[name] = result
                logger.info("维护 Step %s 完成: %s", name, result)
            except asyncio.TimeoutError:
                results[name] = f"timeout ({_STEP_TIMEOUT}s)"
                logger.warning("维护 Step %s 超时 (%ds)，跳过", name, _STEP_TIMEOUT)
            except Exception as e:
                results[name] = f"error: {e}"
                logger.warning("维护 Step %s 失败: %s", name, e)

        # 清除 marker（maintenance 完成后）
        RealtimeUpdater.clear_marker(collection)

        elapsed = time.perf_counter() - t0
        results["total_ms"] = round(elapsed * 1000, 1)
        logger.info("维护完成: %.1fs, %s", elapsed, results)
        return results

    # ── Step 0: Session Review（L0 + LLM，#145 Phase C）──

    async def _step_session_review(
        self, collection: str, session_id: str | None,
    ) -> str:
        """Session-level 评价回顾 — 产出 session_record fact

        短会话（<8 事件）跳过。LLM 不可用时 L0 fallback（同 schema）。
        30s 超时保护。
        """
        from .session_review import (
            MIN_EVENTS_FOR_REVIEW,
            build_review_prompt,
            make_session_record_fact,
            parse_review_output,
            select_diverse_events,
            session_review_l0,
        )

        if not session_id:
            return "skipped: no session_id"

        # 获取所有 session raw_events
        events = self.db_cog.get_recent_raw_events(
            session_id=session_id, collection=collection, limit=200,
        )
        if len(events) < MIN_EVENTS_FOR_REVIEW:
            return f"skipped: {len(events)} events < {MIN_EVENTS_FOR_REVIEW}"

        # 按时间正序排列（get_recent_raw_events 是 DESC）
        events = list(reversed(events))

        # 尝试 LLM session review（30s 超时）
        record = None
        if self._chat_available is not False:
            try:
                top_events = select_diverse_events(events)
                prev_record = self.db_cog.get_previous_session_record(
                    collection, exclude_session_id=session_id,
                )
                prompt = build_review_prompt(events, top_events, prev_record)

                raw = await asyncio.wait_for(
                    self._chat(prompt), timeout=30,
                )
                if raw:
                    record = parse_review_output(raw)
                    if record:
                        logger.info("session_review LLM 成功")
            except asyncio.TimeoutError:
                logger.warning("session_review LLM 超时 (30s)，降级到 L0")
            except Exception as e:
                logger.warning("session_review LLM 失败: %s, 降级到 L0", e)

        # L0 fallback
        if record is None:
            record = session_review_l0(events, collection, session_id)
            logger.info("session_review L0 fallback")

        # 存储为 fact
        fact = make_session_record_fact(record, collection, session_id)
        result = self.db_cog.insert_fact(fact)
        if result is None:
            return "error: insert_fact failed"

        source = record.get("session_record", {}).get("_source", "llm")
        return f"ok ({source})"

    # ── Step 0.5: Worldview 提取（#149 — L0+L1，从规则文件提取偏好）──

    async def _step_extract_worldview(
        self, collection: str, session_id: str | None,
    ) -> str:
        """从 ~/.claude/rules/*.md 提取用户偏好 → worldview facts

        独立 try-except，失败不阻断其他步骤。
        Hash-based 增量检测，只提取变更文件。
        """
        from .rules_extractor import extract_worldview

        result = await extract_worldview(
            db_cog=self.db_cog,
            config=self.config,
            collection=collection,
        )
        extracted = result.get("extracted", 0)
        skipped = result.get("skipped", 0)
        deduped = result.get("deduped", 0)
        if reason := result.get("reason"):
            return f"skipped: {reason}"
        if extracted == 0 and deduped == 0:
            return f"no changes ({skipped} files up-to-date)"
        return f"extracted={extracted}, deduped={deduped}, files={result.get('files', [])}"

    # ── Step 1: 置信度衰减（L0 纯 SQL，永远可用）──

    async def _step_decay(self, collection: str, session_id: str | None) -> int:
        """衰减未验证的旧 facts 的置信度"""
        count = self.db_cog.decay_facts(
            collection,
            stale_days=self.config.cognitive_decay_days,
            beta=self.config.decay_beta,
        )
        return count

    # ── Step 2: 合并重复 facts（L1 向量相似度，需要 embedder）──

    # ── Step: Backfill Embeddings（独立步骤，RC4 修复）──

    async def _step_backfill_embeddings(
        self, collection: str, session_id: str | None,
    ) -> str:
        """独立 backfill 步骤 — 不再嵌套在 consolidate 内

        RC4 修复：之前嵌套在 consolidate 内，如果 extract_worldview 超时，
        consolidate 永远执行不到，backfill 就永远不会运行。
        """
        if not self.embedder or not self.embedder.available:
            return "skipped: embedder unavailable"
        filled = await self._backfill_embeddings(collection)
        return f"filled={filled}"

    async def _step_consolidate(self, collection: str, session_id: str | None) -> int:
        """合并向量相似度 >0.95 的 fact 对"""
        if not self.embedder or not self.embedder.available:
            return 0

        pairs = self.db_cog.find_similar_facts(
            collection=collection, threshold=0.95,
        )

        merged = 0
        for fact_a, fact_b, similarity in pairs:
            keep, drop = (
                (fact_a, fact_b)
                if fact_a.get("confidence", 0) >= fact_b.get("confidence", 0)
                else (fact_b, fact_a)
            )
            try:
                self.db_cog.supersede_fact(drop["id"], keep["id"])
                merged += 1
            except Exception as e:
                logger.warning("合并 fact %d→%d 失败: %s", drop["id"], keep["id"], e)

        return merged

    # ── Step 3: Episode 总结（L1 chat 模型，从 session facts 提炼 why）──

    async def _step_infer_relations(self, collection: str, session_id: str | None) -> str | int:
        """因果链推断 — L0 规则（永远可用）+ L2 Episode 总结（需要 chat）

        L0: 时序 + scope 重叠 → 自动建立 fact_edges
        L2: Ollama chat 提炼 session facts 中隐含的 why
        """
        if not session_id:
            return "skipped_no_session"

        session_facts = self.db_cog.get_session_facts(session_id, collection)
        if not session_facts or len(session_facts) < 2:
            return "skipped_few_facts"

        # L0: 规则推断（realtime 已实时化时跳过）
        edges_created = 0
        if not self._realtime_active:
            edges_created = self._infer_edges_l0(session_facts)

        # L2: Episode 总结（需要 chat）
        episode_inserted = 0
        if await self._chat_model_available():
            has_decisions = any(
                f.get("category") in ("decision", "root_cause")
                for f in session_facts
            )
            if not has_decisions and len(session_facts) >= 3:
                prompt = self._build_episode_prompt(session_facts)
                response = await self._chat(prompt)
                if response:
                    new_facts = self._parse_episode_response(
                        response, collection, session_id,
                    )
                    for fact in new_facts:
                        try:
                            if not self.db_cog.fact_exists(
                                fact["assertion"], session_id, collection,
                            ):
                                result = self.db_cog.insert_fact(fact)
                                if result is not None:
                                    episode_inserted += 1
                        except Exception as e:
                            logger.debug("Episode fact 写入失败: %s", e)

        return edges_created + episode_inserted

    def _infer_edges_l0(self, facts: list[dict]) -> int:
        """L0 规则推断 — 从时间有序的 facts 中检测因果模式

        使用 realtime.py 中的共享规则函数 match_edge_rule / match_triple_rule。
        """
        if len(facts) < 2:
            return 0

        edges = 0
        for i in range(1, len(facts)):
            prev = facts[i - 1]
            curr = facts[i]
            prev_id = prev.get("id")
            curr_id = curr.get("id")
            if not prev_id or not curr_id:
                continue

            # 双元组规则匹配
            rule = match_edge_rule(prev, curr)
            if rule:
                relation, confidence = rule
                if self.db_cog.insert_edge(prev_id, curr_id, relation, confidence, "rule"):
                    edges += 1
                continue

            # 三元组规则匹配
            if i >= 2:
                prev2 = facts[i - 2]
                triples = match_triple_rule(prev2, prev, curr)
                if triples:
                    for from_id, to_id, relation, confidence in triples:
                        if self.db_cog.insert_edge(from_id, to_id, relation, confidence, "rule"):
                            edges += 1

        return edges

    def _build_episode_prompt(self, facts: list[dict]) -> str:
        """构造 Episode 总结 prompt"""

        lines = []
        for f in facts[:15]:
            text = sanitize_fact_for_prompt(f.get("assertion", ""))
            if text is None:
                continue
            lines.append(f"- [{f.get('category', 'code')}] {text[:120]}")
        facts_text = "\n".join(lines)
        return (
            "Analyze these facts from a coding session and identify:\n"
            "1. Any decisions made and WHY (e.g., chose X over Y because...)\n"
            "2. Root causes found (e.g., bug was caused by...)\n\n"
            f"Facts:\n{facts_text}\n\n"
            "Output ONLY a JSON array of objects with 'assertion' and 'category' "
            "(either 'decision' or 'root_cause'). "
            "Each assertion should explain WHY, not just what happened. "
            "If no decisions or root causes are found, output [].\n"
            "Example: [{\"assertion\": \"Chose JWT over sessions for cross-origin support\", "
            "\"category\": \"decision\"}]"
        )

    def _parse_episode_response(
        self, response: str, collection: str, session_id: str | None,
    ) -> list[dict]:
        """解析 LLM Episode 总结响应"""
        # 尝试提取 JSON 数组
        try:
            # 查找 JSON 数组（可能被包裹在 markdown code block 中）
            import re
            json_match = re.search(r'\[.*?\]', response, re.DOTALL)
            if not json_match:
                return []
            items = json.loads(json_match.group())
            if not isinstance(items, list):
                return []
        except (json.JSONDecodeError, TypeError):
            return []

        # 限制 LLM 推断结果的 category — 因果推断只产出 decision/root_cause
        # 完整 category 列表见 db_cog.VALID_CATEGORIES（含 constraint/preference/pattern 等）
        valid_categories = {"decision", "root_cause"}
        facts = []
        for item in items[:5]:  # 限制最多 5 条
            if not isinstance(item, dict):
                continue
            assertion = item.get("assertion", "").strip()
            category = item.get("category", "decision")
            if not assertion or len(assertion) < 10:
                continue
            if category not in valid_categories:
                category = "decision"

            facts.append({
                "assertion": assertion[:200],
                "category": category,
                "collection": collection,
                "session_id": session_id,
                "scope_files": "[]",
                "scope_modules": "[]",
                "scope_entities": "[]",
                "confidence": 0.7,
                "source": "maintenance",
                "status": "active",
                "context_json": "{}",
            })

        return facts

    # ── Step 4: 更新心智模型（L1 chat 模型，需要 Ollama）──

    async def _step_update_models(self, collection: str, session_id: str | None) -> int:
        """L0 自动创建 + 更新心智模型

        拆分为三个子步骤: 自动创建 → 更新统计 → L2 chat 增强
        """
        updated = 0

        # L0: scope 累积 ≥5 个 facts 且无 model → 自动创建
        updated += self._auto_create_models(collection)

        # L0: 更新已有 models 的 causal_chains + 统计
        session_facts = (
            self.db_cog.get_session_facts(session_id, collection)
            if session_id else []
        )
        models = self.db_cog.get_active_models(collection)

        for model in models:
            related = [
                f for f in session_facts
                if self._scope_matches(f, model)
            ] if session_facts else []

            updated += self._update_causal_chains(collection, model, related)
            self._refresh_model_stats(collection, model)

        # L0: open_questions + depends_on_models
        self._fill_open_questions(collection, models)
        self._fill_depends_on_models(collection, models)

        # L2: chat 模型结构化更新（JSON 输出）
        if await self._chat_model_available() and session_facts:
            updated += await self._chat_update_models(collection, models, session_facts)

        return updated

    def _auto_create_models(self, collection: str) -> int:
        """L0 自动创建 model — scope 累积 ≥5 个 facts 且无 model"""
        conn = self.db_cog.conn
        created = 0

        try:
            scope_rows = conn.execute("""
                SELECT scope_files, COUNT(*) as cnt
                FROM facts
                WHERE collection=? AND status='active'
                  AND scope_files != '[]' AND scope_files != ''
                GROUP BY scope_files
                HAVING cnt >= 5
                ORDER BY cnt DESC
                LIMIT 20
            """, [collection]).fetchall()

            module_counts, module_files = self._aggregate_modules(scope_rows)

            for scope, count in module_counts.items():
                if count < 5:
                    continue
                if self.db_cog.get_mental_model(scope, collection):
                    continue

                created += self._create_model_for_scope(conn, collection, scope, count, module_files)
        except Exception as e:
            logger.warning("自动创建 models 失败: %s", e)

        return created

    @staticmethod
    def _aggregate_modules(scope_rows: list) -> tuple[dict[str, int], dict[str, list[str]]]:
        """按目录（模块）聚合 scope_files 统计"""
        from pathlib import Path as P
        module_counts: dict[str, int] = {}
        module_files: dict[str, list[str]] = {}

        for row in scope_rows:
            try:
                files = json.loads(row[0])
            except (json.JSONDecodeError, TypeError):
                continue
            for f in files:
                parts = P(f).parts
                if len(parts) >= 3 and parts[0] in ("src",):
                    module = "/".join(parts[:3])
                elif len(parts) >= 2:
                    module = "/".join(parts[:2]) if parts[0] in ("src", "lib", "app") else parts[0]
                else:
                    module = parts[0] if parts else ""
                if module:
                    module_counts[module] = module_counts.get(module, 0) + row[1]
                    module_files.setdefault(module, []).extend(files)

        return module_counts, module_files

    def _create_model_for_scope(
        self, conn, collection: str, scope: str, count: int, module_files: dict,
    ) -> int:
        """为单个 scope 创建 mental model"""
        level = min(5.0, count / 10.0)
        components = list(set(module_files.get(scope, [])))[:10]

        try:
            error_rows = conn.execute("""
                SELECT assertion FROM facts
                WHERE collection=? AND status='active'
                  AND category IN ('error', 'root_cause')
                  AND scope_files LIKE ?
                ORDER BY created_at DESC LIMIT 5
            """, [collection, f"%{scope}%"]).fetchall()
            gaps = [r[0][:80] for r in error_rows]
        except Exception:
            gaps = []

        try:
            fact_ids = conn.execute("""
                SELECT id FROM facts
                WHERE collection=? AND status='active'
                  AND scope_files LIKE ?
                ORDER BY created_at DESC LIMIT 20
            """, [collection, f"%{scope}%"]).fetchall()
            based_on = [r[0] for r in fact_ids]
        except Exception:
            based_on = []

        try:
            self.db_cog.upsert_mental_model(
                collection=collection,
                scope=scope,
                subject=f"Module {scope} ({count} facts)",
                understanding_level=level,
                components=json.dumps(components, ensure_ascii=False),
                known_gaps=json.dumps(gaps, ensure_ascii=False),
                based_on_facts=json.dumps(based_on),
            )
            logger.info("自动创建 model: %s (%d facts)", scope, count)
            return 1
        except Exception as e:
            logger.warning("自动创建 model %s 失败: %s", scope, e)
            return 0

    def _update_causal_chains(self, collection: str, model: dict, related: list[dict]) -> int:
        """从 fact_edges 聚合 causal_chains 到 model"""
        if not related:
            return 0

        chains = []
        for f in related:
            fid = f.get("id")
            if not fid:
                continue
            edges = self.db_cog.get_fact_edges(fid)
            for e in edges:
                chains.append(
                    f"{e.get('from_assertion', '')[:60]} "
                    f"--[{e['relation']}]--> "
                    f"{e.get('to_assertion', '')[:60]}"
                )

        if not chains:
            return 0

        unique_chains = list(dict.fromkeys(chains))[:10]
        try:
            self.db_cog.upsert_mental_model(
                collection=collection,
                scope=model["scope"],
                subject=model.get("subject", ""),
                causal_chains=json.dumps(unique_chains, ensure_ascii=False),
            )
            return 1
        except Exception as e:
            logger.warning("更新 model causal_chains %s 失败: %s", model["scope"], e)
            return 0

    def _refresh_model_stats(self, collection: str, model: dict) -> None:
        """刷新 model 的 understanding_level + known_gaps"""
        conn = self.db_cog.conn
        scope = model["scope"]
        try:
            fact_count = conn.execute("""
                SELECT COUNT(*) FROM facts
                WHERE collection=? AND status='active'
                  AND scope_files LIKE ?
            """, [collection, f"%{scope}%"]).fetchone()[0]

            level = min(5.0, fact_count / 10.0)

            error_rows = conn.execute("""
                SELECT assertion FROM facts
                WHERE collection=? AND status='active'
                  AND category IN ('error', 'root_cause')
                  AND scope_files LIKE ?
                ORDER BY created_at DESC LIMIT 5
            """, [collection, f"%{scope}%"]).fetchall()
            gaps = [r[0][:80] for r in error_rows]

            self.db_cog.upsert_mental_model(
                collection=collection,
                scope=scope,
                subject=model.get("subject", ""),
                understanding_level=level,
                known_gaps=json.dumps(gaps, ensure_ascii=False),
            )
        except Exception as e:
            logger.debug("刷新 model %s 统计失败: %s", scope, e)

    def _fill_open_questions(self, collection: str, models: list[dict]) -> None:
        """L0: 从 facts assertion 中提取含 ? 的句子作为 open_questions"""
        conn = self.db_cog.conn
        for model in models:
            scope = model["scope"]
            try:
                rows = conn.execute("""
                    SELECT DISTINCT assertion FROM facts
                    WHERE collection=? AND status='active'
                      AND scope_files LIKE ?
                      AND assertion LIKE '%?%'
                    ORDER BY created_at DESC LIMIT 5
                """, [collection, f"%{scope}%"]).fetchall()

                questions = [r[0][:100] for r in rows]
                if not questions:
                    continue

                result = self.db_cog.upsert_mental_model(
                    collection=collection,
                    scope=scope,
                    subject=model.get("subject", ""),
                    open_questions=json.dumps(questions, ensure_ascii=False),
                )
                if result is None:
                    logger.warning("填充 open_questions 失败 (DB 不可写): %s", scope)
            except Exception as e:
                logger.debug("填充 open_questions %s 失败: %s", scope, e)

    def _fill_depends_on_models(self, collection: str, models: list[dict]) -> None:
        """L0: 检测 scope 文件交叉 → 自动推断 model 依赖关系"""
        conn = self.db_cog.conn

        # 预加载每个 model 的关联文件集
        model_files: dict[str, set[str]] = {}
        for model in models:
            scope = model["scope"]
            try:
                rows = conn.execute("""
                    SELECT DISTINCT scope_files FROM facts
                    WHERE collection=? AND status='active'
                      AND scope_files LIKE ?
                      AND scope_files != '[]' AND scope_files != ''
                """, [collection, f"%{scope}%"]).fetchall()

                files: set[str] = set()
                for r in rows:
                    try:
                        for f in json.loads(r[0]):
                            files.add(f)
                    except (json.JSONDecodeError, TypeError):
                        pass
                model_files[scope] = files
            except Exception:
                model_files[scope] = set()

        # 检测交叉：如果 model A 的 facts 引用了 model B 的 scope 路径
        for model in models:
            scope = model["scope"]
            deps: list[str] = []
            my_files = model_files.get(scope, set())

            for other in models:
                other_scope = other["scope"]
                if other_scope == scope:
                    continue
                # 检查文件是否引用了其他 model 的 scope
                for f in my_files:
                    if other_scope in f and other_scope != scope:
                        deps.append(other_scope)
                        break

            if not deps:
                continue

            try:
                result = self.db_cog.upsert_mental_model(
                    collection=collection,
                    scope=scope,
                    subject=model.get("subject", ""),
                    depends_on_models=json.dumps(deps[:10], ensure_ascii=False),
                )
                if result is None:
                    logger.warning("填充 depends_on_models 失败 (DB 不可写): %s", scope)
            except Exception as e:
                logger.debug("填充 depends_on_models %s 失败: %s", scope, e)

    async def _chat_update_models(
        self, collection: str, models: list[dict], session_facts: list[dict],
    ) -> int:
        """L2: chat 结构化更新 — 要求 JSON 输出，回填所有字段"""
        updated = 0
        for model in models:
            related = [
                f for f in session_facts
                if self._scope_matches(f, model)
            ]
            if not related:
                continue

            prompt = self._build_model_update_prompt(model, related)
            response = await self._chat(prompt)
            if not response:
                continue

            # 尝试解析 JSON 结构化输出
            parsed = self._parse_model_json(response)
            if parsed:
                try:
                    kwargs: dict = {}
                    if "subject" in parsed:
                        subject = parsed["subject"][:200]
                    else:
                        subject = model.get("subject", "")
                    for key in ("components", "known_gaps", "open_questions"):
                        if key in parsed and isinstance(parsed[key], list):
                            kwargs[key] = json.dumps(parsed[key], ensure_ascii=False)
                    if "understanding_level" in parsed:
                        val = parsed["understanding_level"]
                        if isinstance(val, (int, float)) and 0 <= val <= 5:
                            kwargs["understanding_level"] = float(val)

                    self.db_cog.upsert_mental_model(
                        collection=collection,
                        scope=model["scope"],
                        subject=subject,
                        **kwargs,
                    )
                    updated += 1
                except Exception as e:
                    logger.warning("L2 更新 model %s 失败: %s", model["scope"], e)
            else:
                # 降级：JSON 解析失败，只更新 subject（原行为）
                logger.debug("JSON 解析失败，降级到文本更新: %s", model["scope"])
                text = response.strip()
                if text:
                    try:
                        self.db_cog.upsert_mental_model(
                            collection=collection,
                            scope=model["scope"],
                            subject=text[:200],
                        )
                        updated += 1
                    except Exception as e:
                        logger.warning("更新 model %s 失败: %s", model["scope"], e)
        return updated

    @staticmethod
    def _parse_model_json(response: str) -> dict | None:
        """解析 LLM 的 JSON 结构化输出，失败返回 None"""
        # 尝试提取 JSON object（从第一个 { 到最后一个 }，支持嵌套）
        start = response.find("{")
        end = response.rfind("}")
        if start == -1 or end == -1 or end <= start:
            return None
        try:
            data = json.loads(response[start : end + 1])
            if isinstance(data, dict) and "subject" in data:
                return data
        except (json.JSONDecodeError, TypeError):
            pass
        return None

    # ── Step 5: tactics 自动提取（L0 跨 session 重复检测）──

    async def _step_extract_tactics(self, collection: str, session_id: str | None) -> int:
        """从跨 session 的重复行为模式自动提取 tactics

        规则:
        1. 同一 scope_file 的 root_cause facts 出现 ≥2 次 → 提取为 tactic
        2. 同类 conventional commit (fix: 同一模块) 重复 ≥2 次 → 提取为 tactic
        """
        conn = self.db_cog.conn
        extracted = 0

        # 规则 1: 重复 root_cause 模式（同一 scope 出现 ≥2 个 root_cause facts）
        try:
            rows = conn.execute("""
                SELECT scope_files, COUNT(*) as cnt,
                       GROUP_CONCAT(assertion, ' | ') as assertions,
                       GROUP_CONCAT(context_json, ' | ') as contexts
                FROM facts
                WHERE collection=? AND status='active'
                  AND category='root_cause'
                  AND scope_files != '[]' AND scope_files != ''
                GROUP BY scope_files
                HAVING cnt >= 2
                ORDER BY cnt DESC
                LIMIT 10
            """, [collection]).fetchall()

            for row in rows:
                scope = row[0]
                count = row[1]
                assertions = row[2] or ""

                # 检查是否已有同 scope 的 tactic
                existing = self.db_cog.search_tactics(scope, collection, limit=1)
                if existing:
                    # 更新 times_used
                    self.db_cog.update_tactic_outcome(existing[0]["id"], True)
                    continue

                # 提取 trigger_pattern 和 steps
                first_assertion = assertions.split(" | ")[0][:100]
                trigger = f"Error in {scope}"

                # 从 context_json 提取 tool_sequence
                contexts_str = row[3] or ""
                steps_parts = []
                for ctx_str in contexts_str.split(" | ")[:3]:
                    try:
                        ctx = json.loads(ctx_str)
                        seq = ctx.get("tool_sequence", "")
                        if seq and seq not in steps_parts:
                            steps_parts.append(seq)
                    except (json.JSONDecodeError, TypeError):
                        pass

                steps = " → ".join(steps_parts) if steps_parts else first_assertion

                try:
                    result = self.db_cog.insert_tactic(
                        trigger_pattern=trigger,
                        steps=steps,
                        collection=collection,
                        trigger_keywords=json.dumps(
                            [w for w in scope.replace("/", " ").split() if len(w) > 2][:5]
                        ),
                    )
                    if result is not None:
                        extracted += 1
                        logger.info("自动提取 tactic: %s (出现 %d 次)", trigger, count)
                except Exception as e:
                    logger.warning("提取 tactic 失败: %s", e)

        except Exception as e:
            logger.warning("tactics 提取规则 1 失败: %s", e)

        # 规则 2: 重复 fix commit 模式
        try:
            rows = conn.execute("""
                SELECT context_json, COUNT(*) as cnt,
                       GROUP_CONCAT(assertion, ' | ') as assertions
                FROM facts
                WHERE collection=? AND status='active'
                  AND category='decision'
                  AND assertion LIKE 'Committed: fix%'
                  AND context_json LIKE '%"scope"%'
                GROUP BY json_extract(context_json, '$.scope')
                HAVING cnt >= 2
                ORDER BY cnt DESC
                LIMIT 10
            """, [collection]).fetchall()

            for row in rows:
                try:
                    ctx = json.loads(row[0])
                except (json.JSONDecodeError, TypeError):
                    continue

                scope = ctx.get("scope", "")
                if not scope:
                    continue

                count = row[1]
                trigger = f"Fix needed in {scope}"

                existing = self.db_cog.search_tactics(trigger, collection, limit=1)
                if existing:
                    self.db_cog.update_tactic_outcome(existing[0]["id"], True)
                    continue

                assertions = (row[2] or "").split(" | ")
                steps = " → ".join(a[:60] for a in assertions[:3])

                try:
                    result = self.db_cog.insert_tactic(
                        trigger_pattern=trigger,
                        steps=steps,
                        collection=collection,
                        trigger_keywords=json.dumps([scope]),
                    )
                    if result is not None:
                        extracted += 1
                except Exception as e:
                    logger.warning("提取 fix tactic 失败: %s", e)

        except Exception as e:
            logger.warning("tactics 提取规则 2 失败: %s", e)

        return extracted

    # ── Step 6: 跨项目知识提升（L0 纯规则，永远可用）──

    async def _step_promote_universal(
        self, collection: str, session_id: str | None,
    ) -> int:
        """L0 纯规则：识别并提升 universal scope 的 facts

        提升条件（全部满足）：
        1. confidence >= 0.85 + access_count >= 3
        2. category 为决策/根因/架构/配置/权衡类
        3. scope_files 为空（不含项目特定路径）
        4. assertion 不含项目特定路径
        """
        candidates = self.db_cog.get_universal_candidates(collection)
        promoted = 0

        for fact in candidates:
            # L0: 跳过有项目特定 scope_files 的 facts
            scope_files = fact.get("scope_files", "[]")
            try:
                files = json.loads(scope_files) if isinstance(scope_files, str) else scope_files
            except (json.JSONDecodeError, TypeError):
                files = []
            if files:
                continue

            # L0: 跳过 assertion 中含项目特定路径的 facts
            assertion = fact.get("assertion", "")
            if looks_project_specific(assertion):
                continue

            if self.db_cog.promote_to_universal(fact["id"]):
                promoted += 1
                logger.info(
                    "提升为 universal: [%d] %s",
                    fact["id"], assertion[:80],
                )

        return promoted

    # ── Step 7: 模式提取（L0+ BM25，向量可选增强）──

    async def _step_extract_patterns(self, collection: str, session_id: str | None) -> int:
        """Stage 2 模式提取 — 3+ 相似 facts → pattern fact

        L0+: 用 SQL 聚合同 category+scope 的 fact 集群。
        当同一 scope_files 下同一 category 有 ≥3 个 active facts 时，
        合并为一条 pattern 类型 fact（升级到 L3a 层）。
        """
        conn = self.db_cog.conn
        extracted = 0

        try:
            # 查找聚类候选：同 scope_files + category 出现 ≥3 次
            rows = conn.execute("""
                SELECT scope_files, category, COUNT(*) as cnt,
                       GROUP_CONCAT(id) as fact_ids,
                       GROUP_CONCAT(assertion, ' ||| ') as assertions
                FROM facts
                WHERE collection=? AND status='active'
                  AND category NOT IN ('pattern', 'constraint', 'preference')
                  AND scope_files != '[]' AND scope_files != ''
                GROUP BY scope_files, category
                HAVING cnt >= 3
                ORDER BY cnt DESC
                LIMIT 20
            """, [collection]).fetchall()
        except Exception as e:
            logger.warning("模式提取查询失败: %s", e)
            return 0

        for row in rows:
            scope_files = row[0]
            category = row[1]
            count = row[2]
            fact_ids = [int(x) for x in (row[3] or "").split(",") if x.strip()]
            assertions = (row[4] or "").split(" ||| ")

            # 检查是否已有同 scope 的 pattern
            existing = conn.execute("""
                SELECT id FROM facts
                WHERE collection=? AND category='pattern'
                  AND scope_files=? AND status='active'
                LIMIT 1
            """, [collection, scope_files]).fetchone()
            if existing:
                continue

            # 构造 pattern assertion — 取最高 confidence 的断言为核心
            core = assertions[0][:100] if assertions else "Recurring pattern"
            pattern_assertion = f"[Pattern ×{count}] {core}"

            try:
                result = self.db_cog.insert_fact({
                    "assertion": pattern_assertion,
                    "category": "pattern",
                    "confidence": 0.8,
                    "source": "maintenance",
                    "collection": collection,
                    "scope_files": scope_files,
                    "layer": "L3a",
                    "context_json": json.dumps({
                        "evidence_count": count,
                        "evidence_fact_ids": fact_ids[:10],
                        "source_category": category,
                    }, ensure_ascii=False),
                })
                if result is not None:
                    extracted += 1
                    logger.info(
                        "提取 pattern: %s (来自 %d 个 %s facts)",
                        pattern_assertion[:60], count, category,
                    )
            except Exception as e:
                logger.warning("pattern fact 写入失败: %s", e)

        return extracted

    # ── Step 8: 邻居演化（L0+ BM25 主力，向量可选）──

    async def _step_evolve_neighbors(self, collection: str, session_id: str | None) -> int:
        """批量邻居演化 — 用 BM25 找相关 facts，丰富 context_json 关联

        L0+: BM25 文本匹配是主力，向量相似度是可选增强。
        Ollama 挂了时不跳过此步骤。

        对高价值 facts（confidence ≥ 0.7, access ≥ 2）：
        用其 assertion 做 BM25 搜索，找到关联 facts 后写入 context_json.related_facts。
        """
        # 获取高价值且未演化的 facts（SQL 层过滤，减少无效 BM25 搜索）
        try:
            candidates = self.db_cog.conn.execute("""
                SELECT id, assertion, context_json
                FROM facts
                WHERE collection=? AND status='active'
                  AND confidence >= 0.7
                  AND COALESCE(access_count, 0) >= 2
                  AND (context_json IS NULL
                       OR context_json NOT LIKE '%"related_facts"%')
                ORDER BY access_count DESC
                LIMIT 20
            """, [collection]).fetchall()
        except Exception as e:
            logger.warning("evolve_neighbors 查询失败: %s", e)
            return 0

        evolved = 0
        for row in candidates:
            fact_id = row[0]
            assertion = row[1]
            ctx_str = row[2] or "{}"

            try:
                ctx = json.loads(ctx_str)
            except (json.JSONDecodeError, TypeError):
                ctx = {}

            # BM25 搜索关联 facts
            neighbors = self.db_cog.fts_search_facts(
                query=assertion[:200],
                collection=collection,
                limit=5,
            )
            # 排除自身
            neighbor_ids = [
                n["id"] for n in neighbors
                if n["id"] != fact_id
            ][:3]

            if not neighbor_ids:
                continue

            # 写入 context_json.related_facts
            ctx["related_facts"] = neighbor_ids
            try:
                self.db_cog.update_fact(
                    fact_id,
                    context_json=json.dumps(ctx, ensure_ascii=False),
                )
                evolved += 1
            except Exception as e:
                logger.debug("evolve fact %d 失败: %s", fact_id, e)

        return evolved

    # ── Step 9a: 质心更新（L0 纯规则，#153 Phase 3 Task 7）──

    async def _step_update_centroids(
        self, collection: str, session_id: str | None,
    ) -> str:
        """L0 纯规则：从已有 embedding 计算集合质心向量

        质心 = confidence 加权平均 embedding，L2 归一化。
        仅处理当前 collection，无外部依赖。
        """
        import math

        from ..db import _blob_to_floats

        try:
            rows = self.db_cog.conn.execute(
                "SELECT embedding, confidence FROM facts "
                "WHERE collection=? AND status='active' AND embedding IS NOT NULL",
                [collection],
            ).fetchall()
        except Exception as e:
            logger.warning("查询 collection embedding 失败: %s", e)
            return "error"

        if not rows:
            self.db_cog.delete_centroid(collection)
            return "no_embeddings"

        dim = self.config.effective_embedding_dim

        centroid = [0.0] * dim
        total_weight = 0.0
        valid_count = 0
        for emb_blob, confidence in rows:
            try:
                vec = _blob_to_floats(emb_blob, dim)
            except Exception:
                continue  # 维度不匹配，跳过
            w = max(confidence or 0.5, 0.1)
            for i in range(dim):
                centroid[i] += vec[i] * w
            total_weight += w
            valid_count += 1

        if valid_count == 0 or total_weight == 0:
            self.db_cog.delete_centroid(collection)
            return "no_valid"

        # 加权平均 + L2 归一化
        for i in range(dim):
            centroid[i] /= total_weight
        norm = math.sqrt(sum(c * c for c in centroid))
        if norm > 0:
            centroid = [c / norm for c in centroid]

        self.db_cog.upsert_centroid(collection, centroid, valid_count)
        return f"updated:{valid_count}facts"

    # ── Step 10: 生成苏醒指引（L1 chat / L0 模板）──

    async def _step_generate_brief(self, collection: str, session_id: str | None) -> str:
        """生成下次 awaken 的苏醒指引"""
        session_facts = []
        if session_id:
            session_facts = self.db_cog.get_session_facts(session_id, collection)

        top_facts = self.db_cog.get_top_facts(
            collection=collection, min_confidence=0.5, limit=5,
        )

        # 尝试用 chat 模型生成
        if await self._chat_model_available() and (session_facts or top_facts):
            brief_text = await self._generate_brief_with_chat(
                collection, session_facts, top_facts,
            )
            if brief_text:
                # 限制长度，防止模型输出过长
                if len(brief_text) > 500:
                    brief_text = brief_text[:500]
                brief_json = json.dumps({"summary": brief_text}, ensure_ascii=False)
                priority = self._extract_priority(brief_text)
                self.db_cog.save_brief(session_id, collection, brief_json, priority)
                return "chat"

        # L0 模板降级
        brief_json = self._template_brief(collection, session_facts, top_facts)
        priority = None
        if session_facts:
            priority = f"{len(session_facts)} facts captured last session"
        self.db_cog.save_brief(session_id, collection, brief_json, priority)
        return "template"

    # ── Step 10: File 引用回填（L0 纯正则，永远可用）──

    # 预编译正则：从 assertion 提取文件路径
    _RE_FULL_PATH = re.compile(
        r"(?:/[\w./-]+/)((?:src|tests|bin|lib|pkg|cmd)/[\w./-]+\.\w{1,4})"
    )
    _RE_CHANGED_IN = re.compile(
        r"(?:Changed (?:function|class|method) '[^']+' in |Modified |Created |Deleted )"
        r"(/[\w./-]+\.[\w]{1,4})"
    )
    _RE_SHORT_MODULE = re.compile(
        r"\b([\w-]+\.(?:py|rs|js|ts|jsx|tsx|go|java|rb|c|cpp|h|hpp|toml|yaml|yml|json|md))\b"
    )

    async def _step_backfill_file_refs(
        self, collection: str, session_id: str | None,
    ) -> int:
        """L0 纯规则：从 assertion 文本提取文件路径，回填到 context_json.file

        提取优先级：
        1. 全路径中的相对部分（src/index1/hooks.py）
        2. "Changed/Modified in" 模式的绝对路径 → 取文件名
        3. 短模块名（hooks.py, config.rs）
        """
        missing = self.db_cog.get_facts_missing_file_ref(collection, limit=200)
        if not missing:
            return 0

        filled = 0
        for fact_id, assertion, ctx_str in missing:
            file_ref = self._extract_file_ref(assertion)
            if not file_ref:
                continue

            try:
                ctx = json.loads(ctx_str) if ctx_str else {}
            except (json.JSONDecodeError, TypeError):
                ctx = {}

            ctx["file"] = file_ref
            self.db_cog.update_fact(fact_id, context_json=json.dumps(ctx, ensure_ascii=False))
            filled += 1

        return filled

    def _extract_file_ref(self, assertion: str) -> str | None:
        """从 assertion 文本提取最具体的文件引用"""
        # 优先级 1：全路径中的相对部分（最具体）
        m = self._RE_FULL_PATH.search(assertion)
        if m:
            return m.group(1)

        # 优先级 2："Changed function 'x' in /path/to/file.py" 模式
        m = self._RE_CHANGED_IN.search(assertion)
        if m:
            full_path = m.group(1)
            # 提取从 src/ 或 tests/ 开始的相对路径
            for prefix in ("src/", "tests/", "bin/", "lib/"):
                idx = full_path.find(prefix)
                if idx >= 0:
                    return full_path[idx:]
            # 无匹配前缀 → 取文件名
            return full_path.rsplit("/", 1)[-1]

        # 优先级 3：短模块名（hooks.py）— 只在 assertion 较短时使用，避免误匹配
        if len(assertion) < 300:
            m = self._RE_SHORT_MODULE.search(assertion)
            if m:
                return m.group(1)

        return None

    # ── Step 11: Stale Fact 检测（L0 纯文件系统检查）──

    async def _step_detect_stale(
        self, collection: str, session_id: str | None,
    ) -> int:
        """L0：检测引用了已删除文件的 facts，降低 confidence

        不直接 invalidate（避免误杀），而是 confidence *= 0.5。
        连续多轮 maintenance 后，stale facts 会自然被 GC 清理。
        """
        facts_with_file = self.db_cog.get_facts_with_file_ref(collection)
        if not facts_with_file:
            return 0

        stale = 0
        for fact_id, file_path, confidence in facts_with_file:
            if not file_path or not isinstance(file_path, str):
                continue
            # 只检测有完整路径特征的引用（含 /），短模块名无法判断
            if "/" not in file_path:
                continue
            # 在常见项目根路径下检查
            full_path = None
            if os.path.isabs(file_path):
                full_path = file_path
            else:
                # 尝试从 CWD 解析
                cwd = os.getcwd()
                candidate = os.path.join(cwd, file_path)
                if os.path.exists(candidate):
                    continue  # 文件存在，跳过
                full_path = candidate

            if full_path and os.path.exists(full_path):
                continue  # 文件存在

            # 文件不在磁盘上 → 降低 confidence
            new_conf = round(max(confidence * 0.5, 0.05), 3)
            if new_conf < confidence:
                self.db_cog.update_fact(fact_id, confidence=new_conf)
                stale += 1
                logger.info(
                    "stale file ref detected: fact=%d file=%s conf=%.2f→%.2f",
                    fact_id, file_path, confidence, new_conf,
                )

        return stale

    # ── Step 12: 垃圾回收（L0 纯 SQL，永远可用）──

    async def _step_gc(self, collection: str, session_id: str | None) -> int:
        """清理低价值 facts"""
        count = self.db_cog.garbage_collect(
            collection,
            min_confidence=self.config.cognitive_gc_min_confidence,
            stale_days=self.config.cognitive_gc_days,
        )
        return count

    # ── 辅助方法 ──

    async def _backfill_embeddings(self, collection: str) -> int:
        """回填缺失 embedding 的 facts — 拼接 context_json 关键字段提升语义覆盖"""
        if not self.embedder or not self.embedder.available:
            return 0

        missing = self.db_cog.get_facts_missing_embedding(collection, limit=500)
        if not missing:
            return 0

        texts = []
        for _, assertion, context_json in missing:
            extra = ""
            if context_json:
                try:
                    ctx = json.loads(context_json) if isinstance(context_json, str) else context_json
                    parts = []
                    for k in ("changes", "reasoning", "error_message", "fix"):
                        v = ctx.get(k)
                        if v:
                            parts.append(str(v)[:100])
                    extra = " ".join(parts)
                except (json.JSONDecodeError, TypeError):
                    pass
            texts.append(f"{assertion} {extra}".strip()[:500])

        try:
            embeddings = await self.embedder.embed_batch(texts)
        except Exception as e:
            logger.warning("Backfill embedding 失败: %s", e)
            return 0

        filled = 0
        for (fact_id, _, _), emb in zip(missing, embeddings):
            if emb is not None:
                try:
                    self.db_cog.update_fact_embedding(fact_id, emb)
                    filled += 1
                except Exception as e:
                    logger.debug("Backfill embedding 写入失败 (id=%d): %s", fact_id, e)
        return filled

    async def _get_chat_client(self):
        """获取或创建复用的 httpx client"""
        if self._chat_client is None:
            import httpx
            self._chat_client = httpx.AsyncClient(
                base_url=self.config.ollama_url,
                transport=httpx.AsyncHTTPTransport(),
            )
        return self._chat_client

    async def _chat_model_available(self) -> bool:
        """检测 chat 模型是否可用（ServiceCooldown 保护）"""
        if self._chat_available is not None:
            return self._chat_available

        if not self.embedder:
            self._chat_available = False
            return False

        if self._chat_cooldown.is_cooling:
            self._chat_available = False
            return False

        try:
            client = await self._get_chat_client()
            resp = await client.get("/api/tags", timeout=3.0)
            resp.raise_for_status()
            models = resp.json().get("models", [])
            model_names = [m.get("name", "") for m in models]
            found = any(
                name == self.config.cognition_model
                or name.startswith(f"{self.config.cognition_model}:")
                for name in model_names
            )
            self._chat_available = found
            if found:
                self._chat_cooldown.record_success()
            else:
                logger.info(
                    "Chat 模型 %s 不可用（已有: %s）",
                    self.config.cognition_model,
                    ", ".join(model_names) or "无",
                )
            return found
        except Exception as e:
            logger.debug("Chat 模型检测失败: %s", e)
            self._chat_available = False
            self._chat_cooldown.record_failure()
            return False

    async def _chat(self, prompt: str) -> str | None:
        """调用 Ollama chat 模型（ServiceCooldown 保护）"""
        if self._chat_cooldown.is_cooling:
            return None

        try:
            client = await self._get_chat_client()
            resp = await client.post(
                "/api/generate",
                json={
                    "model": self.config.cognition_model,
                    "prompt": prompt,
                    "stream": False,
                },
                timeout=30.0,
            )
            resp.raise_for_status()
            self._chat_cooldown.record_success()
            return resp.json().get("response", "")
        except Exception as e:
            logger.warning("Chat 模型调用失败: %s", e)
            self._chat_cooldown.record_failure()
            return None

    def _scope_matches(self, fact: dict, model: dict) -> bool:
        """检查 fact 是否与 model 的 scope 相关"""
        scope = model.get("scope", "")
        if not scope:
            return False
        # 检查 scope_files 和 scope_modules
        for field in ("scope_files", "scope_modules"):
            val = fact.get(field, "[]")
            if isinstance(val, str):
                if scope in val:
                    return True
            elif isinstance(val, list):
                for item in val:
                    if scope in str(item):
                        return True
        # 检查 assertion 中是否提到 scope
        return scope in fact.get("assertion", "")

    def _build_model_update_prompt(self, model: dict, facts: list[dict]) -> str:
        """构建心智模型更新 prompt — 要求 JSON 结构化输出"""

        lines = []
        for f in facts[:5]:
            text = sanitize_fact_for_prompt(f.get("assertion", ""))
            if text is None:
                continue
            lines.append(f"- {text[:80]}")
        facts_text = "\n".join(lines)
        current_subject = model.get("subject", "")
        current_components = model.get("components", "[]")
        return (
            f"Update this understanding of '{model['scope']}'.\n"
            f"Current summary: {current_subject}\n"
            f"Current components: {current_components}\n\n"
            f"New facts from this session:\n{facts_text}\n\n"
            f"Reply with a JSON object (no markdown wrapping):\n"
            f'{{"subject": "1-2 sentence summary",'
            f' "components": ["file_or_module", ...],'
            f' "known_gaps": ["what is still unclear", ...],'
            f' "understanding_level": 0.0-5.0}}'
        )

    def _template_brief(
        self,
        collection: str,
        session_facts: list[dict],
        top_facts: list[dict],
    ) -> str:
        """L0 模板降级 — 无需 chat 模型"""
        lines = [f"Project: {collection}"]
        if session_facts:
            lines.append(f"Last session: {len(session_facts)} facts captured")
            for f in session_facts[:3]:
                lines.append(f"  - {f['assertion'][:80]}")
        if top_facts:
            lines.append("Key knowledge:")
            for f in top_facts[:3]:
                lines.append(f"  - {f['assertion'][:80]}")
        return json.dumps({"summary": "\n".join(lines)}, ensure_ascii=False)

    async def _generate_brief_with_chat(
        self,
        collection: str,
        session_facts: list[dict],
        top_facts: list[dict],
    ) -> str | None:
        """用 chat 模型生成苏醒指引"""

        facts_text = ""
        if session_facts:
            facts_text += f"Session facts ({len(session_facts)}):\n"
            for f in session_facts[:5]:
                text = sanitize_fact_for_prompt(f.get("assertion", ""))
                if text is not None:
                    facts_text += f"- {text[:100]}\n"
        if top_facts:
            facts_text += f"\nKey facts:\n"
            for f in top_facts[:3]:
                text = sanitize_fact_for_prompt(f.get("assertion", ""))
                if text is not None:
                    facts_text += f"- {text[:100]}\n"

        prompt = (
            f"Summarize this session for the next Claude:\n"
            f"Project: {collection}\n{facts_text}\n"
            f"Generate a brief awakening context (<100 tokens):"
        )
        return await self._chat(prompt)

    def _extract_priority(self, brief_text: str) -> str | None:
        """从 brief 中提取优先事项"""
        for keyword in ("priority", "important", "critical", "urgent", "fix"):
            if keyword.lower() in brief_text.lower():
                # 返回包含关键词的第一句
                for sentence in brief_text.split("."):
                    if keyword.lower() in sentence.lower():
                        return sentence.strip()[:100]
        return None
