Skip to content

Commit 874b370

Browse files
authored
Merge pull request #65 from WZ/codex/fix-ticket-prefix-dedupe
Add interactive nudge status updates
2 parents 3212225 + aa5d189 commit 874b370

16 files changed

Lines changed: 1695 additions & 32 deletions

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ See [docs/agentic-features-overview.md](docs/agentic-features-overview.md) for a
109109
| `/gen` | Alias of `/generate-report` |
110110
| `/list` | List this week's work items |
111111
| `/check` | List missing members with nudge buttons |
112+
| `/nudge` | Send a test nudge DM (self by default; managers can target one member) |
112113
| `/retrospect` | Analyze corrections and suggest improvements |
113114
| `/stats` | View classification accuracy dashboard |
114115
| `/help` | Show help and usage |
@@ -376,6 +377,14 @@ Anyone can view this week's items:
376377

377378
**On-demand**: `/check` lists team members who haven't reported this week, with a "Nudge" button next to each member and a "Nudge All" button at the bottom. Clicking opens a confirmation before sending the DM.
378379

380+
**Testing**:
381+
382+
```text
383+
/nudge # Send a test nudge to your own DM
384+
/nudge Member Name # Manager only: send a test nudge to one member
385+
/nudge U123ABC456 # Manager only: target a Slack user ID directly
386+
```
387+
379388
On Monday before `monday_cutoff_time` (default `12:00`) in configured `timezone`, report commands use the previous calendar week.
380389

381390
Accepts any day name: `Monday`, `Tuesday`, ..., `Sunday`.

internal/app/app.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func Main() {
4545
slack.OptionAppLevelToken(cfg.SlackAppToken),
4646
)
4747

48-
nudge.StartNudgeScheduler(cfg, api)
48+
nudge.StartNudgeScheduler(cfg, db, api)
4949
fetch.StartAutoFetchScheduler(cfg, db, api)
5050

5151
log.Println("Starting Engineering Report Bot...")

internal/integrations/llm/llm.go

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"log"
1010
"net/http"
1111
"os"
12+
"regexp"
13+
"strconv"
1214
"strings"
1315
"sync"
1416

@@ -65,6 +67,8 @@ const defaultOpenAIModel = "gpt-4o-mini"
6567
const maxTemplateGuidanceChars = 8000
6668
const defaultLLMMaxConcurrentBatches = 4
6769

