verstak/cmd/verstak-gui/bindings_suggest.go

325 lines
8.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"verstak/internal/core/activity"
"verstak/internal/core/worklog"
syncsvc "verstak/internal/core/sync"
)
// GetSuggestions analyzes today's activity and returns conservative suggestions.
// Only events not already linked in worklog_entry_events are considered.
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
events, err := a.activity.ListTodayEvents()
if err != nil || len(events) == 0 {
return nil, err
}
// Determine which event IDs are already accounted for in any worklog entry.
accounted := make(map[string]bool)
rows, err := a.db.Query(`SELECT DISTINCT event_id FROM worklog_entry_events`)
if err == nil {
defer rows.Close()
for rows.Next() {
var eid string
if rows.Scan(&eid) == nil {
accounted[eid] = true
}
}
}
type acc struct {
title string
kind string
events []activity.Event
}
grouped := make(map[string]*acc)
for _, e := range events {
if accounted[e.ID] {
continue
}
grp, ok := grouped[e.NodeID]
if !ok {
n, err := a.nodes.GetActive(e.NodeID)
title := ""
kind := ""
if err == nil && n != nil {
title = n.Title
kind = n.Type
}
grp = &acc{title: title, kind: kind}
grouped[e.NodeID] = grp
}
grp.events = append(grp.events, e)
}
var suggestions []activity.Suggestion
for nodeID, grp := range grouped {
if grp.title == "" || len(grp.events) == 0 {
continue
}
notes, files, actions, other := countByType(grp.events)
summary := buildSuggestionSummary(notes, files, actions, other)
if summary == "" {
continue
}
spread := timeSpread(grp.events)
bursts := countBursts(grp.events, 10)
min := estimateMinutes(bursts, spread, len(grp.events))
conf, reason := confidence(bursts, spread, len(grp.events))
eventIDs := make([]string, 0, len(grp.events))
evDetails := make([]activity.SuggestionDetail, 0, len(grp.events))
for _, e := range grp.events {
eventIDs = append(eventIDs, e.ID)
evDetails = append(evDetails, activity.SuggestionDetail{
ID: e.ID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
Title: e.Title,
CreatedAt: e.CreatedAt,
NodeID: e.NodeID,
})
}
suggestions = append(suggestions, activity.Suggestion{
NodeID: nodeID,
NodeTitle: grp.title,
Summary: summary,
SuggestedMin: min,
EventCount: len(grp.events),
NodeKind: grp.kind,
Confidence: conf,
ConfidenceReason: reason,
TimeSpreadMin: spread,
EventIDs: eventIDs,
Events: evDetails,
})
}
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].EventCount > suggestions[j].EventCount
})
return suggestions, nil
}
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
return a.AcceptSuggestionWith(nodeID, summary, minutes, date, eventIDsJSON)
}
// AcceptSuggestionWith creates a worklog entry and links events in a single transaction.
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
d := date
if d == "" {
d = time.Now().Format("2006-01-02")
}
var eventIDs []string
if eventIDsJSON != "" {
if err := json.Unmarshal([]byte(eventIDsJSON), &eventIDs); err != nil {
return nil, fmt.Errorf("unmarshal eventIDs: %w", err)
}
}
// Validate that every eventID actually exists in activity_events.
for _, eid := range eventIDs {
var n int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eid).Scan(&n); err != nil {
return nil, fmt.Errorf("check event %s: %w", eid, err)
}
if n == 0 {
return nil, fmt.Errorf("event %s not found in activity_events", eid)
}
}
// Use a transaction to atomically create entry + link events
tx, err := a.db.Begin()
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
entry, err := a.worklog.AddWithSourceTx(tx, nodeID, summary, "", d, minutes, true, false, worklog.SourceSuggestion)
if err != nil {
return nil, fmt.Errorf("create entry: %w", err)
}
for _, eid := range eventIDs {
if _, err := tx.Exec(
`INSERT INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`,
entry.ID, eid); err != nil {
return nil, fmt.Errorf("link event %s: %w", eid, err)
}
}
if len(eventIDs) > 0 {
var linked int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM worklog_entry_events wle
JOIN activity_events ae ON ae.id = wle.event_id
WHERE wle.entry_id = ?`, entry.ID).Scan(&linked); err != nil {
return nil, fmt.Errorf("verify links: %w", err)
}
if linked != len(eventIDs) {
return nil, fmt.Errorf("expected %d linked events, got %d", len(eventIDs), linked)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit tx: %w", err)
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
}
return &WorklogDTO{
ID: entry.ID,
NodeID: entry.NodeID,
Summary: entry.Summary,
Minutes: mins,
Date: entry.Date,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
}, nil
}
// HideSuggestion marks a suggestion as hidden for the session.
// The frontend tracks visibility; this is a no-op on the backend.
func (a *App) HideSuggestion(_ activity.Suggestion) error {
return nil
}
// --- event analysis ---
func countByType(events []activity.Event) (notes, files, actions, other int) {
for _, e := range events {
switch e.EventType {
case activity.TypeNoteCreated, activity.TypeNoteUpdated, activity.TypeNoteDeleted:
notes++
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed,
activity.TypeFileCopied, activity.TypeFileMoved,
activity.TypeFolderAdded, activity.TypeFolderDeleted, activity.TypeFolderRenamed:
files++
case activity.TypeActionCreated, activity.TypeActionDone:
actions++
default:
other++
}
}
return
}
// timeSpread returns minutes between first and last event.
func timeSpread(events []activity.Event) int {
if len(events) < 2 {
return 0
}
minTime := events[0].CreatedAt
maxTime := events[0].CreatedAt
for _, e := range events {
if e.CreatedAt < minTime {
minTime = e.CreatedAt
}
if e.CreatedAt > maxTime {
maxTime = e.CreatedAt
}
}
t1, err1 := time.Parse(time.RFC3339, minTime)
t2, err2 := time.Parse(time.RFC3339, maxTime)
if err1 != nil || err2 != nil {
return 0
}
diff := t2.Sub(t1)
return int(diff.Minutes())
}
// countBursts groups events into bursts where consecutive events
// are within `windowMin` minutes of each other.
func countBursts(events []activity.Event, windowMin int) int {
if len(events) == 0 {
return 0
}
times := make([]time.Time, 0, len(events))
for _, e := range events {
t, err := time.Parse(time.RFC3339, e.CreatedAt)
if err != nil {
continue
}
times = append(times, t)
}
if len(times) == 0 {
return 1
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
bursts := 1
last := times[0]
for _, t := range times[1:] {
if t.Sub(last) > time.Duration(windowMin)*time.Minute {
bursts++
}
last = t
}
return bursts
}
// estimateMinutes conservatively estimates suggested minutes.
func estimateMinutes(bursts, spread, totalEvents int) int {
if totalEvents <= 1 {
return 5
}
switch {
case spread >= 60 && bursts >= 3 && totalEvents >= 8:
return 30
case spread >= 30 && bursts >= 2 && totalEvents >= 5:
return 20
case spread >= 15 && bursts >= 2 && totalEvents >= 3:
return 15
case totalEvents >= 3:
return 10
default:
return 5
}
}
// confidence returns a label and reason string for the estimate.
func confidence(bursts, spread, totalEvents int) (string, string) {
if spread >= 60 && totalEvents >= 10 {
return activity.ConfidenceHigh, fmt.Sprintf("активность растянута на %d минут, %d всплесков", spread, bursts)
}
if spread >= 30 && totalEvents >= 5 && bursts >= 2 {
return activity.ConfidenceMedium, fmt.Sprintf("несколько всплесков активности за %d минут", spread)
}
return activity.ConfidenceLow, fmt.Sprintf("%d событий за %d минут, %d всплесков", totalEvents, spread, bursts)
}
func buildSuggestionSummary(notes, files, actions, other int) string {
var parts []string
if notes > 0 {
parts = append(parts, fmt.Sprintf("заметки (%d)", notes))
}
if files > 0 {
parts = append(parts, fmt.Sprintf("файлы (%d)", files))
}
if actions > 0 {
parts = append(parts, fmt.Sprintf("действия (%d)", actions))
}
if other > 0 {
parts = append(parts, fmt.Sprintf("события (%d)", other))
}
return strings.Join(parts, ", ")
}