use super::{extract_field, ParsedObservation, OBSERVATION_TYPES};
const INVALID_TYPE_PREVIEW_BYTES: usize = 120;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InvalidObservationTypeDrop {
Missing,
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ParseObservationsOutcome {
pub(crate) observations: Vec<ParsedObservation>,
pub(crate) invalid_type_drops: Vec<InvalidObservationTypeDrop>,
}
impl ParseObservationsOutcome {
pub(crate) fn had_invalid_type(&self) -> bool {
!self.invalid_type_drops.is_empty()
}
}
pub(crate) fn find_ascii_ci(haystack: &str, needle: &str) -> Option<usize> {
let needle = needle.as_bytes();
haystack
.as_bytes()
.windows(needle.len())
.position(|w| w.eq_ignore_ascii_case(needle))
}
fn extract_array(content: &str, array_name: &str, element_name: &str) -> Vec<String> {
let open = format!("<{}>", array_name);
let close = format!("</{}>", array_name);
let Some(start) = content.find(&open) else {
return vec![];
};
let start = start + open.len();
let Some(end_rel) = content[start..].find(&close) else {
return vec![];
};
let end = start + end_rel;
let inner = &content[start..end];
let elem_open = format!("<{}>", element_name);
let elem_close = format!("</{}>", element_name);
let mut results = Vec::new();
let mut pos = 0;
while let Some(found) = inner[pos..].find(&elem_open) {
let value_start = pos + found + elem_open.len();
let Some(end_rel) = inner[value_start..].find(&elem_close) else {
break;
};
let value_end = value_start + end_rel;
let value = inner[value_start..value_end].trim().to_string();
if !value.is_empty() {
results.push(value);
}
pos = value_end + elem_close.len();
}
results
}
fn parse_confidence(content: &str) -> Option<f64> {
let raw = extract_field(content, "confidence")?;
match raw.parse::<f64>() {
Ok(value) if value.is_finite() => Some(value.clamp(0.0, 1.0)),
_ => {
crate::log::warn(
"observation-parse",
&format!("invalid <confidence> value {raw:?}; falling back to default"),
);
None
}
}
}
pub fn parse_observations(text: &str) -> Vec<ParsedObservation> {
parse_observations_with_outcome(text).observations
}
pub(crate) fn parse_observations_with_outcome(text: &str) -> ParseObservationsOutcome {
let mut observations = Vec::new();
let mut invalid_type_drops = Vec::new();
let mut pos = 0;
while let Some(tag_start_rel) = find_ascii_ci(&text[pos..], "<observation") {
let tag_start = pos + tag_start_rel;
let Some(open_end_rel) = text[tag_start..].find('>') else {
break;
};
let content_start = tag_start + open_end_rel + 1;
let Some(close_rel) = find_ascii_ci(&text[content_start..], "</observation>") else {
break;
};
let content_end = content_start + close_rel;
let content = &text[content_start..content_end];
let Some(raw_type) = extract_field(content, "type") else {
crate::log::error(
"observation-parse",
"dropping observation: drop_reason=missing_type raw_type=\"\"",
);
invalid_type_drops.push(InvalidObservationTypeDrop::Missing);
pos = content_end + "</observation>".len();
continue;
};
let obs_type = raw_type.trim().to_ascii_lowercase();
if !OBSERVATION_TYPES.contains(&obs_type.as_str()) {
let raw_type_preview = crate::adapter::redaction::redact_and_truncate(
&raw_type,
INVALID_TYPE_PREVIEW_BYTES,
);
crate::log::error(
"observation-parse",
&format!(
"dropping observation: drop_reason=unknown_type raw_type_preview={raw_type_preview:?} raw_type_bytes={}",
raw_type.len()
),
);
invalid_type_drops.push(InvalidObservationTypeDrop::Unknown);
pos = content_end + "</observation>".len();
continue;
}
let mut concepts = extract_array(content, "concepts", "concept");
concepts.retain(|concept| !concept.eq_ignore_ascii_case(&obs_type));
observations.push(ParsedObservation {
obs_type,
title: extract_field(content, "title"),
subtitle: extract_field(content, "subtitle"),
facts: extract_array(content, "facts", "fact"),
narrative: extract_field(content, "narrative"),
concepts,
files_read: extract_array(content, "files_read", "file"),
files_modified: extract_array(content, "files_modified", "file"),
confidence: parse_confidence(content),
});
pos = content_end + "</observation>".len();
}
ParseObservationsOutcome {
observations,
invalid_type_drops,
}
}