Files
61993b4387 feat(i18n): add Russian (ru) language — full parity across backend, web & desktop (#1391)
* 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>
2026-07-11 12:25:38 +07:00

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
}