70+
var confidenceFieldRe = regexp.MustCompile(`("confidence"\s*:\s*)([^,}\]]+)`)
71+
6872
func llmBatchConcurrencyLimit(totalBatches int) int {
6973
if totalBatches <= 0 {
7074
return 1
@@ -295,7 +299,7 @@ Also:
295299
- choose normalized_status from: done, in testing, in progress, other
296300
- extract ticket IDs if present (e.g. [1247202] or bare ticket numbers)
297301
- if this item is the same underlying work as an existing item, set duplicate_of to that existing key (Kxx); otherwise empty string
298-
- set confidence between 0 and 1.
302+
- set confidence between 0 and 1, using digits only (example: 0.91). Never spell out numbers.
299303
%s%s
300304
301305
Respond with JSON only (no markdown):
@@ -362,7 +366,14 @@ func parseSectionClassifiedResponse(responseText string) (map[int64]LLMSectionDe
362366

363367
var classified []sectionClassifiedItem
364368
if err := json.Unmarshal([]byte(responseText), &classified); err != nil {
365-
return nil, fmt.Errorf("parsing LLM section response: %w (response: %s)", err, responseText)
369+
repaired := repairSectionClassifiedResponse(responseText)
370+
if repaired == responseText {
371+
return nil, fmt.Errorf("parsing LLM section response: %w (response: %s)", err, responseText)
372+
}
373+
if repairErr := json.Unmarshal([]byte(repaired), &classified); repairErr != nil {
374+
return nil, fmt.Errorf("parsing LLM section response after repair: %w (original error: %v, response: %s)", repairErr, err, repaired)
375+
}
376+
log.Printf("llm section response repaired before parse")
366377
}
367378

368379
decisions := make(map[int64]LLMSectionDecision)
@@ -424,6 +435,92 @@ func parseTicketIDsField(raw json.RawMessage) string {
424435
return ""
425436
}
426437

438+
func repairSectionClassifiedResponse(responseText string) string {
439+
return confidenceFieldRe.ReplaceAllStringFunc(responseText, func(match string) string {
440+
parts := confidenceFieldRe.FindStringSubmatch(match)
441+
if len(parts) != 3 {
442+
return match
443+
}
444+
return parts[1] + normalizeConfidenceLiteral(parts[2])
445+
})
446+
}
447+
448+
func normalizeConfidenceLiteral(raw string) string {
449+
text := strings.TrimSpace(strings.Trim(raw, `"`))
450+
if text == "" {
451+
return "0"
452+
}
453+
if parsed, ok := parseLooseConfidence(text); ok {
454+
return strconv.FormatFloat(parsed, 'f', -1, 64)
455+
}
456+
return "0"
457+
}
458+
459+
func parseLooseConfidence(raw string) (float64, bool) {
460+
text := strings.TrimSpace(raw)
461+
if text == "" {
462+
return 0, false
463+
}
464+
if parsed, err := strconv.ParseFloat(text, 64); err == nil {
465+
return clampConfidence(parsed), true
466+
}
467+
468+
compact := strings.ReplaceAll(strings.ToLower(text), " ", "")
469+
if parsed, err := strconv.ParseFloat(compact, 64); err == nil {
470+
return clampConfidence(parsed), true
471+
}
472+
473+
if strings.HasPrefix(compact, "0.") {
474+
if digit, ok := digitWord(compact[2:]); ok {
475+
return float64(digit) / 10, true
476+
}
477+
}
478+
if compact == "zero" {
479+
return 0, true
480+
}
481+
if compact == "one" {
482+
return 1, true
483+
}
484+
return 0, false
485+
}
486+
487+
func digitWord(s string) (int, bool) {
488+
switch s {
489+
case "zero":
490+
return 0, true
491+
case "one":
492+
return 1, true
493+
case "two":
494+
return 2, true
495+
case "three":
496+
return 3, true
497+
case "four":
498+
return 4, true
499+
case "five":
500+
return 5, true
501+
case "six":
502+
return 6, true
503+
case "seven":
504+
return 7, true
505+
case "eight":
506+
return 8, true
507+
case "nine":
508+
return 9, true
509+
default:
510+
return 0, false
511+
}
512+
}
513+
514+
func clampConfidence(v float64) float64 {
515+
if v < 0 {
516+
return 0
517+
}
518+
if v > 1 {
519+
return 1
520+
}
521+
return v
522+
}
523+
427524
func loadGlossaryIfConfigured(cfg Config) (*LLMGlossary, error) {
428525
if strings.TrimSpace(cfg.LLMGlossaryPath) == "" {
429526
return nil, nil

internal/integrations/llm/llm_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,47 @@ func TestParseSectionClassifiedResponse_AcceptsArrayTicketIDs(t *testing.T) {
129129
}
130130
}
131131

132+
func TestParseSectionClassifiedResponse_RepairsMalformedConfidence(t *testing.T) {
133+
response := `[
134+
{"id": 1, "section_id": "S0_0", "normalized_status": "done", "ticket_ids": "", "duplicate_of": "", "confidence": 0. Nine},
135+
{"id": 2, "section_id": "S0_1", "normalized_status": "in progress", "ticket_ids": "", "duplicate_of": "", "confidence": 0. seven},
136+
{"id": 3, "section_id": "S0_2", "normalized_status": "done", "ticket_ids": "", "duplicate_of": "", "confidence": nope}
137+
]`
138+
139+
got, err := parseSectionClassifiedResponse(response)
140+
if err != nil {
141+
t.Fatalf("parseSectionClassifiedResponse should repair malformed confidence: %v", err)
142+
}
143+
if got[1].Confidence != 0.9 {
144+
t.Fatalf("expected repaired confidence 0.9, got %v", got[1].Confidence)
145+
}
146+
if got[2].Confidence != 0.7 {
147+
t.Fatalf("expected repaired confidence 0.7, got %v", got[2].Confidence)
148+
}
149+
if got[3].Confidence != 0 {
150+
t.Fatalf("expected fallback confidence 0, got %v", got[3].Confidence)
151+
}
152+
}
153+
154+
func TestNormalizeConfidenceLiteral(t *testing.T) {
155+
tests := []struct {
156+
raw string
157+
want string
158+
}{
159+
{raw: "0.91", want: "0.91"},
160+
{raw: `"0.82"`, want: "0.82"},
161+
{raw: "0. Nine", want: "0.9"},
162+
{raw: "0. seven", want: "0.7"},
163+
{raw: "garbage", want: "0"},
164+
}
165+
166+
for _, tt := range tests {
167+
if got := normalizeConfidenceLiteral(tt.raw); got != tt.want {
168+
t.Fatalf("normalizeConfidenceLiteral(%q) = %q, want %q", tt.raw, got, tt.want)
169+
}
170+
}
171+
}
172+
132173
func TestParseTicketIDsField_MixedArray(t *testing.T) {
133174
raw := json.RawMessage(`[ "123", 456, "", " 789 " ]`)
134175
got := parseTicketIDsField(raw)

internal/integrations/slack/deps.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type ClassificationStats = domain.ClassificationStats
2424
type sectionOption = llm.SectionOption
2525
type BuildResult = report.BuildResult
2626
type LLMSectionDecision = llm.LLMSectionDecision
27+
type RenderedNudge = nudge.RenderedNudge
2728

2829
type loadStatus int
2930

@@ -32,6 +33,13 @@ const (
3233
templateFirstEver
3334
)
3435

36+
const (
37+
actionNudgeDone = nudge.ActionDone
38+
actionNudgeMore = nudge.ActionMore
39+
actionNudgePagePrev = nudge.ActionPagePrev
40+
actionNudgePageNext = nudge.ActionPageNext
41+
)
42+
3543
func ReportWeekRange(cfg Config, now time.Time) (time.Time, time.Time) {
3644
return domain.ReportWeekRange(cfg, now)
3745
}
@@ -136,6 +144,10 @@ func UpdateWorkItemTextAndStatus(db *sql.DB, id int64, description, status strin
136144
return sqlite.UpdateWorkItemTextAndStatus(db, id, description, status)
137145
}
138146

147+
func UpdateWorkItemStatus(db *sql.DB, id int64, status string) error {
148+
return sqlite.UpdateWorkItemStatus(db, id, status)
149+
}
150+
139151
func UpdateWorkItemCategory(db *sql.DB, id int64, category string) error {
140152
return sqlite.UpdateWorkItemCategory(db, id, category)
141153
}
@@ -180,8 +192,12 @@ func extractGlossaryPhrase(description string) string {
180192
return llm.ExtractGlossaryPhrase(description)
181193
}
182194

183-
func sendNudges(api *slack.Client, cfg Config, memberIDs []string, reportChannelID string) {
184-
nudge.SendNudges(api, cfg, memberIDs, reportChannelID)
195+
func sendNudges(api *slack.Client, db *sql.DB, cfg Config, memberIDs []string, reportChannelID string) {
196+
nudge.SendNudges(api, db, cfg, memberIDs, reportChannelID)
197+
}
198+
199+
func RenderNudgeForUser(api *slack.Client, db *sql.DB, cfg Config, userID, reportChannelID string, now time.Time, page int, updated bool) (RenderedNudge, error) {
200+
return nudge.RenderNudgeForUser(api, db, cfg, userID, reportChannelID, now, page, updated)
185201
}
186202

187203
func normalizeTextToken(s string) string {

internal/integrations/slack/functional_slack_github_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ func withMockGitHubAPI(t *testing.T) {
139139

140140
func newMockSlackAPI(t *testing.T) (*slack.Client, *int) {
141141
t.Helper()
142+
resetUserCacheForTest(t)
142143

143144
postEphemeralCalls := 0
144145
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -171,6 +172,7 @@ func newMockSlackAPI(t *testing.T) (*slack.Client, *int) {
171172

172173
func newMockSlackAPIWithUsers(t *testing.T) *slack.Client {
173174
t.Helper()
175+
resetUserCacheForTest(t)
174176

175177
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
176178
path := strings.TrimPrefix(r.URL.Path, "/api/")
@@ -214,6 +216,7 @@ func newMockSlackAPIWithUsers(t *testing.T) *slack.Client {
214216

215217
func newMockSlackAPIWithManagerNotify(t *testing.T) (*slack.Client, *int, *string) {
216218
t.Helper()
219+
resetUserCacheForTest(t)
217220

218221
managerMsgCalls := 0
219222
lastManagerMsg := ""

0 commit comments

Comments
 (0)