mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
* docs(i18n): design spec for Russian (ru) language support Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(i18n): register ru locale constant and IsSupported * feat(i18n): scaffold ru catalog (en placeholders) * feat(i18n): translate ru backend catalog to Russian * fix(i18n): polish ru catalog grammar per review * feat(i18n): wire ru into system messages, config enum, parity test Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(i18n): add ru to web language constants and system-message locales * feat(i18n): scaffold ru web locale dir (en placeholders) * feat(i18n): wire ru resources into web i18next config * feat(i18n): translate ru web locale to Russian (41 namespaces) * fix(i18n): normalize ru web terminology (арендатор, привязка) * fix(i18n): restore dropped 'named' in ru setup OAuth hint * feat(i18n): wire ru into desktop i18next and language pickers Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(i18n): add ru desktop locale (Russian, 16 namespaces) * test(i18n): extend traces/hooks locale guards to cover ru * chore(deploy): add repeatable fork-dev local build+deploy tooling Adds a one-command, idempotent way to run goclaw from a locally built image that merges fresh upstream dev with un-merged fork features (Telegram trigger-words PR #1383 + Russian locale), independent of the published :latest image. - scripts/deploy-forkdev.sh: fetch origin/dev -> merge feature branches into local/fork-dev worktree -> push fork/dev -> build -> up -d. - docker-compose.forkdev.yml: overlay pinning image to goclaw:forkdev with pull_policy:never so :latest never silently replaces the build. - .gitignore: track the deploy helper (past deploy-*.sh), ignore the ../.goclaw-forkdev integration worktree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(telegram): agent-callable message reaction (message action=react) Adds a "react" action to the message tool so an agent can set an emoji reaction on an existing message (e.g. mark its own status post 👍 once fully paid). Mirrors the ChannelEditor wiring: ReactionSetter capability -> Manager.ReactToMessage -> telegram Channel.ReactToMessage (validates against Telegram's supported reaction set; ✅/❌ are rejected). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(config): remove ignored session scoping controls (#1405) Co-authored-by: Collective Developer <man@collective.dev> * feat(telegram): agent-callable message reaction (message action=react) (#1407) Adds a "react" action to the message tool so an agent can set an emoji reaction on an existing message (e.g. mark its own status post 👍 once fully paid). Mirrors the ChannelEditor wiring: ReactionSetter capability -> Manager.ReactToMessage -> telegram Channel.ReactToMessage (validates against Telegram's supported reaction set; ✅/❌ are rejected). Co-authored-by: skensel <skensel@MacBook-Pro-skensel.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: build & publish fork image to GHCR (temporary, pre-upstream) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(memory): use 'russian' FTS config for memory_chunks recall Memory keyword search (memory_search.go FTS + the generated tsv column) used the 'simple' text-search config, which does no stemming. Russian grammatical cases therefore never matched: a genitive query token ("Дианы") could not hit the stored nominative token ("Диана"), so memory_search returned nothing for natural queries even though the fact was saved. This is the sole recall path on deployments without an embedding provider (ChatGPT-OAuth/codex cannot embed), so recall was effectively dead there. Switch both sides to the Snowball 'russian' config so index and query lexemes stem to the same root ("диан"), restoring case-insensitive recall. Migration 000094 recreates the generated tsv column (a GENERATED expression cannot be ALTERed in place; ADD COLUMN backfills all rows). Surface parity: desktop/SQLite N/A — SQLite memory search uses LIKE, a separate mechanism unaffected by the PG regconfig. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: skensel <skensel@MacBook-Pro-skensel.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: ナム <namnhfreelancer@gmail.com> Co-authored-by: Collective Developer <man@collective.dev>
277 lines
7.9 KiB
Go
277 lines
7.9 KiB
Go
package pg
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// Search performs hybrid search (FTS + vector) over memory_chunks.
|
|
// Merges global (user_id IS NULL) + per-user chunks, with user boost.
|
|
func (s *PGMemoryStore) Search(ctx context.Context, query string, agentID, userID string, opts store.MemorySearchOptions) ([]store.MemorySearchResult, error) {
|
|
maxResults := opts.MaxResults
|
|
if maxResults <= 0 {
|
|
maxResults = s.cfg.MaxResults
|
|
}
|
|
|
|
aid, err := parseUUID(agentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("memory search: %w", err)
|
|
}
|
|
|
|
// FTS search using tsvector
|
|
ftsResults, err := s.ftsSearch(ctx, query, aid, userID, maxResults*2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Vector search if provider available
|
|
var vecResults []scoredChunk
|
|
if s.provider != nil {
|
|
embeddings, err := s.provider.Embed(ctx, []string{query})
|
|
if err == nil && len(embeddings) > 0 {
|
|
vecResults, err = s.vectorSearch(ctx, embeddings[0], aid, userID, maxResults*2)
|
|
if err != nil {
|
|
vecResults = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge results — use per-query overrides if set, else store defaults
|
|
textW, vecW := s.cfg.TextWeight, s.cfg.VectorWeight
|
|
if opts.TextWeight > 0 {
|
|
textW = opts.TextWeight
|
|
}
|
|
if opts.VectorWeight > 0 {
|
|
vecW = opts.VectorWeight
|
|
}
|
|
if len(ftsResults) == 0 && len(vecResults) > 0 {
|
|
textW, vecW = 0, 1.0
|
|
} else if len(vecResults) == 0 && len(ftsResults) > 0 {
|
|
textW, vecW = 1.0, 0
|
|
}
|
|
merged := hybridMerge(ftsResults, vecResults, textW, vecW, userID)
|
|
|
|
// Apply min score filter
|
|
var filtered []store.MemorySearchResult
|
|
for _, m := range merged {
|
|
if opts.MinScore > 0 && m.Score < opts.MinScore {
|
|
continue
|
|
}
|
|
if opts.PathPrefix != "" && !strings.HasPrefix(m.Path, opts.PathPrefix) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, m)
|
|
if len(filtered) >= maxResults {
|
|
break
|
|
}
|
|
}
|
|
|
|
return filtered, nil
|
|
}
|
|
|
|
type scoredChunk struct {
|
|
Path string
|
|
StartLine int
|
|
EndLine int
|
|
Text string
|
|
Score float64
|
|
UserID *string
|
|
}
|
|
|
|
func (s *PGMemoryStore) ftsSearch(ctx context.Context, query string, agentID any, userID string, limit int) ([]scoredChunk, error) {
|
|
var q string
|
|
var args []any
|
|
|
|
if store.IsSharedMemory(ctx) {
|
|
// Shared: no user_id filter — search ALL chunks for agent
|
|
tc, tcArgs, _, err := scopeClause(ctx, 4)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limitN := 4 + len(tcArgs)
|
|
q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
|
|
ts_rank(tsv, plainto_tsquery('russian', $1)) AS score
|
|
FROM memory_chunks
|
|
WHERE agent_id = $2 AND tsv @@ plainto_tsquery('russian', $3)%s
|
|
ORDER BY score DESC LIMIT $%d`, tc, limitN)
|
|
args = append([]any{query, agentID, query}, tcArgs...)
|
|
args = append(args, limit)
|
|
} else if userID != "" {
|
|
// fixed params: $1=query, $2=agentID, $3=query, $4=userID
|
|
tc, tcArgs, _, err := scopeClause(ctx, 5)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limitN := 5 + len(tcArgs)
|
|
q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
|
|
ts_rank(tsv, plainto_tsquery('russian', $1)) AS score
|
|
FROM memory_chunks
|
|
WHERE agent_id = $2 AND tsv @@ plainto_tsquery('russian', $3)
|
|
AND (user_id IS NULL OR user_id = $4)%s
|
|
ORDER BY score DESC LIMIT $%d`, tc, limitN)
|
|
args = append([]any{query, agentID, query, userID}, tcArgs...)
|
|
args = append(args, limit)
|
|
} else {
|
|
// fixed params: $1=query, $2=agentID, $3=query
|
|
tc, tcArgs, _, err := scopeClause(ctx, 4)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limitN := 4 + len(tcArgs)
|
|
q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
|
|
ts_rank(tsv, plainto_tsquery('russian', $1)) AS score
|
|
FROM memory_chunks
|
|
WHERE agent_id = $2 AND tsv @@ plainto_tsquery('russian', $3)
|
|
AND user_id IS NULL%s
|
|
ORDER BY score DESC LIMIT $%d`, tc, limitN)
|
|
args = append([]any{query, agentID, query}, tcArgs...)
|
|
args = append(args, limit)
|
|
}
|
|
|
|
var rows []scoredChunkRow
|
|
if err := pkgSqlxDB.SelectContext(ctx, &rows, q, args...); err != nil {
|
|
return nil, err
|
|
}
|
|
results := make([]scoredChunk, len(rows))
|
|
for i := range rows {
|
|
results[i] = rows[i].toScoredChunk()
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func (s *PGMemoryStore) vectorSearch(ctx context.Context, embedding []float32, agentID any, userID string, limit int) ([]scoredChunk, error) {
|
|
vecStr := vectorToString(embedding)
|
|
|
|
var q string
|
|
var args []any
|
|
|
|
if store.IsSharedMemory(ctx) {
|
|
// Shared: no user_id filter — search ALL chunks for agent
|
|
tc, tcArgs, _, err := scopeClause(ctx, 3)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
orderN := 3 + len(tcArgs)
|
|
limitN := orderN + 1
|
|
q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
|
|
1 - (embedding <=> $1::vector) AS score
|
|
FROM memory_chunks
|
|
WHERE agent_id = $2 AND embedding IS NOT NULL%s
|
|
ORDER BY embedding <=> $%d::vector LIMIT $%d`, tc, orderN, limitN)
|
|
args = append([]any{vecStr, agentID}, tcArgs...)
|
|
args = append(args, vecStr, limit)
|
|
} else if userID != "" {
|
|
// fixed params: $1=vec, $2=agentID, $3=userID
|
|
tc, tcArgs, _, err := scopeClause(ctx, 4)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
orderN := 4 + len(tcArgs)
|
|
limitN := orderN + 1
|
|
q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
|
|
1 - (embedding <=> $1::vector) AS score
|
|
FROM memory_chunks
|
|
WHERE agent_id = $2 AND embedding IS NOT NULL
|
|
AND (user_id IS NULL OR user_id = $3)%s
|
|
ORDER BY embedding <=> $%d::vector LIMIT $%d`, tc, orderN, limitN)
|
|
args = append([]any{vecStr, agentID, userID}, tcArgs...)
|
|
args = append(args, vecStr, limit)
|
|
} else {
|
|
// fixed params: $1=vec, $2=agentID
|
|
tc, tcArgs, _, err := scopeClause(ctx, 3)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
orderN := 3 + len(tcArgs)
|
|
limitN := orderN + 1
|
|
q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
|
|
1 - (embedding <=> $1::vector) AS score
|
|
FROM memory_chunks
|
|
WHERE agent_id = $2 AND embedding IS NOT NULL
|
|
AND user_id IS NULL%s
|
|
ORDER BY embedding <=> $%d::vector LIMIT $%d`, tc, orderN, limitN)
|
|
args = append([]any{vecStr, agentID}, tcArgs...)
|
|
args = append(args, vecStr, limit)
|
|
}
|
|
|
|
var rows []scoredChunkRow
|
|
if err := pkgSqlxDB.SelectContext(ctx, &rows, q, args...); err != nil {
|
|
return nil, err
|
|
}
|
|
results := make([]scoredChunk, len(rows))
|
|
for i := range rows {
|
|
results[i] = rows[i].toScoredChunk()
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// hybridMerge combines FTS and vector results with weighted scoring.
|
|
// Per-user results get a 1.2x boost. Deduplication: user copy wins over global.
|
|
// NOTE: when shared memory is active, the 1.2x personal boost still applies —
|
|
// consider removing it in shared mode if all docs should be treated equally.
|
|
func hybridMerge(fts, vec []scoredChunk, textWeight, vectorWeight float64, currentUserID string) []store.MemorySearchResult {
|
|
type key struct {
|
|
Path string
|
|
StartLine int
|
|
}
|
|
seen := make(map[key]*store.MemorySearchResult)
|
|
|
|
addResult := func(r scoredChunk, weight float64) {
|
|
k := key{r.Path, r.StartLine}
|
|
scope := "global"
|
|
boost := 1.0
|
|
if r.UserID != nil && *r.UserID != "" {
|
|
scope = "personal"
|
|
boost = 1.2
|
|
}
|
|
score := r.Score * weight * boost
|
|
|
|
if existing, ok := seen[k]; ok {
|
|
existing.Score += score
|
|
// User copy wins
|
|
if scope == "personal" {
|
|
existing.Scope = "personal"
|
|
existing.Snippet = r.Text
|
|
}
|
|
} else {
|
|
seen[k] = &store.MemorySearchResult{
|
|
Path: r.Path,
|
|
StartLine: r.StartLine,
|
|
EndLine: r.EndLine,
|
|
Score: score,
|
|
Snippet: r.Text,
|
|
Source: "memory",
|
|
Scope: scope,
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, r := range fts {
|
|
addResult(r, textWeight)
|
|
}
|
|
for _, r := range vec {
|
|
addResult(r, vectorWeight)
|
|
}
|
|
|
|
// Collect and sort by score
|
|
results := make([]store.MemorySearchResult, 0, len(seen))
|
|
for _, r := range seen {
|
|
results = append(results, *r)
|
|
}
|
|
|
|
// Simple sort (descending score)
|
|
for i := 0; i < len(results); i++ {
|
|
for j := i + 1; j < len(results); j++ {
|
|
if results[j].Score > results[i].Score {
|
|
results[i], results[j] = results[j], results[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|