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
This commit is contained in:
parent
2f9ab6a414
commit
8dc52976eb
7 changed files with 512 additions and 21 deletions
40
AGENTS.md
Normal file
40
AGENTS.md
Normal file
|
|
@ -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
|
||||
339
CLAUDE.md
Normal file
339
CLAUDE.md
Normal file
|
|
@ -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=<timestamp>`
|
||||
|
||||
**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=<id>&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.
|
||||
1
TODO.md
Normal file
1
TODO.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
[ ] Add Search fucntionality - to search chat logs
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue