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    pub fn mark_guidance_candidate_applied(
81        &self,
82        id: &str,
83        applied_at_ms: u64,
84        treatment_fingerprint: &str,
85        backup_path: &str,
86        experiment_id: Option<&str>,
87    ) -> Result<()> {
88        self.conn.execute(
89            "UPDATE guidance_candidates
90             SET status = 'applied', applied_at_ms = ?2, treatment_fingerprint = ?3,
91                 backup_path = ?4, experiment_id = ?5
92             WHERE id = ?1",
93            params![
94                id,
95                applied_at_ms as i64,
96                treatment_fingerprint,
97                backup_path,
98                experiment_id
99            ],
100        )?;
101        Ok(())
102    }
103}
104
105const GUIDANCE_CANDIDATE_SELECT: &str = "SELECT id, artifact_kind, artifact_id, action_json,
106    status, rationale, evidence_json, created_at_ms, applied_at_ms,
107    treatment_fingerprint, experiment_id, backup_path FROM guidance_candidates";
108
109fn guidance_candidate_row(
110    r: &rusqlite::Row<'_>,
111) -> rusqlite::Result<crate::guidance::GuidanceCandidate> {
112    let kind: String = r.get(1)?;
113    let status: String = r.get(4)?;
114    Ok(crate::guidance::GuidanceCandidate {
115        id: r.get(0)?,
116        artifact: crate::guidance::ArtifactRef {
117            kind: crate::guidance::ArtifactKind::parse(&kind)
118                .unwrap_or(crate::guidance::ArtifactKind::Skill),
119            slug: r.get(2)?,
120        },
121        action: serde_json::from_str(&r.get::<_, String>(3)?)
122            .unwrap_or(crate::guidance::CandidateAction::ReviewOnly),
123        status: crate::guidance::CandidateStatus::parse(&status)
124            .unwrap_or(crate::guidance::CandidateStatus::Proposed),
125        rationale: r.get(5)?,
126        evidence: serde_json::from_str(&r.get::<_, String>(6)?).unwrap_or_default(),
127        created_at_ms: r.get::<_, i64>(7)? as u64,
128        applied_at_ms: r.get::<_, Option<i64>>(8)?.map(|v| v as u64),
129        treatment_fingerprint: r.get(9)?,
130        experiment_id: r.get(10)?,
131        backup_path: r.get(11)?,
132    })
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::guidance::{ArtifactKind, ArtifactRef, CandidateAction, CandidateStatus};
139
140    #[test]
141    fn candidate_round_trip_and_status() -> anyhow::Result<()> {
142        let dir = tempfile::tempdir()?;
143        let store = Store::open(&dir.path().join("k.db"))?;
144        let c = candidate();
145        store.upsert_guidance_candidate(&c)?;
146        store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
147        let got = store.get_guidance_candidate("c1")?.unwrap();
148        assert_eq!(got.status, CandidateStatus::Rejected);
149        Ok(())
150    }
151
152    #[test]
153    fn rejected_candidates_filter_by_artifact() -> anyhow::Result<()> {
154        let dir = tempfile::tempdir()?;
155        let store = Store::open(&dir.path().join("k.db"))?;
156        store.upsert_guidance_candidate(&candidate())?;
157        store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
158        let got = store.rejected_guidance_candidates(&candidate().artifact, 10)?;
159        assert_eq!(got.len(), 1);
160        Ok(())
161    }
162
163    fn candidate() -> crate::guidance::GuidanceCandidate {
164        crate::guidance::GuidanceCandidate {
165            id: "c1".into(),
166            artifact: ArtifactRef {
167                kind: ArtifactKind::Skill,
168                slug: "tdd".into(),
169            },
170            action: CandidateAction::ReviewOnly,
171            status: CandidateStatus::Proposed,
172            rationale: "inspect".into(),
173            evidence: vec!["e".into()],
174            created_at_ms: 1,
175            applied_at_ms: None,
176            treatment_fingerprint: None,
177            experiment_id: None,
178            backup_path: None,
179        }
180    }
181}