From 8dc52976eb4d100160d4eb1b0b7ed743a74c1873 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Fri, 5 Sep 2025 06:58:38 -0500 Subject: [PATCH] feat(summarizer): add SummarizeForPush and use for Pushover; keep full WebUI/on-demand output; clamp only on push\ndocs: add AGENTS.md; revert CLAUDE.md release section --- AGENTS.md | 40 ++++ CLAUDE.md | 339 ++++++++++++++++++++++++++++++ TODO.md | 1 + cmd/sojuboy/main.go | 7 +- internal/httpapi/server.go | 45 ++-- internal/summarizer/openai.go | 94 ++++++++- internal/summarizer/summarizer.go | 7 +- 7 files changed, 512 insertions(+), 21 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 TODO.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..63efd3e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Repository Guidelines + +## Architecture Overview +- Role: Additional IRC client (not a bot) connected to a soju bouncer with your same nick. Uses a distinct clientId/username suffix for per‑client history. +- Ingestion: Raw IRC via `internal/soju` with optional CHATHISTORY backfill, quiet hours, and rate limiting for alerts. +- Storage: SQLite (`internal/store`) for channel logs and metadata. +- Notifications: Pushover (`internal/notifier`) for mentions and scheduled digests. +- Summaries: OpenAI LLM (`internal/summarizer`, default gpt‑5) for link and channel summaries. +- Web UI: `internal/httpapi` serves SSE live view, link cards, inline article summaries, and an on‑demand summary page. + +## Project Structure +- `cmd/sojuboy/`: entrypoint and service wiring +- `internal/{soju,store,notifier,summarizer,httpapi,scheduler}`: core packages +- Root: `Dockerfile`, `docker-compose.yml`, `README.md`, `CHANGELOG.md` + +## Build, Run, and Deploy +- Local: `go build -o sojuboy ./cmd/sojuboy` or `go run ./cmd/sojuboy` +- Docker (Synology‑friendly): `docker compose up --build` (exposes `8080`) +- Health: `./sojuboy --health`, `curl :8080/healthz`, `curl :8080/ready` +- Example .env: `SOJU_HOST=host` `SOJU_PORT=6697` `SOJU_TLS=true` `IRC_NICK=nick` `IRC_USERNAME=user/network@sojuboy` `IRC_PASSWORD=...` `CHANNELS=#chan1,#chan2` `NOTIFIER=pushover` `PUSHOVER_USER_KEY=...` `PUSHOVER_API_TOKEN=...` `LLM_PROVIDER=openai` `OPENAI_API_KEY=...` `DIGEST_CRON=0 0 9 * * *` `HTTP_TOKEN=longtoken` + +## Coding Style & Naming +- Format: `gofmt -s -w .`; lint: `go vet ./...`; deps: `go mod tidy` +- Indentation: Go uses tabs; YAML/JS use 2 spaces +- Naming: packages lower‑case; exported `CamelCase`; files `snake_case.go` +- Logging: use `slog` via `internal/logging`; include context + +## Testing Guidelines +- Use standard `testing` with table‑driven tests; place beside code (`*_test.go`) +- For store tests, use in‑memory DB: `store.Open(ctx, ":memory:")` +- Run: `go test ./...` and `go test -cover ./...` + +## Commits & PRs +- Commits: short, imperative; conventional tags like `feat(ui):`, `fix(store):`, `docs:` (see history) +- PRs: description, linked issues, repro/verification steps; screenshots for Web UI changes +- Update `README.md`/`CHANGELOG.md` when flags, endpoints, or UX change + +## Security & Config +- Keep secrets in `.env` (git‑ignored); never commit API keys +- Synology: map `/data` volume; protect UI with `HTTP_TOKEN` (cookie/Bearer/query); bind `127.0.0.1:8080` behind DSM Reverse Proxy if desired diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1651e53 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,339 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Building and Running +- `go build -o sojuboy ./cmd/sojuboy` - Build the binary +- `go run ./cmd/sojuboy` - Run directly with Go +- `docker-compose up -d --build` - Build and run with Docker Compose +- `docker build -t sojuboy .` - Build Docker image + +### Health Checks +- `./sojuboy --health` - Local readiness check (exits 0/1) +- `curl http://localhost:8080/healthz` - HTTP health check +- `curl http://localhost:8080/ready` - HTTP readiness check (requires soju connection) + +### Testing and Validation +- `go mod tidy` - Clean up module dependencies +- `go vet ./...` - Run Go static analysis +- `go fmt ./...` - Format Go code + +## Architecture Overview + +This is a Go service that acts as an IRC bouncer companion for soju, providing notifications, message storage, and AI summaries. + +### Core Components + +**Entry Point**: `cmd/sojuboy/main.go` - Main application entry point with service wiring and configuration + +**Core Modules** (all in `internal/`): +- `config/` - Environment-based configuration loading +- `soju/` - IRC client for soju bouncer connection with IRCv3 capability negotiation +- `store/` - SQLite storage layer with WAL mode enabled +- `notifier/` - Pushover notification system (pluggable interface) +- `summarizer/` - OpenAI integration for AI digests and link summarization +- `scheduler/` - Cron-based digest scheduling and retention jobs +- `httpapi/` - HTTP API server with Web UI (templates + static assets) +- `logging/` - Structured logging setup + +### Key Architectural Patterns + +**IRC Integration**: Uses raw IRC protocol with sorcix/irc parser, implements irssi-style authentication for soju multi-client support (`username/network@client` format) + +**Message Flow**: IRC messages → storage → mention detection → notifications + AI processing + +**Web Architecture**: Go stdlib `net/http` with `html/template`, embedded static assets, SSE for real-time updates + +**AI Integration**: OpenAI client with GPT-5 default (fallback to GPT-4o-mini), separate prompts for conversation digests vs link summarization + +### Configuration + +All configuration via environment variables (see README.md for complete reference). Key patterns: +- Soju connection: `SOJU_HOST`, `SOJU_PORT`, `SOJU_TLS`, `IRC_USERNAME` format critical for per-client history +- Required for functionality: `OPENAI_API_KEY`, `PUSHOVER_USER_KEY`, `PUSHOVER_API_TOKEN` +- Default data path: `/data/app.db` (SQLite with WAL mode) + +### HTTP API Structure + +- Authentication via `HTTP_TOKEN` (cookie or Bearer/query param) +- JSON APIs: `/tail`, `/history`, `/trigger`, `/linkcard`, `/linksummary`, `/metrics` +- Web UI with SSE tail, infinite scroll, link cards, on-demand summarization +- Channel-based operations (URL encode `#` as `%23` in requests) + +### Storage Schema + +SQLite with modernc.org/sqlite driver: +- Messages table with server-time timestamps +- Channel-based organization +- Retention management (daily cleanup at 03:00) +- WAL mode for concurrent access + +### Development Notes + +- Go 1.23+ required (see go.mod) +- Static binary compilation for distroless Docker image +- No external build tools (Make, npm, etc.) - pure Go toolchain +- Configuration via .env file for local development +- Docker health checks via `--health` flag + +## Code Review Findings + +### 🚀 Major Refactors for iOS Features + +#### 1. Extract Message Processing Service +The `alert` function in `main.go:143-212` is doing too much: +- Message storage +- Notification logic +- Rate limiting +- DM vs channel handling + +**Recommendation:** Create `internal/messaging/processor.go` with separate concerns: +```go +type MessageProcessor struct { + store Store + notifiers []Notifier // Multi-target for iOS + Pushover + rateLimiter RateLimiter + broadcaster Broadcaster +} +``` + +#### 2. Multi-Target Notification System +Current system hardcoded to single Pushover notifier. For iOS: +```go +type NotificationDispatcher struct { + targets []Notifier // Pushover, APNs, etc. +} +``` + +#### 3. Extract Rate Limiter +Move `rateLimiter` struct to `internal/ratelimit/` package for reusability and testing. + +### ⚡ Critical Performance Issues + +#### 1. SQLite Connection Bottleneck +`store.go:30` - `SetMaxOpenConns(1)` is extremely restrictive: +```go +// Current: Major bottleneck +db.SetMaxOpenConns(1) + +// Better: Allow concurrent reads +db.SetMaxOpenConns(5) +db.SetMaxIdleConns(2) +``` + +#### 2. Inefficient Array Reversal +`store.go:147-150` - Manual reversal is inefficient: +```sql +-- Better: Use SQL ORDER BY instead +SELECT ... ORDER BY at ASC LIMIT ? +``` + +#### 3. OpenAI Client Recreation +`openai.go:60` - Client created per request. Should use singleton pattern with connection pooling. + +### 🗑️ Dead Code Cleanup + +1. **Entire unused package:** `internal/ircclient/base64.go` - appears completely unused +2. **Unused imports:** `io` package in `main.go` only for health check discard +3. **Dead helper:** `strconvI()` in `summarizer.go:475` - just use `strconv.Itoa` directly +4. **Rarely used export:** `config.GetEnvInt()` - used once, could be private + +### 🔧 Easy Wins + +#### 1. Fix Deprecated API +`templates.go:48` - `strings.Title()` is deprecated: +```go +// Replace with +data["Title"] = cases.Title(language.English).String(base) +``` + +#### 2. Hard-coded Values +- Health check port hardcoded to `8080` - use configured listen address +- Text limits hardcoded in summarizer - make configurable +- Temperature values hardcoded - make configurable + +#### 3. Database Schema +Missing indexes for performance: +```sql +CREATE INDEX IF NOT EXISTS idx_messages_author ON messages(author); +CREATE INDEX IF NOT EXISTS idx_messages_time ON messages(at); +``` + +### 🛡️ Error Handling Improvements + +#### 1. Silent Failures +`pushover.go:24` - Returns `nil` on error conditions without logging: +```go +// Better error handling +if p == nil || p.app == nil || p.userKey == "" { + return errors.New("pushover not configured") +} +``` + +#### 2. Message Truncation +No user feedback when messages truncated to 1024 chars. + +### 📱 iOS-Specific Optimizations + +#### 1. Message Broadcaster Enhancement +Current SSE broadcasting is basic - needs: +- Connection state management +- Message queuing for disconnected clients +- WebSocket support alongside SSE + +#### 2. Auth Token Management +Simple token auth needs enhancement for mobile: +- JWT tokens with refresh capability +- Device-specific sessions +- Proper expiration handling + +### 🎯 Implementation Priority + +**Phase 1 (High Impact):** +1. Fix SQLite connection limit +2. Extract message processor service +3. Remove dead code +4. Fix deprecated APIs + +**Phase 2 (iOS Foundation):** +1. Multi-target notification system +2. Enhanced message broadcasting +3. JWT authentication improvements + +**Phase 3 (Performance):** +1. OpenAI client pooling +2. Database index optimization +3. Rate limiter improvements + +## Application Behavior & Quirks + +### Message Storage Logic +- **DM Handling**: Direct messages TO the bot are stored under the sender's nick, not the bot's nick (`main.go:146-151`) +- **Message Deduplication**: Uses `INSERT OR IGNORE` with optional `msgid` for soju message deduplication +- **Channel Normalization**: All channel lookups are case-insensitive via `lower()` SQL function + +### Notification Logic +- **Rate Limiting**: Per-channel + per-keyword combinations, not global (`main.go:199`) +- **Quiet Hours**: Local timezone-based, supports midnight wrap-around (`config.go:267-271`) +- **Urgent Keywords**: Bypass quiet hours but still respect rate limits +- **Backfill Suppression**: Messages older than 5 minutes are not notified unless `NOTIFY_BACKFILL=true` + +### IRC Protocol Specifics +- **Authentication**: Uses raw PASS/USER, not SASL (despite config hint) +- **Capability Negotiation**: Requests IRCv3 `draft/event-playback` for automatic message replay +- **Fallback History**: CHATHISTORY LATEST only used when event-playback unavailable +- **Connection Management**: Exponential backoff (1s → 30s max) with automatic reconnection + +### AI Summarization Behavior +- **Message Grouping**: Same author within `SUMM_GROUP_WINDOW` (default 120s) are concatenated +- **Link Processing**: Extracts URLs via regex, fetches with readability for clean text +- **Image Handling**: Direct image URLs sent as vision inputs to GPT-5 +- **Fallback Logic**: Local text-based summary if OpenAI fails +- **Model Selection**: Auto-detects reasoning models (gpt-5, o1) to skip temperature setting + +### Web UI Implementation +- **SSE Streaming**: Real-time message delivery with 15s heartbeat +- **Link Cards**: Cached 24h, special handling for X/Twitter oEmbed and YouTube +- **Authentication**: Cookie-based with 7-day expiration, secure flag auto-detection +- **Channel Selector**: Populated from DB + config fallback for empty databases + +### Storage Patterns +- **WAL Mode**: Enabled for concurrent read access despite single connection limit +- **Retention**: Daily cleanup at 03:00 local time, configurable days +- **Timestamps**: Always stored as UTC, server-time tags preferred over local time +- **Migration**: Best-effort schema updates with ignored errors for missing columns + +### Error Handling Patterns +- **Silent Failures**: Many subsystems return `nil` errors to prevent cascading failures +- **Graceful Degradation**: Missing summarizer/notifier doesn't break core IRC functionality +- **Connection Resilience**: IRC client handles ERROR messages and reconnects automatically + +### Security Considerations +- **Token Storage**: HTTP_TOKEN stored in plain text, suitable for internal deployments +- **CORS**: No CORS headers - designed for same-origin web UI access +- **TLS**: Configurable per connection, defaults to enabled +- **Input Sanitization**: Minimal - relies on template escaping and database parameterization + +### Performance Characteristics +- **Memory Usage**: Link/summary caches unbounded - potential memory leak over time +- **Concurrent Requests**: Severely limited by single DB connection +- **OpenAI Rate Limits**: No built-in rate limiting or retry logic +- **SSE Scalability**: In-memory subscriber map, no persistence across restarts + +### Development Workflow +- **Hot Reload**: Not supported - requires full restart for config/code changes +- **Logging**: Structured JSON to stdout, debug mode shows IRC protocol messages +- **Health Monitoring**: Separate metrics endpoint for Prometheus integration +- **Docker**: Multi-stage build with distroless final image for security + +### Common Gotchas +- **Channel Names**: Must URL-encode `#` as `%23` in HTTP requests +- **Time Formats**: Mix of RFC3339 and RFC3339Nano depending on context +- **Case Sensitivity**: IRC nicks/channels case-insensitive, but stored as-received +- **CHATHISTORY**: Requires soju-specific timestamp format, may not work with other bouncers +- **Health Check**: Hard-coded to port 8080 regardless of configured listen address + +## Planned iOS App Development + +### Context +User wants to build a native iOS Swift app that consumes this Go server's HTTP APIs. The server should remain in Go (excellent fit for backend services), while the iOS app provides a native mobile experience. + +### Required Server Enhancements for iOS + +**1. iOS Push Notifications (Priority 1)** +- Add Apple Push Notification service (APNs) integration alongside Pushover +- New endpoints: `POST /api/devices` (device token registration) +- Enhanced notification system supporting multiple targets +- Environment variables: `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, `APNS_KEY_PATH`, `APNS_SANDBOX` + +**2. Enhanced Real-time Communication** +- Add WebSocket support alongside existing SSE (better for iOS background handling) +- Message queuing for disconnected clients +- New endpoints: `GET /api/ws?channel=#chan`, `GET /api/missed?since=` + +**3. Mobile-Optimized Authentication** +- JWT tokens with refresh capability (current simple token is too basic) +- Device-specific authentication for multiple iOS devices +- New endpoints: `POST /api/auth/login`, `POST /api/auth/refresh`, `POST /api/auth/logout` + +**4. Offline Support & Sync** +- Cursor-based pagination (improve current `before` parameter approach) +- Message read/unread state tracking per device +- Enhanced endpoints: `GET /api/sync?cursor=&limit=100`, `POST /api/read-receipts`, `GET /api/unread-count` + +**5. iOS-Specific Optimizations** +- Batch operations: `POST /api/linkcards/batch` +- Image size variants: `GET /api/linkcard?url=...&size=thumb` +- Performance optimizations for mobile bandwidth + +### iOS App Feature Parity Goals +The native app should match current web UI functionality: +- Live chat view with real-time updates +- Link cards and on-demand link summarization +- Native push notifications (replacing Pushover for mobile users) +- On-demand AI summarization +- Channel switching and history browsing +- Infinite scroll for chat history + +### Database Schema Extensions Needed +- `devices` table - iOS device tokens and user associations +- `read_receipts` table - per-device read state tracking +- `message_queue` table - offline message queueing + +### Implementation Priority for iOS Support +1. APNs integration (native notifications) +2. WebSocket + connection management +3. JWT authentication system +4. Offline sync capabilities +5. Mobile-specific API optimizations + +### Architecture Decision: Keep Go Backend +**Question Asked:** Could this project be rewritten in Swift? +**Answer:** Technically feasible but not recommended. + +**Pros of Swift rewrite:** Strong type safety, modern concurrency (async/await), cross-platform potential +**Cons:** Limited ecosystem for IRC libraries, SQLite libraries, cron scheduling, HTTP templating; larger Docker images; significant development effort to recreate working functionality + +**Recommended Approach:** Keep Go backend (excellent fit for network services, mature ecosystem, single static binary deployment) + build native Swift iOS frontend. This leverages both platforms' strengths effectively. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..60e8277 --- /dev/null +++ b/TODO.md @@ -0,0 +1 @@ +[ ] Add Search fucntionality - to search chat logs diff --git a/cmd/sojuboy/main.go b/cmd/sojuboy/main.go index 25c1a0c..f163063 100644 --- a/cmd/sojuboy/main.go +++ b/cmd/sojuboy/main.go @@ -252,14 +252,17 @@ func main() { logger.Error("digest fetch", "err", err) continue } - summary, err := sum.Summarize(ctx, ch, msgs, window) + summary, err := sum.SummarizeForPush(ctx, ch, msgs, window) if err != nil { logger.Error("digest summarize", "err", err) continue } if nt != nil { title := "IRC digest " + ch + " (" + window.String() + ")" - if err := nt.Notify(ctx, title, summary); err != nil { + // Safety clamp for provider limits + msg := strings.TrimSpace(summary) + if len(msg) > 1024 { msg = msg[:1024] } + if err := nt.Notify(ctx, title, msg); err != nil { logger.Error("digest notify", "err", err) } else { atomic.AddInt64(&metrics.NotificationsSent, 1) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 655f89d..103251c 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -156,7 +156,14 @@ func (s *Server) handleTrigger(w http.ResponseWriter, r *http.Request) { } ctxSum, cancel := context.WithTimeout(ctx, tout) defer cancel() - summary, err := s.Summarizer.Summarize(ctxSum, channel, msgs, window) + var summary string + var err error + // If we will push, use a push-optimized prompt; otherwise allow full-length + if strings.EqualFold(r.URL.Query().Get("push"), "1") || strings.EqualFold(r.URL.Query().Get("push"), "true") { + summary, err = s.Summarizer.SummarizeForPush(ctxSum, channel, msgs, window) + } else { + summary, err = s.Summarizer.Summarize(ctxSum, channel, msgs, window) + } if err != nil { if s.Logger != nil { s.Logger.Error("http trigger summarizer", "err", err) @@ -167,13 +174,18 @@ func (s *Server) handleTrigger(w http.ResponseWriter, r *http.Request) { } // Only push if explicitly requested pushFlag := strings.EqualFold(r.URL.Query().Get("push"), "1") || strings.EqualFold(r.URL.Query().Get("push"), "true") - if pushFlag && s.Notifier != nil { - title := fmt.Sprintf("IRC digest %s (%s)", channel, window) - _ = s.Notifier.Notify(ctx, title, summary) - if s.Metrics != nil { - atomic.AddInt64(&s.Metrics.NotificationsSent, 1) - } - } + if pushFlag && s.Notifier != nil { + title := fmt.Sprintf("IRC digest %s (%s)", channel, window) + // Trim only for push notifications (Pushover limit ~1024) + msg := strings.TrimSpace(summary) + if len(msg) > 1000 { + msg = msg[:1000] + "…" + } + _ = s.Notifier.Notify(ctx, title, msg) + if s.Metrics != nil { + atomic.AddInt64(&s.Metrics.NotificationsSent, 1) + } + } w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte(summary)) } @@ -720,13 +732,16 @@ func (s *Server) handleTriggerJSON(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("summarizer error")) return } - if push && s.Notifier != nil { - title := fmt.Sprintf("IRC digest %s (%s)", channel, dur) - _ = s.Notifier.Notify(r.Context(), title, sum) - if s.Metrics != nil { - atomic.AddInt64(&s.Metrics.NotificationsSent, 1) - } - } + if push && s.Notifier != nil { + title := fmt.Sprintf("IRC digest %s (%s)", channel, dur) + // Safety clamp for provider limits + msg := strings.TrimSpace(sum) + if len(msg) > 1024 { msg = msg[:1024] } + _ = s.Notifier.Notify(r.Context(), title, msg) + if s.Metrics != nil { + atomic.AddInt64(&s.Metrics.NotificationsSent, 1) + } + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"summary": sum}) } diff --git a/internal/summarizer/openai.go b/internal/summarizer/openai.go index 0fc5d10..cc9bf8d 100644 --- a/internal/summarizer/openai.go +++ b/internal/summarizer/openai.go @@ -111,7 +111,8 @@ func (o *OpenAI) Summarize(ctx context.Context, channel string, msgs []store.Mes b.WriteString("- Focus on what happened and why it matters.\n") b.WriteString("- Integrate linked content and pasted multi-line posts naturally.\n") b.WriteString("- Avoid rigid sections; use short paragraphs or light bullets if helpful.\n") - b.WriteString("- Keep it compact but don’t omit important context.\n") + b.WriteString("- Keep it compact but don’t omit important context.\n") + prompt := b.String() sys := "You summarize IRC transcripts. Be concise, natural, and informative." @@ -162,6 +163,95 @@ func (o *OpenAI) Summarize(ctx context.Context, channel string, msgs []store.Mes return out, nil } +// SummarizeForPush produces a digest tailored for push notifications (e.g., Pushover ~1024 chars). +// It uses a slightly more constrained prompt to encourage succinct output. +func (o *OpenAI) SummarizeForPush(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error) { + if o == nil || o.apiKey == "" { + return "", nil + } + cfg := openai.DefaultConfig(o.apiKey) + if strings.TrimSpace(o.baseURL) != "" { + cfg.BaseURL = o.baseURL + } + client := openai.NewClientWithConfig(cfg) + + grouped := groupMessages(msgs, o.groupWindow) + if o.maxGroups > 0 && len(grouped) > o.maxGroups { + grouped = grouped[len(grouped)-o.maxGroups:] + } + + links := extractLinks(grouped) + var imageURLs []string + var nonImageLinks []linkSnippet + for _, l := range links { + if isImageURL(l.url) { imageURLs = append(imageURLs, l.url) } else { nonImageLinks = append(nonImageLinks, l) } + } + if o.followLinks && len(nonImageLinks) > 0 { + nonImageLinks = fetchLinkSnippets(ctx, nonImageLinks, o.linkTimeout, o.linkMaxBytes, o.maxLinks) + } + + var b strings.Builder + b.WriteString("Channel: ") + b.WriteString(channel) + b.WriteString("\nTime window: ") + b.WriteString(window.String()) + b.WriteString("\n\nTranscript (grouped by author):\n") + for _, g := range grouped { + b.WriteString(g.time.Format(time.RFC3339)) + b.WriteString(" ") + b.WriteString(g.author) + b.WriteString(": ") + b.WriteString(g.text) + b.WriteString("\n") + } + if len(nonImageLinks) > 0 { + b.WriteString("\nReferenced content (snippets):\n") + for _, ln := range nonImageLinks { + b.WriteString("- ") + b.WriteString(ln.url) + b.WriteString(" → ") + b.WriteString(ln.snippet) + b.WriteString("\n") + } + } + b.WriteString("\nWrite a concise, readable summary of the conversation above.\n") + b.WriteString("- Focus on what happened and why it matters.\n") + b.WriteString("- Integrate linked content and pasted multi-line posts naturally.\n") + b.WriteString("- Avoid rigid sections; use short paragraphs or light bullets if helpful.\n") + b.WriteString("- Keep it compact but don’t omit important context.\n") + b.WriteString("- Keep the final output under ~900 characters suitable for a single push notification.\n") + prompt := b.String() + + sys := "You summarize IRC transcripts for a push notification. Be concise, natural, and informative." + + model := o.model + if strings.TrimSpace(model) == "" { model = "gpt-4o-mini" } + reasoningLike := strings.HasPrefix(model, "gpt-5") || strings.HasPrefix(model, "o1") || strings.Contains(model, "reasoning") + + var userParts []openai.ChatMessagePart + userParts = append(userParts, openai.ChatMessagePart{Type: openai.ChatMessagePartTypeText, Text: prompt}) + for _, u := range imageURLs { + userParts = append(userParts, openai.ChatMessagePart{Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{URL: u}}) + } + + req := openai.ChatCompletionRequest{ + Model: model, + Messages: []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleSystem, Content: sys}, + {Role: openai.ChatMessageRoleUser, MultiContent: userParts}, + }, + MaxCompletionTokens: o.maxTokens, + } + if !reasoningLike { req.Temperature = 0.3 } + + resp, err := client.CreateChatCompletion(ctx, req) + if err != nil { return "", err } + if len(resp.Choices) == 0 { return localFallbackSummary(grouped, append(nonImageLinks, linksFromImages(imageURLs)...)), nil } + out := strings.TrimSpace(resp.Choices[0].Message.Content) + if out == "" { return localFallbackSummary(grouped, append(nonImageLinks, linksFromImages(imageURLs)...)), nil } + return out, nil +} + func (o *OpenAI) SummarizeLink(ctx context.Context, rawURL string) (string, error) { if o == nil || o.apiKey == "" { return "", nil @@ -274,7 +364,7 @@ func (o *OpenAI) SummarizeLink(ctx context.Context, rawURL string) (string, erro b.WriteString(content) b.WriteString("\n\n") } - b.WriteString("Write a short, skimmable summary of the page/video/image above. If relevant, include key takeaways and any notable cautions. Keep it under a few short paragraphs.") + b.WriteString("Write a short, skimmable summary of the page/video/image above. If relevant, include key takeaways and any notable cautions. Keep it under a few short paragraphs.") userParts = append(userParts, openai.ChatMessagePart{Type: openai.ChatMessagePartTypeText, Text: b.String()}) if img != "" { userParts = append(userParts, openai.ChatMessagePart{Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{URL: img}}) diff --git a/internal/summarizer/summarizer.go b/internal/summarizer/summarizer.go index dd416b9..b057336 100644 --- a/internal/summarizer/summarizer.go +++ b/internal/summarizer/summarizer.go @@ -8,6 +8,9 @@ import ( ) type Summarizer interface { - Summarize(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error) - SummarizeLink(ctx context.Context, rawURL string) (string, error) + Summarize(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error) + // SummarizeForPush creates a digest tuned for push notifications (e.g., Pushover limits). + // Implementations should keep the output succinct and within ~1k characters. + SummarizeForPush(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error) + SummarizeLink(ctx context.Context, rawURL string) (string, error) }