use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use synwire_core::agents::sampling::{SamplingProvider, SamplingRequest};
use super::{CommunityError, CommunityId};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CommunitySummary {
pub community_id: CommunityId,
pub summary: String,
pub generated_at: DateTime<Utc>,
pub is_stale: bool,
}
impl CommunitySummary {
fn new(community_id: CommunityId, summary: String) -> Self {
Self {
community_id,
summary,
generated_at: Utc::now(),
is_stale: false,
}
}
}
#[derive(Debug)]
pub struct SummaryCache {
base_dir: PathBuf,
entries: HashMap<CommunityId, CommunitySummary>,
}
impl SummaryCache {
pub fn new(base_dir: &Path) -> Result<Self, CommunityError> {
let summaries_dir = base_dir.join("communities").join("summaries");
std::fs::create_dir_all(&summaries_dir)?;
Ok(Self {
base_dir: base_dir.to_path_buf(),
entries: HashMap::new(),
})
}
fn summary_path(&self, id: CommunityId) -> PathBuf {
self.base_dir
.join("communities")
.join("summaries")
.join(format!("{}.json", id.0))
}
pub fn set(&mut self, summary: CommunitySummary) -> Result<(), CommunityError> {
let path = self.summary_path(summary.community_id);
let json = serde_json::to_string_pretty(&summary)?;
std::fs::write(&path, json)?;
let _ = self.entries.insert(summary.community_id, summary);
Ok(())
}
pub fn get(&mut self, id: CommunityId) -> Result<Option<&CommunitySummary>, CommunityError> {
if self.entries.contains_key(&id) {
return Ok(self.entries.get(&id));
}
let path = self.summary_path(id);
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&path)?;
let summary: CommunitySummary = serde_json::from_str(&json)?;
let _ = self.entries.insert(id, summary);
Ok(self.entries.get(&id))
}
pub fn mark_stale(&mut self, community_id: CommunityId) -> Result<(), CommunityError> {
if let Some(entry) = self.entries.get_mut(&community_id) {
entry.is_stale = true;
let path = self
.base_dir
.join("communities")
.join("summaries")
.join(format!("{}.json", community_id.0));
let json = serde_json::to_string_pretty(entry)?;
std::fs::write(path, json)?;
} else {
let path = self.summary_path(community_id);
if path.exists() {
let json = std::fs::read_to_string(&path)?;
let mut summary: CommunitySummary = serde_json::from_str(&json)?;
summary.is_stale = true;
let updated = serde_json::to_string_pretty(&summary)?;
std::fs::write(&path, updated)?;
let _ = self.entries.insert(community_id, summary);
}
}
Ok(())
}
}
pub async fn generate_summary(
members: &[String],
sampling: Option<&dyn SamplingProvider>,
) -> String {
let fallback = || format!("Members: {}", members.join(", "));
let Some(provider) = sampling else {
return fallback();
};
if !provider.is_available() {
return fallback();
}
let prompt = format!(
"Summarise this code community in one sentence. Members: {}",
members.join(", ")
);
let request = SamplingRequest::new(prompt)
.with_system(
"You are a code analysis assistant. \
Produce a concise one-sentence summary of a group of related code symbols.",
)
.with_max_tokens(128)
.with_temperature(0.3);
match provider.sample(request).await {
Ok(response) => response.text,
Err(_) => fallback(),
}
}
pub async fn generate_and_cache(
cache: &mut SummaryCache,
community_id: CommunityId,
members: &[String],
sampling: Option<&dyn SamplingProvider>,
) -> Result<String, CommunityError> {
if let Some(entry) = cache.get(community_id)?
&& !entry.is_stale
{
return Ok(entry.summary.clone());
}
let text = generate_summary(members, sampling).await;
let summary = CommunitySummary::new(community_id, text.clone());
cache.set(summary)?;
Ok(text)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use synwire_core::agents::sampling::NoopSamplingProvider;
#[tokio::test]
async fn fallback_when_no_provider() {
let members = vec!["fn_a".to_owned(), "fn_b".to_owned()];
let s = generate_summary(&members, None).await;
assert!(s.contains("fn_a"));
assert!(s.contains("fn_b"));
}
#[tokio::test]
async fn fallback_when_noop_provider() {
let members = vec!["fn_a".to_owned()];
let p = NoopSamplingProvider;
let s = generate_summary(&members, Some(&p)).await;
assert!(s.contains("fn_a"));
}
#[test]
fn mark_stale_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let mut cache = SummaryCache::new(dir.path()).unwrap();
let id = CommunityId(42);
let summary = CommunitySummary::new(id, "test summary".to_owned());
cache.set(summary).unwrap();
cache.mark_stale(id).unwrap();
let entry = cache.get(id).unwrap().unwrap();
assert!(entry.is_stale);
}
#[tokio::test]
async fn generate_and_cache_stores_entry() {
let dir = tempfile::tempdir().unwrap();
let mut cache = SummaryCache::new(dir.path()).unwrap();
let members = vec!["sym_a".to_owned(), "sym_b".to_owned()];
let id = CommunityId(1);
let text = generate_and_cache(&mut cache, id, &members, None)
.await
.unwrap();
assert!(text.contains("sym_a"));
let text2 = generate_and_cache(&mut cache, id, &members, None)
.await
.unwrap();
assert_eq!(text, text2);
}
}