Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions MakeFile
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ run-mcp:

lint:
golangci-lint run

# Обновить локальную read-only реплику боевой БД (прод не мутируется, только pg_dump).
refresh-replica:
bash scripts/refresh-replica.sh
34 changes: 3 additions & 31 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
"simpleAI/config"
llmfactory "simpleAI/internal/adapters/llm"
telegramadapter "simpleAI/internal/adapters/telegram"
"simpleAI/internal/agent"
"simpleAI/internal/appwiring"
"simpleAI/internal/budget"
"simpleAI/internal/core"
"simpleAI/internal/db"
Expand All @@ -31,10 +31,7 @@ import (
"simpleAI/internal/notify"
"simpleAI/internal/observability"
"simpleAI/internal/plugin"
"simpleAI/internal/rag"
"simpleAI/internal/rates"
"simpleAI/internal/skills"
advisorskill "simpleAI/internal/skills/advisor"
budgetskill "simpleAI/internal/skills/budget"
"simpleAI/internal/telegram"
"simpleAI/internal/tools"
Expand Down Expand Up @@ -65,7 +62,7 @@ func main() {
}

// Единый реестр skills для всех компонентов.
registry, budgetSkill := buildRegistry(llmClient, logger, pool, cfg.LLM.AdvisorLLMTimeout)
registry, budgetSkill := appwiring.BuildRegistry(llmClient, logger, pool, cfg.LLM.AdvisorLLMTimeout)

// Контекст с graceful shutdown.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
Expand Down Expand Up @@ -177,7 +174,7 @@ func runTelegram(ctx context.Context, cfg config.Config, logger *slog.Logger, ll
logger.Warn("failed to set telegram commands", "err", err)
}

agentService := agent.NewServiceWithRegistry(llmClient, registry).WithTracer(tracer).WithObservability(obsTracer).WithLogger(logger).WithLLMModel(cfg.LLM.ChatModel)
agentService := appwiring.BuildAgentService(llmClient, registry, tracer, obsTracer, logger, cfg.LLM.ChatModel)

router := telegram.NewRouter()
router.Use(telegram.DeduplicateUpdates(1000))
Expand Down Expand Up @@ -282,31 +279,6 @@ func runWorker(ctx context.Context, cfg config.Config, logger *slog.Logger, pool
}
}

func buildRegistry(llmClient core.LLMClient, logger *slog.Logger, pool *pgxpool.Pool, advisorLLMTimeout time.Duration) (*plugin.Registry, *budgetskill.BudgetSkill) {
registry := plugin.NewRegistry()

retriever := rag.NewRetriever(pool)
ragSkill := skills.NewRAGSearchSkill(retriever, llmClient)
if err := registry.Register(ragSkill); err != nil {
logger.Error("failed to register rag_search skill", "err", err)
}

budgetStore := budget.NewStore(pool)
bs := budgetskill.NewBudgetSkill(budgetStore)
if err := registry.Register(bs); err != nil {
logger.Error("failed to register budget skill", "err", err)
}

advisorSkill := advisorskill.NewAdvisorSkill(budgetStore, llmClient, logger).
WithLLMTimeout(advisorLLMTimeout)
if err := registry.Register(advisorSkill); err != nil {
logger.Error("failed to register advisor skill", "err", err)
}

logger.Info("registry ready", "skills", len(registry.List()))
return registry, bs
}

func buildHelpText(commands []telegramadapter.Command) string {
var b strings.Builder
b.WriteString("Что умею:\n")
Expand Down
92 changes: 92 additions & 0 deletions cmd/botclient/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Command botclient — прямой тест-клиент к ядру бота (agent.Service),
// в обход Telegram-транспорта. Шлёт текст в AskWithMeta и печатает ответ.
//
// По умолчанию read-only: подключается к реплике ролью botclient_ro (запись
// отклоняется БД). Запись — только с --allow-writes и BOTCLIENT_DATABASE_URL_RW.
//
// Пример:
//
// set -a; . ~/.simpleai-replica/botclient.env; set +a
// go run ./cmd/botclient "пришло 127000₽, сколько свободных осталось?"
package main

import (
"context"
"flag"
"fmt"
"log"
"os"
"strings"

"github.com/jackc/pgx/v5/pgxpool"

"simpleAI/config"
llmfactory "simpleAI/internal/adapters/llm"
"simpleAI/internal/appwiring"
"simpleAI/internal/botclient"
"simpleAI/internal/tools"
)

func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}

