code-repo-wiki 0.4.0

自动分析代码仓库结构,通过 LLM 生成结构化项目文档(Code Repo Wiki)
Documentation
//! G3: 阅读指南 index.md 生成
//!
//! 输入 = 模块列表(名/卡片摘要描述)+ 模块间依赖/入度信息,
//! 输出 = wiki/{主语言}/index.md(推荐阅读顺序 + 主题分组)。
//!
//! LLM 失败重试 1 次,仍失败则降级为确定性骨架(按模块入度中心度降序的
//! 链接列表)。降级而非报错的原因:index.md 是仓库导航入口,骨架保证任何
//! 仓库在任何 LLM 配置下都有可用的阅读指南;错误向上传播会中断整条生成
//! 流水线,与全局文档(架构/概览)的"失败只告警不中断"错误策略不一致。

use std::collections::{BTreeSet, HashMap};

use chrono::Utc;
use petgraph::visit::{EdgeRef, IntoEdgeReferences};

use crate::config::schema::WikiConfig;
use crate::generate::llm::{LlmProvider, Message};
use crate::model::{
    DocumentKind, EdgeKind, KnowledgeCard, KnowledgeGraph, NodeId, Reference, WikiDocument,
};

/// LLM 调用失败后的重试次数(共 INDEX_RETRY_MAX + 1 = 2 次尝试)
const INDEX_RETRY_MAX: usize = 1;

/// 单模块的阅读信息快照(LLM prompt 输入与降级骨架的共同数据源)
struct ModuleGuideInfo {
    name: String,
    /// 模块描述:取生成层已产出的卡片摘要(describe_modules 不写回 graph,
    /// 模块聚类自身的 description 恒为空)
    description: String,
    /// 依赖本模块的模块列表(入边,按名称字典序)
    dependents: Vec<String>,
    /// 本模块依赖的模块列表(出边,按名称字典序)
    dependencies: Vec<String>,
    /// 入度 = 依赖本模块的模块数(模块对去重)
    in_degree: usize,
}

/// 生成阅读指南文档(仅主语言)
///
/// LLM 成功 → 直接采用 LLM 输出;失败重试 1 次仍失败 → 确定性骨架。
/// 返回文档的写盘路径 = wiki/{主语言}/index.md(经 render_all 的
/// wiki_page_path 由 title 派生;language 取 config.wiki.language,
/// 扩展语言目录不写)。
pub async fn generate_index_guide<P: LlmProvider>(
    provider: &P,
    graph: &KnowledgeGraph,
    cards: &[KnowledgeCard],
    config: &WikiConfig,
) -> WikiDocument {
    let infos = collect_module_infos(graph, cards);
    let messages = index_guide_prompt(&infos, &config.wiki.language);
    // 失败重试 1 次:单次调用失败可能是瞬时抖动(限流/超时/服务端错误),
    // 重试成本低;连续两次失败说明 LLM 通道不可用,再耗调用无意义,降级。
    let mut last_err = None;
    for _ in 0..=INDEX_RETRY_MAX {
        match provider.complete(&messages).await {
            Ok(content) => {
                // U04/D8:阅读指南页 mermaid 校验——LLM 输出坏图时降级为
                // text 块(不重试:阅读指南无图也可读,重试只针对通道失败;
                // 与架构/概览的降级语义一致,坏图不出现在产物中)。
                let issues = crate::output::mermaid_check::validate_mermaid_blocks(&content);
                let content = if issues.is_empty() {
                    content
                } else {
                    tracing::warn!(
                        "阅读指南含 {} 个坏 Mermaid 块,降级为 text 块",
                        issues.len()
                    );
                    crate::output::mermaid_check::degrade_mermaid_blocks(&content, &issues)
                };
                return make_document(content, config, &infos);
            }
            Err(e) => last_err = Some(e),
        }
    }
    tracing::warn!(
        "阅读指南 LLM 生成失败(重试 {} 次),降级为确定性骨架: {:?}",
        INDEX_RETRY_MAX,
        last_err
    );
    fallback_index_guide(graph, config)
}

/// 确定性降级骨架:按模块入度中心度降序(入度相同按名称字典序)输出链接列表
///
/// 排序唯一性:sort_by 使用 (入度, 名称) 的全序比较,边集用 BTreeSet 去重,
/// 全程不依赖 HashMap 迭代序 —— 同输入必得同输出(CI/人工复跑产物一致)。
pub fn fallback_index_guide(graph: &KnowledgeGraph, config: &WikiConfig) -> WikiDocument {
    let infos = collect_module_infos(graph, &[]);
    let mut body = String::new();
    body.push_str("# 阅读指南\n\n");
    body.push_str("> LLM 生成不可用,本指南为确定性骨架:按模块被依赖程度(入度中心度)降序推荐阅读顺序。\n\n");
    body.push_str("## 推荐阅读顺序\n\n");
    for info in &infos {
        body.push_str(&format!(
            "- [{}](wiki/{}/{}.md) — 入度 {}",
            info.name,
            config.wiki.language,
            info.name.replace("::", "_"),
            info.in_degree
        ));
        if !info.dependents.is_empty() {
            body.push_str(&format!(", 被 {} 依赖", info.dependents.join(", ")));
        }
        body.push('\n');
    }
    make_document(body, config, &infos)
}

