refactor(llm): 拆分提示词与提交解析逻辑到独立模块

将 21 个多语言提示词及选择函数迁入 prompts 模块,将 commit 响应
解析与 GeneratedCommit 迁入 parsing 模块,为 rig 迁移期间新旧
实现共享同一份提示词与解析逻辑做准备。无行为变化。
This commit is contained in:
2026-08-17 15:21:46 +08:00
parent cc80604710
commit bba15501c6
3 changed files with 1226 additions and 1221 deletions

281
src/llm/parsing.rs Normal file
View File

@@ -0,0 +1,281 @@
use anyhow::{Result, bail};
/// Parse commit response from LLM
pub(crate) fn parse_commit_response(
response: &str,
format: crate::config::CommitFormat,
) -> Result<GeneratedCommit> {
// Clean markdown code fences from the response
let cleaned = strip_code_fences(response);
let lines: Vec<&str> = cleaned
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() {
let preview: String = response.chars().take(200).collect();
bail!(
"LLM returned empty or whitespace-only response. \
Raw response preview: '{}'. \
Hint: If using DeepSeek/Kimi with thinking enabled, \
the model may have returned reasoning_content only. \
Try disabling thinking mode or switching models.",
preview
);
}
// Find the line most likely to be the commit subject
let first_line = find_commit_subject_line(&lines, format);
// Parse based on format
match format {
crate::config::CommitFormat::Conventional => {
parse_conventional_commit(first_line, &lines, response)
}
crate::config::CommitFormat::Commitlint => {
parse_commitlint_commit(first_line, &lines, response)
}
}
}
/// Remove surrounding markdown code fences (```) from LLM output
fn strip_code_fences(response: &str) -> String {
let mut lines: Vec<&str> = response.lines().collect();
// Strip leading fence lines (``` or ```lang)
while lines.first().map_or(false, |l| l.trim().starts_with("```")) {
lines.remove(0);
}
// Strip trailing fence lines
while lines.last().map_or(false, |l| l.trim() == "```") {
lines.pop();
}
lines.join("\n")
}
/// Find the line that is most likely the commit subject among extracted lines
fn find_commit_subject_line<'a>(
lines: &[&'a str],
format: crate::config::CommitFormat,
) -> &'a str {
let valid_types = crate::utils::validators::get_commit_types(matches!(
format,
crate::config::CommitFormat::Commitlint
));
// First pass: line starting with a known type that also has proper syntax
// (e.g. "type:", "type(scope):", "type!:")
for &line in lines {
let trimmed = line.trim();
for &t in valid_types {
if let Some(rest) = trimmed.strip_prefix(t) {
if rest.starts_with(':') || rest.starts_with('(') || rest.starts_with("!:") {
return trimmed;
}
}
}
}
// Second pass: any line containing a colon (generic "prefix: description")
for &line in lines {
if line.contains(':') {
return line.trim();
}
}
// Fallback: return the first line as-is
lines[0].trim()
}
fn parse_conventional_commit(
first_line: &str,
lines: &[&str],
raw_response: &str,
) -> Result<GeneratedCommit> {
// Parse: type(scope)!: description
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
let preview: String = raw_response.chars().take(300).collect();
bail!(
"Invalid conventional commit format: missing colon.\n\
Parsed subject line: '{}'\n\
Raw response preview: '{}'\n\
Expected: <type>[optional scope]: <description>",
first_line,
preview
);
}
let type_part = parts[0];
let description = parts[1].trim();
// Extract type, scope, and breaking indicator
let breaking = type_part.ends_with('!');
let type_part = type_part.trim_end_matches('!');
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
if let Some(end) = type_part.find(')') {
let t = &type_part[..start];
let s = &type_part[start + 1..end];
(t.to_string(), Some(s.to_string()))
} else {
bail!("Invalid scope format: missing closing parenthesis");
}
} else {
(type_part.to_string(), None)
};
// Extract body and footer
let (body, footer) = extract_body_footer(lines);
Ok(GeneratedCommit {
commit_type,
scope,
description: description.to_string(),
body,
footer,
breaking,
})
}
fn parse_commitlint_commit(
first_line: &str,
lines: &[&str],
raw_response: &str,
) -> Result<GeneratedCommit> {
// Similar parsing but with commitlint rules
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
let preview: String = raw_response.chars().take(300).collect();
bail!(
"Invalid commit format: missing colon.\n\
Parsed subject line: '{}'\n\
Raw response preview: '{}'\n\
Expected: <type>[optional scope]: <subject>",
first_line,
preview
);
}
let type_part = parts[0];
let subject = parts[1].trim();
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
if let Some(end) = type_part.find(')') {
let t = &type_part[..start];
let s = &type_part[start + 1..end];
(t.to_string(), Some(s.to_string()))
} else {
(type_part.to_string(), None)
}
} else {
(type_part.to_string(), None)
};
let (body, footer) = extract_body_footer(&lines);
Ok(GeneratedCommit {
commit_type,
scope,
description: subject.to_string(),
body,
footer,
breaking: false,
})
}
fn extract_body_footer(lines: &[&str]) -> (Option<String>, Option<String>) {
if lines.len() <= 1 {
return (None, None);
}
let rest: Vec<&str> = lines[1..]
.iter()
.skip_while(|l| l.trim().is_empty())
.copied()
.collect();
if rest.is_empty() {
return (None, None);
}
// Look for footer markers
let footer_markers = [
"BREAKING CHANGE:",
"Closes",
"Fixes",
"Refs",
"Co-authored-by:",
];
let mut body_lines = vec![];
let mut footer_lines = vec![];
let mut in_footer = false;
for line in &rest {
if footer_markers.iter().any(|m| line.starts_with(m)) {
in_footer = true;
}
if in_footer {
footer_lines.push(*line);
} else {
body_lines.push(*line);
}
}
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n"))
};
let footer = if footer_lines.is_empty() {
None
} else {
Some(footer_lines.join("\n"))
};
(body, footer)
}
/// Generated commit structure
#[derive(Debug, Clone)]
pub struct GeneratedCommit {
pub commit_type: String,
pub scope: Option<String>,
pub description: String,
pub body: Option<String>,
pub footer: Option<String>,
pub breaking: bool,
}
impl GeneratedCommit {
/// Format as conventional commit
pub fn to_conventional(&self) -> String {
crate::utils::formatter::format_conventional_commit(
&self.commit_type,
self.scope.as_deref(),
&self.description,
self.body.as_deref(),
self.footer.as_deref(),
self.breaking,
)
}
/// Format as commitlint commit
pub fn to_commitlint(&self) -> String {
crate::utils::formatter::format_commitlint_commit(
&self.commit_type,
self.scope.as_deref(),
&self.description,
self.body.as_deref(),
self.footer.as_deref(),
None,
)
}
}