func run() error {
var (
allowWrites bool
chatID int64
)
flag.BoolVar(&allowWrites, "allow-writes", false, "разрешить мутации БД (по умолчанию read-only реплика)")
flag.Int64Var(&chatID, "chat-id", 420229961, "telegram chat_id для chat-scoped skills")
flag.Parse()

prompt := strings.TrimSpace(strings.Join(flag.Args(), " "))
if prompt == "" {
return fmt.Errorf("укажи текст запроса: botclient [флаги] \"<текст>\"")
}

dbURL, err := botclient.ResolveDatabaseURL(botclient.Options{
AllowWrites: allowWrites,
ReadOnlyURL: os.Getenv("BOTCLIENT_DATABASE_URL"),
WriteURL: os.Getenv("BOTCLIENT_DATABASE_URL_RW"),
})
if err != nil {
return err
}

cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("config: %w", err)
}
logger := tools.NewLogger()

ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
return fmt.Errorf("db pool: %w", err)
}
defer pool.Close()

llmClient, err := llmfactory.NewClient(cfg, logger)
if err != nil {
return fmt.Errorf("llm: %w", err)
}

registry, _ := appwiring.BuildRegistry(llmClient, logger, pool, cfg.LLM.AdvisorLLMTimeout)
svc := appwiring.BuildAgentService(llmClient, registry, nil, nil, logger, cfg.LLM.ChatModel)

mode := "read-only"
if allowWrites {
mode = "READ-WRITE"
}
logger.Info("botclient", "mode", mode, "chat_id", chatID)

answer, err := botclient.Ask(ctx, svc, prompt, chatID)
if err != nil {
return fmt.Errorf("ask: %w", err)
}
fmt.Println(answer)
return nil
}
43 changes: 43 additions & 0 deletions deploy/launchd/com.simpleai.replica-refresh.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Почасовой авто-рефреш локальной read-only реплики боевой БД.
Graceful: если docker/postgres/прод недоступны — скрипт пишет warn в
~/.simpleai-replica/refresh.log и выходит 0 (не спамит, не копит ошибки).
Установка:
cp deploy/launchd/com.simpleai.replica-refresh.plist ~/Library/LaunchAgents/
launchctl unload ~/Library/LaunchAgents/com.simpleai.replica-refresh.plist 2>/dev/null
launchctl load ~/Library/LaunchAgents/com.simpleai.replica-refresh.plist
Проверка/снятие:
launchctl list | grep simpleai
tail -f ~/.simpleai-replica/refresh.log
launchctl unload ~/Library/LaunchAgents/com.simpleai.replica-refresh.plist
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.simpleai.replica-refresh</string>

<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/tarasmalinovskij/dev/simpleAI/scripts/refresh-replica.sh</string>
</array>

<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

<!-- Почасово: каждые 3600 c. RunAtLoad — сразу при загрузке агента. -->
<key>StartInterval</key>
<integer>3600</integer>
<key>RunAtLoad</key>
<true/>

<key>StandardOutPath</key>
<string>/Users/tarasmalinovskij/.simpleai-replica/refresh.log</string>
<key>StandardErrorPath</key>
<string>/Users/tarasmalinovskij/.simpleai-replica/refresh.err.log</string>
</dict>
</plist>
44 changes: 44 additions & 0 deletions evals/clarity/safe_to_spend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Clarity benchmark — safe_to_spend

Регулярный замер **ясности** вывода (не роутинга). Прогонять периодически:
запустить свежий агент БЕЗ контекста проекта, дать ему промпт ниже + текущий
вывод бота, собрать замечания. Цель — ловить деградацию подачи (двойные смыслы,
непрозрачные статьи, перевёрнутые приоритеты, валютная слепота).

## Как прогнать

1. Получить свежий вывод:
```
set -a; . ~/.simpleai-replica/botclient.env; . ~/.simpleai-replica/llm.env; set +a
go run ./cmd/botclient "пришло 127000 рублей, сколько свободно и на что уйдут деньги за 2 недели?"
```
2. Запустить холодного агента (general-purpose, без контекста) с промптом ниже,
подставив вывод.
3. Сверить замечания с известными (см. «Закрытые претензии»). Новые = регресс.

