use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::Result;
use futures::future::join_all;
use crate::config::schema::WikiConfig;
use crate::generate::chunk::Chunk;
use crate::generate::llm::{LlmProvider, Provider};
use crate::generate::prompt;
use crate::model::{EntitySummary, KnowledgeCard};
pub enum CardAction {
Generate { module: String },
Modify { module: String, instruction: String, references: Vec<PathBuf> },
Supplement { module: String, instruction: String, references: Vec<PathBuf> },
Rewrite { module: String, instruction: String, references: Vec<PathBuf> },
}
pub enum CardEditMode {
Modify,
Supplement,
Rewrite,
}
impl CardEditMode {
fn as_str(&self) -> &'static str {
match self {
CardEditMode::Modify => "modify",
CardEditMode::Supplement => "supplement",
CardEditMode::Rewrite => "rewrite",
}
}
}
pub struct CardGenerator<'a, P: LlmProvider> {
provider: &'a P,
call_count: AtomicUsize,
semaphore: tokio::sync::Semaphore,
language: String,
config: WikiConfig,
failed: std::sync::Mutex<Vec<String>>,
}
impl<'a, P: LlmProvider> CardGenerator<'a, P> {
pub fn new(
provider: &'a P,
config: WikiConfig,
max_concurrent: usize,
language: String,
) -> Self {
let max = if max_concurrent == 0 { 1_000_000_000 } else { max_concurrent };
Self {
provider,
call_count: AtomicUsize::new(0),
semaphore: tokio::sync::Semaphore::new(max),
language,
config,
failed: std::sync::Mutex::new(Vec::new()),
}
}
pub fn llm_call_count(&self) -> usize {
self.call_count.load(Ordering::Relaxed)
}
pub fn failed_modules(&self) -> Vec<String> {
self.failed.lock().map(|g| g.clone()).unwrap_or_default()
}
pub async fn generate_card(
&self,
chunk: &Chunk,
pending_manual_edits: &[String],
) -> Result<KnowledgeCard> {
if chunk.is_empty() {
anyhow::bail!("空块,跳过生成");
}
let _permit = self.semaphore.acquire().await.map_err(|_| {
anyhow::anyhow!("信号量已关闭")
})?;
self.call_count.fetch_add(1, Ordering::Relaxed);
let messages = prompt::knowledge_card_prompt(
chunk,
&self.language,
pending_manual_edits,
);
let response = self.provider.complete(&messages).await?;
let mut card = parse_card_response(&response, chunk)?;
if !pending_manual_edits.is_empty() {
card.pending_manual_edits = pending_manual_edits.to_vec();
}
backfill_entity_sources(&mut card, chunk);
Ok(card)
}
pub async fn generate_all_cards(
&self,
chunks: &[Chunk],
extra_edits: &std::collections::HashMap<String, Vec<String>>,
) -> Result<Vec<KnowledgeCard>> {
let mut handles = Vec::with_capacity(chunks.len());
let mut task_modules: Vec<String> = Vec::with_capacity(chunks.len());
for chunk in chunks {
if chunk.is_empty() {
continue;
}
let module = chunk.module_path.join("::");
task_modules.push(module.clone());
let mut pending = match self.recover_pending_manual_edits(&module) {
Ok(p) => p,
Err(e) => {
tracing::warn!(
"读取旧卡片人工修改记录失败,本次生成不携带旧记录 {}: {}",
module,
e
);
Vec::new()
}
};
if let Some(extra) = extra_edits.get(&module) {
for note in extra {
if !pending.contains(note) {
pending.push(note.clone());
}
}
}
let generator = self;
handles.push(async move { generator.generate_card(chunk, &pending).await });
}
let results = join_all(handles).await;
let cards: Vec<KnowledgeCard> = task_modules
.into_iter()
.zip(results)
.filter_map(|(module, r)| {
match r {
Ok(card) => Some(card),
Err(e) => {
tracing::warn!("Knowledge Card 生成失败,跳过 {}: {}", module, e);
if let Ok(mut failed) = self.failed.lock() {
failed.push(module);
}
None
}
}
})
.collect();
Ok(cards)
}
fn recover_pending_manual_edits(&self, module: &str) -> Result<Vec<String>> {
Ok(read_card(&self.config, module)?
.map(|content| extract_pending_manual_edits(&content))
.unwrap_or_default())
}
}
fn card_path(config: &WikiConfig, module: &str) -> PathBuf {
let primary_lang = &crate::output::wiki_languages(config)[0];
crate::output::card_page_path(config.output_dir(), primary_lang, module)
}
pub fn read_card(config: &WikiConfig, module: &str) -> Result<Option<String>> {
let path = card_path(config, module);
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn read_references(references: &[PathBuf]) -> Result<String> {
let mut block = String::new();
for path in references {
let content = std::fs::read_to_string(path)?;
block.push_str(&format!("\n\n### {}\n{}", path.display(), content));
}
Ok(block)
}
fn extract_markdown(text: &str) -> &str {
let text = text.trim();
match text.strip_prefix("```") {
Some(rest) => rest
.split_once('\n')
.map(|(_, body)| body.trim().trim_end_matches("```").trim())
.unwrap_or(text),
None => text,
}
}
fn write_card_atomic(config: &WikiConfig, module: &str, content: &str) -> Result<()> {
let path = card_path(config, module);
crate::fs::write_file_atomic(&path, content)
}
fn extract_pending_manual_edits(content: &str) -> Vec<String> {
let mut items = Vec::new();
let mut in_section = false;
for line in content.lines() {
if line.starts_with("## ") {
in_section = line == "## 人工修改待同步";
continue;
}
if in_section && let Some(item) = line.strip_prefix("- ") {
items.push(item.to_string());
}
}
items
}
pub async fn generate_module_card(
provider: &Provider,
config: &WikiConfig,
root: &crate::project::ProjectRoot,
module: &str,
) -> Result<()> {
let insights = crate::ingest::scan_and_parse_at(root)?.insights;
let graph = crate::analysis::build_graph(&insights)?;
let chunks = crate::generate::chunk::chunk_by_module(&insights, &graph.modules, &graph);
let chunk = chunks
.into_iter()
.find(|c| c.module_path.join("::") == module)
.ok_or_else(|| anyhow::anyhow!(
"未找到模块 {module} 对应的代码分块,请检查模块名或先运行 `code-repo-wiki generate` 全量生成"
))?;
let pending = read_card(config, module)?
.map(|content| extract_pending_manual_edits(&content))
.unwrap_or_default();
let generator = CardGenerator::new(provider, config.clone(), 1, config.wiki.language.clone());
let card = generator.generate_card(&chunk, &pending).await?;
let content = crate::output::markdown::render_knowledge_card(&card);
write_card_atomic(config, module, &content)?;
tracing::info!("卡片已生成: {} → {}", module, card_path(config, module).display());
Ok(())
}
pub async fn edit_card(
provider: &Provider,
config: &WikiConfig,
module: &str,
instruction: &str,
references: &[PathBuf],
mode: CardEditMode,
) -> Result<()> {
let existing = read_card(config, module)?.ok_or_else(|| anyhow::anyhow!(
"模块 {module} 的卡片不存在({}),请先运行 `code-repo-wiki generate` 或 `code-repo-wiki card generate <module>` 生成",
card_path(config, module).display()
))?;
let reference_block = read_references(references)?;
let messages = prompt::edit_card_prompt(
mode.as_str(),
module,
&existing,
instruction,
&reference_block,
&config.wiki.language,
);
let response = provider.complete(&messages).await?;
let content = extract_markdown(&response);
write_card_atomic(config, module, content)?;
tracing::info!("卡片已更新: {} → {}", module, card_path(config, module).display());
Ok(())
}
fn parse_card_response(response: &str, chunk: &Chunk) -> Result<KnowledgeCard> {
let json_str = extract_json(response);
let parsed: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("解析卡片 JSON 失败: {}", e))?;
let summary = parsed["summary"].as_str().unwrap_or("").to_string();
let key_entities: Vec<EntitySummary> = parsed["key_entities"]
.as_array()
.map(|arr| {
arr.iter()
.map(|v| EntitySummary {
name: v["name"].as_str().unwrap_or("").to_string(),
kind: v["kind"].as_str().unwrap_or("").to_string(),
visibility: v["visibility"].as_str().unwrap_or("public").to_string(),
doc: v["doc"].as_str().map(|s| s.to_string()),
source: None,
})
.collect()
})
.unwrap_or_default();
let design_patterns: Vec<String> = parsed["design_patterns"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let todo_notes: Vec<String> = parsed["todo_notes"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let coding_spec = parsed["coding_spec"].as_str().map(|s| s.to_string());
let tech_stack: Vec<String> = parsed["tech_stack"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let architecture = parsed["architecture"].as_str().map(|s| s.to_string());
Ok(KnowledgeCard {
module_name: chunk.module_path.join("::"),
module_type: "module".to_string(),
summary,
key_entities,
dependencies: chunk.dependencies.clone(),
dependents: Vec::new(),
design_patterns,
todo_notes,
related_files: chunk
.file_paths
.iter()
.map(|p| p.display().to_string())
.collect(),
coding_spec,
tech_stack,
architecture,
pending_manual_edits: Vec::new(),
features: Vec::new(),
})
}
fn backfill_entity_sources(card: &mut KnowledgeCard, chunk: &Chunk) {
for es in &mut card.key_entities {
if es.source.is_some() {
continue;
}
if let Some((idx, entity)) = chunk
.entities
.iter()
.enumerate()
.find(|(_, e)| e.name == es.name)
&& let Some(file) = chunk.entity_sources.get(idx)
{
es.source = Some(format!(
"{}:{}-{}",
file.display(),
entity.line_start,
entity.line_end
));
}
}
}
fn extract_json(text: &str) -> &str {
let text = text.trim();
if let Some(start) = text.find('{') {
let end = text.rfind('}').map(|i| i + 1).unwrap_or(text.len());
&text[start..end]
} else {
text
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::chunk::chunk_by_file;
use crate::generate::llm::MockProvider;
use crate::ingest::parser::{Entity, FileInsight, ImportStmt};
use std::path::PathBuf;
fn make_test_chunk() -> Chunk {
let entity = Entity {
name: "Config".into(),
kind: "struct".into(),
line_start: 1,
line_end: 30,
doc_comment: Some("配置管理".into()),
signature: None, visibility: None,
};
let insight = FileInsight {
path: PathBuf::from("src/config.rs"),
language: "rust".into(),
entities: vec![entity],
imports: vec![ImportStmt {
source: "serde".into(),
alias: None,
line: 1,
}],
doc_comments: vec![],
source: String::new(),
};
chunk_by_file(&insight)
}
#[test]
fn test_extract_json() {
let input = "```json\n{\"summary\": \"test\"}\n```";
assert_eq!(extract_json(input), "{\"summary\": \"test\"}");
let input = "{\"summary\": \"test\"}";
assert_eq!(extract_json(input), "{\"summary\": \"test\"}");
}
#[test]
fn test_parse_card_response() {
let response = r#"{"summary": "配置模块", "key_entities": [{"name": "Config", "kind": "struct", "visibility": "public", "doc": "配置结构"}], "design_patterns": ["Builder"], "todo_notes": [], "coding_spec": "遵循 rustfmt", "tech_stack": ["serde"], "architecture": "分层"}"#;
let chunk = make_test_chunk();
let card = parse_card_response(response, &chunk).unwrap();
assert_eq!(card.summary, "配置模块");
assert_eq!(card.key_entities.len(), 1);
assert_eq!(card.key_entities[0].name, "Config");
assert_eq!(card.related_files, vec!["src/config.rs".to_string()]);
assert_eq!(card.coding_spec.as_deref(), Some("遵循 rustfmt"));
assert_eq!(card.tech_stack, vec!["serde".to_string()]);
assert_eq!(card.architecture.as_deref(), Some("分层"));
}
#[test]
fn test_parse_card_empty_response() {
let response = r#"{"summary": "", "key_entities": [], "design_patterns": [], "todo_notes": []}"#;
let chunk = make_test_chunk();
let card = parse_card_response(response, &chunk).unwrap();
assert!(card.summary.is_empty());
assert!(card.key_entities.is_empty());
assert!(card.coding_spec.is_none());
assert!(card.tech_stack.is_empty());
assert!(card.architecture.is_none());
}
fn card_fixture(tag: &str, module: &str, content: &str) -> (WikiConfig, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_card_{tag}_{}_{}", module.replace("::", "_"), std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let config = WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
std::fs::create_dir_all(config.output_dir().join("cards").join("zh")).unwrap();
std::fs::write(card_path(&config, module), content).unwrap();
(config, dir)
}
#[tokio::test]
async fn test_edit_card_supplement_roundtrip() {
let (config, dir) = card_fixture("supplement", "crate::test", "# crate::test\n\n## 摘要\n旧内容");
let provider = Provider::Mock(MockProvider::new());
edit_card(
&provider,
&config,
"crate::test",
"追加新内容",
&[],
CardEditMode::Supplement,
)
.await
.unwrap();
let written = std::fs::read_to_string(config.output_dir().join("cards").join("zh").join("crate_test.md")).unwrap();
assert!(written.contains("模拟摘要"), "应写入 Mock Provider 的响应内容");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_edit_card_requires_existing() {
let (config, dir) = card_fixture("missing", "crate::test", "内容");
let provider = Provider::Mock(MockProvider::new());
let err = edit_card(
&provider,
&config,
"crate::missing",
"指令",
&[],
CardEditMode::Rewrite,
)
.await
.unwrap_err();
assert!(err.to_string().contains("卡片不存在"), "应报卡片不存在: {}", err);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_extract_markdown_strips_codeblock() {
assert_eq!(extract_markdown("```markdown\n# 标题\n```"), "# 标题");
assert_eq!(extract_markdown("# 标题"), "# 标题");
}
#[test]
fn test_extract_pending_manual_edits() {
let content = "# src::test\n\n## 摘要\n旧内容\n\n## 人工修改待同步\n\n- 人工修改待同步: a.md 内容摘要: 1\n- 人工修改待同步: b.md 内容摘要: 2\n\n## 待办事项\n\n- [ ] x\n";
let items = extract_pending_manual_edits(content);
assert_eq!(items.len(), 2, "应提取节内两条记录");
assert!(items[0].contains("a.md"));
assert!(items[1].contains("b.md"));
assert!(extract_pending_manual_edits("# t\n\n## 摘要\nx").is_empty());
}
#[tokio::test]
async fn test_generate_card_keeps_pending_manual_edits() {
let chunk = make_test_chunk();
let provider = Provider::Mock(MockProvider::new());
let (config, dir) = card_fixture("pending", "src", "旧卡片内容");
let generator = CardGenerator::new(&provider, config, 1, "zh".into());
let pending = vec!["人工修改待同步: wiki/zh/src_config.md 内容摘要: 用户改的".into()];
let card = generator.generate_card(&chunk, &pending).await.unwrap();
assert_eq!(card.pending_manual_edits, pending);
let card = generator.generate_card(&chunk, &[]).await.unwrap();
assert!(card.pending_manual_edits.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_generate_all_cards_merges_recovered_and_extra_edits() {
let chunk = make_test_chunk();
let provider = Provider::Mock(MockProvider::new());
let (config, dir) = card_fixture(
"merge",
"src",
"# src\n\n## 摘要\n旧内容\n\n## 人工修改待同步\n\n- 人工修改待同步: wiki/zh/src.md 内容摘要: 旧记录\n",
);
let generator = CardGenerator::new(&provider, config, 1, "zh".into());
let mut extra = std::collections::HashMap::new();
extra.insert(
"src".to_string(),
vec!["人工修改待同步: wiki/zh/src.md 内容摘要: 新修改".to_string()],
);
let cards = generator.generate_all_cards(&[chunk], &extra).await.unwrap();
assert_eq!(cards.len(), 1);
assert_eq!(cards[0].pending_manual_edits.len(), 2, "旧记录 + 新记录应合并为 2 条");
assert!(cards[0].pending_manual_edits.iter().any(|n| n.contains("旧记录")));
assert!(cards[0].pending_manual_edits.iter().any(|n| n.contains("新修改")));
let cards2 = generator.generate_all_cards(
&[make_test_chunk()],
&extra,
).await.unwrap();
assert!(
cards2[0].pending_manual_edits.len() <= 2,
"重复记录应被去重: {:?}",
cards2[0].pending_manual_edits
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_generate_all_cards_survives_card_read_failure() {
let chunk = make_test_chunk();
let provider = Provider::Mock(MockProvider::new());
let (config, dir) = card_fixture("readfail", "src", "# src\n\n## 摘要\n旧内容");
let card_file = card_path(&config, "src");
std::fs::remove_file(&card_file).unwrap();
std::fs::create_dir_all(&card_file).unwrap();
let generator = CardGenerator::new(&provider, config, 1, "zh".into());
let mut extra = std::collections::HashMap::new();
extra.insert(
"src".to_string(),
vec!["人工修改待同步: wiki/zh/src.md 内容摘要: 新修改".to_string()],
);
let cards = generator.generate_all_cards(&[chunk], &extra).await.unwrap();
assert_eq!(cards.len(), 1, "旧卡片读失败不应中断整批生成");
assert_eq!(
cards[0].pending_manual_edits,
vec!["人工修改待同步: wiki/zh/src.md 内容摘要: 新修改".to_string()],
"旧记录读取失败降级为空,只携带本次 extra 记录"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_generate_all_cards_skips_empty_chunks_records_real_failures() {
let mut empty = make_test_chunk();
empty.module_path = vec!["zzz".into()];
empty.entities = Vec::new();
empty.imports = Vec::new();
let provider = Provider::Mock(MockProvider::new());
let failing = FailingProvider;
let (config, dir) = card_fixture("interleave-fail", "src", "# src\n\n## 摘要\n旧内容");
let fail_gen = CardGenerator::new(&failing, config, 1, "zh".into());
let cards = fail_gen
.generate_all_cards(&[empty.clone(), make_test_chunk()], &std::collections::HashMap::new())
.await
.unwrap();
assert!(cards.is_empty(), "失败模块不产出卡片");
assert_eq!(
fail_gen.failed_modules(),
vec!["src"],
"失败必须归因到真实失败模块(空 chunk 已被入口剔除,不参与对齐): {:?}",
fail_gen.failed_modules()
);
let _ = std::fs::remove_dir_all(&dir);
let (config2, dir2) = card_fixture("interleave-ok", "src", "# src\n\n## 摘要\n旧内容");
let gen2 = CardGenerator::new(&provider, config2, 1, "zh".into());
let cards2 = gen2
.generate_all_cards(&[empty, make_test_chunk()], &std::collections::HashMap::new())
.await
.unwrap();
assert_eq!(cards2.len(), 1, "成功卡片不得静默丢失");
assert_eq!(cards2[0].module_name, "src");
assert!(gen2.failed_modules().is_empty(), "无真实失败时不记 failed_modules");
let _ = std::fs::remove_dir_all(&dir2);
}
struct FailingProvider;
impl LlmProvider for FailingProvider {
async fn complete(
&self,
_messages: &[crate::generate::llm::Message],
) -> anyhow::Result<String> {
anyhow::bail!("模拟 LLM 调用失败")
}
async fn complete_stream(
&self,
_messages: &[crate::generate::llm::Message],
) -> anyhow::Result<Vec<String>> {
anyhow::bail!("模拟 LLM 调用失败")
}
fn call_count(&self) -> usize {
0
}
}
}