1use std::path::PathBuf;
2use std::sync::atomic::{AtomicUsize, Ordering};
3
4use anyhow::Result;
5use futures::future::join_all;
6
7use crate::config::schema::WikiConfig;
8use crate::generate::chunk::Chunk;
9use crate::generate::llm::{LlmProvider, Provider};
10use crate::generate::prompt;
11use crate::model::{EntitySummary, KnowledgeCard};
12
13pub enum CardAction {
15 Generate { module: String },
17 Modify { module: String, instruction: String, references: Vec<PathBuf> },
19 Supplement { module: String, instruction: String, references: Vec<PathBuf> },
21 Rewrite { module: String, instruction: String, references: Vec<PathBuf> },
23}
24
25pub enum CardEditMode {
27 Modify,
29 Supplement,
31 Rewrite,
33}
34
35impl CardEditMode {
36 fn as_str(&self) -> &'static str {
37 match self {
38 CardEditMode::Modify => "modify",
39 CardEditMode::Supplement => "supplement",
40 CardEditMode::Rewrite => "rewrite",
41 }
42 }
43}
44
45pub struct CardGenerator<'a, P: LlmProvider> {
50 provider: &'a P,
51 call_count: AtomicUsize,
52 semaphore: tokio::sync::Semaphore,
53 language: String,
55 config: WikiConfig,
57 failed: std::sync::Mutex<Vec<String>>,
59}
60
61impl<'a, P: LlmProvider> CardGenerator<'a, P> {
62 pub fn new(
68 provider: &'a P,
69 config: WikiConfig,
70 max_concurrent: usize,
71 language: String,
72 ) -> Self {
73 let max = if max_concurrent == 0 { 1_000_000_000 } else { max_concurrent };
76 Self {
77 provider,
78 call_count: AtomicUsize::new(0),
79 semaphore: tokio::sync::Semaphore::new(max),
80 language,
81 config,
82 failed: std::sync::Mutex::new(Vec::new()),
83 }
84 }
85
86 pub fn llm_call_count(&self) -> usize {
88 self.call_count.load(Ordering::Relaxed)
89 }
90
91 pub fn failed_modules(&self) -> Vec<String> {
93 self.failed.lock().map(|g| g.clone()).unwrap_or_default()
94 }
95
96 pub async fn generate_card(
103 &self,
104 chunk: &Chunk,
105 pending_manual_edits: &[String],
106 ) -> Result<KnowledgeCard> {
107 if chunk.is_empty() {
108 anyhow::bail!("空块,跳过生成");
109 }
110
111 let _permit = self.semaphore.acquire().await.map_err(|_| {
113 anyhow::anyhow!("信号量已关闭")
114 })?;
115
116 self.call_count.fetch_add(1, Ordering::Relaxed);
117
118 let messages = prompt::knowledge_card_prompt(
119 chunk,
120 &self.language,
121 pending_manual_edits,
122 );
123 let response = self.provider.complete(&messages).await?;
124
125 let mut card = parse_card_response(&response, chunk)?;
126 if !pending_manual_edits.is_empty() {
127 card.pending_manual_edits = pending_manual_edits.to_vec();
128 }
129 backfill_entity_sources(&mut card, chunk);
132 Ok(card)
133 }
134
135 pub async fn generate_all_cards(
146 &self,
147 chunks: &[Chunk],
148 extra_edits: &std::collections::HashMap<String, Vec<String>>,
149 ) -> Result<Vec<KnowledgeCard>> {
150 let mut handles = Vec::with_capacity(chunks.len());
151
152 let mut task_modules: Vec<String> = Vec::with_capacity(chunks.len());
155 for chunk in chunks {
156 if chunk.is_empty() {
162 continue;
163 }
164 let module = chunk.module_path.join("::");
165 task_modules.push(module.clone());
166 let mut pending = match self.recover_pending_manual_edits(&module) {
169 Ok(p) => p,
170 Err(e) => {
171 tracing::warn!(
172 "读取旧卡片人工修改记录失败,本次生成不携带旧记录 {}: {}",
173 module,
174 e
175 );
176 Vec::new()
177 }
178 };
179 if let Some(extra) = extra_edits.get(&module) {
180 for note in extra {
181 if !pending.contains(note) {
182 pending.push(note.clone());
183 }
184 }
185 }
186 let generator = self;
189 handles.push(async move { generator.generate_card(chunk, &pending).await });
190 }
191
192 let results = join_all(handles).await;
193 let cards: Vec<KnowledgeCard> = task_modules
194 .into_iter()
195 .zip(results)
196 .filter_map(|(module, r)| {
197 match r {
198 Ok(card) => Some(card),
199 Err(e) => {
200 tracing::warn!("Knowledge Card 生成失败,跳过 {}: {}", module, e);
202 if let Ok(mut failed) = self.failed.lock() {
203 failed.push(module);
204 }
205 None
206 }
207 }
208 })
209 .collect();
210
211 Ok(cards)
212 }
213
214 fn recover_pending_manual_edits(&self, module: &str) -> Result<Vec<String>> {
223 Ok(read_card(&self.config, module)?
224 .map(|content| extract_pending_manual_edits(&content))
225 .unwrap_or_default())
226 }
227}
228
229fn card_path(config: &WikiConfig, module: &str) -> PathBuf {
231 let primary_lang = &crate::output::wiki_languages(config)[0];
232 crate::output::card_page_path(config.output_dir(), primary_lang, module)
233}
234
235pub fn read_card(config: &WikiConfig, module: &str) -> Result<Option<String>> {
239 let path = card_path(config, module);
240 match std::fs::read_to_string(&path) {
241 Ok(content) => Ok(Some(content)),
242 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
243 Err(e) => Err(e.into()),
244 }
245}
246
247fn read_references(references: &[PathBuf]) -> Result<String> {
249 let mut block = String::new();
250 for path in references {
251 let content = std::fs::read_to_string(path)?;
252 block.push_str(&format!("\n\n### {}\n{}", path.display(), content));
253 }
254 Ok(block)
255}
256
257fn extract_markdown(text: &str) -> &str {
259 let text = text.trim();
260 match text.strip_prefix("```") {
261 Some(rest) => rest
262 .split_once('\n')
263 .map(|(_, body)| body.trim().trim_end_matches("```").trim())
264 .unwrap_or(text),
265 None => text,
266 }
267}
268
269fn write_card_atomic(config: &WikiConfig, module: &str, content: &str) -> Result<()> {
274 let path = card_path(config, module);
275 crate::fs::write_file_atomic(&path, content)
276}
277
278fn extract_pending_manual_edits(content: &str) -> Vec<String> {
283 let mut items = Vec::new();
284 let mut in_section = false;
285 for line in content.lines() {
286 if line.starts_with("## ") {
287 in_section = line == "## 人工修改待同步";
288 continue;
289 }
290 if in_section && let Some(item) = line.strip_prefix("- ") {
291 items.push(item.to_string());
292 }
293 }
294 items
295}
296
297pub async fn generate_module_card(
302 provider: &Provider,
303 config: &WikiConfig,
304 root: &crate::project::ProjectRoot,
305 module: &str,
306) -> Result<()> {
307 let insights = crate::ingest::scan_and_parse_at(root)?.insights;
308 let graph = crate::analysis::build_graph(&insights)?;
309 let chunks = crate::generate::chunk::chunk_by_module(&insights, &graph.modules, &graph);
310 let chunk = chunks
311 .into_iter()
312 .find(|c| c.module_path.join("::") == module)
313 .ok_or_else(|| anyhow::anyhow!(
314 "未找到模块 {module} 对应的代码分块,请检查模块名或先运行 `code-repo-wiki generate` 全量生成"
315 ))?;
316
317 let pending = read_card(config, module)?
319 .map(|content| extract_pending_manual_edits(&content))
320 .unwrap_or_default();
321
322 let generator = CardGenerator::new(provider, config.clone(), 1, config.wiki.language.clone());
323 let card = generator.generate_card(&chunk, &pending).await?;
324 let content = crate::output::markdown::render_knowledge_card(&card);
325
326 write_card_atomic(config, module, &content)?;
327 tracing::info!("卡片已生成: {} → {}", module, card_path(config, module).display());
328 Ok(())
329}
330
331pub async fn edit_card(
333 provider: &Provider,
334 config: &WikiConfig,
335 module: &str,
336 instruction: &str,
337 references: &[PathBuf],
338 mode: CardEditMode,
339) -> Result<()> {
340 let existing = read_card(config, module)?.ok_or_else(|| anyhow::anyhow!(
341 "模块 {module} 的卡片不存在({}),请先运行 `code-repo-wiki generate` 或 `code-repo-wiki card generate <module>` 生成",
342 card_path(config, module).display()
343 ))?;
344 let reference_block = read_references(references)?;
345
346 let messages = prompt::edit_card_prompt(
347 mode.as_str(),
348 module,
349 &existing,
350 instruction,
351 &reference_block,
352 &config.wiki.language,
353 );
354 let response = provider.complete(&messages).await?;
355 let content = extract_markdown(&response);
356
357 write_card_atomic(config, module, content)?;
358 tracing::info!("卡片已更新: {} → {}", module, card_path(config, module).display());
359 Ok(())
360}
361
362fn parse_card_response(response: &str, chunk: &Chunk) -> Result<KnowledgeCard> {
364 let json_str = extract_json(response);
365
366 let parsed: serde_json::Value =
367 serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("解析卡片 JSON 失败: {}", e))?;
368
369 let summary = parsed["summary"].as_str().unwrap_or("").to_string();
370
371 let key_entities: Vec<EntitySummary> = parsed["key_entities"]
372 .as_array()
373 .map(|arr| {
374 arr.iter()
375 .map(|v| EntitySummary {
376 name: v["name"].as_str().unwrap_or("").to_string(),
377 kind: v["kind"].as_str().unwrap_or("").to_string(),
378 visibility: v["visibility"].as_str().unwrap_or("public").to_string(),
379 doc: v["doc"].as_str().map(|s| s.to_string()),
380 source: None,
381 })
382 .collect()
383 })
384 .unwrap_or_default();
385
386 let design_patterns: Vec<String> = parsed["design_patterns"]
387 .as_array()
388 .map(|arr| {
389 arr.iter()
390 .filter_map(|v| v.as_str().map(|s| s.to_string()))
391 .collect()
392 })
393 .unwrap_or_default();
394
395 let todo_notes: Vec<String> = parsed["todo_notes"]
396 .as_array()
397 .map(|arr| {
398 arr.iter()
399 .filter_map(|v| v.as_str().map(|s| s.to_string()))
400 .collect()
401 })
402 .unwrap_or_default();
403
404 let coding_spec = parsed["coding_spec"].as_str().map(|s| s.to_string());
406 let tech_stack: Vec<String> = parsed["tech_stack"]
407 .as_array()
408 .map(|arr| {
409 arr.iter()
410 .filter_map(|v| v.as_str().map(|s| s.to_string()))
411 .collect()
412 })
413 .unwrap_or_default();
414 let architecture = parsed["architecture"].as_str().map(|s| s.to_string());
415
416 Ok(KnowledgeCard {
417 module_name: chunk.module_path.join("::"),
418 module_type: "module".to_string(),
419 summary,
420 key_entities,
421 dependencies: chunk.dependencies.clone(),
422 dependents: Vec::new(),
423 design_patterns,
424 todo_notes,
425 related_files: chunk
427 .file_paths
428 .iter()
429 .map(|p| p.display().to_string())
430 .collect(),
431 coding_spec,
432 tech_stack,
433 architecture,
434 pending_manual_edits: Vec::new(),
435 features: Vec::new(),
436 })
437}
438
439fn backfill_entity_sources(card: &mut KnowledgeCard, chunk: &Chunk) {
445 for es in &mut card.key_entities {
446 if es.source.is_some() {
447 continue;
448 }
449 if let Some((idx, entity)) = chunk
450 .entities
451 .iter()
452 .enumerate()
453 .find(|(_, e)| e.name == es.name)
454 && let Some(file) = chunk.entity_sources.get(idx)
455 {
456 es.source = Some(format!(
457 "{}:{}-{}",
458 file.display(),
459 entity.line_start,
460 entity.line_end
461 ));
462 }
463 }
464}
465
466fn extract_json(text: &str) -> &str {
468 let text = text.trim();
469 if let Some(start) = text.find('{') {
470 let end = text.rfind('}').map(|i| i + 1).unwrap_or(text.len());
471 &text[start..end]
472 } else {
473 text
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use crate::generate::chunk::chunk_by_file;
481 use crate::generate::llm::MockProvider;
482
483 use crate::ingest::parser::{Entity, FileInsight, ImportStmt};
484 use std::path::PathBuf;
485
486 fn make_test_chunk() -> Chunk {
487 let entity = Entity {
488 name: "Config".into(),
489 kind: "struct".into(),
490 line_start: 1,
491 line_end: 30,
492 doc_comment: Some("配置管理".into()),
493 signature: None, visibility: None,
494 };
495 let insight = FileInsight {
496 path: PathBuf::from("src/config.rs"),
497 language: "rust".into(),
498 entities: vec![entity],
499 imports: vec![ImportStmt {
500 source: "serde".into(),
501 alias: None,
502 line: 1,
503 }],
504 doc_comments: vec![],
505 source: String::new(),
506 };
507 chunk_by_file(&insight)
508 }
509
510 #[test]
511 fn test_extract_json() {
512 let input = "```json\n{\"summary\": \"test\"}\n```";
513 assert_eq!(extract_json(input), "{\"summary\": \"test\"}");
514
515 let input = "{\"summary\": \"test\"}";
516 assert_eq!(extract_json(input), "{\"summary\": \"test\"}");
517 }
518
519 #[test]
520 fn test_parse_card_response() {
521 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": "分层"}"#;
522 let chunk = make_test_chunk();
523 let card = parse_card_response(response, &chunk).unwrap();
524
525 assert_eq!(card.summary, "配置模块");
526 assert_eq!(card.key_entities.len(), 1);
527 assert_eq!(card.key_entities[0].name, "Config");
528 assert_eq!(card.related_files, vec!["src/config.rs".to_string()]);
530 assert_eq!(card.coding_spec.as_deref(), Some("遵循 rustfmt"));
532 assert_eq!(card.tech_stack, vec!["serde".to_string()]);
533 assert_eq!(card.architecture.as_deref(), Some("分层"));
534 }
535
536 #[test]
537 fn test_parse_card_empty_response() {
538 let response = r#"{"summary": "", "key_entities": [], "design_patterns": [], "todo_notes": []}"#;
539 let chunk = make_test_chunk();
540 let card = parse_card_response(response, &chunk).unwrap();
541
542 assert!(card.summary.is_empty());
543 assert!(card.key_entities.is_empty());
544 assert!(card.coding_spec.is_none());
546 assert!(card.tech_stack.is_empty());
547 assert!(card.architecture.is_none());
548 }
549
550 fn card_fixture(tag: &str, module: &str, content: &str) -> (WikiConfig, std::path::PathBuf) {
552 let dir = std::env::temp_dir().join(format!("code_repo_wiki_card_{tag}_{}_{}", module.replace("::", "_"), std::process::id()));
553 let _ = std::fs::remove_dir_all(&dir);
554 let config = WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
555 std::fs::create_dir_all(config.output_dir().join("cards").join("zh")).unwrap();
556 std::fs::write(card_path(&config, module), content).unwrap();
557 (config, dir)
558 }
559
560 #[tokio::test]
561 async fn test_edit_card_supplement_roundtrip() {
562 let (config, dir) = card_fixture("supplement", "crate::test", "# crate::test\n\n## 摘要\n旧内容");
563 let provider = Provider::Mock(MockProvider::new());
564 edit_card(
565 &provider,
566 &config,
567 "crate::test",
568 "追加新内容",
569 &[],
570 CardEditMode::Supplement,
571 )
572 .await
573 .unwrap();
574
575 let written = std::fs::read_to_string(config.output_dir().join("cards").join("zh").join("crate_test.md")).unwrap();
577 assert!(written.contains("模拟摘要"), "应写入 Mock Provider 的响应内容");
578 let _ = std::fs::remove_dir_all(&dir);
579 }
580
581 #[tokio::test]
582 async fn test_edit_card_requires_existing() {
583 let (config, dir) = card_fixture("missing", "crate::test", "内容");
584 let provider = Provider::Mock(MockProvider::new());
585 let err = edit_card(
586 &provider,
587 &config,
588 "crate::missing",
589 "指令",
590 &[],
591 CardEditMode::Rewrite,
592 )
593 .await
594 .unwrap_err();
595 assert!(err.to_string().contains("卡片不存在"), "应报卡片不存在: {}", err);
596 let _ = std::fs::remove_dir_all(&dir);
597 }
598
599 #[test]
600 fn test_extract_markdown_strips_codeblock() {
601 assert_eq!(extract_markdown("```markdown\n# 标题\n```"), "# 标题");
602 assert_eq!(extract_markdown("# 标题"), "# 标题");
603 }
604
605 #[test]
606 fn test_extract_pending_manual_edits() {
607 let content = "# src::test\n\n## 摘要\n旧内容\n\n## 人工修改待同步\n\n- 人工修改待同步: a.md 内容摘要: 1\n- 人工修改待同步: b.md 内容摘要: 2\n\n## 待办事项\n\n- [ ] x\n";
608 let items = extract_pending_manual_edits(content);
609 assert_eq!(items.len(), 2, "应提取节内两条记录");
610 assert!(items[0].contains("a.md"));
611 assert!(items[1].contains("b.md"));
612 assert!(extract_pending_manual_edits("# t\n\n## 摘要\nx").is_empty());
614 }
615
616 #[tokio::test]
617 async fn test_generate_card_keeps_pending_manual_edits() {
618 let chunk = make_test_chunk();
619 let provider = Provider::Mock(MockProvider::new());
620 let (config, dir) = card_fixture("pending", "src", "旧卡片内容");
621 let generator = CardGenerator::new(&provider, config, 1, "zh".into());
622 let pending = vec!["人工修改待同步: wiki/zh/src_config.md 内容摘要: 用户改的".into()];
624 let card = generator.generate_card(&chunk, &pending).await.unwrap();
625 assert_eq!(card.pending_manual_edits, pending);
626 let card = generator.generate_card(&chunk, &[]).await.unwrap();
628 assert!(card.pending_manual_edits.is_empty());
629
630 let _ = std::fs::remove_dir_all(&dir);
631 }
632
633 #[tokio::test]
637 async fn test_generate_all_cards_merges_recovered_and_extra_edits() {
638 let chunk = make_test_chunk();
639 let provider = Provider::Mock(MockProvider::new());
640 let (config, dir) = card_fixture(
642 "merge",
643 "src",
644 "# src\n\n## 摘要\n旧内容\n\n## 人工修改待同步\n\n- 人工修改待同步: wiki/zh/src.md 内容摘要: 旧记录\n",
645 );
646
647 let generator = CardGenerator::new(&provider, config, 1, "zh".into());
648 let mut extra = std::collections::HashMap::new();
650 extra.insert(
651 "src".to_string(),
652 vec!["人工修改待同步: wiki/zh/src.md 内容摘要: 新修改".to_string()],
653 );
654
655 let cards = generator.generate_all_cards(&[chunk], &extra).await.unwrap();
656 assert_eq!(cards.len(), 1);
657 assert_eq!(cards[0].pending_manual_edits.len(), 2, "旧记录 + 新记录应合并为 2 条");
658 assert!(cards[0].pending_manual_edits.iter().any(|n| n.contains("旧记录")));
659 assert!(cards[0].pending_manual_edits.iter().any(|n| n.contains("新修改")));
660
661 let cards2 = generator.generate_all_cards(
663 &[make_test_chunk()],
664 &extra,
665 ).await.unwrap();
666 assert!(
667 cards2[0].pending_manual_edits.len() <= 2,
668 "重复记录应被去重: {:?}",
669 cards2[0].pending_manual_edits
670 );
671
672 let _ = std::fs::remove_dir_all(&dir);
673 }
674
675 #[tokio::test]
679 async fn test_generate_all_cards_survives_card_read_failure() {
680 let chunk = make_test_chunk();
681 let provider = Provider::Mock(MockProvider::new());
682 let (config, dir) = card_fixture("readfail", "src", "# src\n\n## 摘要\n旧内容");
683
684 let card_file = card_path(&config, "src");
686 std::fs::remove_file(&card_file).unwrap();
687 std::fs::create_dir_all(&card_file).unwrap();
688
689 let generator = CardGenerator::new(&provider, config, 1, "zh".into());
690 let mut extra = std::collections::HashMap::new();
691 extra.insert(
692 "src".to_string(),
693 vec!["人工修改待同步: wiki/zh/src.md 内容摘要: 新修改".to_string()],
694 );
695
696 let cards = generator.generate_all_cards(&[chunk], &extra).await.unwrap();
697 assert_eq!(cards.len(), 1, "旧卡片读失败不应中断整批生成");
698 assert_eq!(
699 cards[0].pending_manual_edits,
700 vec!["人工修改待同步: wiki/zh/src.md 内容摘要: 新修改".to_string()],
701 "旧记录读取失败降级为空,只携带本次 extra 记录"
702 );
703
704 let _ = std::fs::remove_dir_all(&dir);
705 }
706
707 #[tokio::test]
712 async fn test_generate_all_cards_skips_empty_chunks_records_real_failures() {
713 let mut empty = make_test_chunk();
715 empty.module_path = vec!["zzz".into()];
716 empty.entities = Vec::new();
717 empty.imports = Vec::new();
718 let provider = Provider::Mock(MockProvider::new());
719
720 let failing = FailingProvider;
722 let (config, dir) = card_fixture("interleave-fail", "src", "# src\n\n## 摘要\n旧内容");
723 let fail_gen = CardGenerator::new(&failing, config, 1, "zh".into());
724 let cards = fail_gen
725 .generate_all_cards(&[empty.clone(), make_test_chunk()], &std::collections::HashMap::new())
726 .await
727 .unwrap();
728 assert!(cards.is_empty(), "失败模块不产出卡片");
729 assert_eq!(
730 fail_gen.failed_modules(),
731 vec!["src"],
732 "失败必须归因到真实失败模块(空 chunk 已被入口剔除,不参与对齐): {:?}",
733 fail_gen.failed_modules()
734 );
735 let _ = std::fs::remove_dir_all(&dir);
736
737 let (config2, dir2) = card_fixture("interleave-ok", "src", "# src\n\n## 摘要\n旧内容");
739 let gen2 = CardGenerator::new(&provider, config2, 1, "zh".into());
740 let cards2 = gen2
741 .generate_all_cards(&[empty, make_test_chunk()], &std::collections::HashMap::new())
742 .await
743 .unwrap();
744 assert_eq!(cards2.len(), 1, "成功卡片不得静默丢失");
745 assert_eq!(cards2[0].module_name, "src");
746 assert!(gen2.failed_modules().is_empty(), "无真实失败时不记 failed_modules");
747 let _ = std::fs::remove_dir_all(&dir2);
748 }
749
750 struct FailingProvider;
752
753 impl LlmProvider for FailingProvider {
754 async fn complete(
755 &self,
756 _messages: &[crate::generate::llm::Message],
757 ) -> anyhow::Result<String> {
758 anyhow::bail!("模拟 LLM 调用失败")
759 }
760
761 async fn complete_stream(
762 &self,
763 _messages: &[crate::generate::llm::Message],
764 ) -> anyhow::Result<Vec<String>> {
765 anyhow::bail!("模拟 LLM 调用失败")
766 }
767
768 fn call_count(&self) -> usize {
769 0
770 }
771 }
772}