Skip to main content

kaizen/store/sqlite/
guidance_candidates.rs

1use super::*;
2
3impl Store {
4    pub fn upsert_guidance_candidate(&self, c: &crate::guidance::GuidanceCandidate) -> Result<()> {
5        let action_json = serde_json::to_string(&c.action)?;
6        let evidence_json = serde_json::to_string(&c.evidence)?;
7        self.conn.execute(
8            "INSERT OR REPLACE INTO guidance_candidates
9             (id, artifact_kind, artifact_id, action_json, status, rationale,
10              evidence_json, created_at_ms, applied_at_ms, treatment_fingerprint,
11              experiment_id, backup_path)
12             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
13            params![
14                c.id,
15                c.artifact.kind.as_str(),
16                c.artifact.slug,
17                action_json,
18                c.status.as_str(),
19                c.rationale,
20                evidence_json,
21                c.created_at_ms as i64,
22                c.applied_at_ms.map(|v| v as i64),
23                c.treatment_fingerprint.as_deref(),
24                c.experiment_id.as_deref(),
25                c.backup_path.as_deref(),
26            ],
27        )?;
28        Ok(())
29    }
30
31    pub fn list_guidance_candidates(&self) -> Result<Vec<crate::guidance::GuidanceCandidate>> {
32        let mut stmt = self.conn.prepare(GUIDANCE_CANDIDATE_SELECT)?;
33        let rows = stmt.query_map([], guidance_candidate_row)?;
34        rows.map(|r| r.map_err(anyhow::Error::from)).collect()
35    }
36
37    pub fn get_guidance_candidate(
38        &self,
39        id: &str,
40    ) -> Result<Option<crate::guidance::GuidanceCandidate>> {
41        self.conn
42            .query_row(
43                &format!("{GUIDANCE_CANDIDATE_SELECT} WHERE id = ?1"),
44                params![id],
45                guidance_candidate_row,
46            )
47            .optional()
48            .map_err(Into::into)
49    }
50
51    pub fn rejected_guidance_candidates(
52        &self,
53        artifact: &crate::guidance::ArtifactRef,
54        limit: usize,
55    ) -> Result<Vec<crate::guidance::GuidanceCandidate>> {
56        let sql = format!(
57            "{GUIDANCE_CANDIDATE_SELECT} WHERE artifact_kind = ?1 AND artifact_id = ?2
58             AND status = 'rejected' ORDER BY created_at_ms DESC LIMIT ?3"
59        );
60        let mut stmt = self.conn.prepare(&sql)?;
61        let rows = stmt.query_map(
62            params![artifact.kind.as_str(), artifact.slug.as_str(), limit as i64],
63            guidance_candidate_row,
64        )?;
65        rows.map(|r| r.map_err(anyhow::Error::from)).collect()
66    }
67
68    pub fn set_guidance_candidate_status(
69        &self,
70        id: &str,
71        status: crate::guidance::CandidateStatus,
72    ) -> Result<()> {
73        self.conn.execute(
74            "UPDATE guidance_candidates SET status = ?2 WHERE id = ?1",
75            params![id, status.as_str()],
76        )?;
77        Ok(())
78    }
79}
80
81const GUIDANCE_CANDIDATE_SELECT: &str = "SELECT id, artifact_kind, artifact_id, action_json,
82    status, rationale, evidence_json, created_at_ms, applied_at_ms,
83    treatment_fingerprint, experiment_id, backup_path FROM guidance_candidates";
84
85fn guidance_candidate_row(
86    r: &rusqlite::Row<'_>,
87) -> rusqlite::Result<crate::guidance::GuidanceCandidate> {
88    let kind: String = r.get(1)?;
89    let status: String = r.get(4)?;
90    Ok(crate::guidance::GuidanceCandidate {
91        id: r.get(0)?,
92        artifact: crate::guidance::ArtifactRef {
93            kind: crate::guidance::ArtifactKind::parse(&kind)
94                .unwrap_or(crate::guidance::ArtifactKind::Skill),
95            slug: r.get(2)?,
96        },
97        action: serde_json::from_str(&r.get::<_, String>(3)?)
98            .unwrap_or(crate::guidance::CandidateAction::ReviewOnly),
99        status: crate::guidance::CandidateStatus::parse(&status)
100            .unwrap_or(crate::guidance::CandidateStatus::Proposed),
101        rationale: r.get(5)?,
102        evidence: serde_json::from_str(&r.get::<_, String>(6)?).unwrap_or_default(),
103        created_at_ms: r.get::<_, i64>(7)? as u64,
104        applied_at_ms: r.get::<_, Option<i64>>(8)?.map(|v| v as u64),
105        treatment_fingerprint: r.get(9)?,
106        experiment_id: r.get(10)?,
107        backup_path: r.get(11)?,
108    })
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::guidance::{ArtifactKind, ArtifactRef, CandidateAction, CandidateStatus};
115
116    #[test]
117    fn candidate_round_trip_and_status() -> anyhow::Result<()> {
118        let dir = tempfile::tempdir()?;
119        let store = Store::open(&dir.path().join("k.db"))?;
120        let c = candidate();
121        store.upsert_guidance_candidate(&c)?;
122        store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
123        let got = store.get_guidance_candidate("c1")?.unwrap();
124        assert_eq!(got.status, CandidateStatus::Rejected);
125        Ok(())
126    }
127
128    #[test]
129    fn rejected_candidates_filter_by_artifact() -> anyhow::Result<()> {
130        let dir = tempfile::tempdir()?;
131        let store = Store::open(&dir.path().join("k.db"))?;
132        store.upsert_guidance_candidate(&candidate())?;
133        store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
134        let got = store.rejected_guidance_candidates(&candidate().artifact, 10)?;
135        assert_eq!(got.len(), 1);
136        Ok(())
137    }
138
139    fn candidate() -> crate::guidance::GuidanceCandidate {
140        crate::guidance::GuidanceCandidate {
141            id: "c1".into(),
142            artifact: ArtifactRef {
143                kind: ArtifactKind::Skill,
144                slug: "tdd".into(),
145            },
146            action: CandidateAction::ReviewOnly,
147            status: CandidateStatus::Proposed,
148            rationale: "inspect".into(),
149            evidence: vec!["e".into()],
150            created_at_ms: 1,
151            applied_at_ms: None,
152            treatment_fingerprint: None,
153            experiment_id: None,
154            backup_path: None,
155        }
156    }
157}