use crate::db::models::{CodeElement, Relationship};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MinedItemKind {
Decision,
Preference,
Milestone,
Problem,
General,
}
impl MinedItemKind {
pub fn classify(text: &str) -> Self {
let t = text.trim();
let lower = t.to_ascii_lowercase();
for (label, kind) in [
("decision:", Self::Decision),
("preference:", Self::Preference),
("milestone:", Self::Milestone),
("problem:", Self::Problem),
] {
if lower.starts_with(label) {
return kind;
}
}
if [
"fails",
"failure",
"broken",
"bug",
"timeout",
"crashes",
"error",
"timing out",
"stuck",
"keeps",
]
.iter()
.any(|k| lower.contains(k))
{
return Self::Problem;
}
if [
"we decided",
"decision",
"we will",
"we should",
"we are going to",
"let's use",
]
.iter()
.any(|k| lower.contains(k))
{
return Self::Decision;
}
if [
"milestone",
"goal",
"deadline",
"by end of",
"target date",
"ship by",
"by q",
]
.iter()
.any(|k| lower.contains(k))
{
return Self::Milestone;
}
if [
"prefer",
"preference",
"preferred",
"rather use",
"would like",
]
.iter()
.any(|k| lower.contains(k))
{
return Self::Preference;
}
Self::General
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Decision => "decision",
Self::Preference => "preference",
Self::Milestone => "milestone",
Self::Problem => "problem",
Self::General => "general",
}
}
}
#[derive(Debug, Clone)]
pub struct MinedItem {
pub kind: MinedItemKind,
pub verbatim: String,
pub source: String,
pub participants: Vec<String>,
pub timestamp: String,
pub topic: String,
pub code_targets: Vec<String>,
}
fn extract_topic(text: &str) -> String {
let stopwords = [
"decision",
"preference",
"milestone",
"problem",
"adopt",
"use",
"switch",
"migrate",
"prefer",
"go",
"pick",
"choose",
"we",
"the",
"our",
"new",
"for",
"with",
"and",
];
for token in text.split(|c: char| !c.is_alphanumeric() && c != '_') {
let t = token.trim();
let lower = t.to_ascii_lowercase();
if t.len() >= 3 && !stopwords.contains(&lower.as_str()) {
return t.to_string();
}
}
text.chars().take(40).collect()
}
fn extract_code_targets(text: &str) -> Vec<String> {
use regex::Regex;
let mut targets = Vec::new();
let backtick = Regex::new(r"`([^`]+)`").unwrap();
for cap in backtick.captures_iter(text) {
if let Some(m) = cap.get(1) {
targets.push(m.as_str().to_string());
}
}
let file_symbol =
Regex::new(r"\b([A-Za-z0-9_\-./]+\.(?:rs|go|ts|js|py|java|kt))::([A-Za-z0-9_]+)\b")
.unwrap();
for cap in file_symbol.captures_iter(text) {
let full = format!("{}::{}", &cap[1], &cap[2]);
if !targets.contains(&full) {
targets.push(full);
}
}
let src_path = Regex::new(r"\bsrc/[A-Za-z0-9_\-./]+\.(?:rs|go|ts|js|py|java|kt)\b").unwrap();
for m in src_path.find_iter(text) {
let s = m.as_str().to_string();
if !targets.contains(&s) {
targets.push(s);
}
}
targets
}
impl MinedItem {
pub fn from_message(message: crate::conversation_indexer::parsers::RawMessage) -> Option<Self> {
let text = message.text.trim().to_string();
if text.is_empty() {
return None;
}
let kind = MinedItemKind::classify(&text);
if kind == MinedItemKind::General {
return None;
}
let topic = extract_topic(&text);
let code_targets = extract_code_targets(&text);
Some(Self {
kind,
verbatim: text,
source: message.source,
participants: vec![message.participant],
timestamp: message.timestamp,
topic,
code_targets,
})
}
pub fn qualified_name(&self, project: &Path) -> String {
let project_name = project
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project");
let slug: String = self
.topic
.chars()
.map(|c| {
if c.is_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
format!(
"conversations/{project_name}/{}/{}",
self.kind.as_str(),
slug
)
}
pub fn to_graph_elements(&self, project: &Path) -> (CodeElement, Vec<Relationship>) {
let qn = self.qualified_name(project);
let element = CodeElement {
qualified_name: qn.clone(),
element_type: self.kind.as_str().to_string(),
name: self.topic.clone(),
file_path: format!("conversations/{}/{}", self.source, self.kind.as_str()),
line_start: 1,
line_end: 1,
language: "conversation".to_string(),
parent_qualified: None,
metadata: serde_json::json!({
"kind": self.kind.as_str(),
"verbatim": self.verbatim,
"source": self.source,
"participants": self.participants,
"timestamp": self.timestamp,
"topic": self.topic,
}),
..Default::default()
};
let mut relationships = Vec::new();
if self.kind == MinedItemKind::Decision {
for target in &self.code_targets {
relationships.push(Relationship {
id: None,
source_qualified: qn.clone(),
target_qualified: target.clone(),
rel_type: "decided_about".to_string(),
confidence: 1.0,
metadata: serde_json::json!({
"verbatim": self.verbatim,
"confidence_label": "EXTRACTED",
}),
..Default::default()
});
}
}
(element, relationships)
}
}
#[derive(Debug, Clone)]
pub struct MiningResult {
pub items: Vec<MinedItem>,
pub sources: usize,
pub elements_indexed: usize,
pub relationships_created: usize,
}
impl MiningResult {
pub fn summary(&self) -> String {
let mut kinds: Vec<String> = self
.items
.iter()
.map(|i| i.kind.as_str().to_string())
.collect();
kinds.sort();
kinds.dedup();
let item_word = if self.items.len() == 1 {
"item"
} else {
"items"
};
format!(
"Mined {} {} from {} source(s) [{}]: {} elements, {} relationships",
self.items.len(),
item_word,
self.sources,
kinds.join(", "),
self.elements_indexed,
self.relationships_created
)
}
}