diff --git a/crates/claudear-analysis/src/knowledgebase/discord/mod.rs b/crates/claudear-analysis/src/knowledgebase/discord/mod.rs index 894eef39..a6980c0e 100644 --- a/crates/claudear-analysis/src/knowledgebase/discord/mod.rs +++ b/crates/claudear-analysis/src/knowledgebase/discord/mod.rs @@ -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, ); @@ -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()); @@ -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(), @@ -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) -------------- diff --git a/crates/claudear-analysis/src/knowledgebase/mod.rs b/crates/claudear-analysis/src/knowledgebase/mod.rs index 252dbd8f..238f310f 100644 --- a/crates/claudear-analysis/src/knowledgebase/mod.rs +++ b/crates/claudear-analysis/src/knowledgebase/mod.rs @@ -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, }; diff --git a/crates/claudear-engine/src/processing.rs b/crates/claudear-engine/src/processing.rs index bcb0fb8e..55d185ec 100644 --- a/crates/claudear-engine/src/processing.rs +++ b/crates/claudear-engine/src/processing.rs @@ -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 @@ -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) @@ -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() { @@ -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) + }; + 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( @@ -2409,7 +2417,8 @@ impl IssueProcessor { attempt_id: Option, ) -> 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, @@ -2529,7 +2538,7 @@ impl IssueProcessor { attempt_id: Option, ) -> 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 @@ -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 = 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(()) @@ -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(()) @@ -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) -> 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) -> (String, String) { let mut retrieved_items: Vec = Vec::new(); let mut context = String::new(); if let Some(ref code_search) = self.code_search_service { @@ -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() { @@ -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 @@ -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, - ) -> (String, Vec) { + ) -> (String, Vec, 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 { @@ -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()), } }