Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 93 additions & 15 deletions crates/claudear-analysis/src/knowledgebase/discord/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,18 +319,11 @@ pub fn format_discord_search_context(results: &[DiscordSearchResult]) -> String

for (i, result) in results.iter().enumerate() {
let chunk = &result.chunk;
let label = match chunk.guild_id.as_deref().filter(|g| !g.is_empty()) {
Some(guild_id) => format!(
"[Channel `{}`](https://discord.com/channels/{}/{}/{})",
chunk.channel_id, guild_id, chunk.channel_id, chunk.start_message_id,
),
None => format!("Channel `{}`", chunk.channel_id),
};
let _ = writeln!(
context,
"### {}. {} (Similarity: {:.0}%)",
i + 1,
label,
discord_span_links(chunk),
result.score * 100.0,
);

Expand Down Expand Up @@ -361,6 +354,61 @@ pub fn format_discord_search_context(results: &[DiscordSearchResult]) -> String
context
}

/// Build a Discord jump URL to a single message. Falls back to the `@me`
/// (DM) path when the guild id is missing so we always emit a clickable link.
fn discord_jump_url(guild_id: Option<&str>, channel_id: &str, message_id: &str) -> String {
match guild_id.filter(|g| !g.is_empty()) {
Some(guild_id) => format!(
"https://discord.com/channels/{}/{}/{}",
guild_id, channel_id, message_id
),
None => format!(
"https://discord.com/channels/@me/{}/{}",
channel_id, message_id
),
}
}

/// Render a chunk's channel label with jump links spanning its window: `from`
/// (first message) and `to` (last message). Collapses to a single link when the
/// window is one message.
fn discord_span_links(chunk: &DiscordMessageChunk) -> String {
let guild = chunk.guild_id.as_deref();
let start = discord_jump_url(guild, &chunk.channel_id, &chunk.start_message_id);
if chunk.end_message_id == chunk.start_message_id {
format!("[Channel `{}`]({})", chunk.channel_id, start)
} else {
let end = discord_jump_url(guild, &chunk.channel_id, &chunk.end_message_id);
format!(
"Channel `{}` — [from]({}) → [to]({})",
chunk.channel_id, start, end
)
}
}

/// Build a compact Discord-markdown block of jump links to the retrieved
/// discussions, for appending to an outgoing notification so readers can open
/// the referenced conversations. Each entry links the start and end of the
/// conversation window. Returns an empty string when there are no results.
pub fn format_discord_reference_links(results: &[DiscordSearchResult]) -> String {
use std::fmt::Write;

if results.is_empty() {
return String::new();
}

let mut out = String::from("\n\n\u{1F4CE} **Referenced Discord discussions**\n");
for result in results {
let _ = writeln!(
out,
"- {} ({:.0}%)",
discord_span_links(&result.chunk),
result.score * 100.0,
);
}
out
}

