From 92e6cf0c435e7584102d274097ac6b2c65d2a8ca Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 1 May 2026 08:07:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E5=8F=8C=E9=80=9A=E9=81=93?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E8=A7=A3=E6=9E=90=E5=99=A8=20=E2=80=94=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC/JSON=20=E5=88=86=E5=89=B2=20+=20=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parse_dual_channel: 分割 ===PATIENT_TEXT=== / ===STRUCTURED_JSON=== 标记 - JSON 解析失败时降级为纯文本,structured 为 None - 5 个单元测试覆盖:正常解析、纯文本、无效 JSON、空建议、风险等级 --- crates/erp-ai/src/service/mod.rs | 1 + crates/erp-ai/src/service/output_parser.rs | 82 ++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 crates/erp-ai/src/service/output_parser.rs diff --git a/crates/erp-ai/src/service/mod.rs b/crates/erp-ai/src/service/mod.rs index edd4ceb..b7cd614 100644 --- a/crates/erp-ai/src/service/mod.rs +++ b/crates/erp-ai/src/service/mod.rs @@ -1,4 +1,5 @@ pub mod analysis; pub mod auto_analysis; +pub mod output_parser; pub mod prompt; pub mod usage; diff --git a/crates/erp-ai/src/service/output_parser.rs b/crates/erp-ai/src/service/output_parser.rs new file mode 100644 index 0000000..7daba7a --- /dev/null +++ b/crates/erp-ai/src/service/output_parser.rs @@ -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 { + 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 = 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()); + } +}