feat: M2 composite locator + idle reconcile
Refs #1 - observations: hcl backfilled from local rowid; (node_id, hcl) UNIQUE locator index becomes the gossip merge key - threads: cluster_key column + partial UNIQUE index; CreateThreadCluster is idempotent, threader folds into exact cluster_key before heuristic - AutoLinkThreadObservations dedup re-expressed on hcl DESC - new `knox reconcile [--dry-run]` rebuilds entries from the observation log and re-links threads idempotently (entry count, thread cluster_key verified bit-identical from log-only DB)
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -80,8 +82,14 @@ func (t *AutoThreader) AutoThread() (created, linked int, err error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fold into an existing active thread if one already covers these terms.
|
||||
if existing := t.findExisting(c); existing > 0 {
|
||||
// Fold into the exact thread this cluster keyed to (idempotent across
|
||||
// nodes/reconciles); otherwise fall back to the keyword heuristic which
|
||||
// also matches human-edited threads that keep their auto cluster key.
|
||||
existing := t.DB.ThreadByClusterKey(clusterKey(c))
|
||||
if existing == 0 {
|
||||
existing = t.findExisting(c)
|
||||
}
|
||||
if existing > 0 {
|
||||
if t.DryRun {
|
||||
fmt.Printf("[threader] (dry-run) would link %d obs into existing thread #%d: %s\n", len(fps), existing, c.Name)
|
||||
continue
|
||||
@@ -118,7 +126,7 @@ func (t *AutoThreader) AutoThread() (created, linked int, err error) {
|
||||
continue
|
||||
}
|
||||
|
||||
id, err := t.DB.CreateThread(title, motivation, priority, tags, string(provJSON))
|
||||
id, isNew, err := t.DB.CreateThreadCluster(title, motivation, priority, tags, string(provJSON), clusterKey(c))
|
||||
if err != nil {
|
||||
log.Printf("[knox] threader create err: %v", err)
|
||||
continue
|
||||
@@ -126,12 +134,34 @@ func (t *AutoThreader) AutoThread() (created, linked int, err error) {
|
||||
if n, err := t.DB.AutoLinkThreadObservations(id, fps); err == nil {
|
||||
linked += n
|
||||
}
|
||||
created++
|
||||
log.Printf("[knox] auto-thread #%d: %s (%d obs, %s)", id, title, len(fps), priority)
|
||||
if isNew {
|
||||
created++
|
||||
}
|
||||
log.Printf("[knox] auto-thread %s #%d: %s (%d obs, %s)", statusWord(isNew), id, title, len(fps), priority)
|
||||
}
|
||||
return created, linked, nil
|
||||
}
|
||||
|
||||
func statusWord(isNew bool) string {
|
||||
if isNew {
|
||||
return "created"
|
||||
}
|
||||
return "extended"
|
||||
}
|
||||
|
||||
// clusterKey returns a deterministic content hash keying a thread to the topic
|
||||
// cluster that generated it. Sorted keywords make it machine-independent, so
|
||||
// two nodes auto-creating the same cluster converge on the same key (idempotent
|
||||
// thread creation via CreateThreadCluster).
|
||||
func clusterKey(c index.TopicCluster) string {
|
||||
kws := make([]string, len(c.Keywords))
|
||||
copy(kws, c.Keywords)
|
||||
sort.Strings(kws)
|
||||
joined := "thread:cluster:" + strings.Join(kws, "\x00")
|
||||
sum := sha256.Sum256([]byte(joined))
|
||||
return fmt.Sprintf("cluster:%x", sum[:16])
|
||||
}
|
||||
|
||||
// crossesBar decides whether a cluster reflects real intent.
|
||||
func (t *AutoThreader) crossesBar(c index.TopicCluster, fps []string, now time.Time) bool {
|
||||
if len(c.Entries) < t.MinEntries {
|
||||
|
||||
Reference in New Issue
Block a user