fn sha256_hex(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
Expand Down Expand Up @@ -450,19 +498,20 @@ mod tests {
assert!(out.contains("95%"));
assert!(out.contains("alice, bob"));
assert!(out.contains("alice: hi"));
// Channel heading is a clickable deep link to the first message.
assert!(out.contains("[Channel `chan1`](https://discord.com/channels/g/chan1/1)"));
// A multi-message window links both the first and last message.
assert!(out.contains("[from](https://discord.com/channels/g/chan1/1)"));
assert!(out.contains("[to](https://discord.com/channels/g/chan1/2)"));
}

#[test]
fn test_format_context_without_guild_is_not_linked() {
fn test_format_context_without_guild_uses_me_fallback() {
let chunk = DiscordMessageChunk {
id: Some(1),
guild_id: None,
channel_id: "chan1".to_string(),
channel_kind: DiscordChannelKind::Channel,
start_message_id: "1".to_string(),
end_message_id: "2".to_string(),
end_message_id: "1".to_string(),
participant_ids: None,
start_message_time: "2024-01-01T10:00:00Z".to_string(),
end_message_time: "2024-01-01T10:05:00Z".to_string(),
Expand All @@ -472,9 +521,38 @@ mod tests {
};
let out = format_discord_search_context(&[DiscordSearchResult { chunk, score: 0.5 }]);

assert!(out.contains("Channel `chan1`"));
// No guild id => no permalink.
assert!(!out.contains("https://discord.com/channels"));
// Missing guild id => still linkable via the @me path; single message
// collapses to one link.
assert!(out.contains("[Channel `chan1`](https://discord.com/channels/@me/chan1/1)"));
}

#[test]
fn test_reference_links_empty_is_empty_string() {
assert!(format_discord_reference_links(&[]).is_empty());
}

#[test]
fn test_reference_links_span_and_score() {
let chunk = DiscordMessageChunk {
id: Some(1),
guild_id: Some("g".to_string()),
channel_id: "chan1".to_string(),
channel_kind: DiscordChannelKind::Channel,
start_message_id: "10".to_string(),
end_message_id: "20".to_string(),
participant_ids: None,
start_message_time: "2024-01-01T10:00:00Z".to_string(),
end_message_time: "2024-01-01T10:05:00Z".to_string(),
chunk_text: "hi".to_string(),
context_text: "ctx".to_string(),
content_hash: Some("h".to_string()),
};
let out = format_discord_reference_links(&[DiscordSearchResult { chunk, score: 0.87 }]);

assert!(out.contains("Referenced Discord discussions"));
assert!(out.contains("[from](https://discord.com/channels/g/chan1/10)"));
assert!(out.contains("[to](https://discord.com/channels/g/chan1/20)"));
assert!(out.contains("87%"));
}

// ---- end-to-end index (needs embedding model + sqlite) --------------
Expand Down
4 changes: 2 additions & 2 deletions crates/claudear-analysis/src/knowledgebase/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
pub mod discord;

pub use discord::{
format_discord_search_context, DiscordIndexer, DiscordMessageInput, DiscordSearchService,
DISCORD_INDEX_VERSION,
format_discord_reference_links, format_discord_search_context, DiscordIndexer,
DiscordMessageInput, DiscordSearchService, DISCORD_INDEX_VERSION,
};
59 changes: 41 additions & 18 deletions crates/claudear-engine/src/processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ impl IssueProcessor {
format!("Red phase: authoring failing test for {}", issue.short_id),
json!({}),
);
let context = self.build_rag_context(issue, attempt_id).await;
let (context, _discord_refs) = self.build_rag_context(issue, attempt_id).await;
let prompt = build_failing_test_prompt(issue, &context);
match self
.agent
Expand Down Expand Up @@ -1145,7 +1145,8 @@ impl IssueProcessor {
}

// Enrich context with indexed Discord discussions (independent of code index).
let (discord_ctx, discord_items) =
// Reference links are for user-facing notifications, not this agent context.
let (discord_ctx, discord_items, _discord_refs) =
self.discord_grounding_context(issue, 5, attempt_id).await;
if !discord_ctx.is_empty() {
let metric = ProcessingMetric::new("discord_search_context_added", 1.0)
Expand Down Expand Up @@ -2100,7 +2101,7 @@ impl IssueProcessor {
} else {
String::new()
};
let (discord_ctx, discord_items) = self
let (discord_ctx, discord_items, discord_refs) = self
.discord_grounding_context(issue, self.config.qa.max_context_chunks, attempt_id)
.await;
if !discord_ctx.is_empty() {
Expand Down Expand Up @@ -2141,7 +2142,14 @@ impl IssueProcessor {

match answer_result {
Ok(Ok(answer)) => {
match self.notifier.notify_answer(issue, &answer).await {
// Append jump links to the referenced discussions for delivery only;
// the stored answer (for reply-chain grounding) stays clean.
let answer_to_send = if discord_refs.is_empty() {
answer.clone()
} else {
format!("{}{}", answer, discord_refs)
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
match self.notifier.notify_answer(issue, &answer_to_send).await {
Ok(sent_ids) => {
if !sent_ids.is_empty() {
if let Err(e) = self.tracker.record_answer_message_ids(
Expand Down Expand Up @@ -2409,7 +2417,8 @@ impl IssueProcessor {
attempt_id: Option<i64>,
) -> VerifyResult {
let project_dir = self.action_project_dir(resolution);
let context = self.build_rag_context(issue, attempt_id).await;
// Verify is read-only and posts no user-facing message, so drop the refs.
let (context, _discord_refs) = self.build_rag_context(issue, attempt_id).await;

self.record_issue_decision(
issue,
Expand Down Expand Up @@ -2529,7 +2538,7 @@ impl IssueProcessor {
attempt_id: Option<i64>,
) -> ProcessingOutcome {
let project_dir = self.action_project_dir(resolution);
let context = self.build_rag_context(issue, attempt_id).await;
let (context, discord_refs) = self.build_rag_context(issue, attempt_id).await;

// The inbox key is the HelpScout mailbox id when present, else the source.
let inbox_key = issue
Expand Down Expand Up @@ -2566,9 +2575,16 @@ impl IssueProcessor {
// Deliver: conversational sources go via the notifier; tracker
// sources post a comment on the ticket (falling back to notifier).
// Capture any sent message ids so a later reply maps back here.
// Referenced-discussion jump links are appended to notifier
// deliveries only; ticket comments keep the plain reply.
let reply_notify = if discord_refs.is_empty() {
reply.clone()
} else {
format!("{}{}", reply, discord_refs)
};
let mut answer_ids: Vec<String> = Vec::new();
let delivered: Result<()> = if qa_eligible_source(source_name) {
match self.notifier.notify_answer(issue, &reply).await {
match self.notifier.notify_answer(issue, &reply_notify).await {
Ok(ids) => {
answer_ids = ids;
Ok(())
Expand All @@ -2580,7 +2596,7 @@ impl IssueProcessor {
Ok(()) => Ok(()),
Err(e) => {
tracing::warn!(short_id = %issue.short_id, error = %e, "post_reply failed; falling back to notifier");
match self.notifier.notify_answer(issue, &reply).await {
match self.notifier.notify_answer(issue, &reply_notify).await {
Ok(ids) => {
answer_ids = ids;
Ok(())
Expand Down Expand Up @@ -2662,7 +2678,10 @@ impl IssueProcessor {
/// Retrieve RAG grounding context for an issue from the code index, plus any
/// indexed Discord discussions.
/// Build the RAG grounding context for the action pipeline (verify/reply).
async fn build_rag_context(&self, issue: &Issue, attempt_id: Option<i64>) -> String {
/// Returns the agent-facing context plus a Discord-markdown block of jump
/// links to any referenced discussions, for appending to the outgoing
/// notification (empty when there are none).
async fn build_rag_context(&self, issue: &Issue, attempt_id: Option<i64>) -> (String, String) {
let mut retrieved_items: Vec<RetrievedItem> = Vec::new();
let mut context = String::new();
if let Some(ref code_search) = self.code_search_service {
Expand Down Expand Up @@ -2703,7 +2722,7 @@ impl IssueProcessor {
}
}
}
let (discord_ctx, discord_items) = self
let (discord_ctx, discord_items, discord_refs) = self
.discord_grounding_context(issue, self.config.qa.max_context_chunks, attempt_id)
.await;
if !discord_ctx.is_empty() {
Expand All @@ -2723,7 +2742,7 @@ impl IssueProcessor {
self.spawn_retrieval_judge(id, issue, &retrieved_items);
}

context
(context, discord_refs)
}

/// Debug-gated confirmation that retrieval rows were persisted. Only emitted
Expand Down Expand Up @@ -2883,17 +2902,19 @@ impl IssueProcessor {

/// Retrieve grounding context from the indexed Discord knowledge source.
/// Returns the formatted context (empty when the source is disabled or yields
/// no results) plus the retrieved chunks as [`RetrievedItem`]s so the caller
/// can feed them to the relevance judge. When `attempt_id` is set, also
/// records the retrieved chunks for quality assessment.
/// no results), the retrieved chunks as [`RetrievedItem`]s so the caller can
/// feed them to the relevance judge, and a Discord-markdown block of jump
/// links to the referenced discussions for appending to the outgoing
/// notification. When `attempt_id` is set, also records the retrieved chunks
/// for quality assessment.
async fn discord_grounding_context(
&self,
issue: &Issue,
limit: usize,
attempt_id: Option<i64>,
) -> (String, Vec<RetrievedItem>) {
) -> (String, Vec<RetrievedItem>, String) {
let Some(ref discord_search) = self.discord_search_service else {
return (String::new(), Vec::new());
return (String::new(), Vec::new(), String::new());
};
let query = claudear_analysis::repo::code_index::build_code_search_query(issue);
match discord_search.search(&query, None, limit).await {
Expand Down Expand Up @@ -2929,9 +2950,11 @@ impl IssueProcessor {
}
let context =
claudear_analysis::knowledgebase::format_discord_search_context(&results);
(context, items)
let refs =
claudear_analysis::knowledgebase::format_discord_reference_links(&results);
(context, items, refs)
}
_ => (String::new(), Vec::new()),
_ => (String::new(), Vec::new(), String::new()),
}
}

Expand Down