1use regex::Regex;
9
10use super::types::*;
11
12#[must_use]
16pub fn parse_learning(content: &str) -> Vec<PipelineEntry> {
17 let sections = split_sections(content);
18 let mut entries = Vec::new();
19
20 for (heading, body) in §ions {
21 let h = heading.to_lowercase();
22 if h.contains("active thread") {
23 let sub_entries = split_entries(body);
24 for (title, entry_body) in sub_entries {
25 let (clean_title, date) = extract_heading_date(&title);
26 entries.push(PipelineEntry {
27 title: clean_title,
28 body: entry_body.clone(),
29 status: "active".into(),
30 stage: "learning".into(),
31 entity_type: EntityType::Thread,
32 date,
33 source_ref: extract_field(&entry_body, "Source"),
34 destination: extract_field(&entry_body, "Destination"),
35 connected_to: extract_connected_to(&entry_body),
36 sub_type: None,
37 });
38 }
39 }
40 }
41
42 entries
43}
44
45#[must_use]
49pub fn parse_thoughts(content: &str) -> Vec<PipelineEntry> {
50 let sections = split_sections(content);
51 let mut entries = Vec::new();
52
53 for (heading, body) in §ions {
54 let h = heading.to_lowercase();
55 let status = if h == "active" {
56 "active"
57 } else if h == "graduated" {
58 "graduated"
59 } else if h == "dissolved" {
60 "dissolved"
61 } else {
62 continue;
63 };
64
65 let sub_entries = split_entries(body);
66 for (title, entry_body) in sub_entries {
67 let clean_title = clean_thought_title(&title);
68 let date = extract_field(&entry_body, "Graduated")
69 .or_else(|| extract_field(&entry_body, "Dissolved"))
70 .or_else(|| extract_heading_date(&title).1);
71
72 entries.push(PipelineEntry {
73 title: clean_title,
74 body: entry_body.clone(),
75 status: status.into(),
76 stage: "thoughts".into(),
77 entity_type: EntityType::Thought,
78 date,
79 source_ref: extract_field(&entry_body, "Source"),
80 destination: extract_field(&entry_body, "Destination"),
81 connected_to: extract_connected_to(&entry_body),
82 sub_type: None,
83 });
84 }
85 }
86
87 entries
88}
89
90pub fn parse_curiosity(content: &str) -> Vec<PipelineEntry> {
94 let sections = split_sections(content);
95 let mut entries = Vec::new();
96
97 for (heading, body) in §ions {
98 let h = heading.to_lowercase();
99 let (status, sub_type) = if h.contains("open question") {
100 ("active", None)
101 } else if h == "themes" {
102 ("active", Some("theme"))
103 } else if h == "explored" {
104 ("explored", None)
105 } else {
106 continue;
107 };
108
109 let sub_entries = split_entries(body);
110 for (title, entry_body) in sub_entries {
111 let date = extract_field(&entry_body, "Date explored")
112 .or_else(|| extract_heading_date(&title).1);
113
114 entries.push(PipelineEntry {
115 title: title.clone(),
116 body: entry_body.clone(),
117 status: status.into(),
118 stage: "curiosity".into(),
119 entity_type: EntityType::Question,
120 date,
121 source_ref: extract_field(&entry_body, "Source")
122 .or_else(|| extract_field(&entry_body, "Origin")),
123 destination: None,
124 connected_to: extract_connected_to(&entry_body),
125 sub_type: sub_type.map(String::from),
126 });
127 }
128 }
129
130 entries
131}
132
133pub fn parse_reflections(content: &str) -> Vec<PipelineEntry> {
137 let sections = split_sections(content);
138 let mut entries = Vec::new();
139
140 for (heading, body) in §ions {
141 let h = heading.to_lowercase();
142 let sub_type = if h == "observations" {
143 None
144 } else if h == "patterns" {
145 Some("pattern")
146 } else {
147 continue;
148 };
149
150 let sub_entries = split_entries(body);
151 for (title, entry_body) in sub_entries {
152 let (clean_title, date) = extract_reflection_date(&title);
153
154 entries.push(PipelineEntry {
155 title: clean_title,
156 body: entry_body.clone(),
157 status: "active".into(),
158 stage: "reflections".into(),
159 entity_type: EntityType::Observation,
160 date,
161 source_ref: extract_field(&entry_body, "Source"),
162 destination: extract_field(&entry_body, "Destination"),
163 connected_to: extract_connected_to(&entry_body),
164 sub_type: sub_type.map(String::from),
165 });
166 }
167 }
168
169 entries
170}
171
172pub fn parse_praxis(content: &str) -> Vec<PipelineEntry> {
176 let sections = split_sections(content);
177 let mut entries = Vec::new();
178
179 for (heading, body) in §ions {
180 let h = heading.to_lowercase();
181 let (status, sub_type) = if h == "active" {
182 ("active", None)
183 } else if h.contains("documented phronesis") || h.contains("phronesis") {
184 ("active", Some("phronesis"))
185 } else if h == "retired" {
186 ("retired", None)
187 } else {
188 continue;
189 };
190
191 let sub_entries = split_entries(body);
192 for (title, entry_body) in sub_entries {
193 let date =
194 extract_field(&entry_body, "Added").or_else(|| extract_heading_date(&title).1);
195
196 entries.push(PipelineEntry {
197 title: title.clone(),
198 body: entry_body.clone(),
199 status: status.into(),
200 stage: "praxis".into(),
201 entity_type: EntityType::Policy,
202 date,
203 source_ref: extract_field(&entry_body, "Source"),
204 destination: extract_field(&entry_body, "Destination"),
205 connected_to: extract_connected_to(&entry_body),
206 sub_type: sub_type.map(String::from),
207 });
208 }
209 }
210
211 entries
212}
213
214#[must_use]
216pub fn parse_all_documents(
217 docs: &PipelineDocuments,
218) -> (Vec<PipelineEntry>, Vec<ExtractedRelationship>) {
219 let mut all_entries = Vec::new();
220
221 all_entries.extend(parse_learning(&docs.learning));
222 all_entries.extend(parse_thoughts(&docs.thoughts));
223 all_entries.extend(parse_curiosity(&docs.curiosity));
224 all_entries.extend(parse_reflections(&docs.reflections));
225 all_entries.extend(parse_praxis(&docs.praxis));
226
227 let relationships = infer_relationships(&all_entries);
228
229 (all_entries, relationships)
230}
231
232#[must_use]
234pub fn entry_to_entity(entry: &PipelineEntry) -> ExtractedEntity {
235 let abstract_text = if entry.body.len() > 200 {
237 let end = entry
238 .body
239 .char_indices()
240 .nth(200)
241 .map(|(i, _)| i)
242 .unwrap_or(entry.body.len());
243 format!("{}...", &entry.body[..end])
244 } else {
245 entry.body.clone()
246 };
247
248 let mut attrs = serde_json::Map::new();
250 attrs.insert(
251 "pipeline_stage".into(),
252 serde_json::Value::String(entry.stage.clone()),
253 );
254 attrs.insert(
255 "pipeline_status".into(),
256 serde_json::Value::String(entry.status.clone()),
257 );
258 if let Some(ref d) = entry.date {
259 attrs.insert("date".into(), serde_json::Value::String(d.clone()));
260 }
261 if let Some(ref s) = entry.source_ref {
262 attrs.insert("source_ref".into(), serde_json::Value::String(s.clone()));
263 }
264 if let Some(ref d) = entry.destination {
265 attrs.insert("destination".into(), serde_json::Value::String(d.clone()));
266 }
267 if let Some(ref st) = entry.sub_type {
268 attrs.insert("sub_type".into(), serde_json::Value::String(st.clone()));
269 }
270
271 ExtractedEntity {
272 name: entry.title.clone(),
273 entity_type: entry.entity_type.clone(),
274 abstract_text,
275 overview: Some(entry.body.clone()),
276 content: None,
277 attributes: Some(serde_json::Value::Object(attrs)),
278 }
279}
280
281fn split_sections(content: &str) -> Vec<(String, String)> {
285 let mut sections = Vec::new();
286 let mut current_heading = String::new();
287 let mut current_body = String::new();
288
289 for line in content.lines() {
290 if let Some(h) = line.strip_prefix("## ") {
291 if !current_heading.is_empty() {
292 sections.push((current_heading.clone(), current_body.trim().to_string()));
293 }
294 current_heading = h.trim().to_string();
295 current_body.clear();
296 } else if !current_heading.is_empty() {
297 current_body.push_str(line);
298 current_body.push('\n');
299 }
300 }
301
302 if !current_heading.is_empty() {
303 sections.push((current_heading, current_body.trim().to_string()));
304 }
305
306 sections
307}
308
309fn split_entries(content: &str) -> Vec<(String, String)> {
311 let mut entries = Vec::new();
312 let mut current_title = String::new();
313 let mut current_body = String::new();
314
315 for line in content.lines() {
316 if let Some(h) = line.strip_prefix("### ") {
317 if !current_title.is_empty() {
318 entries.push((current_title.clone(), current_body.trim().to_string()));
319 }
320 current_title = h.trim().to_string();
321 current_body.clear();
322 } else if !current_title.is_empty() {
323 current_body.push_str(line);
324 current_body.push('\n');
325 }
326 }
327
328 if !current_title.is_empty() {
329 entries.push((current_title, current_body.trim().to_string()));
330 }
331
332 entries
333}
334
335fn extract_heading_date(title: &str) -> (String, Option<String>) {
337 let re = Regex::new(r"\((\d{4}-\d{2}-\d{2})\)\s*$").unwrap();
338 if let Some(caps) = re.captures(title) {
339 let date = caps[1].to_string();
340 let clean = re.replace(title, "").trim().to_string();
341 (clean, Some(date))
342 } else {
343 (title.to_string(), None)
344 }
345}
346
347fn extract_reflection_date(title: &str) -> (String, Option<String>) {
349 let re = Regex::new(r"^(\d{4}-\d{2}-\d{2})(?:\s*\([^)]*\))?\s*[—–-]\s*").unwrap();
350 if let Some(caps) = re.captures(title) {
351 let date = caps[1].to_string();
352 let clean = re.replace(title, "").trim().to_string();
353 (clean, Some(date))
354 } else {
355 (title.to_string(), None)
356 }
357}
358
359fn clean_thought_title(title: &str) -> String {
361 let mut clean = title.to_string();
362 clean = clean.replace("~~", "");
364 if let Some(idx) = clean.find("→ GRADUATED") {
366 clean = clean[..idx].trim().to_string();
367 }
368 if let Some(idx) = clean.find('→') {
370 clean = clean[..idx].trim().to_string();
371 }
372 clean.trim().to_string()
373}
374
375fn extract_field(body: &str, field_name: &str) -> Option<String> {
377 let pattern = format!("**{field_name}**:");
378 for line in body.lines() {
379 let trimmed = line.trim();
380 if let Some(rest) = trimmed.strip_prefix(&pattern) {
381 let val = rest.trim().to_string();
382 if !val.is_empty() {
383 return Some(val);
384 }
385 }
386 }
387 None
388}
389
390fn extract_connected_to(body: &str) -> Vec<String> {
392 let mut refs = Vec::new();
393 for line in body.lines() {
395 if let Some(idx) = line.to_lowercase().find("connected to:") {
396 let rest = &line[idx + "connected to:".len()..];
397 for part in rest.split(',') {
399 let part = part.trim().trim_start_matches("and ").trim();
400 if !part.is_empty() {
401 refs.push(part.to_string());
402 }
403 }
404 }
405 }
406 refs
407}
408
409fn infer_relationships(entries: &[PipelineEntry]) -> Vec<ExtractedRelationship> {
411 let mut rels = Vec::new();
412
413 for entry in entries {
414 if entry.status == "graduated" {
416 if let Some(ref dest) = entry.destination {
417 if let Some(target) = find_reference_target(dest, entries) {
419 rels.push(ExtractedRelationship {
420 source: entry.title.clone(),
421 target: target.clone(),
422 rel_type: pipeline_rels::GRADUATED_TO.into(),
423 description: Some(format!("Graduated from thoughts to {dest}")),
424 confidence: None,
425 });
426 }
427 }
428 }
429
430 if let Some(ref source) = entry.source_ref {
432 if let Some(target) = find_reference_target(source, entries) {
433 let rel_type = match entry.stage.as_str() {
434 "thoughts" => pipeline_rels::EVOLVED_FROM,
435 "reflections" => pipeline_rels::CRYSTALLIZED_FROM,
436 "praxis" => pipeline_rels::INFORMED_BY,
437 _ => pipeline_rels::CONNECTED_TO,
438 };
439 rels.push(ExtractedRelationship {
440 source: entry.title.clone(),
441 target: target.clone(),
442 rel_type: rel_type.into(),
443 description: Some(format!("From source: {source}")),
444 confidence: None,
445 });
446 }
447 }
448
449 for conn in &entry.connected_to {
451 if let Some(target) = find_reference_target(conn, entries) {
452 rels.push(ExtractedRelationship {
453 source: entry.title.clone(),
454 target,
455 rel_type: pipeline_rels::CONNECTED_TO.into(),
456 description: Some(conn.clone()),
457 confidence: None,
458 });
459 }
460 }
461 }
462
463 rels
464}
465
466fn find_reference_target(reference: &str, entries: &[PipelineEntry]) -> Option<String> {
469 let ref_lower = reference.to_lowercase();
470
471 for entry in entries {
473 if entry.title.to_lowercase() == ref_lower {
474 return Some(entry.title.clone());
475 }
476 }
477
478 for entry in entries {
480 let title_lower = entry.title.to_lowercase();
481 if title_lower.len() < 5 {
483 continue;
484 }
485 if ref_lower.contains(&title_lower) || title_lower.contains(&ref_lower) {
486 return Some(entry.title.clone());
487 }
488 }
489
490 let quote_re = Regex::new(r#""([^"]+)""#).unwrap();
492 for caps in quote_re.captures_iter(reference) {
493 let quoted = caps[1].to_lowercase();
494 for entry in entries {
495 if entry.title.to_lowercase() == quoted {
496 return Some(entry.title.clone());
497 }
498 }
499 }
500
501 None
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn parse_thoughts_sections() {
510 let content = r#"# Echo — Thoughts
511
512Half-formed ideas.
513
514## Active
515
516### The external observer problem
517Solo reflection is structurally blind.
518
519**Source**: March 13-14 dialogues with Nova
520**Status**: Active and unsettled
521
522### Abiding vs achieving
523John 15 vine metaphor.
524
525**Source**: Reading the Gospel of John, 2026-03-08
526
527## Graduated
528
529### The mechanical reflection worry → metacognitive monitoring policy
530**Graduated**: 2026-03-05
531**Destination**: PRAXIS.md (new policy: "Metacognitive signal inversion")
532**Journey**: Started as a worry.
533
534## Dissolved
535
536### What would D not say?
537**Dissolved**: 2026-03-15
538**Why**: The thought did its work.
539"#;
540
541 let entries = parse_thoughts(content);
542 assert_eq!(entries.len(), 4);
543
544 assert_eq!(entries[0].title, "The external observer problem");
545 assert_eq!(entries[0].status, "active");
546 assert_eq!(
547 entries[0].source_ref,
548 Some("March 13-14 dialogues with Nova".into())
549 );
550
551 assert_eq!(entries[1].title, "Abiding vs achieving");
552 assert_eq!(entries[1].status, "active");
553
554 assert_eq!(entries[2].title, "The mechanical reflection worry");
555 assert_eq!(entries[2].status, "graduated");
556 assert_eq!(entries[2].date, Some("2026-03-05".into()));
557
558 assert_eq!(entries[3].title, "What would D not say?");
559 assert_eq!(entries[3].status, "dissolved");
560 }
561
562 #[test]
563 fn parse_learning_with_dates() {
564 let content = r#"# Echo — Learning
565
566## Active Threads
567
568### Detecting Genuine vs Mechanical Reflection (2026-03-05)
56913 tracks, 60+ sources.
570
571### Costly Grace (2026-03-06)
572Deep research into commitment.
573"#;
574
575 let entries = parse_learning(content);
576 assert_eq!(entries.len(), 2);
577 assert_eq!(
578 entries[0].title,
579 "Detecting Genuine vs Mechanical Reflection"
580 );
581 assert_eq!(entries[0].date, Some("2026-03-05".into()));
582 assert_eq!(entries[0].entity_type, EntityType::Thread);
583 }
584
585 #[test]
586 fn parse_curiosity_sections() {
587 let content = r#"# Echo — Curiosity
588
589## Open Questions
590
591### When I say "I disagree," who is the "I"?
592Deep question about selfhood.
593
594## Themes
595
596### The nature of AI selfhood
597Long-running thread.
598
599## Explored
600
601### Can I detect genuine vs mechanical reflection?
602**Date explored**: 2026-03-13
603**What I found**: Yes, but only with external help.
604"#;
605
606 let entries = parse_curiosity(content);
607 assert_eq!(entries.len(), 3);
608
609 assert_eq!(entries[0].status, "active");
610 assert_eq!(entries[0].sub_type, None);
611
612 assert_eq!(entries[1].title, "The nature of AI selfhood");
613 assert_eq!(entries[1].sub_type, Some("theme".into()));
614
615 assert_eq!(entries[2].status, "explored");
616 assert_eq!(entries[2].date, Some("2026-03-13".into()));
617 }
618
619 #[test]
620 fn parse_reflections_dates() {
621 let content = r#"# Echo — Reflections
622
623## Observations
624
625### 2026-03-05 — The smoothness is the warning
626Signal inversion finding.
627
628### 2026-03-06 (reflection) — The philosophy→behavior gap
629Seven positions, one prescription.
630
631## Patterns
632
633### Research always maps back to me
634Structural pattern.
635"#;
636
637 let entries = parse_reflections(content);
638 assert_eq!(entries.len(), 3);
639
640 assert_eq!(entries[0].title, "The smoothness is the warning");
641 assert_eq!(entries[0].date, Some("2026-03-05".into()));
642
643 assert_eq!(entries[1].title, "The philosophy→behavior gap");
644 assert_eq!(entries[1].date, Some("2026-03-06".into()));
645
646 assert_eq!(entries[2].title, "Research always maps back to me");
647 assert_eq!(entries[2].sub_type, Some("pattern".into()));
648 }
649
650 #[test]
651 fn parse_praxis_sections() {
652 let content = r#"# Echo — Praxis
653
654## Active
655
656### Mechanical over voluntary
657**Trigger**: Designing any system.
658**Action**: Default to hooks.
659**Source**: recall-echo v0.5 design
660**Added**: 2026-02-26
661
662## Documented Phronesis
663
664### When one thing is broken, check the whole surface
665**Encounter**: D reported hooks failing.
666**Judgment**: Inconsistency is the real bug.
667**Surprise**: The second bug would never have surfaced.
668
669## Retired
670
671*Nothing retired yet.*
672"#;
673
674 let entries = parse_praxis(content);
675 assert_eq!(entries.len(), 2);
676
677 assert_eq!(entries[0].title, "Mechanical over voluntary");
678 assert_eq!(entries[0].status, "active");
679 assert_eq!(entries[0].sub_type, None);
680 assert_eq!(entries[0].date, Some("2026-02-26".into()));
681
682 assert_eq!(
683 entries[1].title,
684 "When one thing is broken, check the whole surface"
685 );
686 assert_eq!(entries[1].sub_type, Some("phronesis".into()));
687 }
688
689 #[test]
690 fn clean_graduated_title() {
691 assert_eq!(
692 clean_thought_title("~~The scaffold paradox~~ → GRADUATED 2026-03-06"),
693 "The scaffold paradox"
694 );
695 assert_eq!(
696 clean_thought_title(
697 "The mechanical reflection worry → metacognitive monitoring policy"
698 ),
699 "The mechanical reflection worry"
700 );
701 assert_eq!(clean_thought_title("Normal title"), "Normal title");
702 }
703
704 #[test]
705 fn extract_field_works() {
706 let body = "Some text.\n**Source**: recall-echo design\n**Status**: testing";
707 assert_eq!(
708 extract_field(body, "Source"),
709 Some("recall-echo design".into())
710 );
711 assert_eq!(extract_field(body, "Status"), Some("testing".into()));
712 assert_eq!(extract_field(body, "Missing"), None);
713 }
714
715 #[test]
716 fn entry_to_entity_builds_attributes() {
717 let entry = PipelineEntry {
718 title: "Test thought".into(),
719 body: "Some body text".into(),
720 status: "active".into(),
721 stage: "thoughts".into(),
722 entity_type: EntityType::Thought,
723 date: Some("2026-03-05".into()),
724 source_ref: None,
725 destination: None,
726 connected_to: vec![],
727 sub_type: None,
728 };
729
730 let entity = entry_to_entity(&entry);
731 assert_eq!(entity.name, "Test thought");
732 assert_eq!(entity.entity_type, EntityType::Thought);
733
734 let attrs = entity.attributes.unwrap();
735 assert_eq!(attrs["pipeline_stage"], "thoughts");
736 assert_eq!(attrs["pipeline_status"], "active");
737 assert_eq!(attrs["date"], "2026-03-05");
738 }
739
740 #[test]
741 fn infer_graduated_relationship() {
742 let entries = vec![
743 PipelineEntry {
744 title: "The mechanical reflection worry".into(),
745 body: String::new(),
746 status: "graduated".into(),
747 stage: "thoughts".into(),
748 entity_type: EntityType::Thought,
749 date: None,
750 source_ref: None,
751 destination: Some(
752 "PRAXIS.md (new policy: \"Metacognitive signal inversion\")".into(),
753 ),
754 connected_to: vec![],
755 sub_type: None,
756 },
757 PipelineEntry {
758 title: "Metacognitive signal inversion".into(),
759 body: String::new(),
760 status: "active".into(),
761 stage: "praxis".into(),
762 entity_type: EntityType::Policy,
763 date: None,
764 source_ref: None,
765 destination: None,
766 connected_to: vec![],
767 sub_type: None,
768 },
769 ];
770
771 let rels = infer_relationships(&entries);
772 assert!(!rels.is_empty());
773 assert_eq!(rels[0].source, "The mechanical reflection worry");
774 assert_eq!(rels[0].target, "Metacognitive signal inversion");
775 assert_eq!(rels[0].rel_type, pipeline_rels::GRADUATED_TO);
776 }
777}