use anyhow::Result;
use crate::config::schema::WikiConfig;
use crate::generate::llm::{LlmProvider, Message};
use crate::model::WikiDocument;
use crate::output::lint::LintIssue;
const PAGE_EVIDENCE_LIMIT: usize = 4_000;
const TOTAL_EVIDENCE_LIMIT: usize = 40_000;
const API_EVIDENCE_LIMIT: usize = 6_000;
pub fn check_semantic_consistency(
config: &WikiConfig,
docs: &[WikiDocument],
) -> Result<Vec<LintIssue>> {
if docs.is_empty() {
return Ok(Vec::new());
}
let provider = crate::generate::create_provider(config)?;
let mut evidence = String::new();
for doc in docs {
if evidence.len() >= TOTAL_EVIDENCE_LIMIT {
break;
}
evidence.push_str(&format!(
"### 页面 {}\n{}\n",
doc.title,
doc.content.chars().take(PAGE_EVIDENCE_LIMIT).collect::<String>()
));
}
let api_path = crate::output::api_doc_path(config.output_dir(), &config.wiki.language);
if let Ok(api_content) = std::fs::read_to_string(&api_path) {
evidence.push_str(&format!(
"### api.md 权威清单\n{}\n",
api_content.chars().take(API_EVIDENCE_LIMIT).collect::<String>()
));
}
let evidence = evidence.chars().take(TOTAL_EVIDENCE_LIMIT).collect::<String>();
let messages = semantic_conflict_prompt(&config.wiki.language, &evidence);
let content = crate::get_global_runtime().block_on(provider.complete(&messages))?;
Ok(parse_conflicts(&content))
}
fn semantic_conflict_prompt(lang: &str, evidence: &str) -> Vec<Message> {
let system = format!(
r#"你是代码仓库 Wiki 文档一致性裁判。检查下面文档证据中是否存在**跨页矛盾**或**与权威清单冲突**的声明:
1. 页面之间的矛盾(同一实体/概念两页描述不一致)
2. 页面声明与 api.md 权威清单冲突(如声称已废弃/不存在,但 api.md 仍列为核心实体)
3. 页面内部的过期语义(声称的功能与实体清单矛盾)
只报告明确矛盾,不做猜测。输出 JSON(语言:{lang}):
{{"conflicts": [{{"page": "页面标题", "claim": "矛盾声明原文", "conflict": "与什么矛盾"}}]}}
没有矛盾时输出 {{"conflicts": []}}。只输出 JSON。"#
);
vec![
Message::system(system),
Message::user(evidence.to_string()),
]
}
fn parse_conflicts(content: &str) -> Vec<LintIssue> {
let stripped = content
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let Ok(value) = serde_json::from_str::<serde_json::Value>(stripped) else {
tracing::warn!("语义一致性检查输出非 JSON,跳过(增强项不阻断)");
return Vec::new();
};
let Some(list) = value.get("conflicts").and_then(|c| c.as_array()) else {
return Vec::new();
};
list.iter()
.filter_map(|item| {
let page = item.get("page")?.as_str()?.to_string();
let claim = item.get("claim")?.as_str()?.to_string();
let conflict = item.get("conflict").and_then(|c| c.as_str()).unwrap_or("").to_string();
Some(LintIssue {
kind: "semantic-conflict",
path: format!("wiki/{}", page),
message: format!("语义矛盾: 声明「{claim}」与{conflict}"),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_conflicts_forms() {
let with_fence = r#"```json
{"conflicts": [{"page": "src::net", "claim": "模块已废弃", "conflict": "api.md 仍列为核心模块"}]}
```"#;
let issues = parse_conflicts(with_fence);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].kind, "semantic-conflict");
assert!(issues[0].path.contains("src::net"));
assert!(issues[0].message.contains("模块已废弃"));
assert!(parse_conflicts(r#"{"conflicts": []}"#).is_empty(), "无矛盾应返回空");
assert!(parse_conflicts("not json").is_empty(), "非 JSON 应降级为空");
assert!(parse_conflicts(r#"{"other": 1}"#).is_empty(), "缺 conflicts 键应返回空");
}
#[test]
fn test_check_semantic_empty_docs_returns_empty() {
let config = WikiConfig::default();
let issues = check_semantic_consistency(&config, &[]).unwrap();
assert!(issues.is_empty());
}
}