## Промпт ревьюера (полная ситуация)

> Ситуация пользователя: он получает зарплату в рублях, но живёт в Таиланде и
> тратит в батах. Когда приходит зарплата, он хочет решить: сколько уйдёт на
> обязательные и повседневные траты и может ли он что-то ОТЛОЖИТЬ (накопить) или
> всё уйдёт. Оцени вывод как инструмент для ЭТОГО решения:
> 1. Отвечает ли вывод на «сколько свободно и могу ли отложить»?
> 2. Чего критически не хватает?
> 3. Что лишнее или сбивает с толку?
> 4. Как переработать, чтобы решение принималось за 5 секунд?
> Не пиши код. Вот вывод: <PASTE>

## Закрытые претензии (ожидаемо НЕ должны всплывать)

- Двойное «свободно» (промежуточное vs итог) — вердикт вынесен наверх, ложный
зелёный снят.
- «−X ₽ запаса» оксюморон — теперь «нехватка X ₽».
- Плановые как чёрный ящик — раскрыты по пунктам.
- Дубли категорий по регистру — нормализованы; свёртка названа «Остальные статьи».
- Курс бат↔рубль скрыт — показан явно (`курс N ₽/฿`).
- Приоритет мелочи над крупным — вердикт и рычаг (дефицит) сверху.

## Открытые (осознанно НЕ делаем пока)

- Валютный разрез (сумма к обмену в батах, разные карманы ₽/฿) — отложено,
пока достаточно рублей + явный курс.
18 changes: 10 additions & 8 deletions evals/cmd/routing/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,24 @@ import (
"simpleAI/internal/plugin"
advisorskill "simpleAI/internal/skills/advisor"
budgetskill "simpleAI/internal/skills/budget"
safetospendskill "simpleAI/internal/skills/safetospend"

"simpleAI/evals"
)

const (
caseTimeout = 60 * time.Second
promptHashLen = 12
caseTimeout = 60 * time.Second
promptHashLen = 12
)

