#!/usr/bin/env bash
# kb_dream.sh — Agent "做梦"引擎 v2：MapReduce 全量 KB 探索
#
# 核心理念：不是总结，而是探索。在数据宇宙中寻找跨领域关联、反直觉趋势、被忽视的信号。
# 每天凌晨系统空闲时触发，对 KB 全量数据进行两阶段"计算性想象"。
#
# 架构（MapReduce 分离调度）：
#   Phase 1 (Map)   — 每个 source 文件独立发送给 LLM，提取关键信号和异常点
#   Phase 2 (Reduce) — 汇总所有信号 + notes + 状态，进行跨领域关联发现
#
# 调度策略：Map 和 Reduce 可分离执行，利用缓存解耦（V37.4 加固）
#   00:00  bash kb_dream.sh --map-sources  # 预热 Sources（~10-15min，15 源，90min 预算）
#   00:40  bash kb_dream.sh --map-notes    # 预热 Notes（~30min 首日 bootstrap / ~90s 增量，90min 预算）
#   03:00  bash kb_dream.sh               # Reduce cache-only fast path → 跨域关联(~3min) → 推送（60min 预算）
#   V37.4 fix: 03:00 full run 不再重跑 Phase 1a/1b LLM 循环，只从 $MAP_DIR 读缓存
#             (SKIP_MAP_LOOPS=true)。预期 03:00 总耗时 <5min。
#
# 输出：~/.kb/dreams/YYYY-MM-DD.md + WhatsApp+Discord 推送精华洞察
#
# 用法：bash kb_dream.sh              # 正常运行（MapReduce 全量）
#       bash kb_dream.sh --map-only   # 只跑全部 Map（Sources + Notes），预热缓存
#       bash kb_dream.sh --map-sources # 只跑 Sources Map（00:00 cron）
#       bash kb_dream.sh --map-notes   # 只跑 Notes Map（00:25 cron）
#       bash kb_dream.sh --dry-run    # 只展示输入数据统计，不调用 LLM
#       bash kb_dream.sh --fast       # 跳过 Map 阶段，直接采样做梦（旧模式）
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
set -o pipefail
# 注意：不用 set -e，因为 find/wc/grep 在空目录下返回非零会中断脚本

# 加载环境变量（cron 环境极简，OPENCLAW_PHONE/DISCORD_CH_* 等必须从 profile 获取）
# V37.1: 这是 Dream 推送长期失败的根因之一 — cron 中 env 为空导致 notify.sh 用占位号发送
source "$HOME/.bash_profile" 2>/dev/null || source "$HOME/.env_shared" 2>/dev/null || true

# 诊断日志（cron 环境调试）
echo "[$(date '+%Y-%m-%d %H:%M:%S')] dream: START pid=$$ args=$* PATH=$PATH"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] dream: jq=$(which jq 2>/dev/null || echo MISSING) python3=$(which python3 2>/dev/null || echo MISSING) curl=$(which curl 2>/dev/null || echo MISSING)"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] dream: OPENCLAW_PHONE=${OPENCLAW_PHONE:-(unset)} DISCORD_CH_DAILY=${DISCORD_CH_DAILY:-(unset)}"

# 防重叠执行（含残留锁检测：超过 60 分钟视为残留，自动清理）
LOCK="/tmp/kb_dream.lockdir"
if [ -d "$LOCK" ]; then
    lock_age=$(( $(date +%s) - $(stat -f '%m' "$LOCK" 2>/dev/null || stat -c '%Y' "$LOCK" 2>/dev/null || echo 0) ))
    if [ "$lock_age" -gt 3600 ]; then
        echo "[dream] Stale lock detected (${lock_age}s old), removing"
        rmdir "$LOCK" 2>/dev/null || rm -rf "$LOCK"
    else
        echo "[dream] Already running (${lock_age}s), skip"
        exit 0
    fi
fi
mkdir "$LOCK" 2>/dev/null || { echo "[dream] Lock contention, skip"; exit 0; }
trap 'rmdir "$LOCK" 2>/dev/null' EXIT

# 全局超时保护（V37.2 fix: 防止卡住影响后续 job；V37.3 fix: 按模式动态预算）
# Map 预热（--map-sources / --map-notes / --map-only）: 90 min（KB 全量 bootstrap 可能耗时）
# Reduce full run（无参数）: 60 min（读缓存 + 1 次 Reduce LLM call，应 <5 min）
# 最终 DREAM_TIMEOUT_SEC 在参数解析之后才确定（见下方"动态预算"段落）
DREAM_START_EPOCH=$(date +%s)
DREAM_TIMEOUT_SEC=3600  # 默认占位；实际值在 MAP_ONLY 解析后覆盖
check_deadline() {
    local elapsed=$(( $(date +%s) - DREAM_START_EPOCH ))
    if [ "$elapsed" -ge "$DREAM_TIMEOUT_SEC" ]; then
        log "⚠️ 全局超时 (${elapsed}s >= ${DREAM_TIMEOUT_SEC}s)，中止当前阶段"
        return 1
    fi
    return 0
}

# Dream LLM 配置：直接调 Adapter(:5001)，绕过 Proxy
# 资源竞争缓解策略：cron 调度到凌晨 03:00（00:00-06:00 Qwen3 算力专属窗口）+ 短响应自动重试
# Gemini 2.5 Flash 已验证不适合（中文质量差、免费 tier 限速、输出极短）
LLM_URL="http://localhost:5001/v1/chat/completions"
LLM_AUTH=""
LLM_MODEL="any"
DAY="$(TZ=Asia/Hong_Kong date '+%Y-%m-%d')"
TS="$(TZ=Asia/Hong_Kong date '+%Y-%m-%d %H:%M:%S')"
DRY_RUN=false
FAST_MODE=false
MAP_ONLY=false
MAP_SOURCES_ONLY=false
MAP_NOTES_ONLY=false
[ "${1:-}" = "--dry-run" ] && DRY_RUN=true
[ "${1:-}" = "--fast" ] && FAST_MODE=true
[ "${1:-}" = "--map-only" ] && MAP_ONLY=true
[ "${1:-}" = "--map-sources" ] && { MAP_ONLY=true; MAP_SOURCES_ONLY=true; }
[ "${1:-}" = "--map-notes" ] && { MAP_ONLY=true; MAP_NOTES_ONLY=true; }

# V37.3 fix: 按模式动态预算
#   Map 预热：需要处理全量 KB（286+ notes），首日 bootstrap 可能耗时 → 90 min
#   Reduce full run：只读缓存 + 1 次 Reduce LLM → 60 min 足矣
# 环境变量 DREAM_TIMEOUT_SEC_OVERRIDE 可覆盖（用于调试）
if [ -n "${DREAM_TIMEOUT_SEC_OVERRIDE:-}" ]; then
    DREAM_TIMEOUT_SEC="$DREAM_TIMEOUT_SEC_OVERRIDE"
elif [ "$MAP_ONLY" = true ]; then
    DREAM_TIMEOUT_SEC=5400  # 90 minutes
else
    DREAM_TIMEOUT_SEC=3600  # 60 minutes
fi

KB_BASE="${KB_BASE:-$HOME/.kb}"
DREAM_DIR="$KB_BASE/dreams"
DREAM_FILE="$DREAM_DIR/$DAY.md"
STATUS_FILE="$DREAM_DIR/.last_run.json"        # Reduce 阶段状态
MAP_STATUS_FILE="$DREAM_DIR/.last_map.json"    # Map 阶段状态（独立，不被 Reduce 覆盖）
MAP_DONE_FLAG="$DREAM_DIR/.map_cache/${DAY}_MAP_DONE"  # Map 完成标记（Reduce 可感知）
MAP_DIR="$DREAM_DIR/.map_cache"
mkdir -p "$DREAM_DIR" "$MAP_DIR"

log() { echo "[$(TZ=Asia/Hong_Kong date '+%Y-%m-%d %H:%M:%S')] dream: $1" >&2; }
# V37.8.6 血案教训：log() 必须写到 stderr，否则 signals=$(llm_call ...) 会把 log 输出
# 一起捕获进 signals，连带把"LLM raw response: 400 Bad JSON..."等错误日志写入缓存，
# 下次 Reduce 读缓存把错误文本当作外部信号喂给 LLM → 编造出虚假的"Hugging Face 危机"
# 级联幻觉。stderr 保证 log 与命令替换完全隔离。cron 用 `>> logfile 2>&1` 仍全量收集。

# 提前加载 notify.sh（用于失败告警，不仅限于成功推送）
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
_DREAM_NOTIFY_LOADED=false
for _np in "$SCRIPT_DIR/notify.sh" "$HOME/notify.sh"; do
    if [ -f "$_np" ]; then
        source "$_np"
        _DREAM_NOTIFY_LOADED=true
        log "notify.sh loaded from $_np"
        break
    fi
done
$_DREAM_NOTIFY_LOADED || log "WARN: notify.sh not found, push/alerts will be skipped"

# 失败告警：无论哪个阶段失败都通知用户（V37.1 核心修复：静默失败→显式告警）
dream_fail_alert() {
    local reason="$1"
    local msg="⚠️ Agent Dream 失败 ($DAY): $reason — 检查 ~/kb_dream.log"
    log "FAIL ALERT: $reason"
    if $_DREAM_NOTIFY_LOADED; then
        notify "$msg" --topic alerts 2>/dev/null || true
    fi
}

# 前置依赖检查：python3 + jq 是核心依赖，缺失则整个脚本无法工作（V37.2 fix: fail-fast）
for _dep in python3 jq curl; do
    if ! command -v "$_dep" >/dev/null 2>&1; then
        dream_fail_alert "$_dep 未找到，Dream 无法运行"
        exit 1
    fi
done

# LLM 健康检查：在开始 30 分钟的 MapReduce 前确认 Adapter 可达
# V37.1: 避免 LLM 不可用时白白浪费时间（dry-run 不需要 LLM）
if ! $DRY_RUN; then
    llm_health=$(curl -s --max-time 10 "http://localhost:5001/health" 2>/dev/null || echo "")
    if [ -z "$llm_health" ]; then
        dream_fail_alert "Adapter(:5001) 不可达，LLM 服务可能未启动"
        printf '{"time":"%s","status":"llm_unreachable"}\n' "$TS" > "$STATUS_FILE"
        exit 1
    fi
    log "LLM health OK: $(echo "$llm_health" | head -c 100)"
fi

# UTF-8 安全截断函数
utf8_truncate() {
    local max_chars="${1:-20000}"
    python3 -c "
import sys
text = sys.stdin.read()
if len(text) > $max_chars:
    text = text[:$max_chars]
    last_nl = text.rfind('\n')
    if last_nl > int($max_chars * 0.9):
        text = text[:last_nl]
print(text)
"
}

