feat(ai): 双通道输出解析器 — 文本/JSON 分割 + 降级策略

- parse_dual_channel: 分割 ===PATIENT_TEXT=== / ===STRUCTURED_JSON=== 标记
- JSON 解析失败时降级为纯文本,structured 为 None
- 5 个单元测试覆盖:正常解析、纯文本、无效 JSON、空建议、风险等级
This commit is contained in:
iven
2026-05-01 08:07:26 +08:00
parent 9b8307fbba
commit 92e6cf0c43
2 changed files with 83 additions and 0 deletions

View File

@@ -1,4 +1,5 @@
pub mod analysis;
pub mod auto_analysis;
pub mod output_parser;
pub mod prompt;
pub mod usage;

View File

@@ -0,0 +1,82 @@
use crate::dto::suggestion::{ParsedOutput, StructuredOutput};
use crate::error::AiResult;
const TEXT_MARKER: &str = "===PATIENT_TEXT===";
const JSON_MARKER: &str = "===STRUCTURED_JSON===";
/// 解析 AI 双通道输出。JSON 解析失败时降级为纯文本。
pub fn parse_dual_channel(raw: &str) -> AiResult<ParsedOutput> {
let text_content = extract_section(raw, TEXT_MARKER, JSON_MARKER)
.unwrap_or(raw)
.trim()
.to_string();
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER)
.and_then(|json_str| {
let parsed: Result<StructuredOutput, _> = serde_json::from_str(json_str.trim());
parsed.ok()
});
Ok(ParsedOutput {
text_content,
structured,
})
}
fn extract_section<'a>(raw: &'a str, start: &str, end: &str) -> Option<&'a str> {
let start_idx = raw.find(start)?;
let content_start = start_idx + start.len();
let content_end = raw[content_start..]
.find(end)
.map(|i| content_start + i)
.unwrap_or(raw.len());
Some(&raw[content_start..content_end])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::suggestion::RiskLevel;
#[test]
fn parse_dual_channel_output_success() {
let raw = "===PATIENT_TEXT===\n张三的收缩压呈上升趋势\n===STRUCTURED_JSON===\n{\"risk_level\":\"medium\",\"risk_factors\":[\"收缩压偏高\"],\"suggestions\":[{\"type\":\"followup\",\"priority\":1,\"timing\":\"14天内\",\"reason\":\"血压异常\",\"params\":{},\"auto_executable\":false}],\"baseline_summary\":{}}";
let result = parse_dual_channel(raw).unwrap();
assert_eq!(result.text_content, "张三的收缩压呈上升趋势");
assert!(result.structured.is_some());
let s = result.structured.unwrap();
assert_eq!(s.risk_level, RiskLevel::Medium);
assert_eq!(s.suggestions.len(), 1);
}
#[test]
fn parse_text_only_fallback() {
let raw = "纯文本分析结果,没有结构化部分";
let result = parse_dual_channel(raw).unwrap();
assert_eq!(result.text_content, "纯文本分析结果,没有结构化部分");
assert!(result.structured.is_none());
}
#[test]
fn parse_invalid_json_falls_back() {
let raw = "===PATIENT_TEXT===\n分析内容\n===STRUCTURED_JSON===\n{invalid json}";
let result = parse_dual_channel(raw).unwrap();
assert_eq!(result.text_content, "分析内容");
assert!(result.structured.is_none());
}
#[test]
fn empty_suggestions_is_valid() {
let raw = "===PATIENT_TEXT===\n指标正常\n===STRUCTURED_JSON===\n{\"risk_level\":\"low\",\"risk_factors\":[],\"suggestions\":[],\"baseline_summary\":{}}";
let result = parse_dual_channel(raw).unwrap();
let s = result.structured.unwrap();
assert!(s.suggestions.is_empty());
}
#[test]
fn risk_level_auto_executable() {
assert!(RiskLevel::Low.is_auto_executable());
assert!(!RiskLevel::Medium.is_auto_executable());
assert!(!RiskLevel::High.is_auto_executable());
}
}