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