Skip to main content

ao_core/
parity_feedback_tools.rs

1//! TS feedback tooling (ported from `packages/core/src/feedback-tools.ts`).
2//!
3//! Parity status: test-only.
4//!
5//! No runtime consumer. Uses `parity_metadata` for atomic file writes and
6//! key/value parsing. See `docs/ts-core-parity-report.md` →
7//! "Parity-only modules".
8
9use crate::parity_metadata::{atomic_write_file, parse_key_value_content};
10use std::hash::{Hash, Hasher};
11use std::path::PathBuf;
12
13pub const FEEDBACK_TOOL_BUG_REPORT: &str = "bug_report";
14pub const FEEDBACK_TOOL_IMPROVEMENT_SUGGESTION: &str = "improvement_suggestion";
15
16fn normalize_text(value: &str) -> String {
17    value.split_whitespace().collect::<Vec<_>>().join(" ")
18}
19
20#[derive(Debug, Clone, PartialEq)]
21pub struct FeedbackInput {
22    pub title: String,
23    pub body: String,
24    pub evidence: Vec<String>,
25    pub session: String,
26    pub source: String,
27    pub confidence: f64,
28}
29
30pub fn validate_feedback_tool_input(tool: &str, input: &FeedbackInput) -> Result<(), String> {
31    if tool != FEEDBACK_TOOL_BUG_REPORT && tool != FEEDBACK_TOOL_IMPROVEMENT_SUGGESTION {
32        return Err(format!("Unknown feedback tool: {tool}"));
33    }
34    let title = normalize_text(&input.title);
35    let body = normalize_text(&input.body);
36    let session = normalize_text(&input.session);
37    let source = normalize_text(&input.source);
38    if title.is_empty()
39        || body.is_empty()
40        || session.is_empty()
41        || source.is_empty()
42        || input.evidence.is_empty()
43    {
44        return Err("Missing required fields".into());
45    }
46    if !input.confidence.is_finite() || input.confidence < 0.0 || input.confidence > 1.0 {
47        return Err("Invalid confidence".into());
48    }
49    Ok(())
50}
51
52pub fn generate_feedback_dedupe_key(tool: &str, input: &FeedbackInput) -> String {
53    let mut evidence: Vec<String> = input
54        .evidence
55        .iter()
56        .map(|e| normalize_text(e).to_lowercase())
57        .collect();
58    evidence.sort();
59
60    let canonical = format!(
61        "{}|{}|{}|{}|{}|{}",
62        tool,
63        normalize_text(&input.title).to_lowercase(),
64        normalize_text(&input.body).to_lowercase(),
65        normalize_text(&input.session).to_lowercase(),
66        normalize_text(&input.source).to_lowercase(),
67        evidence.join("|")
68    );
69
70    let mut h = std::collections::hash_map::DefaultHasher::new();
71    canonical.hash(&mut h);
72    format!("{:016x}", h.finish())
73}
74
75#[derive(Debug, Clone, PartialEq)]
76pub struct PersistedFeedbackReport {
77    pub id: String,
78    pub tool: String,
79    pub created_at: String,
80    pub dedupe_key: String,
81    pub input: FeedbackInput,
82}
83
84fn serialize_report(report: &PersistedFeedbackReport) -> String {
85    let mut lines: Vec<String> = vec![
86        "version=1".into(),
87        format!("id={}", report.id),
88        format!("tool={}", report.tool),
89        format!("createdAt={}", report.created_at),
90        format!("dedupeKey={}", report.dedupe_key),
91        format!("title={}", report.input.title),
92        format!("body={}", report.input.body),
93        format!("session={}", report.input.session),
94        format!("source={}", report.input.source),
95        format!("confidence={}", report.input.confidence),
96    ];
97    for (i, ev) in report.input.evidence.iter().enumerate() {
98        lines.push(format!("evidence.{i}={ev}"));
99    }
100    lines.join("\n") + "\n"
101}
102
103fn is_report_file_name(name: &str) -> bool {
104    name.starts_with("report_") && name.ends_with(".kv")
105}
106
107pub struct FeedbackReportStore {
108    reports_dir: PathBuf,
109}
110
111impl FeedbackReportStore {
112    pub fn new(reports_dir: impl Into<PathBuf>) -> Self {
113        Self {
114            reports_dir: reports_dir.into(),
115        }
116    }
117
118    pub fn persist(
119        &self,
120        tool: &str,
121        input: FeedbackInput,
122    ) -> Result<PersistedFeedbackReport, String> {
123        validate_feedback_tool_input(tool, &input)?;
124        let created_at = iso_now();
125        let dedupe_key = generate_feedback_dedupe_key(tool, &input);
126        let id = format!(
127            "report_{}_{}",
128            created_at.replace([':', '.'], "-"),
129            short_id()
130        );
131        let report = PersistedFeedbackReport {
132            id: id.clone(),
133            tool: tool.to_string(),
134            created_at,
135            dedupe_key,
136            input,
137        };
138        std::fs::create_dir_all(&self.reports_dir).map_err(|e| e.to_string())?;
139        let path = self.reports_dir.join(format!("{id}.kv"));
140        atomic_write_file(&path, &serialize_report(&report)).map_err(|e| e.to_string())?;
141        Ok(report)
142    }
143
144    pub fn list(&self) -> Vec<PersistedFeedbackReport> {
145        let Ok(rd) = std::fs::read_dir(&self.reports_dir) else {
146            return vec![];
147        };
148        let mut out = vec![];
149        for ent in rd.flatten() {
150            let name = ent.file_name().to_string_lossy().to_string();
151            if !is_report_file_name(&name) {
152                continue;
153            }
154            let Ok(content) = std::fs::read_to_string(ent.path()) else {
155                continue;
156            };
157            let Ok(report) = parse_report_file(&content) else {
158                continue;
159            };
160            out.push(report);
161        }
162        out.sort_by(|a, b| a.created_at.cmp(&b.created_at));
163        out
164    }
165}
166
167fn parse_report_file(content: &str) -> Result<PersistedFeedbackReport, String> {
168    let raw = parse_key_value_content(content);
169    let tool = raw.get("tool").cloned().unwrap_or_default();
170    if tool != FEEDBACK_TOOL_BUG_REPORT && tool != FEEDBACK_TOOL_IMPROVEMENT_SUGGESTION {
171        return Err("Invalid tool".into());
172    }
173    let mut evidence: Vec<(usize, String)> = raw
174        .iter()
175        .filter_map(|(k, v)| {
176            k.strip_prefix("evidence.")
177                .map(|idx| (idx.parse::<usize>().unwrap_or(0), v.clone()))
178        })
179        .collect();
180    evidence.sort_by_key(|(i, _)| *i);
181    let evidence = evidence.into_iter().map(|(_, v)| v).collect::<Vec<_>>();
182    let input = FeedbackInput {
183        title: raw.get("title").cloned().unwrap_or_default(),
184        body: raw.get("body").cloned().unwrap_or_default(),
185        evidence,
186        session: raw.get("session").cloned().unwrap_or_default(),
187        source: raw.get("source").cloned().unwrap_or_default(),
188        confidence: raw
189            .get("confidence")
190            .and_then(|s| s.parse::<f64>().ok())
191            .unwrap_or(f64::NAN),
192    };
193    validate_feedback_tool_input(&tool, &input)?;
194    Ok(PersistedFeedbackReport {
195        id: raw.get("id").cloned().unwrap_or_default(),
196        tool,
197        created_at: raw.get("createdAt").cloned().unwrap_or_default(),
198        dedupe_key: raw.get("dedupeKey").cloned().unwrap_or_default(),
199        input,
200    })
201}
202
203fn iso_now() -> String {
204    use std::time::{SystemTime, UNIX_EPOCH};
205    let ms = SystemTime::now()
206        .duration_since(UNIX_EPOCH)
207        .unwrap_or_default()
208        .as_millis();
209    format!("{ms}Z")
210}
211
212fn short_id() -> String {
213    use std::time::{SystemTime, UNIX_EPOCH};
214    let n = SystemTime::now()
215        .duration_since(UNIX_EPOCH)
216        .unwrap_or_default()
217        .subsec_nanos();
218    format!("{:08x}", n)
219}