use serde::{Deserialize, Serialize};
pub const REVIEW_MARKER: &str = "⚠ review";
pub const DEFAULT_REVIEW_THRESHOLD: f32 = crate::DEFAULT_ACCEPT_THRESHOLD;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BilingualSegment {
pub id: String,
pub index: usize,
pub source: String,
pub target: String,
pub confidence: Option<f32>,
pub needs_review: bool,
}
impl BilingualSegment {
#[must_use]
pub fn new(
id: impl Into<String>,
index: usize,
source: impl Into<String>,
target: impl Into<String>,
) -> Self {
Self {
id: id.into(),
index,
source: source.into(),
target: target.into(),
confidence: None,
needs_review: false,
}
}
#[must_use]
pub fn is_flagged(&self, threshold: f32) -> bool {
self.needs_review || self.confidence.is_some_and(|c| c < threshold)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Layout {
Interleaved,
SideBySideTable,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BilingualOptions {
pub layout: Layout,
pub low_confidence_threshold: f32,
}
impl Default for BilingualOptions {
fn default() -> Self {
Self { layout: Layout::Interleaved, low_confidence_threshold: DEFAULT_REVIEW_THRESHOLD }
}
}
impl BilingualOptions {
#[must_use]
pub fn interleaved() -> Self {
Self { layout: Layout::Interleaved, ..Self::default() }
}
#[must_use]
pub fn side_by_side() -> Self {
Self { layout: Layout::SideBySideTable, ..Self::default() }
}
}
#[must_use]
pub fn render_bilingual(segments: &[BilingualSegment], options: &BilingualOptions) -> String {
if segments.is_empty() {
return String::new();
}
let ordered = ordered_refs(segments);
match options.layout {
Layout::Interleaved => render_interleaved(&ordered, options.low_confidence_threshold),
Layout::SideBySideTable => render_table(&ordered, options.low_confidence_threshold),
}
}
#[must_use]
pub fn review_targets(segments: &[BilingualSegment]) -> Vec<&str> {
ordered_refs(segments)
.into_iter()
.filter(|s| s.is_flagged(DEFAULT_REVIEW_THRESHOLD))
.map(|s| s.id.as_str())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ReviewCoverage {
pub total: usize,
pub flagged: usize,
pub review_fraction: f32,
}
#[must_use]
pub fn review_coverage(segments: &[BilingualSegment]) -> ReviewCoverage {
let total = segments.len();
let flagged =
segments.iter().filter(|s| s.is_flagged(DEFAULT_REVIEW_THRESHOLD)).count();
let review_fraction = if total == 0 { 0.0 } else { flagged as f32 / total as f32 };
ReviewCoverage { total, flagged, review_fraction }
}
#[must_use]
pub fn from_two_pass(
plans: &[crate::SegmentPlan],
targets: &[String],
) -> Vec<BilingualSegment> {
plans
.iter()
.zip(targets.iter())
.map(|(plan, target)| BilingualSegment {
id: plan.index.to_string(),
index: plan.index,
source: plan.source.clone(),
target: target.clone(),
confidence: Some(plan.confidence),
needs_review: plan.decision == crate::Decision::SendToCloud,
})
.collect()
}
fn ordered_refs(segments: &[BilingualSegment]) -> Vec<&BilingualSegment> {
let mut refs: Vec<&BilingualSegment> = segments.iter().collect();
refs.sort_by_key(|s| s.index);
refs
}
fn anchor(id: &str) -> String {
format!("<!-- seg {id} -->")
}
fn render_interleaved(ordered: &[&BilingualSegment], threshold: f32) -> String {
let mut blocks: Vec<String> = Vec::with_capacity(ordered.len());
for seg in ordered {
let mut lines: Vec<String> = Vec::new();
if seg.is_flagged(threshold) {
lines.push(format!("{} {}", anchor(&seg.id), review_marker(seg.confidence)));
} else {
lines.push(anchor(&seg.id));
}
lines.push(seg.source.clone());
lines.push(String::new());
for line in blockquote(&seg.target) {
lines.push(line);
}
blocks.push(lines.join("\n"));
}
format!("{}\n", blocks.join("\n\n"))
}
fn render_table(ordered: &[&BilingualSegment], threshold: f32) -> String {
let mut out = String::new();
out.push_str("| seg | source | target | review |\n");
out.push_str("| --- | --- | --- | --- |\n");
for seg in ordered {
let review = if seg.is_flagged(threshold) { review_marker(seg.confidence) } else { String::new() };
let seg_cell = format!("{} {}", anchor(&seg.id), escape_table_cell(&seg.id));
out.push_str(&format!(
"| {} | {} | {} | {} |\n",
seg_cell,
escape_table_cell(&seg.source),
escape_table_cell(&seg.target),
escape_table_cell(&review),
));
}
out
}
fn review_marker(confidence: Option<f32>) -> String {
match confidence {
Some(c) => format!("{REVIEW_MARKER} (confidence {c:.2})"),
None => REVIEW_MARKER.to_string(),
}
}
fn blockquote(text: &str) -> Vec<String> {
if text.is_empty() {
return vec!["> ".to_string()];
}
text.lines().map(|l| format!("> {l}")).collect()
}
fn escape_table_cell(text: &str) -> String {
let piped = text.replace('|', "\\|");
piped.replace("\r\n", "\n").replace('\r', "\n").replace('\n', "<br>")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Decision, TwoPassConfig, draft_and_route, EchoAdapter};
fn seg(id: &str, index: usize, source: &str, target: &str) -> BilingualSegment {
BilingualSegment::new(id, index, source, target)
}
#[test]
fn interleaved_emits_one_anchor_per_segment_in_index_order() {
let segs = vec![
seg("b", 1, "源二", "target two"),
seg("a", 0, "源一", "target one"),
seg("c", 2, "源三", "target three"),
];
let out = render_bilingual(&segs, &BilingualOptions::interleaved());
assert_eq!(out.matches("<!-- seg ").count(), 3, "one anchor per segment");
let pa = out.find("<!-- seg a -->").unwrap();
let pb = out.find("<!-- seg b -->").unwrap();
let pc = out.find("<!-- seg c -->").unwrap();
assert!(pa < pb && pb < pc, "anchors must be in ascending index order");
assert!(out.contains("源一"));
assert!(out.contains("> target one"));
}
#[test]
fn low_confidence_segment_is_marked_and_targeted() {
let mut low = seg("lo", 0, "questionable source", "questionable target");
low.confidence = Some(0.20); let mut high = seg("hi", 1, "solid source", "solid target");
high.confidence = Some(0.99);
let segs = vec![low, high];
let out = render_bilingual(&segs, &BilingualOptions::interleaved());
assert!(out.contains(REVIEW_MARKER), "flagged segment must carry the review marker");
let targets = review_targets(&segs);
assert_eq!(targets, vec!["lo"]);
}
#[test]
fn explicit_needs_review_flags_without_confidence() {
let mut s = seg("x", 0, "src", "tgt");
s.needs_review = true;
assert!(s.confidence.is_none());
assert_eq!(review_targets(&[s]), vec!["x"]);
}
#[test]
fn side_by_side_table_escapes_pipes_and_has_anchor() {
let s = seg("p", 0, "a | b\nc", "x | y");
let out = render_bilingual(&[s], &BilingualOptions::side_by_side());
assert!(out.contains("| seg | source | target | review |"), "header row present");
assert!(out.contains("<!-- seg p -->"), "anchor present in the row");
assert!(out.contains("a \\| b"), "pipe must be escaped: {out}");
assert!(!out.contains("a | b"), "raw unescaped pipe must not survive");
assert!(out.contains("<br>c"), "newline must fold to <br>: {out}");
}
#[test]
fn from_two_pass_bridges_iii6_and_preserves_index_anchor() {
let cfg = TwoPassConfig { accept_threshold: 0.4, ..TwoPassConfig::default() };
let long = "这是一段足够长的技术性中文文本用于测试双语对齐输出的锚点桥接";
let batch = vec![long.to_string(), "##".to_string()];
let plan = draft_and_route(&EchoAdapter, &batch, None, &cfg);
assert_eq!(plan.segments[0].decision, Decision::AcceptLocal);
assert_eq!(plan.segments[1].decision, Decision::SendToCloud);
let targets = vec!["accepted target".to_string(), "cloud target".to_string()];
let bi = from_two_pass(&plan.segments, &targets);
assert_eq!(bi.len(), 2);
assert_eq!(bi[0].index, 0);
assert_eq!(bi[0].id, "0");
assert_eq!(bi[1].index, 1);
assert_eq!(bi[1].id, "1");
assert_eq!(bi[0].confidence, Some(plan.segments[0].confidence));
assert!(!bi[0].needs_review);
assert!(bi[1].needs_review);
let targets = review_targets(&bi);
assert!(targets.contains(&"1"), "cloud-routed anchor must be a review target: {targets:?}");
assert!(bi[0].confidence.unwrap() < DEFAULT_REVIEW_THRESHOLD);
assert!(targets.contains(&"0"), "a below-threshold accepted draft is still flagged by confidence");
let out = render_bilingual(&bi, &BilingualOptions::interleaved());
assert!(out.contains("<!-- seg 1 -->"), "anchor must be the plan index");
}
#[test]
fn review_coverage_quantifies_the_saving() {
let mut a = seg("0", 0, "s", "t");
a.confidence = Some(0.9); let mut b = seg("1", 1, "s", "t");
b.confidence = Some(0.1); let mut c = seg("2", 2, "s", "t");
c.needs_review = true; let cov = review_coverage(&[a, b, c]);
assert_eq!(cov.total, 3);
assert_eq!(cov.flagged, 2);
assert!((cov.review_fraction - 2.0 / 3.0).abs() < 1e-6);
}
#[test]
fn empty_input_is_empty_output() {
assert_eq!(render_bilingual(&[], &BilingualOptions::interleaved()), "");
assert_eq!(render_bilingual(&[], &BilingualOptions::side_by_side()), "");
assert!(review_targets(&[]).is_empty());
let cov = review_coverage(&[]);
assert_eq!(cov.total, 0);
assert_eq!(cov.flagged, 0);
assert_eq!(cov.review_fraction, 0.0);
}
#[test]
fn render_is_deterministic() {
let ordered = vec![seg("0", 0, "一", "one"), seg("1", 1, "二", "two"), seg("2", 2, "三", "three")];
let shuffled = vec![seg("2", 2, "三", "three"), seg("0", 0, "一", "one"), seg("1", 1, "二", "two")];
let opts = BilingualOptions::interleaved();
assert_eq!(render_bilingual(&ordered, &opts), render_bilingual(&shuffled, &opts));
let table = BilingualOptions::side_by_side();
assert_eq!(render_bilingual(&ordered, &table), render_bilingual(&shuffled, &table));
let a = render_bilingual(&ordered, &opts);
let b = render_bilingual(&ordered, &opts);
assert_eq!(a, b);
}
#[test]
fn serde_derives_are_present() {
fn assert_serde<T: serde::Serialize + serde::de::DeserializeOwned>() {}
assert_serde::<BilingualSegment>();
assert_serde::<BilingualOptions>();
assert_serde::<Layout>();
assert_serde::<ReviewCoverage>();
}
}