Repository navigation
feat(reports): support threads digest and reviewed answer drafts - #162
HarshMN2345 wants to merge 15 commits into
Conversation
Scans a Discord support forum, ranks open threads by how urgently they need a reply (no reply yet, wait time, bumps, impact keywords, others reporting the same problem) and posts the top ones to Discord when a thread enters the list. Also lists threads that look resolved so they can be closed. Report-only: threads never enter the fix pipeline. Configured under [reports.support_digest], off by default.
🔵 Tier A · Mergeable after minor fixes
Adds an opt-in Appwrite-backed support-thread digest that ranks threads needing replies, with optional QA-generated answer drafts for human review. A new Support dashboard lets admins inspect conversations, edit drafts, and approve or reject them; posting approved answers remains delegated to the threads project's function. The pull request also expands configuration, API, scheduling, shutdown, telemetry, documentation, and live-QA controls. Latest changes: The newest commits add unit tests for title resolution, triage statuses, unanswered streaks and bumps, and impact ranking.
Fix with agent prompt### Issue 1
crates/claudear-integrations/src/reports/support.rs:187-318
**No tests for triage/scoring or the new Discord client paths**
The triage logic has many branches: team promise vs. answer, LikelyResolved vs. STILL_BROKEN, bump counting, the streak index, and impact regexes. `is_solved` and the archived `since` paging and `get_member_roles` are new as well. None of it has tests, although comparable modules in this repo do. Table-style unit tests for `triage` and `is_solved`, plus a mock-HTTP test for `list_public_archived_threads_since`, would guard the ranking.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.📂 Walkthrough · 17
⏳ Still open from earlier reviews · 1
Reviewed the commits since |
| pub fn triage(&self, team: &HashSet<String>, now: DateTime<Utc>) -> Option<SupportEntry> { | ||
| let last = self.messages.last()?; | ||
| let speakers: Vec<Speaker> = self | ||
| .messages | ||
| .iter() | ||
| .map(|message| self.speaker(message, team)) | ||
| .collect(); | ||
| let last_speaker = speakers[speakers.len() - 1]; | ||
| let replies = speakers.iter().filter(|s| **s != Speaker::Op).count(); | ||
| let team_replied = speakers.contains(&Speaker::Team); | ||
|
|
||
| // The poster's current unanswered streak: every trailing message of theirs. | ||
| let streak = speakers | ||
| .iter() | ||
| .rposition(|s| *s != Speaker::Op) | ||
| .map_or(0, |i| i + 1); | ||
| let waiting_from = if last_speaker == Speaker::Op { | ||
| &self.messages[streak] | ||
| } else { | ||
| last | ||
| }; | ||
| let waiting_hours = (now - waiting_from.timestamp).num_hours().max(0); | ||
|
|
||
| let mut reasons = Vec::new(); | ||
| let mut score = 0.0; | ||
| let status = if last_speaker == Speaker::Team | ||
| && !(PROMISE.is_match(&last.content) && waiting_hours >= 24) | ||
| { | ||
| reasons.push(format!("team replied last, {}d ago", waiting_hours / 24)); | ||
| SupportStatus::WaitingOnUser | ||
| } else if last_speaker == Speaker::Op | ||
| && replies > 0 | ||
| && THANKS.is_match(&last.content) | ||
| && !STILL_BROKEN.is_match(&last.content) | ||
| { | ||
| reasons.push("poster's last message reads like it is fixed".to_string()); | ||
| SupportStatus::LikelyResolved | ||
| } else { | ||
| if last_speaker == Speaker::Team { | ||
| score += 20.0; | ||
| reasons.push(format!( | ||
| "team said \"{}\" with no follow-up", | ||
| excerpt(&last.content, 60) | ||
| )); | ||
| } else { | ||
| score += 30.0; | ||
| if replies == 0 { | ||
| score += 20.0; | ||
| reasons.push("no replies yet".to_string()); | ||
| } else if !team_replied { | ||
| score += 10.0; | ||
| reasons.push("no team reply".to_string()); | ||
| } | ||
| if last_speaker == Speaker::Community { | ||
| reasons.push("community member replied last".to_string()); | ||
| } | ||
| } | ||
| SupportStatus::NeedsReply | ||
| }; | ||
|
|
||
| if status == SupportStatus::NeedsReply { | ||
| score += (waiting_hours as f64 / 24.0).min(7.0) * 3.0; | ||
| if waiting_hours >= 24 { | ||
| reasons.push(format!("waiting {}d", waiting_hours / 24)); | ||
| } | ||
|
|
||
| let bumps = self.messages[streak..] | ||
| .windows(2) | ||
| .filter(|pair| pair[1].timestamp - pair[0].timestamp > Duration::hours(2)) | ||
| .count(); | ||
| if bumps > 0 { | ||
| score += bumps.min(3) as f64 * 6.0; | ||
| reasons.push(format!("poster bumped {bumps}x")); | ||
| } | ||
|
|
||
| let poster_text = std::iter::once(self.title.as_str()) | ||
| .chain( | ||
| self.messages | ||
| .iter() | ||
| .zip(&speakers) | ||
| .filter(|(_, s)| **s == Speaker::Op) | ||
| .map(|(m, _)| m.content.as_str()), | ||
| ) | ||
| .collect::<Vec<_>>() | ||
| .join("\n") | ||
| .to_lowercase(); | ||
| let impacts: Vec<&str> = IMPACT | ||
| .iter() | ||
| .filter(|(_, pattern)| pattern.is_match(&poster_text)) | ||
| .map(|(label, _)| *label) | ||
| .take(3) | ||
| .collect(); | ||
| if !impacts.is_empty() { | ||
| score += impacts.len() as f64 * 12.0; | ||
| reasons.push(format!("mentions {}", impacts.join(", "))); | ||
| } | ||
|
|
||
| let affected: HashSet<&str> = self | ||
| .messages | ||
| .iter() | ||
| .zip(&speakers) | ||
| .filter(|(m, s)| **s == Speaker::Community && ALSO_AFFECTED.is_match(&m.content)) | ||
| .map(|(m, _)| m.author_id.as_str()) | ||
| .collect(); | ||
| if !affected.is_empty() { | ||
| score += affected.len().min(2) as f64 * 10.0; | ||
| reasons.push(format!("{} other user(s) report the same", affected.len())); | ||
| } | ||
|
|
||
| if self | ||
| .tags | ||
| .iter() | ||
| .any(|tag| tag.eq_ignore_ascii_case("cloud")) | ||
| { | ||
| score += 5.0; | ||
| reasons.push("Cloud".to_string()); | ||
| } | ||
| } | ||
|
|
||
| Some(SupportEntry { | ||
| thread_id: self.id.clone(), | ||
| title: self.title.clone(), | ||
| url: self.url.clone(), | ||
| status, | ||
| score: score.round() as u32, | ||
| reasons, | ||
| last_author: last.author.clone(), | ||
| last_message: excerpt(&last.content, 200), | ||
| waiting_hours, | ||
| is_new: false, | ||
| }) | ||
| } |
There was a problem hiding this comment.
No tests for triage/scoring or the new Discord client paths
The triage logic has many branches: team promise vs. answer, LikelyResolved vs. STILL_BROKEN, bump counting, the streak index, and impact regexes. is_solved and the archived since paging and get_member_roles are new as well. None of it has tests, although comparable modules in this repo do. Table-style unit tests for triage and is_solved, plus a mock-HTTP test for list_public_archived_threads_since, would guard the ranking.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/claudear-integrations/src/reports/support.rs
Line: 187-318
Comment:
**No tests for triage/scoring or the new Discord client paths**
The triage logic has many branches: team promise vs. answer, LikelyResolved vs. STILL_BROKEN, bump counting, the streak index, and impact regexes. `is_solved` and the archived `since` paging and `get_member_roles` are new as well. None of it has tests, although comparable modules in this repo do. Table-style unit tests for `triage` and `is_solved`, plus a mock-HTTP test for `list_public_archived_threads_since`, would guard the ranking.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.🟡 Minor · testing · Reply if this doesn't apply.
|
| /// are marked new. | ||
| pub async fn collect(&self) -> Result<SupportDigest> { |
There was a problem hiding this comment.
Digest behavior lacks tests The new collection and triage paths have no behavioral tests for truncated histories, status changes, delivery failures, or configured entry limits. That leaves the decisions about which threads appear—and whether another notification is attempted—unguarded. Test those observable outcomes rather than mirroring regexes or configuration values.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/claudear-engine/src/support_digest.rs
Line: 76-77
Comment:
**Digest behavior lacks tests** The new collection and triage paths have no behavioral tests for truncated histories, status changes, delivery failures, or configured entry limits. That leaves the decisions about which threads appear—and whether another notification is attempted—unguarded. Test those observable outcomes rather than mirroring regexes or configuration values.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
notify_support_digest now returns whether a channel posted the digest, so a failed Discord post no longer silences those threads. max_entries is capped at the ten threads the Discord message lists, so threads cut from the message are not marked sent either.
- Read up to 1000 messages per thread instead of only the newest 100. - Run the scan as its own task so it never holds up housekeeping. - Re-read team roles after 6 hours instead of caching them forever. - Only read bare thanks as resolved in short messages, and never when the poster promises a next step. - Validate interval_hours and days, and never panic on a bad interval. - Keep the last sent digest in a file next to the database so a restart does not re-announce the same threads.
The support digest now reads open threads, their messages and team authors from the Appwrite project the threads bot syncs the Discord forum into, over its REST API, instead of calling Discord. Claudear needs no access to the forum, and nothing is posted to it; the digest still goes only to the configured notifier channel. - Config takes endpoint, project_id, database_id, an optional api_key and guild_id (for thread links) in place of the forum bot token and channel; team_roles are matched against the authors table. - Drop the Discord client additions (archived-thread paging, member roles, forum tags) that only the digest used. - Scan inside the housekeeping tick instead of a detached task. - Bound days to 365, and key the sent-state file on the database file. - Report a digest as posted only when Discord has a delivery path, and log success only when it was posted. - A poster saying it is fixed counts as resolved even without replies; "will try" style follow-ups wait on the poster.
With `drafts` on, each digest scan asks the QA agent, grounded in code search and indexed Discord discussions, to answer up to `max_drafts` of the listed threads whose draft is missing or older than their latest message. Answers go to the threads project's `drafts` table as `pending`, keyed by thread id; approved drafts are left alone. A new Support page lists pending and failed drafts with their conversation. Admins can edit an answer and approve or reject it, which updates the row; the threads project posts approved answers to the forum. The digest mentions how many drafts wait for review. ThreadsStore now holds all reads and writes to the threads project and is shared by the digest scan and the dashboard API.
# Conflicts: # crates/claudear-engine/src/lib.rs # crates/claudear-engine/src/watcher.rs
…iewed - A replacement draft is saved only over the revision it was written against, so a review made while the agent was answering is kept. - Reviews carry the draft revision the admin saw; the API answers 409 when it changed, or when the draft is already being posted. - The Support page follows a rewritten draft when there are no local edits, flags it when there are, and shows a load error instead of an empty queue. - Each thread's newest 100 messages are read newest first, instead of sharing a row cap across a batch of threads. - A poster missing an author id on older rows is matched to their id on newer messages. - "expected results" counts as resolved only when stated as an outcome. - The scan runs as its own task again and holds a run claim, so it no longer delays housekeeping and shutdown waits for it. - Drafts that are being posted are left alone, and editing an answer resets the threads project's send progress.
Threads longer than one page were ranked on their newest 100 messages only, which could hide earlier team replies or the poster's first report. Each thread is now paged newest first up to 1,000 messages.
A title like "[SOLVE] please help" can be an open request, and solved titles hide the thread from the digest entirely. [SOLVED], [CLOSED], [FIXED] and [RESOLVED] still count, as do the forum's solved tags.
Saving a regenerated draft and saving a review read the row, compared its revision and then wrote it in a separate request, so a concurrent write between the two could still be overwritten. Writes are now conditional in Appwrite itself. A new draft is created, which fails when the row already exists. A replacement or a review updates the row with X-Appwrite-Timestamp set to the revision it was based on, which Appwrite refuses with a 409 when the row was updated after it. Upserts are no longer used, since they ignore that header.
…ample An answer the threads project posts is the bot's message, so unless the bot is in team_user_ids the thread reads as needing a reply and is drafted again.
Table tests for how triage classifies a thread (team answer vs. a promise to look, fixed vs. still broken, follow-ups), the poster's unanswered streak and bumps, impact ranking, and which titles count as solved.
Surfaces Discord support threads that need a reply and drafts answers for a person to review. Configured under
[reports.support_digest], off by default. Claudear never posts to the forum.Digest
endpoint,project_id,database_id,api_key). Claudear needs no access to the forum.interval_hours(default 1), ranks threads active in the lastdaysby: no reply / no team reply, wait time, bumps, impact keywords (production, outage, data loss, billing, lockout, security), and other users reporting the same issue. Team isteam_user_idsplus thread authors holding ateam_rolesrole.max_entriesto Claudear's Discord notifier channel only when a new thread enters the list, plus a short "looks resolved" list and how many drafts wait for review.Drafts (
drafts = true, needsapi_keywith rows read/write)max_draftslisted threads whose draft is missing or older than their latest message, grounded in code search and indexed Discord discussions. Answers go to the threads project'sdraftstable aspending.sentorfailed.Checked
-D warnings) and fmt pass for claudear-config, claudear-integrations and claudear-engine; dashboard typecheck and its 258 tests pass. Draft generation with the real agent was not run.