/// 收集模块阅读信息(确定性:边集用 BTreeSet,邻接列表按名称字典序)
fn collect_module_infos(graph: &KnowledgeGraph, cards: &[KnowledgeCard]) -> Vec<ModuleGuideInfo> {
    // 实体节点 → 所属模块:先到先得(graph.modules 按深度 3→1 排列,
    // 子模块优先写入,父模块(src 兜底)不覆盖子模块实体——
    // 与 output::mermaid 的模块归属同规则,保证依赖聚合口径一致)
    let mut node_module: HashMap<NodeId, String> = HashMap::new();
    for module in &graph.modules {
        for nid in &module.node_ids {
            node_module
                .entry(*nid)
                .or_insert_with(|| module.name.clone());
        }
    }

    // 跨模块依赖边(源模块 → 目标模块):Calls + Imports,排除 Contains;
    // 同一对模块的多条边合并为一条依赖(模块级语义),BTreeSet 迭代有序
    let mut edges: BTreeSet<(String, String)> = BTreeSet::new();
    for edge in graph.graph.edge_references() {
        if matches!(
            graph.graph[edge.id()].kind,
            EdgeKind::Calls | EdgeKind::Imports
        ) {
            let (Some(src), Some(tgt)) = (
                node_module.get(&edge.source()),
                node_module.get(&edge.target()),
            ) else {
                continue;
            };
            if src != tgt {
                edges.insert((src.clone(), tgt.clone()));
            }
        }
    }

    let mut infos: Vec<ModuleGuideInfo> = graph
        .modules
        .iter()
        // U04/P3:过滤空模块(node_ids 为空、无实体无文件)——此类模块
        // 无 chunk 无页面(wiki.rs 空块 bail),骨架链接它们会产断链;
        // 空 src 兜底模块同理(describe_modules 也跳过它)。
        .filter(|m| !m.node_ids.is_empty())
        .map(|m| ModuleGuideInfo {
            name: m.name.clone(),
            description: cards
                .iter()
                .find(|c| c.module_name == m.name)
                .map(|c| c.summary.clone())
                .unwrap_or_default(),
            dependents: Vec::new(),
            dependencies: Vec::new(),
            in_degree: 0,
        })
        .collect();
    let mut index_of: HashMap<String, usize> = HashMap::new();
    for (i, info) in infos.iter().enumerate() {
        index_of.insert(info.name.clone(), i);
    }
    for (src, tgt) in &edges {
        if let (Some(&si), Some(&ti)) = (index_of.get(src), index_of.get(tgt)) {
            infos[ti].in_degree += 1;
            infos[ti].dependents.push(src.clone());
            infos[si].dependencies.push(tgt.clone());
        }
    }
    for info in &mut infos {
        info.dependents.sort();
        info.dependencies.sort();
    }
    // 入度降序,同入度按名称字典序(全序比较,确定性)
    infos.sort_by(|a, b| {
        b.in_degree
            .cmp(&a.in_degree)
            .then_with(|| a.name.cmp(&b.name))
    });
    infos
}

/// 构建阅读指南 prompt
///
/// system:角色 + 输出结构(推荐阅读顺序/主题分组);user:模块列表
/// (名/描述/入度/依赖方/被依赖方),按入度降序排列并显式提示"被依赖越多
/// 越基础、建议先读"——LLM 自由组织内容时,基础性信息也已传达。
fn index_guide_prompt(infos: &[ModuleGuideInfo], language: &str) -> Vec<Message> {
    let system = format!(
        r#"你是一个资深软件架构师,负责为代码仓库生成人类可读的阅读指南(index.md)。

请基于模块列表与模块间依赖信息,输出以下结构:

# 阅读指南

## 推荐阅读顺序
按从基础到应用、从被依赖方到依赖方的顺序推荐阅读路径,每条给出理由。

## 主题分组
将模块按主题/分层分组(如基础设施、核心领域、接口层),每组给出组内阅读顺序。

要求:
1. 使用 Markdown 格式输出;
2. 模块链接必须写成 [模块名](wiki/{language}/{{模块名去"::"为"_"}}.md) 形式;
3. 覆盖输入的全部模块,不得遗漏;
4. 用 {language} 语言输出。"#
    );
    let mut user = String::from("## 模块列表\n");
    for info in infos {
        let desc = if info.description.is_empty() {
            ""
        } else {
            info.description.as_str()
        };
        user.push_str(&format!(
            "- {}: {}(入度 {},被 {} 依赖,依赖 {}\n",
            info.name,
            desc,
            info.in_degree,
            if info.dependents.is_empty() {
                "".to_string()
            } else {
                info.dependents.join(", ")
            },
            if info.dependencies.is_empty() {
                "".to_string()
            } else {
                info.dependencies.join(", ")
            },
        ));
    }
    vec![Message::system(system), Message::user(user)]
}

/// 组装阅读指南 WikiDocument
///
/// kind 用 TableOfContents(非 WikiPage):WikiPage 会在状态层按 module_path
/// 记录模块归属,index 无模块归属(module_path 空 → 归属空串),污染人工
/// 修改反向同步的归属表。写盘文件名由 title 派生(index.md),language 取
/// 主语言 → 仅主语言目录落盘。
/// references 填模块引用(与架构/概览一致,供交叉引用索引与断链校验)。
fn make_document(content: String, config: &WikiConfig, infos: &[ModuleGuideInfo]) -> WikiDocument {
    WikiDocument {
        title: "index".into(),
        kind: DocumentKind::TableOfContents,
        content,
        language: config.wiki.language.clone(),
        module_path: vec![],
        references: infos
            .iter()
            .map(|info| Reference {
                target_title: info.name.clone(),
                target_path: format!(
                    "wiki/{}/{}.md",
                    config.wiki.language,
                    info.name.replace("::", "_")
                ),
                relation: "module".into(),
            })
            .collect(),
        last_updated: Utc::now().to_rfc3339(),
        // 索引指南页由代码图渲染(非 LLM 页),不带 git 基线行
        based_on_commit: None,
        fingerprint: None,
    }
}