use regex::Regex;
use serde_json::Value;
use std::sync::LazyLock;
fn strip_markdown_fenced_blocks(s: &str) -> String {
let parts: Vec<&str> = s.split("```").collect();
let mut out = String::new();
for (i, part) in parts.iter().enumerate() {
if i % 2 == 0 {
out.push_str(part);
}
}
out
}
fn step_object_to_line(obj: &serde_json::Map<String, Value>) -> Option<String> {
obj.get("description")
.and_then(|x| x.as_str())
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.or_else(|| {
obj.get("id")
.and_then(|x| x.as_str())
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.map(|id| format!("完成步骤「{id}」"))
})
}
fn try_unwrap_embedded_step_json(t: &str) -> Option<String> {
let t = t.trim();
if !t.starts_with('{') && !t.starts_with('[') {
return None;
}
let v: Value = serde_json::from_str(t).ok()?;
if let Some(arr) = v.as_array() {
if arr.len() == 1 {
return arr
.first()
.and_then(|x| x.as_object())
.and_then(step_object_to_line);
}
return None;
}
if let Some(obj) = v.as_object() {
if let Some(steps) = obj.get("steps").and_then(|x| x.as_array())
&& steps.len() == 1
{
return steps
.first()
.and_then(|x| x.as_object())
.and_then(step_object_to_line);
}
return step_object_to_line(obj);
}
None
}
static RE_ORDERED_LINE_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\s*\d+[.)]\s+").expect("ordered list line prefix regex (static) must compile")
});
fn trim_assistant_prose_line(s: &str) -> String {
s.replace('\u{a0}', " ")
.trim()
.trim_matches(|c: char| {
matches!(
c,
'\u{feff}' | '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}'
)
})
.to_string()
}
fn prose_dedup_normalize(s: &str) -> String {
let mapped: String = trim_assistant_prose_line(s)
.chars()
.map(|c| match c {
'\u{ff1a}' => ':',
'\u{ff0c}' => ',',
'\u{ff01}' => '!',
'\u{ff1f}' => '?',
_ => c,
})
.collect();
mapped
.trim_end_matches(|c: char| {
matches!(c, '。' | '!' | '?' | ':' | '…' | '.' | '!' | '?' | ':')
})
.trim()
.to_string()
}
fn dedupe_adjacent_non_empty_trimmed_lines(s: &str) -> String {
let mut out: Vec<String> = Vec::new();
for line in s.lines() {
let t = trim_assistant_prose_line(line);
if t.is_empty() {
continue;
}
if out
.last()
.is_some_and(|last| prose_dedup_normalize(last) == prose_dedup_normalize(&t))
{
continue;
}
out.push(t);
}
out.join("\n")
}
fn collapse_duplicate_prose_fused_twice_once(s: &str) -> String {
const MIN_CHARS: usize = 12;
let t = trim_assistant_prose_line(s);
if t.is_empty() {
return String::new();
}
let n = t.chars().count();
if n < MIN_CHARS * 2 {
return t;
}
let chars: Vec<char> = t.chars().collect();
let n = chars.len();
if n.is_multiple_of(2) {
let h = n / 2;
let left: String = chars[..h].iter().collect();
let right: String = chars[h..].iter().collect();
let l = trim_assistant_prose_line(&left);
let r = trim_assistant_prose_line(&right);
if l.chars().count() >= MIN_CHARS && prose_dedup_normalize(&l) == prose_dedup_normalize(&r)
{
return l;
}
}
for i in (MIN_CHARS..=n.saturating_sub(MIN_CHARS)).rev() {
let left: String = chars[..i].iter().collect();
let right: String = chars[i..].iter().collect();
let l = trim_assistant_prose_line(&left);
let r = trim_assistant_prose_line(&right);
if l.chars().count() >= MIN_CHARS && prose_dedup_normalize(&l) == prose_dedup_normalize(&r)
{
return l;
}
}
t
}
fn collapse_duplicate_prose_fused_twice(s: &str) -> String {
let mut t = s.to_string();
loop {
let next = collapse_duplicate_prose_fused_twice_once(&t);
if next == t {
return t;
}
t = next;
}
}
fn flatten_bullet_lines_to_prose(s: &str) -> String {
let lines: Vec<String> = s
.lines()
.map(trim_assistant_prose_line)
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() {
return String::new();
}
if lines.len() == 1 {
return lines.into_iter().next().unwrap_or_default();
}
let all_bullets = lines.iter().all(|l| {
l.starts_with("- ")
|| l.starts_with("* ")
|| l.starts_with("• ")
|| RE_ORDERED_LINE_PREFIX.is_match(l)
});
let cleaned: Vec<String> = lines
.into_iter()
.map(|l| {
if let Some(x) = l.strip_prefix("- ") {
x.to_string()
} else if let Some(x) = l.strip_prefix("* ") {
x.to_string()
} else if let Some(x) = l.strip_prefix("• ") {
x.to_string()
} else {
RE_ORDERED_LINE_PREFIX
.replace(l.trim(), "")
.trim()
.to_string()
}
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if cleaned.is_empty() {
return String::new();
}
if all_bullets && cleaned.len() > 1 {
cleaned.join(";")
} else {
cleaned.join(" ")
}
}
pub fn naturalize_plan_step_description(s: &str) -> String {
let t = strip_markdown_fenced_blocks(s);
let trimmed = t.trim();
let mut out = if let Some(inner) = try_unwrap_embedded_step_json(trimmed) {
inner
} else {
trimmed.to_string()
};
let t2 = out.trim();
if (t2.starts_with('{') || t2.starts_with('['))
&& let Some(inner) = try_unwrap_embedded_step_json(t2)
{
out = inner;
}
flatten_bullet_lines_to_prose(&out)
}
pub fn naturalize_assistant_plan_prose_tail(s: &str) -> String {
let t = dedupe_adjacent_non_empty_trimmed_lines(s.trim());
let flat = flatten_bullet_lines_to_prose(t.trim());
collapse_duplicate_prose_fused_twice(&flat)
}
pub fn dedupe_plain_assistant_preamble(s: &str) -> String {
let t = dedupe_adjacent_non_empty_trimmed_lines(s.trim());
collapse_duplicate_prose_fused_twice(&t)
}