func main() {
var (
inputPath = flag.String("input", "evals/golden_set.jsonl", "путь к golden_set.jsonl")
outDir = flag.String("out", "evals/runs", "каталог куда писать run-файлы")
tagFilter = flag.String("tag", "", "фильтр по тегу (пусто = без фильтра)")
limit = flag.Int("limit", 0, "smoke: ограничить N кейсами (0 = все)")
prevPath = flag.String("prev", "", "путь к предыдущему run для diff (пусто = без diff)")
dryRun = flag.Bool("dry-run", false, "не вызывать LLM, проверить только загрузку и схему")
inputPath = flag.String("input", "evals/golden_set.jsonl", "путь к golden_set.jsonl")
outDir = flag.String("out", "evals/runs", "каталог куда писать run-файлы")
tagFilter = flag.String("tag", "", "фильтр по тегу (пусто = без фильтра)")
limit = flag.Int("limit", 0, "smoke: ограничить N кейсами (0 = все)")
prevPath = flag.String("prev", "", "путь к предыдущему run для diff (пусто = без diff)")
dryRun = flag.Bool("dry-run", false, "не вызывать LLM, проверить только загрузку и схему")
)
flag.Parse()

Expand Down Expand Up @@ -126,6 +127,7 @@ func buildManifests() []plugin.Manifest {
return []plugin.Manifest{
budgetskill.NewBudgetSkill(nil).Manifest(),
advisorskill.NewAdvisorSkill(nil, nil, nil).Manifest(),
safetospendskill.NewSafeToSpendSkill(nil, nil, nil).Manifest(),
}
}

Expand Down
6 changes: 6 additions & 0 deletions evals/golden_set.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@
{"id":"r034","input":"покажи доходы предыдущего месяца","expected":{"skill":"budget","action":"summary"},"tags":["summary","prev_month","income","ru","regression"]}
{"id":"r035","input":"перечисли все доходы за апрель","expected":{"skill":"budget","action":"list_transactions"},"tags":["list_transactions","income","ru"]}
{"id":"r036","input":"сколько я заработал в марте","expected":{"skill":"budget","action":"summary"},"tags":["summary","income","ru"]}
{"id":"r037","input":"пришло 127000, сколько свободных денег осталось на этот месяц?","expected":{"skill":"safe_to_spend","action":""},"tags":["safe_to_spend","income_arrived","free","ru","regression"],"notes":"ADR-007 бага №1 — раньше уходило в budget.add_income (запись прихода вместо ответа)"}
{"id":"r038","input":"получил премию 40000, на что её лучше распределить?","expected":{"skill":"safe_to_spend","action":""},"tags":["safe_to_spend","allocate","ru"]}
{"id":"r039","input":"запиши доход зарплата 130000","expected":{"skill":"budget","action":"add_income"},"tags":["add_income","record","ru","regression"],"notes":"чистая запись без вопроса о свободных деньгах остаётся add_income"}
{"id":"r040","input":"запланируй трату 15600 на визу","expected":{"skill":"budget","action":"add_planned_expense"},"tags":["add_planned_expense","planned","ru"]}
{"id":"r041","input":"запомни приход 127000 на 2 недели","expected":{"skill":"budget","action":"start_envelope"},"tags":["start_envelope","envelope","ru"]}
{"id":"r042","input":"сколько свободных денег осталось?","expected":{"skill":"safe_to_spend","action":""},"tags":["remaining","envelope","ru"]}
75 changes: 75 additions & 0 deletions internal/appwiring/appwiring.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Package appwiring собирает переиспользуемую обвязку приложения — единый
// реестр skills и agent.Service — чтобы её могли разделять разные точки входа
// (cmd/app, cmd/botclient) без дублирования конструирования.
//
// Логика перенесена из cmd/app без смены поведения (задача simpleAI-fm3g).
package appwiring

import (
"log/slog"
"time"

"github.com/jackc/pgx/v5/pgxpool"

"simpleAI/internal/agent"
"simpleAI/internal/budget"
"simpleAI/internal/core"
"simpleAI/internal/observability"
"simpleAI/internal/plugin"
"simpleAI/internal/rag"
"simpleAI/internal/skills"
advisorskill "simpleAI/internal/skills/advisor"
budgetskill "simpleAI/internal/skills/budget"
safetospendskill "simpleAI/internal/skills/safetospend"
"simpleAI/internal/trace"
)

// BuildRegistry собирает единый реестр skills (rag_search, budget, advisor)
// поверх пула БД. Возвращает реестр и BudgetSkill (нужен отдельно для
// Telegram callback-хендлера).
func BuildRegistry(llmClient core.LLMClient, logger *slog.Logger, pool *pgxpool.Pool, advisorLLMTimeout time.Duration) (*plugin.Registry, *budgetskill.BudgetSkill) {
if logger == nil {
logger = slog.Default()
}
registry := plugin.NewRegistry()

retriever := rag.NewRetriever(pool)
ragSkill := skills.NewRAGSearchSkill(retriever, llmClient)
if err := registry.Register(ragSkill); err != nil {
logger.Error("failed to register rag_search skill", "err", err)
}

budgetStore := budget.NewStore(pool)
bs := budgetskill.NewBudgetSkill(budgetStore)
if err := registry.Register(bs); err != nil {
logger.Error("failed to register budget skill", "err", err)
}

advisorSkill := advisorskill.NewAdvisorSkill(budgetStore, llmClient, logger).
WithLLMTimeout(advisorLLMTimeout)
if err := registry.Register(advisorSkill); err != nil {
logger.Error("failed to register advisor skill", "err", err)
}

safeToSpendSkill := safetospendskill.NewSafeToSpendSkill(budgetStore, llmClient, logger).
WithLLMTimeout(advisorLLMTimeout)
if err := registry.Register(safeToSpendSkill); err != nil {
logger.Error("failed to register safe_to_spend skill", "err", err)
}

logger.Info("registry ready", "skills", len(registry.List()))
return registry, bs
}

// BuildAgentService собирает agent.Service с реестром и опциональными
// трейсерами. Перенесено из cmd/app runTelegram без смены поведения.
func BuildAgentService(llmClient core.LLM, registry *plugin.Registry, tracer *trace.Store, obsTracer *observability.Tracer, logger *slog.Logger, chatModel string) *agent.Service {
if logger == nil {
logger = slog.Default()
}
return agent.NewServiceWithRegistry(llmClient, registry).
WithTracer(tracer).
WithObservability(obsTracer).
WithLogger(logger).
WithLLMModel(chatModel)
}
Loading
Loading