# LLM 调用封装（含智能退避重试和错误诊断）
# V37.1: 用 Python 构造 JSON + 临时文件传递，彻底避免 shell/jq 对大型 KB 数据的编码问题
# 旧方式 `jq --arg p "$prompt"` 在 KB 含特殊字符时会损坏请求体导致 LLM 空响应
# V38: 智能退避 — 429(rate limit)等60s, 524(timeout)等20s, 指数退避最多4次
LLM_CALL_MAX_RETRIES=4
llm_call() {
    local prompt="$1"
    local max_tokens="${2:-1500}"
    local temp="${3:-0.8}"
    local timeout="${4:-120}"
    local system_msg="${5:-}"          # V37.8.3: 可选 system message（拆分元指令与数据）
    local provider_override="${6:-}"   # V37.9.77 enforcement: optional ?provider=X 路由覆盖
    local result=""
    local raw=""
    local attempt=0
    local err_file=$(mktemp)
    local body_file=$(mktemp)
    # V37.9.77 enforcement: 如有 provider_override 则追加 ?provider= 让 adapter 用指定 provider
    # FAIL-OPEN: adapter 不识别 / 缺 API key 时自动 fallback 默认 PROVIDER_NAME (adapter._resolve_primary_provider)
    local effective_url="$LLM_URL"
    if [ -n "$provider_override" ]; then
        effective_url="${LLM_URL}?provider=${provider_override}"
    fi

    while [ $attempt -lt $LLM_CALL_MAX_RETRIES ]; do
        # 用 Python 安全构造 JSON（处理所有 Unicode/转义/控制字符）
        # V37.8.3: 支持 system+user 双消息模式，system role 携带元指令
        # V37.8.6: sanitize 孤立代理码点（U+D800-U+DFFF）+ 文件 errors='replace' 双防线
        #         触发：2026-04-15 03:00 血案 — scraped 内容中的 surrogate 让 json.dump
        #         触发 UnicodeEncodeError → body_file 截断 → adapter.py:386 返回 400 Bad JSON
        #         → 4 次重试全部确定性失败（43s 浪费）→ silent skip → Reduce 仍产出输出
        #         → LLM 把"Bad JSON 400"的残留当成外部平台信号编造 Hugging Face 危机
        _LLM_SYSTEM_MSG="$system_msg" python3 -c "
import json, sys, os
prompt = sys.stdin.read()
system_msg = os.environ.get('_LLM_SYSTEM_MSG', '')

def _sanitize(s):
    # V37.8.6: 剥离孤立 UTF-16 代理（surrogates）防止 json.dump 触发 UnicodeEncodeError
    # U+D800-U+DFFF 不应出现在合法 UTF-8 字符串中，来源于抓取内容的编码错误
    # 用 Unicode 替换字符 U+FFFD (\ufffd) 替代，保持字符边界不破坏 token 对齐
    if not s:
        return s
    return ''.join('\ufffd' if 0xD800 <= ord(c) <= 0xDFFF else c for c in s)

prompt = _sanitize(prompt)
system_msg = _sanitize(system_msg)
if system_msg:
    messages = [
        {'role': 'system', 'content': system_msg},
        {'role': 'user', 'content': prompt}
    ]
else:
    messages = [{'role': 'user', 'content': prompt}]
body = {
    'model': 'any',
    'messages': messages,
    'max_tokens': $max_tokens,
    'temperature': $temp,
    'stream': False
}
# errors='replace' 作为 sanitize 的第二道防线（万一漏网的其他无效字节）
with open('$body_file', 'w', encoding='utf-8', errors='replace') as f:
    json.dump(body, f, ensure_ascii=False)
" <<< "$prompt"

        raw=$(curl -sS --max-time "$timeout" "$effective_url" \
            -H 'Content-Type: application/json' \
            -d @"$body_file" \
            2>"$err_file" || true)

        # 尝试从标准 JSON 提取
        result=$(echo "$raw" | jq -r '.choices[0].message.content // empty' 2>/dev/null || true)

        # 如果标准 JSON 失败，尝试解析 SSE 格式（data: {...}\n\n）
        if [ -z "${result// }" ] && echo "$raw" | grep -q '^data: ' 2>/dev/null; then
            log "  检测到 SSE 响应，解析中..."
            result=$(echo "$raw" | grep '^data: ' | grep -v '\[DONE\]' | sed 's/^data: //' \
                | jq -rs '[.[].choices[0].delta.content // empty] | join("")' 2>/dev/null || true)
        fi

        if [ -n "${result// }" ]; then
            # V37.8.3: 剥离 Qwen3 <think>...</think> 推理块（run_hn_fixed.sh 同款处理）
            # Qwen3 thinking 模式会在 content 中嵌入推理过程，消耗 token 但对 Dream 无用
            result=$(echo "$result" | python3 -c "
import re, sys
out = sys.stdin.read()
out = re.sub(r'<think>.*?</think>', '', out, flags=re.DOTALL).strip()
print(out)
" 2>/dev/null || echo "$result")
            rm -f "$err_file" "$body_file"
            echo "$result"
            return 0
        fi

        # 诊断失败原因（V37.1: 记录原始响应前 500 字符）
        local curl_err=$(cat "$err_file" 2>/dev/null)
        local error_msg=$(echo "$raw" | jq -r '.error.message // .error // empty' 2>/dev/null || true)
        local raw_len=${#raw}
        local body_len=$(wc -c < "$body_file" 2>/dev/null | tr -d ' ')
        [ -n "$curl_err" ] && log "  LLM curl error: $curl_err"
        [ -n "$error_msg" ] && log "  LLM API error: $error_msg"
        if [ -z "$raw" ]; then
            log "  LLM returned completely empty response (0 bytes, request body was ${body_len} bytes)"
        else
            log "  LLM raw response: ${raw_len} bytes, first 500 chars: ${raw:0:500}"
        fi

        attempt=$((attempt + 1))
        if [ $attempt -lt $LLM_CALL_MAX_RETRIES ]; then
            # 智能退避：根据错误类型决定等待时间
            local wait_sec=5
            if echo "$raw" "$error_msg" | grep -qi "429\|rate.limit\|too many" 2>/dev/null; then
                # 429 Rate Limit: 等较长时间让配额恢复
                wait_sec=$((30 + attempt * 30))  # 60s, 90s, 120s
                log "  Rate limited (429), waiting ${wait_sec}s before retry $((attempt+1))/$LLM_CALL_MAX_RETRIES..."
            elif echo "$raw" "$error_msg" "$curl_err" | grep -qi "524\|timeout\|timed.out" 2>/dev/null; then
                # 524 Timeout: 服务端过载，适度等待
                wait_sec=$((10 + attempt * 10))  # 20s, 30s, 40s
                log "  Timeout (524), waiting ${wait_sec}s before retry $((attempt+1))/$LLM_CALL_MAX_RETRIES..."
            else
                # 其他错误: 指数退避
                wait_sec=$((3 * attempt * attempt))  # 3s, 12s, 27s
                log "  Waiting ${wait_sec}s before retry $((attempt+1))/$LLM_CALL_MAX_RETRIES..."
            fi
            sleep "$wait_sec"
        fi
    done
    rm -f "$err_file" "$body_file"
    return 1
}

# ═══════════════════════════════════════════════════════════════════
# 1. 收集 KB 全量文件列表
# ═══════════════════════════════════════════════════════════════════

ALL_SOURCES=""
SRC_COUNT=0
if [ -d "$KB_BASE/sources" ]; then
    ALL_SOURCES=$(find "$KB_BASE/sources" -name "*.md" -size +0c 2>/dev/null | sort || true)
    [ -n "$ALL_SOURCES" ] && SRC_COUNT=$(echo "$ALL_SOURCES" | wc -l | tr -d ' ')
fi

ALL_NOTES=""
NOTE_COUNT=0
if [ -d "$KB_BASE/notes" ]; then
    ALL_NOTES=$(find "$KB_BASE/notes" -name "*.md" -size +0c 2>/dev/null | sort || true)
    [ -n "$ALL_NOTES" ] && NOTE_COUNT=$(echo "$ALL_NOTES" | wc -l | tr -d ' ')
fi

TOTAL_KB_BYTES=0
if [ -d "$KB_BASE/sources" ]; then
    TOTAL_KB_BYTES=$(find "$KB_BASE/sources" "$KB_BASE/notes" -name "*.md" -exec cat {} + 2>/dev/null | wc -c | tr -d ' ')
fi

log "KB 全量: sources=$SRC_COUNT files, notes=$NOTE_COUNT files, total=${TOTAL_KB_BYTES} bytes"

if $DRY_RUN; then
    echo "=== DRY RUN ==="
    echo "Sources: $SRC_COUNT files"
    echo "Notes: $NOTE_COUNT files"
    echo "Total KB size: $TOTAL_KB_BYTES bytes (~$((TOTAL_KB_BYTES / 1024))KB)"
    echo "Dream file: $DREAM_FILE"
    if $MAP_SOURCES_ONLY; then
        echo "Mode: MAP-SOURCES (pre-warm sources cache only)"
    elif $MAP_NOTES_ONLY; then
        echo "Mode: MAP-NOTES (pre-warm notes cache only)"
    elif $MAP_ONLY; then
        echo "Mode: MAP-ONLY (pre-warm all cache, no Reduce)"
    elif $FAST_MODE; then
        echo "Mode: FAST (single-pass)"
    else
        echo "Mode: MAPREDUCE (two-phase)"
    fi
    echo "=== Sources ==="
    echo "$ALL_SOURCES" 2>/dev/null
    echo "=== Notes ==="
    echo "$ALL_NOTES" 2>/dev/null
    exit 0
fi

# 素材太少则跳过
if [ "$SRC_COUNT" -eq 0 ] && [ "$NOTE_COUNT" -eq 0 ]; then
    log "KB 为空，跳过做梦"
    printf '{"time":"%s","status":"skip_no_data","sources":0,"notes":0}\n' "$TS" > "$STATUS_FILE"
    exit 0
fi

# ═══════════════════════════════════════════════════════════════════
# 2. 历史梦境 + 项目状态（两个阶段都需要）
# ═══════════════════════════════════════════════════════════════════

# V37.9.68: 最近 14 天梦境主题 + 主题归一化硬约束（从 V37.4 的 3 天扩展）
# 用户视角原则 #13 兑现：用户反馈"连续几周 Qwen-BIM 重复推送"，根因之一是
# PREV_THEMES 只看 3 天 + 提示留后门"同主题新角度可深挖"。
# V37.9.68 修：扩 14 天 + Python helper 归一化主题关键词 + DEEP 硬规则禁止重复。
PREV_THEMES=""
BANNED_THEMES_BLOCK=""
if [ -d "$DREAM_DIR" ]; then
    # V37.9.68 主要防御：调 kb_dream_helpers 提取 14 天主题（含关键词归一化）
    # FAIL-OPEN: helper 模块缺失或解析失败 → BANNED_THEMES_BLOCK 为空，
    #            DEEP prompt 退回到无 ban-list 模式（不阻塞梦境主流程）
    BANNED_THEMES_BLOCK=$(python3 -c "
import sys, os
sys.path.insert(0, os.path.expanduser('~'))
sys.path.insert(0, '$SCRIPT_DIR')
try:
    from kb_dream_helpers import extract_recent_themes, format_banned_themes_block
    themes = extract_recent_themes('$DREAM_DIR', days=14)
    print(format_banned_themes_block(themes))
except Exception as e:
    sys.stderr.write(f'WARN: kb_dream_helpers V37.9.68 加载失败: {e}\\n')
" 2>/dev/null || echo "")

    # 旧 PREV_THEMES 字符串（保留用于 user message 显示，但不再作为硬约束）
    PREV_FILES=$(ls -t "$DREAM_DIR"/*.md 2>/dev/null | head -14 || true)
    if [ -n "$PREV_FILES" ]; then
        while IFS= read -r pf; do
            [ -z "$pf" ] || [ ! -f "$pf" ] && continue
            pdate=$(basename "$pf" .md)
            themes=$(grep -E '^(##|###|\*\*|[*] )' "$pf" 2>/dev/null | head -3 | head -c 200)
            PREV_THEMES+="[$pdate] $themes
"
        done <<< "$PREV_FILES"
    fi
fi

# 项目状态
STATUS_CONTEXT=""
if [ -f "$KB_BASE/status.json" ]; then
    STATUS_CONTEXT=$(python3 -c "
import json
with open('$KB_BASE/status.json') as f:
    s = json.load(f)
priorities = s.get('priorities', [])
active = [p for p in priorities if p.get('status') == 'active']
print('当前活跃任务:')
for p in active[:5]:
    print(f'- {p.get(\"task\", \"\")}')
focus = s.get('focus', '')
if focus:
    print(f'本周焦点: {focus}')
" 2>/dev/null || echo "(status.json 解析失败)")
fi

# KB 趋势
TREND_CONTEXT=""
TREND_FILE="$KB_BASE/weekly_trend.md"
if [ -f "$TREND_FILE" ]; then
    TREND_CONTEXT=$(tail -40 "$TREND_FILE" 2>/dev/null | head -c 1500)
fi

# ═══════════════════════════════════════════════════════════════════
# 3. Phase 1 (Map)：每个 source 独立提取信号
#    每个文件 → LLM 提取 5-8 个关键信号（事实+日期+异常点）
#    并行度受 LLM 限制，串行处理但每次调用很轻量
# ═══════════════════════════════════════════════════════════════════

MAP_SIGNALS=""
MAP_COUNT=0
NOTES_SIGNALS=""
NOTES_MAP_COUNT=0
MAP_CONSECUTIVE_FAILS=0  # 连续失败计数：超过 3 次停止 Map，保护 fallback 配额

# ─────────────────────────────────────────────────────────────────────
# V37.3 fix A: Reduce 路径的 "Cache-Only Fast Path"
#
# 背景：2026-04-11 的 Dream 失败暴露了一个结构性 bug —— 03:00 full run
# 过去会再跑一遍 Phase 1a/1b Map 循环（即使 00:00 --map-sources 和
# 00:40 --map-notes 已经把缓存写好了），导致重复燃烧 LLM 时间，60 min
# 预算被 Notes Map 循环吃光，Reduce 从未开始。
#
# 修复：当不是 MAP_ONLY 也不是 FAST_MODE 时（即默认 full-run 模式），
# 跳过 Phase 1a/1b 循环，直接从 $MAP_DIR 扫描缓存文件，把内容填进
# MAP_SIGNALS / NOTES_SIGNALS。缓存缺失的部分视为"这次不深挖"而非
# "立刻 LLM 重跑"。
#
# 边缘情况：
#   1. 缓存全空 → MAP_SIGNALS/NOTES_SIGNALS 空 → 降级 Fast mode（line 697）
#   2. 缓存部分存在（如 00:40 Notes 超时时的 207/286）→ 使用部分，继续推送
#   3. --map-sources / --map-notes / --map-only 仍然走 Map 循环（它们就是预热）
# ─────────────────────────────────────────────────────────────────────
SKIP_MAP_LOOPS=false
if [ "$FAST_MODE" = false ] && [ "$MAP_ONLY" = false ] && [ -d "$MAP_DIR" ]; then
    log "Reduce 路径：从缓存加载 Map 信号（V37.3 fix A，跳过 Map 循环）"

    # ── Sources：文件名带 source_name，可以直接对齐 ALL_SOURCES ──
    if [ -n "$ALL_SOURCES" ]; then
        while IFS= read -r src; do
            [ -z "$src" ] && continue
            [ -f "$src" ] || continue
            name=$(basename "$src" .md)
            file_size=$(wc -c < "$src" 2>/dev/null | tr -d ' ')
            prompt_hash="v3"
            cache_key="${name}_${file_size}_${prompt_hash}"
            cache_file="$MAP_DIR/${DAY}_${cache_key}.txt"
            if [ -f "$cache_file" ]; then
                signals=$(cat "$cache_file")
                MAP_SIGNALS+="
## $name
$signals
"
                MAP_COUNT=$((MAP_COUNT + 1))
            fi
        done <<< "$ALL_SOURCES"
        log "  Sources 缓存: $MAP_COUNT/$SRC_COUNT 命中"
    fi

    # ── Notes：per-note cache (V37.3 fix C)，按 content_hash 匹配并 dedupe signals ──
    # V37.4.2: 先收集独立信号块到数组，再带结构 header（编号+总数）一次性 flush
    # 避免 bland 重复标题 × 22，给 Reduce LLM 可辨识的密度信号
    NOTES_SIGNALS=""
    NOTES_MAP_COUNT=0
    seen_signal_hashes=""
    declare -a UNIQ_NOTE_SIG_BLOCKS=()
    if [ -n "$ALL_NOTES" ]; then
        while IFS= read -r note; do
            [ -z "$note" ] && continue
            [ -f "$note" ] || continue
            content=$(cat "$note" 2>/dev/null | utf8_truncate 2000)
            [ -z "${content// }" ] && continue
            # V37.3: cache key = md5(content) — 稳定于 mtime 和 batch 组合漂移
            content_hash=$(printf '%s' "$content" | md5sum 2>/dev/null | cut -c1-12)
            [ -z "$content_hash" ] && content_hash=$(printf '%s' "$content" | md5 -q 2>/dev/null | cut -c1-12)
            [ -z "$content_hash" ] && continue
            cache_file="$MAP_DIR/${DAY}_note_${content_hash}.txt"
            if [ -f "$cache_file" ]; then
                signals=$(cat "$cache_file")
                [ -z "${signals// }" ] && continue
                # dedupe: 同一个 batch 的 N 个 notes 共享一份 signals，只纳入一次
                sig_hash=$(printf '%s' "$signals" | md5sum 2>/dev/null | cut -c1-12)
                [ -z "$sig_hash" ] && sig_hash=$(printf '%s' "$signals" | md5 -q 2>/dev/null | cut -c1-12)
                case "$seen_signal_hashes" in
                    *"|$sig_hash|"*) : ;;
                    *)
                        seen_signal_hashes+="|$sig_hash|"
                        UNIQ_NOTE_SIG_BLOCKS+=("$signals")
                        ;;
                esac
                NOTES_MAP_COUNT=$((NOTES_MAP_COUNT + 1))
            fi
        done <<< "$ALL_NOTES"

        # V37.4.2: 带结构 header 一次性 flush — 编号 + 总数 + 覆盖笔记数
        UNIQ_BLOCK_COUNT=${#UNIQ_NOTE_SIG_BLOCKS[@]}
        if [ "$UNIQ_BLOCK_COUNT" -gt 0 ]; then
            NOTES_SIGNALS="
## 用户笔记信号总览
> 覆盖 $NOTES_MAP_COUNT 条用户笔记，提取出 $UNIQ_BLOCK_COUNT 个独立信号簇
> 每个信号簇由一个 Map 批次独立生成，代表一组主题相关的观察点
"
            for i in "${!UNIQ_NOTE_SIG_BLOCKS[@]}"; do
                idx=$((i + 1))
                NOTES_SIGNALS+="
### 笔记信号簇 $idx / $UNIQ_BLOCK_COUNT
${UNIQ_NOTE_SIG_BLOCKS[$i]}
"
            done
        fi
        log "  Notes 缓存: $NOTES_MAP_COUNT/$NOTE_COUNT 命中（去重后 $UNIQ_BLOCK_COUNT 个独特信号块）"
    fi

    SKIP_MAP_LOOPS=true
    log "Reduce 路径缓存加载完成，跳过 Phase 1a/1b 循环"
fi

if [ "$SKIP_MAP_LOOPS" = false ] && [ "$FAST_MODE" = false ] && [ "$SRC_COUNT" -gt 0 ] && [ "$MAP_NOTES_ONLY" = false ]; then
    log "Phase 1a (Map Sources): 开始逐源提取信号..."

    while IFS= read -r src; do
        # 全局超时检查
        if ! check_deadline; then
            log "  ⚠️ Map 阶段超时，跳过剩余 sources"
            break
        fi
        # 连续失败熔断：3 次连续 LLM 失败 → 停止 Map，保留配额给 Reduce
        if [ "$MAP_CONSECUTIVE_FAILS" -ge 3 ]; then
            log "  ⚠️ 连续 ${MAP_CONSECUTIVE_FAILS} 次 LLM 失败，停止 Map 阶段（保护 fallback 配额）"
            break
        fi
        [ -z "$src" ] && continue
        [ -f "$src" ] || continue
        name=$(basename "$src" .md)
        total_lines=$(wc -l < "$src" 2>/dev/null | tr -d ' ')
        [ -z "$total_lines" ] && total_lines=0
        [ "$total_lines" -eq 0 ] && continue

        # 检查 map 缓存（同一天同一文件大小不重复提取）
        file_size=$(wc -c < "$src" 2>/dev/null | tr -d ' ')
        # 缓存 key 含 prompt 版本哈希，prompt 变化时自动重新提取
        prompt_hash="v3"  # bump when prompt template changes to invalidate cache
        cache_key="${name}_${file_size}_${prompt_hash}"
        cache_file="$MAP_DIR/${DAY}_${cache_key}.txt"

        if [ -f "$cache_file" ]; then
            log "  Map [$name]: 使用缓存"
            signals=$(cat "$cache_file")
            MAP_CONSECUTIVE_FAILS=0  # 缓存命中视为成功，重置计数（V37.2 fix: 防止假熔断）
        else
            # 读取文件全文，用 UTF-8 安全截断到 15000 字符
            # 15K chars ≈ 4-5K tokens，Qwen3 262K context 轻松容纳
            full_content=$(cat "$src" 2>/dev/null | utf8_truncate 15000)

            # 用 Python 安全拼接 prompt（避免 printf 对 KB 内容中 % 字符的格式化注入）
            prompt=$(python3 -c "
import sys
tpl = sys.stdin.read()
print(tpl.replace('TPL_NAME', sys.argv[1]).replace('TPL_LINES', sys.argv[2]).replace('TPL_CONTENT', sys.argv[3]))
" "$name" "$total_lines" "$full_content" <<'PROMPT_EOF'
你是一个数据矿工。从以下数据源中挖掘值得注意的信号。

数据源名称: TPL_NAME
数据量: TPL_LINES 行

完整内容:
---
TPL_CONTENT
---

请提取 10-15 个值得注意的信号，每个信号一行，格式：
- [日期或时间段] 信号描述（具体事实，含关键数字/人名/技术名）

提取维度（每个维度至少 2 个信号）：
1. 反常数据点（数字突变、趋势逆转、异常沉默、与预期相反的结果）
2. 具体实体（人名/公司/技术/产品/论文标题——越具体越好，不要泛化为"某AI公司"）
3. 时间维度变化（加速、减速、消失、首次出现、周期性波动）
4. 容易被忽略的细节（脚注里的数字、附带提及的事实、数据中的空白区域）
5. 量化事实（具体数字、百分比、金额、排名变化——这些是最有价值的信号）

不要试图关联其他领域，只忠实提取本数据源中的事实。
只输出信号列表，不要前言或总结。控制在 500 字以内。
PROMPT_EOF
)

            log "  Map [$name]: ${total_lines}行, ${file_size}B → 提取信号..."
            signals=$(llm_call "$prompt" 1200 0.5 90 || true)

            if [ -n "${signals// }" ]; then
                echo "$signals" > "$cache_file"
                MAP_CONSECUTIVE_FAILS=0  # 成功，重置计数
                # 节流：Map 调用之间等待 5 秒，避免密集调用耗尽 fallback 配额
                sleep 5
            else
                MAP_CONSECUTIVE_FAILS=$((MAP_CONSECUTIVE_FAILS + 1))
                log "  Map [$name]: LLM 返回空，跳过 (连续失败: $MAP_CONSECUTIVE_FAILS)"
                sleep 10  # 失败后等更久再继续
                continue
            fi
        fi

        MAP_SIGNALS+="
## $name
$signals
"
        MAP_COUNT=$((MAP_COUNT + 1))
    done <<< "$ALL_SOURCES"

    log "Phase 1 完成: $MAP_COUNT/$SRC_COUNT sources 提取了信号"
fi

# ═══════════════════════════════════════════════════════════════════
# 4. Notes 也走 Map 阶段（与 Sources 同等待遇）
#    Notes 含用户与 PA 的重要交互信息，不能被 Sources 信号淹没
#
#    V37.3 fix B+C（解决 60min 预算被 47 批 Notes Map 吃光的问题）：
#      B. 批次从 15 条/12000B → 30 条/24000B，LLM 调用次数减半（47→24 左右）
#      C. 缓存粒度从"每批一个 hash"→"每 note 一个 md5(content) hash"
#         - 稳定于 mtime 漂移 / sort 顺序 / 新笔记插入（之前因为这几个因素
#           每天 cache key 都变，03:00 full run 全 miss）
#         - 一次 LLM 调用的 signals 写入这批所有 note 的缓存文件（同内容多副本）
#         - 第二天增量：只有新增 notes 是 cache miss，其余直接走缓存
#      结果：首日 bootstrap ~24 批 ≈ 32min（在 90min Map 预算内）
#            后续每日 ~1 批 ≈ 90s
# ═══════════════════════════════════════════════════════════════════

if [ "$SKIP_MAP_LOOPS" = false ] && [ "$FAST_MODE" = false ] && [ -n "$ALL_NOTES" ] && [ "$MAP_CONSECUTIVE_FAILS" -lt 3 ] && [ "$MAP_SOURCES_ONLY" = false ]; then
    log "Phase 1b (Map Notes, V37.3 fix B+C): per-note 缓存 + 动态批次(30 条/24KB)..."

    # 按修改时间倒序（处理顺序 — 最新在前）
    # 注意：sort 顺序只影响处理次序，不影响 cache key（cache key = md5(content)）
    SORTED_NOTES=$(echo "$ALL_NOTES" | while read f; do
        [ -f "$f" ] && echo "$(stat -f '%m' "$f" 2>/dev/null || stat -c '%Y' "$f" 2>/dev/null || echo 0) $f"
    done | sort -rn | awk '{print $2}')

    # 动态批次累积区（cache miss 的 notes 累积成 batch，flush 时一次 LLM 调用）
    PENDING_BATCH=""
    PENDING_COUNT=0
    PENDING_SIZE=0
    PENDING_CACHE_FILES=""  # newline-separated cache file paths for this batch
    BATCH_NUM=0
    seen_note_signal_hashes=""  # "|hash1|hash2|" dedup: 同 batch 的 N 个 note 共享 signals

    # helper: flush pending batch → 1 次 LLM 调用 → 写入每个参与 note 的独立 cache 文件
    flush_pending_batch() {
        [ "$PENDING_COUNT" -le 0 ] && return 0
        BATCH_NUM=$((BATCH_NUM + 1))

        # flush 前超时检查：如果预算已用完，放弃这一批（不调 LLM 浪费配额）
        if ! check_deadline; then
            log "  ⚠️ Notes Map flush 前超时，丢弃 ${PENDING_COUNT} 条 pending"
            PENDING_BATCH=""
            PENDING_COUNT=0
            PENDING_SIZE=0
            PENDING_CACHE_FILES=""
            return 1
        fi

        local prompt signals sig_hash cfile
        prompt=$(python3 -c "
import sys
tpl = sys.stdin.read()
print(tpl.replace('TPL_COUNT', sys.argv[1]).replace('TPL_CONTENT', sys.argv[2]))
" "$PENDING_COUNT" "$PENDING_BATCH" <<'PROMPT_EOF'
你是一个数据矿工。以下是用户的个人笔记和与AI助手的交互记录。这些笔记包含用户认为重要的信息、决策、发现和想法。

数据类型: 用户笔记/交互记录
笔记数量: TPL_COUNT 条

完整内容:
---
TPL_CONTENT
---

请提取 8-12 个最值得注意的信号，每个信号一行，格式：
- [日期或笔记名] 信号描述（具体事实，含关键内容）

重点关注：
1. 用户明确标记为重要的信息（主动保存 = 用户认为有价值）
2. 用户的决策、判断、观点（反映用户的思考方向）
3. 跨多条笔记出现的主题（重复出现 = 持续关注）
4. 与外部数据源（论文、新闻、技术趋势）可能关联的线索
5. 反常或意外的记录（与日常模式不同的条目）

只输出信号列表，不要前言或总结。控制在 500 字以内。
PROMPT_EOF
)
        log "  Map Notes [批次$BATCH_NUM]: ${PENDING_COUNT}条, ${PENDING_SIZE}B → 提取信号..."
        signals=$(llm_call "$prompt" 1200 0.5 90 || true)

        if [ -n "${signals// }" ]; then
            # 把 signals 写入本批所有 note 的 cache 文件（同内容多副本，换取 cache key 稳定性）
            while IFS= read -r cfile; do
                [ -z "$cfile" ] && continue
                echo "$signals" > "$cfile"
            done <<< "$PENDING_CACHE_FILES"
            MAP_CONSECUTIVE_FAILS=0

            # 加入 NOTES_SIGNALS，按 signal hash 去重（防止缓存重复读取同批时多次计入）
            sig_hash=$(printf '%s' "$signals" | md5sum 2>/dev/null | cut -c1-12)
            [ -z "$sig_hash" ] && sig_hash=$(printf '%s' "$signals" | md5 -q 2>/dev/null | cut -c1-12)
            case "$seen_note_signal_hashes" in
                *"|$sig_hash|"*) : ;;
                *)
                    seen_note_signal_hashes+="|$sig_hash|"
                    NOTES_SIGNALS+="
## 用户笔记（批次$BATCH_NUM, ${PENDING_COUNT}条）
$signals
"
                    ;;
            esac
            NOTES_MAP_COUNT=$((NOTES_MAP_COUNT + PENDING_COUNT))
            sleep 5  # 节流：批次之间等待
        else
            MAP_CONSECUTIVE_FAILS=$((MAP_CONSECUTIVE_FAILS + 1))
            log "  Map Notes [批次$BATCH_NUM]: LLM 返回空 (连续失败: $MAP_CONSECUTIVE_FAILS)"
            sleep 10
        fi

        PENDING_BATCH=""
        PENDING_COUNT=0
        PENDING_SIZE=0
        PENDING_CACHE_FILES=""
        return 0
    }

    while IFS= read -r note; do
        # 全局超时检查
        if ! check_deadline; then
            log "  ⚠️ Notes Map 阶段超时，跳过剩余 notes"
            break
        fi
        # 连续失败熔断
        if [ "$MAP_CONSECUTIVE_FAILS" -ge 3 ]; then
            log "  ⚠️ 连续 ${MAP_CONSECUTIVE_FAILS} 次 LLM 失败，停止 Notes Map"
            break
        fi
        [ -z "$note" ] && continue
        [ -f "$note" ] || continue
        name=$(basename "$note" .md)
        content=$(cat "$note" 2>/dev/null | utf8_truncate 2000)
        [ -z "${content// }" ] && continue

        # V37.3 fix C: cache key = md5(content)，稳定于 mtime/sort 顺序/批次组合
        content_hash=$(printf '%s' "$content" | md5sum 2>/dev/null | cut -c1-12)
        [ -z "$content_hash" ] && content_hash=$(printf '%s' "$content" | md5 -q 2>/dev/null | cut -c1-12)
        [ -z "$content_hash" ] && continue
        cache_file="$MAP_DIR/${DAY}_note_${content_hash}.txt"

        if [ -f "$cache_file" ]; then
            # cache hit：直接读 signals + dedupe（一批 note 共享 signals，防止重复）
            # V37.4.2: 用 note 名做 header，不用 bland 重复标题
            signals=$(cat "$cache_file")
            if [ -n "${signals// }" ]; then
                sig_hash=$(printf '%s' "$signals" | md5sum 2>/dev/null | cut -c1-12)
                [ -z "$sig_hash" ] && sig_hash=$(printf '%s' "$signals" | md5 -q 2>/dev/null | cut -c1-12)
                case "$seen_note_signal_hashes" in
                    *"|$sig_hash|"*) : ;;
                    *)
                        seen_note_signal_hashes+="|$sig_hash|"
                        NOTES_SIGNALS+="
## 用户笔记缓存命中: $name
$signals
"
                        ;;
                esac
                NOTES_MAP_COUNT=$((NOTES_MAP_COUNT + 1))
                MAP_CONSECUTIVE_FAILS=0  # 缓存命中重置计数
            fi
            continue
        fi

        # cache miss：加入 pending batch
        PENDING_BATCH+="
### $name
$content
"
        PENDING_COUNT=$((PENDING_COUNT + 1))
        PENDING_CACHE_FILES+="$cache_file
"
        PENDING_SIZE=${#PENDING_BATCH}

        # V37.3 fix B: 30 条或 24000B 才 flush（原 15/12000 翻倍 → LLM 调用减半）
        if [ "$PENDING_COUNT" -ge 30 ] || [ "$PENDING_SIZE" -gt 24000 ]; then
            flush_pending_batch
        fi
    done <<< "$SORTED_NOTES"

    # 处理最后不足一批的剩余
    if [ "$PENDING_COUNT" -gt 0 ]; then
        flush_pending_batch
    fi

    log "Phase 1b 完成: $NOTES_MAP_COUNT/$NOTE_COUNT notes 提取了信号 ($BATCH_NUM 批, V37.3 fix B+C)"

elif [ "$SKIP_MAP_LOOPS" = false ] && [ -n "$ALL_NOTES" ]; then
    # Fast 模式：直接采样 notes 原文
    # 注意：SKIP_MAP_LOOPS=true 时（cache-only fast path）已经填好 NOTES_SIGNALS，
    # 不应该再进这个 elif，否则会覆盖 cache-only 的结果
    SORTED_NOTES=$(echo "$ALL_NOTES" | while read f; do
        [ -f "$f" ] && echo "$(stat -f '%m' "$f" 2>/dev/null || stat -c '%Y' "$f" 2>/dev/null || echo 0) $f"
    done | sort -rn | awk '{print $2}')

    NOTES_MATERIAL=""
    NOTE_IDX=0
    while IFS= read -r note; do
        [ -z "$note" ] && continue
        [ -f "$note" ] || continue
        NOTE_IDX=$((NOTE_IDX + 1))
        [ "$NOTE_IDX" -gt 80 ] && break
        name=$(basename "$note" .md)
        content=$(cat "$note" 2>/dev/null | utf8_truncate 2000)
        [ -z "${content// }" ] && continue
        NOTES_MATERIAL+="
### $name
$content
"
    done <<< "$SORTED_NOTES"
fi

# --map-* 模式：Map 完成后直接退出，缓存已写入，Reduce 交给后续调度
if $MAP_ONLY; then
    MAP_ELAPSED=$(( $(date +%s) - DREAM_START_EPOCH ))
    MAP_SCOPE="all"
    $MAP_SOURCES_ONLY && MAP_SCOPE="sources"
    $MAP_NOTES_ONLY && MAP_SCOPE="notes"
    log "Map-${MAP_SCOPE} 完成: sources=$MAP_COUNT/$SRC_COUNT, notes=$NOTES_MAP_COUNT/$NOTE_COUNT, 缓存已写入 $MAP_DIR, 耗时 ${MAP_ELAPSED}s"
    # 写入独立的 Map status 文件（不覆盖 Reduce 的 .last_run.json）
    printf '{"time":"%s","status":"map_%s_done","map_count":%d,"notes_map_count":%d,"sources":%d,"notes":%d,"elapsed_sec":%d,"consecutive_fails":%d}\n' \
        "$(TZ=Asia/Hong_Kong date '+%Y-%m-%d %H:%M:%S')" "$MAP_SCOPE" "$MAP_COUNT" "$NOTES_MAP_COUNT" "$SRC_COUNT" "$NOTE_COUNT" "$MAP_ELAPSED" "$MAP_CONSECUTIVE_FAILS" > "$MAP_STATUS_FILE"
    # 写入完成标记（Reduce 阶段可检测 Map 是否成功完成）
    # --map-sources 只更新 sources 计数，--map-notes 只更新 notes 计数
    if [ "$MAP_COUNT" -gt 0 ] || [ "$NOTES_MAP_COUNT" -gt 0 ]; then
        echo "$MAP_COUNT:$NOTES_MAP_COUNT" > "$MAP_DONE_FLAG"
        log "Map 完成标记已写入: $MAP_DONE_FLAG (scope=$MAP_SCOPE)"
    else
        log "WARN: Map 零成功，不写入完成标记 (scope=$MAP_SCOPE)"
    fi
    exit 0
fi

# 全局超时检查：Map 阶段如果用了太长时间，跳过 Reduce 直接用已有信号生成简报
if ! check_deadline; then
    dream_fail_alert "全局超时 — Map 阶段耗时过长，跳过 Reduce"
    # 写入已收集的 Map 信号作为简化报告
    {
        echo "# Agent Dream $DAY (超时简报)"
        echo ""
        echo "> Map 阶段超时，以下为已收集的原始信号（未经 Reduce 分析）"
        echo ""
        [ -n "${MAP_SIGNALS// }" ] && echo "$MAP_SIGNALS"
        [ -n "${NOTES_SIGNALS// }" ] && echo "$NOTES_SIGNALS"
    } > "$DREAM_FILE"
    printf '{"time":"%s","status":"timeout","map_count":%d,"sources":%d,"notes":%d}\n' \
        "$(TZ=Asia/Hong_Kong date '+%Y-%m-%d %H:%M:%S')" "$MAP_COUNT" "$SRC_COUNT" "$NOTE_COUNT" > "$STATUS_FILE"
    log "超时退出，已保存简化报告到 $DREAM_FILE"
    exit 0
fi

# ═══════════════════════════════════════════════════════════════════
# 5. Phase 2 (Reduce)：跨领域关联发现
#    输入：Map 阶段提取的所有信号 + Notes + 状态 + 趋势
#    输出：梦境报告
# ═══════════════════════════════════════════════════════════════════

# Map 完成度检查：检测 00:00 的 --map-only 是否成功完成（INV-CACHE-001）
# 如果 Map 预热失败导致缓存为空，降级时必须输出明确信号，不能静默
MAP_DEGRADED=false
if [ "$FAST_MODE" = false ] && [ -z "${MAP_SIGNALS// }" ] && [ -z "${NOTES_SIGNALS// }" ]; then
    if [ -f "$MAP_DONE_FLAG" ]; then
        log "WARN: Map 完成标记存在但信号为空（缓存可能已损坏）"
    else
        log "WARN: Map 缓存为空且无完成标记 — 00:00 Map 预热可能失败"
    fi
    MAP_DEGRADED=true
    dream_fail_alert "Reduce 降级为 Fast 模式 — Map 缓存为空（00:00 预热可能失败），梦境质量将降低"
fi

# ═══════════════════════════════════════════════════════════════════
# 4.5. Phase 1.5 (V37.9.49 Sub-Stage 4a + V37.9.56 Sub-Stage 4c) — Opportunity Radar 三件套
#    输入: 已有 KB notes/sources + 8 ALIGNED source 今日 llm_results.jsonl
#    输出: 跨 source 共振信号 (#1) + 趋势加速度 (#3) + 高对齐 Top 5 (#2)
#         → 注入 Reduce LLM prompt
#    设计文档: docs/opportunity_radar_design.md 3.3 + 5.2 节
#    FAIL-OPEN: 任意脚本失败 → 信号设为空字符串不阻塞 Reduce
# ═══════════════════════════════════════════════════════════════════
log "Phase 1.5 (Opportunity Radar): 采集跨 source 信号 + 趋势加速度 + 今日高对齐..."

RADAR_SIGNALS_BLOCK=""
TREND_SIGNALS_BLOCK=""
TOP_ALIGNMENT_BLOCK=""  # V37.9.56 #2: 今日 ⭐≥4 高对齐 Top 5

# Locate scorers (deploy 在 $HOME 或 dev 仓库根)
RADAR_SCORER="${HOME}/cross_source_signal_aggregator.py"
TREND_SCORER="${HOME}/kb_trend_acceleration.py"
ALIGNMENT_PICKER="${HOME}/top_alignment_picker.py"  # V37.9.56
[ -f "$RADAR_SCORER" ] || RADAR_SCORER="$(dirname "$0")/cross_source_signal_aggregator.py"
[ -f "$TREND_SCORER" ] || TREND_SCORER="$(dirname "$0")/kb_trend_acceleration.py"
[ -f "$ALIGNMENT_PICKER" ] || ALIGNMENT_PICKER="$(dirname "$0")/top_alignment_picker.py"  # V37.9.56

# #1 cross_source_signal_aggregator → daily_signals JSON → top 5 共振 block
if [ -f "$RADAR_SCORER" ]; then
    _RADAR_TMP=$(mktemp 2>/dev/null) || _RADAR_TMP=/tmp/dream_radar_$$.json
    if python3 "$RADAR_SCORER" --json > "$_RADAR_TMP" 2>/dev/null; then
        RADAR_SIGNALS_BLOCK=$(python3 - "$_RADAR_TMP" << 'PYEOF' 2>/dev/null || echo ""
import json, os, sys
try:
    with open(sys.argv[1]) as f:
        d = json.load(f)
    path = d.get("output_path", "")
    if not path or not os.path.isfile(path):
        sys.exit(0)
    with open(path) as f:
        data = json.load(f)
    signals = data.get("signals", [])[:5]
    if not signals:
        print("(今日无跨 source 共振信号)")
    else:
        out = ["# 今日跨 source 共振信号 (V37.9.46 #1, top 5)"]
        for s in signals:
            topic = (s.get("suggested_topic", "") or "")[:60]
            sources = ", ".join(s.get("sources", [])[:5])
            note_count = s.get("note_count", 0)
            src_count = s.get("source_count", 0)
            out.append(f"- {topic} | 跨 {src_count} 源 ({sources}) | {note_count} 篇")
        print("\n".join(out))
except Exception:
    pass
PYEOF
)
    else
        log "Phase 1.5: cross_source_signal_aggregator 调用失败 (FAIL-OPEN)"
    fi
    rm -f "$_RADAR_TMP" 2>/dev/null
fi

# #3 kb_trend_acceleration → weekly_trends JSON → top archetypes block
if [ -f "$TREND_SCORER" ]; then
    _TREND_TMP=$(mktemp 2>/dev/null) || _TREND_TMP=/tmp/dream_trend_$$.json
    if python3 "$TREND_SCORER" --json > "$_TREND_TMP" 2>/dev/null; then
        TREND_SIGNALS_BLOCK=$(python3 - "$_TREND_TMP" << 'PYEOF' 2>/dev/null || echo ""
import json, os, sys
from collections import defaultdict
try:
    with open(sys.argv[1]) as f:
        d = json.load(f)
    path = d.get("output_path", "")
    if not path or not os.path.isfile(path):
        sys.exit(0)
    with open(path) as f:
        data = json.load(f)
    signals = data.get("signals", [])
    groups = defaultdict(list)
    for s in signals:
        groups[s.get("classification", "")].append(s)
    out = []
    for cls in ("\U0001F680 strong_acceleration",
                "\U0001F4C8 mild_acceleration",
                "⚰️ obsolescence"):
        entries = groups.get(cls, [])[:3]
        if entries:
            for e in entries:
                kw = e.get("keyword", "")
                a1 = e.get("accel_1w", 0)
                emoji = cls.split()[0] if " " in cls else cls
                out.append(f"- {emoji} {kw}: 本周 {a1}x")
    if out:
        print("# 本周趋势加速度 (V37.9.48 #3, 4 周历史)")
        print("\n".join(out))
    else:
        print("(无显著趋势变化)")
except Exception:
    pass
PYEOF
)
    else
        log "Phase 1.5: kb_trend_acceleration 调用失败 (FAIL-OPEN)"
    fi
    rm -f "$_TREND_TMP" 2>/dev/null
fi

# #2 top_alignment_picker → 今日 ⭐≥4 高对齐 Top 5 block (V37.9.56 Sub-Stage 4c)
# 直接用 --block-only 模式取 markdown 段, 不需 inline Python parse JSON
if [ -f "$ALIGNMENT_PICKER" ]; then
    # 仓库根: dev 取 $(dirname "$ALIGNMENT_PICKER"), Mac Mini 取 $HOME
    _PICKER_REPO=$(dirname "$ALIGNMENT_PICKER")
    TOP_ALIGNMENT_BLOCK=$(python3 "$ALIGNMENT_PICKER" --repo-root "$_PICKER_REPO" --block-only 2>/dev/null || echo "")
    [ -n "$TOP_ALIGNMENT_BLOCK" ] && log "Phase 1.5: 今日高对齐 Top 5 采集完成 (#2)"
fi

[ -n "$RADAR_SIGNALS_BLOCK" ] && log "Phase 1.5: 雷达信号采集完成 (#1)"
[ -n "$TREND_SIGNALS_BLOCK" ] && log "Phase 1.5: 趋势加速度采集完成 (#3)"

log "Phase 2 (Reduce): 开始跨领域关联..."

# 组装 Reduce 素材
if [ "$FAST_MODE" = true ] || [ -z "${MAP_SIGNALS// }" ]; then
    # Fast 模式或 Map 缓存为空：回退到直接采样
    log "使用直接采样模式$([ "$MAP_DEGRADED" = true ] && echo '（Map 预热失败降级）')"
    REDUCE_INTRO="以下是系统知识库的全量采样数据（涵盖论文、技术博客、HackerNews、航运动态、项目笔记等多个领域）："
    REDUCE_DATA=""

    # Sources: 加大采样量
    if [ -n "$ALL_SOURCES" ]; then
        while IFS= read -r src; do
            [ -z "$src" ] && continue
            [ -f "$src" ] || continue
            name=$(basename "$src" .md)
            total_lines=$(wc -l < "$src" 2>/dev/null | tr -d ' ')
            [ -z "$total_lines" ] && total_lines=0
            [ "$total_lines" -eq 0 ] && continue

            # 头 10 行 + 尾 100 行 + 随机 20 行
            head_content=$(head -10 "$src" 2>/dev/null | head -c 500)
            tail_content=$(tail -100 "$src" 2>/dev/null | utf8_truncate 3000)
            mid_content=""
            if [ "$total_lines" -gt 120 ]; then
                rand_offset=$(( RANDOM % (total_lines / 2) + total_lines / 4 ))
                mid_content=$(tail -n +${rand_offset} "$src" 2>/dev/null | head -20 | utf8_truncate 1000)
            fi

            REDUCE_DATA+="
### $name (${total_lines}行)
[起源] $head_content
[历史] $mid_content
[最新] $tail_content
"
        done <<< "$ALL_SOURCES"
    fi

    # Notes 全量
    REDUCE_DATA+="$NOTES_MATERIAL"
else
    # MapReduce 模式：Sources 信号 + Notes 信号并列
    REDUCE_INTRO="以下是系统知识库的 **全量深度分析结果**。Phase 1 已对 $MAP_COUNT 个数据源和 $NOTES_MAP_COUNT 条用户笔记逐一进行了信号提取（覆盖全部 ${TOTAL_KB_BYTES} 字节数据）。

**重要：用户笔记中的信号同样重要。** 这些笔记是用户主动保存的重要信息、决策和发现，反映了用户的关注方向和判断。分析时必须同时考虑外部数据源信号和用户笔记信号。"
    REDUCE_DATA="
# Phase 1a: 外部数据源信号
$MAP_SIGNALS

# Phase 1b: 用户笔记/交互记录信号（用户主动保存的重要信息）
$NOTES_SIGNALS
"
fi

# 加上状态、趋势、历史梦境
REDUCE_DATA+="
# 项目状态
$STATUS_CONTEXT

# 本周趋势
$TREND_CONTEXT
"

# V37.9.49 Sub-Stage 4a + V37.9.56 Sub-Stage 4c (Opportunity Radar 三件套全量):
# 注入跨 source 雷达 (#1) + 今日高对齐 Top 5 (#2) + 趋势加速度 (#3)
# 让 Reduce LLM 在跨域关联中识别"早期机会点"
if [ -n "$RADAR_SIGNALS_BLOCK" ]; then
    REDUCE_DATA+="

═══ Opportunity Radar #1 (跨 source 共振信号) ═══
$RADAR_SIGNALS_BLOCK
"
fi
if [ -n "$TOP_ALIGNMENT_BLOCK" ]; then
    REDUCE_DATA+="

═══ Opportunity Radar #2 (今日高对齐 Top 5, ⭐≥4) ═══
$TOP_ALIGNMENT_BLOCK
"
fi
if [ -n "$TREND_SIGNALS_BLOCK" ]; then
    REDUCE_DATA+="

═══ Opportunity Radar #3 (本周趋势加速度) ═══
$TREND_SIGNALS_BLOCK

注意: 上述 Radar 信号用于辅助识别'早期机会点', 标 🚨 在输出中.
但只有当今日笔记/sources 中有具体内容支持时, 才能在最终结论里展开.
"
fi

# 截断 Reduce 素材到 80K chars（直接调 Adapter，无 Proxy 200KB 限制）
# Qwen3-235B 262K context，80K chars ≈ 25-30K tokens，留足空间给 prompt + 8K output
REDUCE_MATERIAL=$(echo "$REDUCE_DATA" | utf8_truncate 80000)
REDUCE_CHARS=$(echo "$REDUCE_MATERIAL" | wc -c | tr -d ' ')
log "Reduce 素材: ${REDUCE_CHARS} bytes (截断前 $(echo "$REDUCE_DATA" | wc -c | tr -d ' ') bytes)"

# V37.8.3: 拆分 system + user 双消息
# 根因：82KB 单条 user message 中，长度要求被 "lost in the middle"，
# Qwen3 在长 prompt 下摘要收敛到 ~900 chars（completion_tokens 仅 328/8000）。
# 修复：system role 携带元指令（角色+长度硬限+格式），模型注意力最高位置；
# user role 只携带数据和分析结构模板。
# V37.9.57: 公共反幻觉守卫 (MR-8 single-source-of-truth)
# V37.9.89: 升级 LEVEL_5_RADAR_AWARE → LEVEL_6_DREAM_CROSS_DOMAIN_AWARE
#   - 触发: 2026-05-29 V37.9.84 observer 在 5/28 dream 中发现 "光子计算 →
#     心理导航" 链式幻觉血案 (基于弱信号的"创造性跨域桥接", LEVEL_5 没
#     拦住)
#   - 2026-05-29 dream 4 个 🔗 隐藏关联全部 `A → B → 因此 C` 模式
#   - LEVEL_6 强制 Dream cross-domain 段位每条隐藏关联标 [强证据]/[弱关联]
#     tag, 禁 "因此/必然/为 X 提供路径" 等无源支持的"必然推论"句式
#   - LEVEL_5 全部约束 (反虚构 + 反项目动态 + Radar 信号源契约) 完整保留
# Reduce 注入 Opportunity Radar #1+#2+#3 三件套, 必须用最高严格度防止 LLM
# 编造"项目动态"或链式推论. V37.8.6 反污染守卫保留 (互补层: V37.8.6 防错误日志
# 被当外部信号, V37.9.89 防跨域链式幻觉).
DREAM_HG_GUARD=$(python3 -c "
import sys, os
sys.path.insert(0, os.path.expanduser('~'))
sys.path.insert(0, '$SCRIPT_DIR')
import hallucination_guards as hg
print(hg.get_guard('LEVEL_6_DREAM_CROSS_DOMAIN_AWARE'))
" 2>/dev/null || echo "")
[ -z "$DREAM_HG_GUARD" ] && log "WARN: hallucination_guards 模块加载失败, 使用 V37.8.6 inline 守卫 fallback"

# V37.9.98: 来源可信度评级 (observer 5/28 proposal #2 — 非主流源如 chaspark 首次引用须标注可信度)
# 与 DREAM_HG_GUARD 互补: HG 守"内容真实性 + 跨域推论可信度"(关联强度);
# credibility 守"出处权威性"(学术同行评审/预印本/工业/博客/社媒). 同款 lazy import +
# FAIL-OPEN, 同款 5 注入点 (REDUCE/DEEP/DEEP_RETRY/WIDE_RADAR/WIDE_RADAR_RETRY).
DREAM_CREDIBILITY=$(python3 -c "
import sys, os
sys.path.insert(0, os.path.expanduser('~'))
sys.path.insert(0, '$SCRIPT_DIR')
import source_credibility as sc
print(sc.format_credibility_block())
" 2>/dev/null || echo "")
[ -z "$DREAM_CREDIBILITY" ] && log "WARN: source_credibility 模块加载失败, dream 推送将缺少来源可信度评级"

REDUCE_SYSTEM="你是一个在海量数据中寻找蛛丝马迹的专业分析师。

【V37.8.6 反污染守卫 — 违反则整份输出作废】
输入信号可能残留以下'内部运行时痕迹'，它们 **不是** 外部数据源发生的真实事件：
  ❌ HTTP 状态码报错字样（400/502/Bad JSON/Error code/Gateway Error）
  ❌ Python 异常名（UnicodeEncodeError / JSONDecodeError / Timeout）
  ❌ 错误页 HTML 片段（<!DOCTYPE html / Error response / BaseHTTPRequestHandler）
  ❌ 工具链名泄露（curl: / jq: / grep: / sed:）
  ❌ 替换字符 U+FFFD（�）连续出现的片段（= 编码异常的证据）

严格禁止：
  1. 将以上任何字样作为'外部平台状态'的证据引用
  2. 基于这些字样推断任何公司/平台/服务（如 Hugging Face / GitHub / npm）的状态
  3. 在'数据证据'或'信号'章节引用这些字样作为事实支撑
  4. 在'行动建议'中提议监控/应对这些'平台错误'（因为它们不是外部事件）
  遇到上述模式，必须跳过并改用真实的业务领域信号；若真实信号不足则章节可短。

【输出长度硬性要求 — 违反则视为失败】
- 你必须产出 2000-3000 字的完整深度分析报告
- 严格覆盖全部 6 个章节：发现过程 / 隐藏关联 / 趋势推演 / 被忽视的信号 / 行动建议 / 数据质量备注
- 每个章节必须充分展开（每章至少 200 字），禁止一句话带过
- 回复少于 1500 字将被自动丢弃并重试，浪费算力

写作风格：
- 像写给技术决策者的专业分析备忘录
- 每个论点必须引用具体数据源名称和日期，不允许空泛断言
- 不要客套话和铺垫，直接进入核心发现
- 行动建议必须具体到可以立即执行
- Markdown 格式输出${DREAM_HG_GUARD}${DREAM_CREDIBILITY}"

# V37.8.3: pre-compute conditional sections outside double-quoted strings (bash 3.2 compat)
_REDUCE_PREV_SECTION=""
if [ -n "$PREV_THEMES" ]; then
    _REDUCE_PREV_SECTION="### 最近梦境主题（仅供参考，如果同一热点有新角度可以继续深挖）
$PREV_THEMES

"
fi

REDUCE_PROMPT="$REDUCE_INTRO

---
$REDUCE_MATERIAL
---

这些数据是花费大量算力（14 个数据源逐一深度分析）的结果。不要浪费在浅尝辄止的多主题分析上。

**核心要求：每天只深挖一个主题，但用全部分析维度去钻透它。**

第一步：从所有信号中选出今天最有价值的一个发现。选题标准：
1. 有扎实的多源证据链（至少 3 个不同数据源互相印证）
2. 对我们的项目或技术方向有直接可操作的启示
3. 反直觉、容易被忽视、但有数据支撑的信号
4. 如果与最近梦境同一主题，必须有新角度或新证据，不要简单重复

${_REDUCE_PREV_SECTION}第二步：围绕这一个主题，严格按以下结构深度展开：

## 🌙 今日深度发现：[一句话主题]

### 发现过程
像侦探一样描述：哪些数据源的哪些条目最先引起注意？信号是如何从不同数据源中逐步浮现并互相印证的？

### 🔗 隐藏关联
围绕这个主题，列出 3-5 个隐藏的关联：
- 每个关联需标注证据链：A事实([数据源, 日期]) → B事实([数据源, 日期]) → 因此C
- 关联可以是同一领域内的深层联系，也可以是跨领域的意外连接
- 如果有矛盾的证据，也要列出并分析为什么矛盾

### 🔮 趋势推演
基于这个主题的证据，推演 2-3 个未来走向：
- **趋势名**
- **数据证据**（具体引用源、日期、数字）
- **推演逻辑**（为什么这些数据暗示了这个方向）
- **时间窗口**（萌芽期/加速期/拐点？6 个月/1 年/3 年后会怎样？）
- **如果成真的影响**

### 💎 被忽视的信号
围绕这个主题，找出 2-3 个藏在数据中容易被忽略的信号：
- **是什么**（具体数字、事件、异常）
- **在哪发现的**（数据源、日期）
- **为什么被忽视**（人们通常怎么忽略它）
- **为什么值得关注**（它暗示了什么更深层的变化）

### 🎯 行动建议（按优先级排列）
基于以上全部分析，给出 3-5 个具体可执行的建议：
- **做什么**（具体到这周可以直接执行的操作）
- **为什么现在做**（时间窗口/机会成本）
- **怎么验证**（怎么知道做对了）
- **预期产出**（做完后能得到什么具体的东西）

### 📊 数据质量备注
哪些数据源为这个主题贡献了关键证据？哪些数据源信息密度低或更新滞后？本次分析存在哪些信息盲区，需要补充什么新的数据源？"

PROMPT_BYTES=$(echo "$REDUCE_PROMPT" | wc -c | tr -d ' ')
SYSTEM_BYTES=$(echo "$REDUCE_SYSTEM" | wc -c | tr -d ' ')
log "Reduce prompt: ${PROMPT_BYTES} bytes (user) + ${SYSTEM_BYTES} bytes (system) → 发送 LLM..."

# 安全检查：prompt 超过 500KB 则截断（Qwen3 262K context 可处理 130KB prompt）
if [ "$PROMPT_BYTES" -gt 500000 ]; then
    log "WARN: Reduce prompt 过大 (${PROMPT_BYTES}B > 500KB)，回退到 40K 素材"
    REDUCE_MATERIAL=$(echo "$REDUCE_DATA" | utf8_truncate 40000)
    # 重新构建 prompt（用简化版，避免递归展开；system message 保持不变）
    REDUCE_PROMPT="$REDUCE_INTRO

---
$REDUCE_MATERIAL
---

从所有信号中选出最有价值的一个发现，深度分析：证据全景（多源互证）→ 深层分析（本质+阶段+推演）→ 对我们的启示 → 2-3 个这周可执行的行动步骤。
每个论点必须引用具体数据源名称和日期。"
    PROMPT_BYTES=$(echo "$REDUCE_PROMPT" | wc -c | tr -d ' ')
    log "回退后 prompt: ${PROMPT_BYTES} bytes"
fi

# V37.9.68: 三阶推送（DEEP + WIDE + RADAR + Overview）— 替代 V37.8.3 chunked 单主题深挖
# 用户视角原则 #13 兑现（2026-05-14）：用户反馈"连续几周 Qwen-BIM 重复推送"
# 根因 6 因子叠加（详见 kb_dream_helpers.py 注释）：
#   1. 用户笔记反复 ⭐⭐⭐⭐⭐ Qwen-BIM 主动保存
#   2. Phase 1b prompt 偏好"重复出现=持续关注"
#   3. content-stable cache 锁定 signals
#   4. mtime 倒序处理 + user notes 静态
#   5. NOTES_SIGNALS 按 signal-hash dedup 但不按主题
#   6. PREV_THEMES 只看 3 天 + prompt 留后门"同主题新角度"
#
# V37.9.68 设计:
#   - DEEP (LLM 调用 1): 1500 字单主题深挖 + 14 天 ban-list 硬规则
#   - WIDE+RADAR (LLM 调用 2): 单 LLM 调用产 2 段
#       · WIDE: 5 个跨领域"鲜人知"信号 × 200 字 (≈1000 字)
#       · RADAR: 5 个"准期信号"（今日 1 份证据但累积有趋势）× 300 字 (≈1500 字)
#   - 总览 (规则提取): build_overview_block() 不调 LLM
#
# 失败降级（用户决策：DEEP 必过 / WIDE+RADAR 独立降级）:
#   - DEEP 失败 → exit 1 fail-fast (推 [SYSTEM_ALERT])
#   - WIDE/RADAR 单段失败 → 该段标 [DEGRADED] 但不阻塞推送 DEEP + 其他段
#
# 推送多窗口（4 段独立 ## header → V37.9.21 多窗口分片机制 split by `\n## ` 自然切 4 窗口）

REDUCE_MULTI_MATERIAL=$(echo "$REDUCE_DATA" | utf8_truncate 30000)
REDUCE_MULTI_CHARS=$(echo "$REDUCE_MULTI_MATERIAL" | wc -c | tr -d ' ')
log "V37.9.68 三阶推送: 素材 ${REDUCE_MULTI_CHARS} bytes, 2 次 LLM 调用 + 1 规则提取"

# ─── LLM 调用 1: DEEP（单主题深挖，14 天 ban-list 硬规则）───
DEEP_SYSTEM="/no_think
你是专业数据分析师。从多源信号中选出 1 个**未在过去 14 天 dream 中出现过的全新主题**进行深度分析。

【V37.9.68 主题去重硬规则 — 违反整份输出作废】
- 14 天 ban-list 中的主题（关键词集合）不得作为选题
- 即使数据中相同主题信号最强，也必须选**完全不同维度的新主题**
- 不接受'同主题新角度'

【输出格式硬要求】
- 必须以 '## 🌙 今日深度: <主题名>' 为第一行
- 后续 3 章节：### 发现过程 / ### 隐藏关联 / ### 行动建议
- 每章节 ≥ 400 字，全段 ≥ 1400 字
- Markdown 格式，引用具体数据源名称和日期

【V37.8.6 反污染】禁止把 HTTP 错误码 / Python 异常 / 错误页 HTML / U+FFFD(�) 当外部信号
${DREAM_HG_GUARD}${DREAM_CREDIBILITY}"

DEEP_PROMPT="$REDUCE_INTRO

---
$REDUCE_MULTI_MATERIAL
---

${BANNED_THEMES_BLOCK}

第一步：从所有信号中选出 1 个**完全未在 14 天 ban-list 中出现**的主题。

第二步：围绕这个新主题写 3 章节：

## 🌙 今日深度: [主题名]

### 发现过程
像侦探一样描述：哪些数据源的哪些条目最先引起注意？信号是如何从不同数据源中逐步浮现并互相印证的？(≥400 字)

### 🔗 隐藏关联
列出 3-5 个隐藏的关联：A事实([数据源, 日期]) → B事实([数据源, 日期]) → 因此C (≥400 字)

### 🎯 行动建议
给出 3-5 个具体可执行的建议: 做什么 / 为什么现在做 / 怎么验证 / 预期产出 (≥400 字)

【硬性要求：合计 ≥ 1400 字。少于 1200 字视为失败 (fail-fast exit 1)。主题如与 ban-list 任一关键词重叠 ≥2 视为失败。】"

log "LLM 调用 1/2 (DEEP): 单主题深挖 + 14 天 ban-list → 发送..."
DEEP_RESULT=$(llm_call "$DEEP_PROMPT" 5000 0.85 300 "$DEEP_SYSTEM" || true)
DEEP_CHARS=$(echo "$DEEP_RESULT" | wc -c | tr -d ' ')
DEEP_HEAD=$(echo "$DEEP_RESULT" | head -c 120 | LC_ALL=C tr '\n' ' ')
log "DEEP 完成: ${DEEP_CHARS} chars, head: ${DEEP_HEAD}"

# DEEP fail-fast 阈值（必须在 V37.9.75 retry block 之前定义供 retry 触发条件用）
MIN_DEEP_CHARS=1200  # UTF-8 字节, ≈400 汉字最低

# V37.9.75 DEEP retry 兜底 (镜像 V37.9.74 RADAR retry 同款模式)
# 触发场景: 2026-05-18 03:02 Mac Mini cron LLM provider 端返回 823 chars partial content
#   - 5/15-17 三天 DEEP 稳定 6314-7125 chars / 100-113s 时长
#   - 5/18 突然 823 chars / 214s 时长 (慢一倍 + 短 1/8) = LLM provider 端 stream 异常截断
#   - head 显示 header 完整 "## 🌙 今日深度: ..." 但 "### 发现过程" 章节刚开头就停
# 用户决策 V37.9.75 retry 走默认 primary (Qwen3), 失败仍 fail-fast (V37.9.68 DEEP 必过原则不破)
# 多层防御: prompt 强 (预防) + retry 一次 (恢复) + fail-fast (兜底) 三层
# V37.9.75+ 候选 (V37.9.76+ 评估): retry 路径切走 doubao (需 adapter ?provider= 支持, V37.9.55+ 承诺)
DEEP_RETRIED=false
DEEP_RETRY_CHARS=0
# 仅在 LLM 真返回内容但偏短时 retry (partial content). 空字符串视为 LLM 完全失败, 不浪费 retry token.
if [ -n "${DEEP_RESULT// }" ] && [ "$DEEP_CHARS" -lt "$MIN_DEEP_CHARS" ]; then
    log "V37.9.75: DEEP 段过短 (${DEEP_CHARS} chars < ${MIN_DEEP_CHARS}), 主动 retry (LLM partial content 兜底)..."
    DEEP_RETRIED=true

    DEEP_RETRY_SYSTEM="/no_think
你是专业数据分析师。DEEP_RETRY 模式: 上次 DEEP 段产出过短 (partial content), 现在重新产 1 个完整的深度分析主题。

【V37.9.75 retry 硬要求 — 违反整份输出作废】
- 总长**必须 ≥ 1400 字** (上次只有 ${DEEP_CHARS} 字, 远不达标, 这次不能再短)
- 第一行**必须**是字面量 \"## 🌙 今日深度: <主题名>\"
- 必须含 3 章节: ### 发现过程 / ### 🔗 隐藏关联 / ### 🎯 行动建议
- 每章节 ≥ 400 字, 章节间换行清晰

【V37.9.68 主题去重硬规则 — ban-list 仍生效】
- 14 天 ban-list 中的主题（关键词集合）不得作为选题
- 即使数据中相同主题信号最强, 也必须选**完全不同维度的新主题**

【V37.8.6 反污染】禁止把 HTTP 错误码 / Python 异常 / 错误页 HTML / U+FFFD(�) 当外部信号
${DREAM_HG_GUARD}${DREAM_CREDIBILITY}"

    DEEP_RETRY_PROMPT="上一次 DEEP LLM 调用产出过短 (${DEEP_CHARS} 字, 远低于 1200 字最低要求)。
现在请重新产出**完整**深度分析, 不允许再次截断。

---
$REDUCE_MULTI_MATERIAL
---

${BANNED_THEMES_BLOCK}

第一步：从所有信号中选出 1 个**完全未在 14 天 ban-list 中出现**的主题。

第二步：围绕这个新主题写 3 章节, 每章节 ≥ 400 字, 合计 ≥ 1400 字：

## 🌙 今日深度: [主题名]

### 发现过程
像侦探一样描述：哪些数据源的哪些条目最先引起注意？信号是如何从不同数据源中逐步浮现并互相印证的？(≥400 字)

### 🔗 隐藏关联
列出 3-5 个隐藏的关联：A事实([数据源, 日期]) → B事实([数据源, 日期]) → 因此C (≥400 字)

### 🎯 行动建议
给出 3-5 个具体可执行的建议: 做什么 / 为什么现在做 / 怎么验证 / 预期产出 (≥400 字)

【V37.9.75 retry 输出自检 — 提交前对照】
1. 是否以 \"## 🌙 今日深度:\" 开头？
2. 三章节都齐备且 ≥400 字？
3. 合计 ≥ 1400 字 (上次只有 ${DEEP_CHARS} 字, 这次不能再短)？
4. 主题与 14 天 ban-list 任一关键词重叠 < 2？
5. 没有写到一半截断？

【硬性要求：合计 ≥ 1400 字。少于 1200 字或缺 \"## 🌙 今日深度\" header 视为 retry 失败 (走 V37.9.68 fail-fast exit 1)。】"

    DEEP_RETRY_RESULT=$(llm_call "$DEEP_RETRY_PROMPT" 5000 0.85 300 "$DEEP_RETRY_SYSTEM" || true)
    DEEP_RETRY_CHARS=$(echo "$DEEP_RETRY_RESULT" | wc -c | tr -d ' ')
    DEEP_RETRY_HAS_HEADER=false
    echo "$DEEP_RETRY_RESULT" | grep -q '## 🌙 今日深度' && DEEP_RETRY_HAS_HEADER=true
    log "V37.9.75 DEEP retry 完成: ${DEEP_RETRY_CHARS} chars, has_header=${DEEP_RETRY_HAS_HEADER}"

    # 双重验证: chars >= MIN + 含 ## 🌙 今日深度 header (防 LLM 产 1400 字垃圾但漏 header)
    if [ "$DEEP_RETRY_CHARS" -ge "$MIN_DEEP_CHARS" ] && [ "$DEEP_RETRY_HAS_HEADER" = "true" ]; then
        DEEP_RESULT="$DEEP_RETRY_RESULT"
        DEEP_CHARS=$DEEP_RETRY_CHARS
        log "V37.9.75 DEEP retry 成功: ${DEEP_CHARS} chars → 继续 WIDE+RADAR (retry-recovered)"
    else
        log "V37.9.75 DEEP retry 失败 (${DEEP_RETRY_CHARS} chars / has_header=${DEEP_RETRY_HAS_HEADER}), 走 V37.9.68 fail-fast"
    fi
fi

# DEEP fail-fast 验证（V37.9.68 用户决策：DEEP 必过，不及格 exit 1）
# V37.9.75 升级: retry 已尝试过 (若适用), 此时仍不达标 = 真实 LLM 故障, fail-fast 保 V37.9.68 设计原则
if [ -z "${DEEP_RESULT// }" ] || [ "$DEEP_CHARS" -lt "$MIN_DEEP_CHARS" ]; then
    log "ERROR: DEEP 生成失败 (${DEEP_CHARS} chars < ${MIN_DEEP_CHARS}, retried=${DEEP_RETRIED})"
    printf '{"time":"%s","status":"llm_failed","phase":"deep","deep_chars":%d,"min_required":%d,"prompt_bytes":%d,"deep_retried":%s,"deep_retry_chars":%d}\n' \
        "$TS" "$DEEP_CHARS" "$MIN_DEEP_CHARS" "$REDUCE_MULTI_CHARS" "$DEEP_RETRIED" "$DEEP_RETRY_CHARS" > "$STATUS_FILE"
    if [ "$DEEP_RETRIED" = "true" ]; then
        dream_fail_alert "V37.9.75 DEEP 段双失败 (原始 ${DEEP_CHARS} chars + retry ${DEEP_RETRY_CHARS} chars 都 < ${MIN_DEEP_CHARS}) — fail-fast，跳过 WIDE+RADAR"
    else
        dream_fail_alert "V37.9.68 DEEP 段失败 (${DEEP_CHARS} chars < ${MIN_DEEP_CHARS}, retry 未触发因 LLM 完全空) — fail-fast，跳过 WIDE+RADAR"
    fi
    exit 1
fi

# 从 DEEP 输出提取主题（用于 WIDE+RADAR 排除 + 总览段）
DEEP_THEME=$(python3 -c "
import sys, os
sys.path.insert(0, os.path.expanduser('~'))
sys.path.insert(0, '$SCRIPT_DIR')
try:
    from kb_dream_helpers import extract_deep_theme_from_chunk
    print(extract_deep_theme_from_chunk(sys.stdin.read()))
except Exception:
    print('(未识别主题)')
" <<< "$DEEP_RESULT" 2>/dev/null || echo "(未识别主题)")
log "DEEP 主题: $DEEP_THEME"

# ─── LLM 调用 2: WIDE+RADAR（单调用产 2 段）───
# V37.9.74 prompt 强化 (方案 2): 加强 RADAR header 必须字面量 + 两段完整性约束
# 触发: 2026-05-16+17 连续两天 Mac Mini cron LLM drift 漏 ## 📡 RADAR header → RADAR 段空
WIDE_RADAR_SYSTEM="/no_think
你是专业数据分析师。在多源信号中识别两类**互不重复**的视野信号：
- WIDE: 跨领域'鲜人知'（用户日常关注外的信号，多源证据但被埋没）
- RADAR: '准期信号'（今日仅 1 份证据但背后可能酝酿趋势的早期信号）

【V37.9.74 硬要求 — 缺任一段视为失败】
- **必须**输出完整 2 段, 不允许写完 WIDE 就结束
- 第一段开头**必须**是字面量行 \"## 🌐 跨领域鲜人知 × 5\"
- 第二段开头**必须**是字面量行 \"## 📡 准期信号 × 5\"
- 写完 WIDE 第 5 条后, **必须立即换行接 RADAR header**, 不允许直接收尾
- 每段恰好 5 条目, 每条目独立 '- **<标题>**:' 起首
- WIDE 每条 ≈200 字 (1000 字总), RADAR 每条 ≈300 字 (1500 字总)
- 必须**排除 DEEP 主题**: ${DEEP_THEME}
- WIDE 与 RADAR 不重复
- Markdown 格式，每条引用具体数据源名称和日期

【V37.8.6 反污染】禁止把 HTTP 错误码 / Python 异常 / 错误页 HTML / U+FFFD(�) 当外部信号
${DREAM_HG_GUARD}${DREAM_CREDIBILITY}"

WIDE_RADAR_PROMPT="$REDUCE_INTRO

---
$REDUCE_MULTI_MATERIAL
---

【今日 DEEP 主题】${DEEP_THEME} (避免重复，本段必须选其他维度)

【WIDE 选材标准】
- 跨领域：今日数据中至少 2 个不同数据源都提到
- 鲜人知：用户日常关注外的信号（不在 status.json 当前活跃任务范围）
- 多角度: 5 条目跨不同领域 (技术/行业/学术/工程实践等)

【RADAR 选材标准】
- 准期：今日仅出现 1 次但有 trend acceleration 历史或前瞻意义
- 早期：可能 1-3 个月后变成主流但现在还小众
- 跨度: 5 条目可能从不同维度（技术信号/政策信号/资本信号/社区信号等）

请按以下结构输出：

## 🌐 跨领域鲜人知 × 5

- **[主题 1 标题]**: 一段 200 字分析，含证据链 + 跨领域解读
- **[主题 2 标题]**: ...
- **[主题 3 标题]**: ...
- **[主题 4 标题]**: ...
- **[主题 5 标题]**: ...

## 📡 准期信号 × 5

- **[信号 1 标题]**: 300 字分析，含证据 + 为什么是早期 + 时间窗口推断
- **[信号 2 标题]**: ...
- **[信号 3 标题]**: ...
- **[信号 4 标题]**: ...
- **[信号 5 标题]**: ...

【V37.9.74 输出自检 — 提交前对照】
1. 第一段开头是字面量 \"## 🌐 跨领域鲜人知 × 5\" ?
2. WIDE 5 条都写了, 每条 ≈200 字 ?
3. 第二段开头是字面量 \"## 📡 准期信号 × 5\" ?
4. RADAR 5 条都写了, 每条 ≈300 字 ?
5. 没有写完 WIDE 就直接收尾 (常见漂移, 严禁) ?

【硬性要求：WIDE 段 ≈1000 字 (5×200), RADAR 段 ≈1500 字 (5×300), 合计 ≥ 2200 字。少于 1800 字或缺任一段视为不合格（V37.9.74 会自动 retry RADAR 一次, retry 仍失败则该段独立降级）。】"

log "LLM 调用 2/2 (WIDE+RADAR): 跨领域 5 + 准期 5 → 发送..."
WIDE_RADAR_RESULT=$(llm_call "$WIDE_RADAR_PROMPT" 6000 0.8 300 "$WIDE_RADAR_SYSTEM" || true)
WIDE_RADAR_CHARS=$(echo "$WIDE_RADAR_RESULT" | wc -c | tr -d ' ')
WIDE_RADAR_HEAD=$(echo "$WIDE_RADAR_RESULT" | head -c 120 | LC_ALL=C tr '\n' ' ')
log "WIDE+RADAR 完成: ${WIDE_RADAR_CHARS} chars, head: ${WIDE_RADAR_HEAD}"

# 拆分 WIDE 与 RADAR 两段（用户决策：可独立降级）
WIDE_BLOCK=""
RADAR_BLOCK=""
WIDE_STATUS="degraded"
RADAR_STATUS="degraded"
WIDE_BLOCK_CHARS=0
RADAR_BLOCK_CHARS=0
MIN_SECTION_CHARS=600  # UTF-8 ≈200 汉字（最低可接受）

if [ -n "${WIDE_RADAR_RESULT// }" ]; then
    SPLIT_RESULT=$(python3 -c "
import sys, os
sys.path.insert(0, os.path.expanduser('~'))
sys.path.insert(0, '$SCRIPT_DIR')
try:
    from kb_dream_helpers import split_wide_radar_output
    w, r = split_wide_radar_output(sys.stdin.read())
    print('===WIDE_START===')
    print(w)
    print('===WIDE_END===')
    print('===RADAR_START===')
    print(r)
    print('===RADAR_END===')
except Exception as e:
    sys.stderr.write(f'WARN: split_wide_radar_output 失败: {e}\\n')
" <<< "$WIDE_RADAR_RESULT" 2>/dev/null || echo "")

    WIDE_BLOCK=$(echo "$SPLIT_RESULT" | awk '/===WIDE_START===/{flag=1;next} /===WIDE_END===/{flag=0} flag')
    RADAR_BLOCK=$(echo "$SPLIT_RESULT" | awk '/===RADAR_START===/{flag=1;next} /===RADAR_END===/{flag=0} flag')

    WIDE_BLOCK_CHARS=$(echo "$WIDE_BLOCK" | wc -c | tr -d ' ')
    RADAR_BLOCK_CHARS=$(echo "$RADAR_BLOCK" | wc -c | tr -d ' ')
    [ "$WIDE_BLOCK_CHARS" -ge "$MIN_SECTION_CHARS" ] && WIDE_STATUS="ok"
    [ "$RADAR_BLOCK_CHARS" -ge "$MIN_SECTION_CHARS" ] && RADAR_STATUS="ok"
    log "WIDE 段: ${WIDE_BLOCK_CHARS} chars (status=${WIDE_STATUS}), RADAR 段: ${RADAR_BLOCK_CHARS} chars (status=${RADAR_STATUS})"
fi

# V37.9.74 RADAR retry 兜底 (方案 3): 当 WIDE 段 ok 但 RADAR 段缺失时, 主动调一次 LLM 只产 RADAR
# 触发场景: 2026-05-16+17 LLM drift 漏 ## 📡 header → RADAR_BLOCK="" → wc -c = 1 (echo "" 加换行)
# retry 走默认 primary (Qwen3), 失败仍走 V37.9.73 DEGRADED 兜底, 多层防御不依赖任一层 100%
# V37.9.75+ 候选 B: retry 路径切走 doubao (需 adapter 加 ?provider= 路由参数支持)
RADAR_RETRIED=false
RADAR_RETRY_CHARS=0
if [ "$RADAR_STATUS" = "degraded" ] && [ "$WIDE_STATUS" = "ok" ]; then
    log "V37.9.74: RADAR 段缺失 (${RADAR_BLOCK_CHARS} chars < ${MIN_SECTION_CHARS}), 主动 retry RADAR-only (LLM drift 兜底)..."
    RADAR_RETRIED=true

    RADAR_RETRY_SYSTEM="/no_think
你是数据分析师。RADAR_RETRY 模式: 只产出 1 段【准期信号 × 5】, 不要 WIDE 段。

【V37.9.74 retry 硬要求 — 违反整份输出作废】
- 第一行**必须**是字面量 \"## 📡 准期信号 × 5\"
- 5 条目, 每条 '- **<标题>**: <300 字分析>'
- 总长 ≥ 1500 字
- 必须**排除主题**: ${DEEP_THEME} (DEEP 已用) + WIDE 已覆盖主题

【V37.8.6 反污染】严禁把 HTTP 错误码 / Python 异常 / 错误页 HTML 当外部信号
${DREAM_HG_GUARD}${DREAM_CREDIBILITY}"

    RADAR_RETRY_PROMPT="上一次 WIDE+RADAR LLM 调用只产了 WIDE 段, 漏了 RADAR 段。
现在请只产 RADAR (准期信号 × 5), 不要重复 WIDE 已覆盖内容。

【RADAR 选材标准】
- 准期: 今日仅出现 1 次但有 trend acceleration 历史或前瞻意义
- 早期: 可能 1-3 个月后变成主流但现在还小众
- 跨度: 5 条目跨不同维度 (技术信号 / 政策信号 / 资本信号 / 社区信号等)

【今日 DEEP 主题, 严禁重复】${DEEP_THEME}

【上次 WIDE 已覆盖主题, 避免重复】
${WIDE_BLOCK}

请直接输出, 第一行必须是字面量 \"## 📡 准期信号 × 5\":

## 📡 准期信号 × 5

- **[信号 1 标题]**: 300 字分析, 含证据 + 为什么是早期 + 时间窗口推断
- **[信号 2 标题]**: ...
- **[信号 3 标题]**: ...
- **[信号 4 标题]**: ...
- **[信号 5 标题]**: ...

【硬性要求: 总长 ≥ 1500 字。少于 1200 字或缺 RADAR header 视为失败 (走 V37.9.73 DEGRADED 占位)。】"

    # V37.9.76 Capability Router (shadow 模式): 记录决策不改 LLM 路由行为
    # 一周观察 ~/.kb/router_decisions.jsonl 数据后, V37.9.77+ 评估 enforcement (adapter ?provider= 支持)
    # FAIL-OPEN: router_decide.py 缺失/异常不阻塞 retry, ROUTER_CHOICE 留空即可
    if [ -x "$HOME/router_decide.py" ]; then
        ROUTER_CHOICE=$(python3 "$HOME/router_decide.py" --job-id kb_dream --task radar_retry 2>/dev/null || true)
    elif [ -f "$SCRIPT_DIR/router_decide.py" ]; then
        ROUTER_CHOICE=$(python3 "$SCRIPT_DIR/router_decide.py" --job-id kb_dream --task radar_retry 2>/dev/null || true)
    else
        ROUTER_CHOICE=""
    fi
    # V37.9.77 enforcement: 默认 ROUTER_ENFORCE=off → 行为同 V37.9.76 shadow (向后兼容).
    # ROUTER_ENFORCE=on env 时把 ROUTER_CHOICE 通过 ?provider=X 传给 adapter, 真路由切换.
    # FAIL-OPEN: 如 chosen=no_router_profile/unknown 或 adapter 不识别 → adapter 自动回退默认 provider.
    EFFECTIVE_PROVIDER=""
    if [ "${ROUTER_ENFORCE:-off}" = "on" ] && [ -n "$ROUTER_CHOICE" ] \
       && [ "$ROUTER_CHOICE" != "unknown" ] && [ "$ROUTER_CHOICE" != "no_router_profile" ] \
       && [ "$ROUTER_CHOICE" != "no_matching_provider" ]; then
        EFFECTIVE_PROVIDER="$ROUTER_CHOICE"
        log "V37.9.77 enforcement: ROUTER_ENFORCE=on → 走 ?provider=$EFFECTIVE_PROVIDER (router 真路由)"
    else
        log "V37.9.76 router (shadow): chosen=${ROUTER_CHOICE:-unknown} (decision logged, not enforced — adapter PROVIDER_NAME 真实路由不变)"
    fi
    RADAR_RETRY_RESULT=$(llm_call "$RADAR_RETRY_PROMPT" 3000 0.8 200 "$RADAR_RETRY_SYSTEM" "$EFFECTIVE_PROVIDER" || true)
    RADAR_RETRY_CHARS=$(echo "$RADAR_RETRY_RESULT" | wc -c | tr -d ' ')
    RADAR_RETRY_HAS_HEADER=false
    echo "$RADAR_RETRY_RESULT" | grep -q '## 📡' && RADAR_RETRY_HAS_HEADER=true
    log "V37.9.74 RADAR retry 完成: ${RADAR_RETRY_CHARS} chars, has_header=${RADAR_RETRY_HAS_HEADER}"

    if [ "$RADAR_RETRY_CHARS" -ge "$MIN_SECTION_CHARS" ] && [ "$RADAR_RETRY_HAS_HEADER" = "true" ]; then
        RADAR_BLOCK="$RADAR_RETRY_RESULT"
        RADAR_BLOCK_CHARS=$RADAR_RETRY_CHARS
        RADAR_STATUS="ok"   # retry 成功, 下游零改动
        log "V37.9.74 RADAR retry 成功: ${RADAR_BLOCK_CHARS} chars → status=ok (retry-recovered)"
    else
        log "V37.9.74 RADAR retry 失败 (${RADAR_RETRY_CHARS} chars / has_header=${RADAR_RETRY_HAS_HEADER}), 走 V37.9.73 DEGRADED 兜底"
    fi
fi

# 单段失败用 [DEGRADED] 标记但不阻塞推送（用户决策：DEEP 必过 / WIDE+RADAR 独立降级）
if [ "$WIDE_STATUS" = "degraded" ]; then
    WIDE_BLOCK="## 🌐 跨领域鲜人知 × 5

⚠️ [DEGRADED] WIDE 段本日生成失败 (${WIDE_BLOCK_CHARS} chars < ${MIN_SECTION_CHARS}). DEEP 主题段仍有效, 明日重试。"
    dream_fail_alert "V37.9.68 WIDE 段降级 (${WIDE_BLOCK_CHARS} chars)，DEEP 推送不阻塞"
fi
if [ "$RADAR_STATUS" = "degraded" ]; then
    RADAR_BLOCK="## 📡 准期信号 × 5

⚠️ [DEGRADED] RADAR 段本日生成失败 (${RADAR_BLOCK_CHARS} chars < ${MIN_SECTION_CHARS}, V37.9.74 retry 也失败). DEEP 主题段仍有效, 明日重试。"
    dream_fail_alert "V37.9.74 RADAR 段双失败 (原始 chars + retry chars 都 < ${MIN_SECTION_CHARS})，DEEP 推送不阻塞"
fi

# ─── 总览段（规则提取，不调 LLM）───
OVERVIEW_BLOCK=$(WIDE_INPUT="$WIDE_BLOCK" RADAR_INPUT="$RADAR_BLOCK" DEEP_TH="$DEEP_THEME" SRC_C="$SRC_COUNT" NOTE_C="$NOTE_COUNT" KB_KB="$((TOTAL_KB_BYTES / 1024))" REDUCE_C="$REDUCE_CHARS" python3 -c "
import sys, os
sys.path.insert(0, os.path.expanduser('~'))
sys.path.insert(0, '$SCRIPT_DIR')
try:
    from kb_dream_helpers import build_overview_block, extract_section_titles
    wide_md = os.environ.get('WIDE_INPUT', '')
    radar_md = os.environ.get('RADAR_INPUT', '')
    wide_titles = extract_section_titles(wide_md, max_n=5)
    radar_titles = extract_section_titles(radar_md, max_n=5)
    overview = build_overview_block(
        deep_theme=os.environ.get('DEEP_TH', ''),
        wide_themes=wide_titles,
        radar_themes=radar_titles,
        kb_stats={
            'sources_count': int(os.environ.get('SRC_C', '0') or '0'),
            'notes_count': int(os.environ.get('NOTE_C', '0') or '0'),
            'kb_kbytes': int(os.environ.get('KB_KB', '0') or '0'),
            'reduce_chars': int(os.environ.get('REDUCE_C', '0') or '0'),
        },
    )
    print(overview)
except Exception as e:
    sys.stderr.write(f'WARN: build_overview_block 失败: {e}\\n')
    print('## 📋 今日连动 + 明日关注\\n\\n(总览段生成失败)')
" 2>/dev/null || echo "## 📋 今日连动 + 明日关注

(总览段生成失败)")
log "总览段构造完成"

# ─── 拼接 4 段 DREAM_RESULT ───
DREAM_RESULT="$DEEP_RESULT

$WIDE_BLOCK

$RADAR_BLOCK

$OVERVIEW_BLOCK"
DREAM_CHARS=$(echo "$DREAM_RESULT" | wc -c | tr -d ' ')

# 段成功率统计（兼容旧 SUCCESSFUL_CHUNKS 命名）
SUCCESSFUL_SECTIONS=1  # DEEP 必过（已 fail-fast 守过）
[ "$WIDE_STATUS" = "ok" ] && SUCCESSFUL_SECTIONS=$((SUCCESSFUL_SECTIONS + 1))
[ "$RADAR_STATUS" = "ok" ] && SUCCESSFUL_SECTIONS=$((SUCCESSFUL_SECTIONS + 1))
SUCCESSFUL_SECTIONS=$((SUCCESSFUL_SECTIONS + 1))  # 总览（规则提取永远成功）
SUCCESSFUL_CHUNKS=$SUCCESSFUL_SECTIONS  # V37.9.68 向后兼容旧 status.json schema

log "三阶推送完成: ${SUCCESSFUL_SECTIONS}/4 段成功 (DEEP / WIDE=${WIDE_STATUS} / RADAR=${RADAR_STATUS} / Overview), 总计 ${DREAM_CHARS} chars"

# ═══════════════════════════════════════════════════════════════════
# 6. 输出"梦境"
# ═══════════════════════════════════════════════════════════════════

MODE_DESC="MapReduce 全量（$MAP_COUNT 源 × 独立信号提取 → 跨域关联）"
[ "$FAST_MODE" = true ] || [ "$MAP_COUNT" -eq 0 ] && MODE_DESC="直接采样（80K chars）"

{
    echo "# 🌙 Agent Dream — $DAY"
    echo ""
    echo "> 模式: $MODE_DESC"
    echo "> 覆盖: $SRC_COUNT sources ($((TOTAL_KB_BYTES / 1024))KB) + $NOTE_COUNT notes"
    echo "> Reduce 素材: ${REDUCE_CHARS} chars"
    echo "> 生成时间: $(TZ=Asia/Hong_Kong date '+%Y-%m-%d %H:%M:%S')"
    echo ""
    echo "$DREAM_RESULT"
    echo ""
    echo "---"
    echo "*Generated by kb_dream.sh v2 (MapReduce) — ${TOTAL_KB_BYTES} bytes of knowledge, ${MAP_COUNT} sources deep-analyzed, every signal counts.*"
} > "$DREAM_FILE"

log "梦境已写入: $DREAM_FILE ($(wc -c < "$DREAM_FILE" | tr -d ' ') bytes)"

# ═══════════════════════════════════════════════════════════════════
# 7. 推送 + 状态记录
# ═══════════════════════════════════════════════════════════════════

# 推送完整梦境（分段发送，每段 ≤ 4000 字符，确保 WhatsApp 可读性）
# notify.sh 已在脚本开头加载（用于失败告警），这里直接使用

SENT=false
if $_DREAM_NOTIFY_LOADED; then
    log "推送开始: OPENCLAW_PHONE=${OPENCLAW_PHONE:-(unset)} DISCORD_CH_DAILY=${DISCORD_CH_DAILY:-(unset)}"

    # 用 helper 函数按章节智能分段，写入临时文件
    # V37.9.68-hotfix: 改用 kb_dream_helpers.split_dream_into_chunks (MR-8 single-source-of-truth)
    # 修复 V37.9.68 设计假设错配 (类别 B): 旧合并算法把 WIDE+RADAR 等小段合并成 1 chunk
    # 导致 Mac Mini 5/15 03:00 cron 推送 [3/3] 而非 [4/4]
    CHUNK_DIR=$(mktemp -d)
    TOTAL_PARTS=$(python3 -c "
import sys, os
sys.path.insert(0, os.path.expanduser('~'))
sys.path.insert(0, '$SCRIPT_DIR')
try:
    from kb_dream_helpers import split_dream_into_chunks
except ImportError as e:
    sys.stderr.write(f'FATAL: kb_dream_helpers.split_dream_into_chunks import 失败: {e}\\n')
    sys.exit(1)

text = sys.stdin.read()
chunk_dir = '$CHUNK_DIR'
max_chunk = 4000

chunks = split_dream_into_chunks(text, max_chunk=max_chunk)

for idx, chunk in enumerate(chunks):
    with open(os.path.join(chunk_dir, f'{idx:03d}.txt'), 'w') as f:
        f.write(chunk)

print(len(chunks))
" <<< "$DREAM_RESULT")

    PART_IDX=0
    SEND_OK=0

    for chunk_file in "$CHUNK_DIR"/*.txt; do
        [ -f "$chunk_file" ] || continue
        PART_IDX=$((PART_IDX + 1))
        segment=$(cat "$chunk_file")

        if [ "$TOTAL_PARTS" -gt 1 ]; then
            PUSH_MSG="🌙 Agent Dream ($DAY) [$PART_IDX/$TOTAL_PARTS]

$segment"
        else
            PUSH_MSG="🌙 Agent Dream ($DAY)

$segment"
        fi

        if notify "$PUSH_MSG" --topic daily; then
            SEND_OK=$((SEND_OK + 1))
        else
            log "WARN: 第 $PART_IDX/$TOTAL_PARTS 段推送失败"
        fi

        # 段间间隔 1 秒，避免消息乱序
        [ "$PART_IDX" -lt "$TOTAL_PARTS" ] && sleep 1
    done

    rm -rf "$CHUNK_DIR"

    if [ "$SEND_OK" -eq "$TOTAL_PARTS" ]; then
        log "梦境已推送 $SEND_OK/$TOTAL_PARTS 段到 WhatsApp + Discord"
        SENT=true
    elif [ "$SEND_OK" -gt 0 ]; then
        log "WARN: 梦境部分推送成功 $SEND_OK/$TOTAL_PARTS 段"
        SENT="\"partial:${SEND_OK}/${TOTAL_PARTS}\""
        dream_fail_alert "梦境推送不完整: $SEND_OK/$TOTAL_PARTS 段成功"
    else
        log "ERROR: 所有 $TOTAL_PARTS 段推送均失败"
        SENT=false
        dream_fail_alert "梦境已生成(${DREAM_CHARS}字)但推送全部失败(${TOTAL_PARTS}段) — 检查 OPENCLAW_PHONE/DISCORD_CH_DAILY 环境变量"
    fi
else
    log "WARN: notify.sh 未找到，跳过推送"
fi

# 状态记录（V37.9.68: schema 升级含三段独立 status）
# Reduce 写 .last_run.json，Map 写 .last_map.json — 互不覆盖，INV-JOB-001
# V37.9.68 字段：deep_chars / wide_status / radar_status / wide_chars / radar_chars / multitheme
# V37.9.74 新增：radar_retried / radar_retry_chars — RADAR retry 真激活可观测 (LLM drift 频率监测)
# V37.9.75 新增：deep_retried / deep_retry_chars — DEEP retry 真激活可观测 (LLM partial content 频率监测)
printf '{"time":"%s","status":"ok","mode":"%s","multitheme":true,"map_count":%d,"sources":%d,"notes":%d,"kb_bytes":%d,"reduce_chars":%d,"dream_bytes":%d,"deep_chars":%d,"deep_retried":%s,"deep_retry_chars":%d,"wide_status":"%s","wide_chars":%d,"radar_status":"%s","radar_chars":%d,"radar_retried":%s,"radar_retry_chars":%d,"sent":%s,"map_degraded":%s}\n' \
    "$(TZ=Asia/Hong_Kong date '+%Y-%m-%d %H:%M:%S')" \
    "$([ "$FAST_MODE" = true ] && echo 'fast' || echo 'mapreduce')" \
    "$MAP_COUNT" "$SRC_COUNT" "$NOTE_COUNT" "$TOTAL_KB_BYTES" "$REDUCE_CHARS" \
    "$(wc -c < "$DREAM_FILE" | tr -d ' ')" \
    "${DEEP_CHARS:-0}" "${DEEP_RETRIED:-false}" "${DEEP_RETRY_CHARS:-0}" \
    "${WIDE_STATUS:-unknown}" "${WIDE_BLOCK_CHARS:-0}" "${RADAR_STATUS:-unknown}" "${RADAR_BLOCK_CHARS:-0}" \
    "${RADAR_RETRIED:-false}" "${RADAR_RETRY_CHARS:-0}" \
    "$SENT" "$MAP_DEGRADED" > "$STATUS_FILE"

# 清理过期 map 缓存（保留 3 天）
find "$MAP_DIR" -name "*.txt" -mtime +3 -delete 2>/dev/null || true

# rsync 备份（V37.9.4: 失败 log WARN 而非静默吞错，MR-4 silent-failure 修复）
bash "$HOME/movespeed_rsync_helper.sh" "$0" -- -a "$KB_BASE/dreams/" "/Volumes/MOVESPEED/KB/dreams/"  # V37.9.27 jitter+retry+fail-loud+capture (replaces V37.9.4/V37.9.14 inline pattern)
log "完成。模式=$MODE_DESC"
