1use oxibrain_ports::Timestamp;
25use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ChunkPolicy {
32 pub max_chunk_bytes: usize,
33 pub min_chunk_bytes: usize,
34}
35
36impl Default for ChunkPolicy {
37 fn default() -> Self {
38 Self {
39 max_chunk_bytes: 4_096,
40 min_chunk_bytes: 64,
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Chunk {
49 pub ordinal: u32,
50 pub span_start: usize,
51 pub span_end: usize,
52}
53
54pub fn split_into_chunks(content: &str, policy: &ChunkPolicy) -> Vec<Chunk> {
58 if content.is_empty() {
59 return Vec::new();
60 }
61 let mut out: Vec<Chunk> = Vec::new();
62 let mut ordinal = 0u32;
63 split_recursive(
64 content.as_bytes(),
65 0,
66 content.len(),
67 &mut ordinal,
68 policy,
69 &mut out,
70 );
71 out
72}
73
74fn split_recursive(
75 bytes: &[u8],
76 offset: usize,
77 end: usize,
78 ordinal: &mut u32,
79 policy: &ChunkPolicy,
80 out: &mut Vec<Chunk>,
81) {
82 let len = end - offset;
83 if len <= policy.max_chunk_bytes {
84 push_chunk(out, ordinal, offset, end);
85 return;
86 }
87 let candidate = pick_separator(bytes, offset, end, policy.max_chunk_bytes);
88 if let Some((sep_byte, sep_len)) = candidate {
89 let mut start = offset;
90 let mut i = offset;
91 while i + sep_len <= end {
92 if &bytes[i..i + sep_len] == sep_byte {
93 if i - start >= policy.min_chunk_bytes {
94 push_chunk(out, ordinal, start, i);
95 }
96 start = i + sep_len;
97 i += sep_len;
98 } else {
99 i += 1;
100 }
101 }
102 if end - start >= policy.min_chunk_bytes {
103 push_chunk(out, ordinal, start, end);
104 } else if let Some(last) = out.last_mut() {
105 last.span_end = end;
106 }
107 } else {
108 push_chunk(out, ordinal, offset, offset + policy.max_chunk_bytes);
109 }
110}
111
112fn push_chunk(out: &mut Vec<Chunk>, ordinal: &mut u32, start: usize, end: usize) {
113 out.push(Chunk {
114 ordinal: *ordinal,
115 span_start: start,
116 span_end: end,
117 });
118 *ordinal += 1;
119}
120
121fn pick_separator(
125 bytes: &[u8],
126 offset: usize,
127 end: usize,
128 _max: usize,
129) -> Option<(&'static [u8], usize)> {
130 const SEPARATORS: &[(&[u8], &str)] = &[
131 (b"\n\n", "paragraph"),
132 (b"\n", "line"),
133 (b". ", "sentence-ascii"),
134 ("\u{3002}".as_bytes(), "sentence-cjk"),
135 (b" ", "word"),
136 ];
137 for (sep, _label) in SEPARATORS {
138 if find_in_range(bytes, sep, offset, end).is_some() {
139 return Some((sep, sep.len()));
140 }
141 }
142 None
143}
144
145fn find_in_range(bytes: &[u8], needle: &[u8], offset: usize, end: usize) -> Option<usize> {
146 if needle.is_empty() {
147 return None;
148 }
149 if end > bytes.len() {
150 return None;
151 }
152 let mut i = offset;
153 while i + needle.len() <= end {
154 if &bytes[i..i + needle.len()] == needle {
155 return Some(i);
156 }
157 i += 1;
158 }
159 None
160}
161
162pub fn render_context_prefix(
167 occurred_at: Timestamp,
168 source_kind: &str,
169 mentions: &[String],
170 community_label: Option<&str>,
171) -> String {
172 let mut parts: Vec<String> = Vec::new();
173 parts.push(format!("[{}", short_ts(occurred_at)));
174 parts.push(format!("· {source_kind}"));
175 if !mentions.is_empty() {
176 let m = mentions
177 .iter()
178 .take(8)
179 .map(|s| s.as_str())
180 .collect::<Vec<_>>()
181 .join(", ");
182 parts.push(format!("· mentions: {m}"));
183 }
184 if let Some(label) = community_label {
185 parts.push(format!("· community: {label}"));
186 }
187 format!("{} ]", parts.join(" "))
188}
189
190pub fn short_ts(t: Timestamp) -> String {
194 let ms = t.0;
195 if ms <= 0 {
196 return "1970-01-01".into();
197 }
198 let days = ms.div_euclid(86_400_000);
199 let (y, m, d) = days_to_ymd(days);
200 format!("{y:04}-{m:02}-{d:02}")
201}
202
203fn days_to_ymd(days_since_epoch: i64) -> (i32, u32, u32) {
204 let z = days_since_epoch + 719_468;
205 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
206 let doe = (z - era * 146_097) as u32;
207 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
208 let y = (yoe as i32) + (era as i32) * 400;
209 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
210 let mp = (5 * doy + 2) / 153;
211 let d = doy - (153 * mp + 2) / 5 + 1;
212 let m = if mp < 10 { mp + 3 } else { mp - 9 };
213 let y = if m <= 2 { y + 1 } else { y };
214 (y, m, d)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn empty_content_produces_no_chunks() {
223 let chunks = split_into_chunks("", &ChunkPolicy::default());
224 assert!(chunks.is_empty());
225 }
226
227 #[test]
228 fn short_content_produces_single_chunk() {
229 let chunks = split_into_chunks("hello world", &ChunkPolicy::default());
230 assert_eq!(chunks.len(), 1);
231 assert_eq!(chunks[0].span_start, 0);
232 assert_eq!(chunks[0].span_end, 11);
233 }
234
235 #[test]
236 fn paragraph_split_when_oversized() {
237 let para1 = "x".repeat(3_000);
238 let para2 = "y".repeat(3_000);
239 let content = format!("{para1}\n\n{para2}");
240 let chunks = split_into_chunks(&content, &ChunkPolicy::default());
241 assert!(
242 chunks.len() >= 2,
243 "expected at least 2 chunks, got {}",
244 chunks.len()
245 );
246 assert_eq!(chunks.first().unwrap().span_start, 0);
247 assert_eq!(chunks.last().unwrap().span_end, content.len());
248 }
249
250 #[test]
251 fn cjk_sentence_split() {
252 let s = "中".repeat(2_000);
253 let content: String = s
254 .chars()
255 .enumerate()
256 .map(|(i, c)| {
257 if i % 200 == 199 {
258 format!("{c}\u{3002}")
259 } else {
260 c.to_string()
261 }
262 })
263 .collect();
264 let chunks = split_into_chunks(&content, &ChunkPolicy::default());
265 assert!(
266 chunks.len() >= 2,
267 "expected at least 2 chunks, got {}",
268 chunks.len()
269 );
270 }
271
272 #[test]
273 fn prefix_format_is_deterministic() {
274 let p1 = render_context_prefix(
275 Timestamp(1_700_000_000_000),
276 "Note: meeting.md",
277 &["Alice(Person)".to_string(), "ProjectX(Project)".to_string()],
278 Some("infra"),
279 );
280 let p2 = render_context_prefix(
281 Timestamp(1_700_000_000_000),
282 "Note: meeting.md",
283 &["Alice(Person)".to_string(), "ProjectX(Project)".to_string()],
284 Some("infra"),
285 );
286 assert_eq!(p1, p2);
287 assert!(p1.contains("Alice(Person)"));
288 assert!(p1.contains("ProjectX(Project)"));
289 assert!(p1.contains("infra"));
290 }
291
292 #[test]
293 fn prefix_short_ts_format() {
294 let s = short_ts(Timestamp(1_700_000_000_000));
295 assert_eq!(s.len(), 10);
296 assert!(s.starts_with("20"));
297 }
298}