feat: RAG 知识库真实数据 + 渠道端到端接线(并行∦wire.go)+ 主会话收口
【渠道端到端·接线(agent-72)】 - 新建 internal/services/channelbridge/:桥接服务(Start 对配置渠道 Connect(10s超时/warn)+SetMessageHandler+ReadMessages; 入站 UnifiedMessage→sessions.GetOrCreate(ResolveActor 白名单结构性识别)→loop.Run→SendMessage 回执,chat 文本)。 - cmd/cyrene/channels.go:从 CHANNEL_* env 读 4 渠道凭据(telegram/qq_obv11/weixin_ilink/webhook)。 - wire.go:app 加 channelBridge 字段,wireChannels 构造适配器+bridge.Start(),close() 收尾。 - 无真凭据降级:无 CHANNEL_* → configured()false 跳过不 dial;Connect 失败 Warn 不阻启动; webhook 仅入站(回执 ErrUnsupported 记 debug)。身份识别绝不靠提示词。收发端到端待凭据验证。 【RAG 知识库(agent-71)】 - internal/runtime/knowledge/:真实数据源(doc=资料/记忆=经历 分离 design/03 §3③)。 ListDocuments 走 projectindex.ListProjects(+ListChapters)、Search 走 projectindex.Search(标题/描述+章节 命中行+上下文)+retriever.Retrieve(配 vector 真语义,命中标 semantic:true)。 - gateway/server.go:RagStore 接口+Deps.RAG 可空+3 路由(GET /admin/rag、POST /admin/rag/query、/delete)。 - admin.go:adminListRag/adminSearchRag(空 query 400)/adminDeleteRag(写 501 not_implemented,M10)。 - projectindex/store.go:ListProjects/ListChapters。 - 前端 Rag.tsx:docs 从 GET /admin/rag 拉、检索走 POST /admin/rag/query、删除调真端点(501 显示 Notice), 上传/分块/嵌入显示 M10 Notice;api.ts/types.ts 加 adminListRag/adminSearchRag/adminDeleteRag。 【主会话收口 wire.go】projStore 提到 app struct+wireMemory 赋值;knowledge.New(...) 注入 gateway.Deps.RAG。 实机验证:admin/rag 200(documents[] 空表非壳)、channels 枚举 4 渠道、health ok。 go build/vet/test 全绿 + 前端 tsc。wire.go 567 行。 遗留(记录):① 群聊/陌生人 MemWritable 记忆隔离未接 loop(AGENTS.md 红线,待修) ② webhook InboundHandler 需 gateway 挂载 ③ RAG 语义路径 persona-scoped(project 记忆其他 persona 不浮现)。
This commit is contained in:
1 parent
518bd0a230
commit
dbcf3df8a3
13 files changed
+1510
-104
No files matched your search
+9
-1
@@ -42,8 +42,16 @@
|
|||||||
| 9 | `/api/v1/admin/llm` | GET/PUT | ✅(GET) | LLM 模型/路由/降级(GET 已实现,APIKey 脱敏;PUT 热重载 M10) |
|
| 9 | `/api/v1/admin/llm` | GET/PUT | ✅(GET) | LLM 模型/路由/降级(GET 已实现,APIKey 脱敏;PUT 热重载 M10) |
|
||||||
| 10 | `/api/v1/admin/automations` | GET/POST | ✅(GET) | 自动化与提醒(GET 已实现;POST 创建 M10) |
|
| 10 | `/api/v1/admin/automations` | GET/POST | ✅(GET) | 自动化与提醒(GET 已实现;POST 创建 M10) |
|
||||||
| 11 | `/api/v1/admin/backthink` | GET | ✅ | 后台思考记录 |
|
| 11 | `/api/v1/admin/backthink` | GET | ✅ | 后台思考记录 |
|
||||||
|
| 12 | `/api/v1/admin/rag` | GET | ✅ | 知识库文档列表(分块/嵌入状态);带 `?query=` 时并入检索结果 |
|
||||||
|
| 13 | `/api/v1/admin/rag/query` | POST | ✅ | 知识库检索(真实检索;配置向量增强走语义路径) |
|
||||||
|
| 14 | `/api/v1/admin/rag/delete` | POST | 🕐(写) | 删除知识库文档;写操作 M10,当前返回 `501 not_implemented` |
|
||||||
|
|
||||||
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/trace` · `GET /api/v1/admin/audit` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。写操作(策略/档案/密钥/系统设置/用户与自动化创建)经前端审计登记,待后端 M10+ 落地真实持久化与热重载。
|
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/trace` · `GET /api/v1/admin/audit` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。写操作(策略/档案/密钥/系统设置/用户与自动化创建、知识库上传/删除)经前端审计登记,待后端 M10+ 落地真实持久化与热重载。
|
||||||
|
|
||||||
|
**知识库 RAG 契约**:
|
||||||
|
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
|
||||||
|
- `POST /admin/rag/query` body `{"query":"..."}` → `{"data":{"query":"...","hits":[{docId,docName,chapter?,text,score,semantic}]}}`。检索走项目索引关键词+行上下文;配置 pgvector/向量增强时并入语义/向量命中(`semantic:true`)。
|
||||||
|
- `POST /admin/rag/delete` body `{"id":"..."}` → 写操作 M10,返回 `501` `{"error":{"code":"not_implemented",...}}`。
|
||||||
|
|
||||||
## 3. WebSocket `/ws/chat`(实时)
|
## 3. WebSocket `/ws/chat`(实时)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// This file builds the four built-in IM channel adapters from environment
|
||||||
|
// configuration. The config package carries no channel block, so credentials are
|
||||||
|
// read from CHANNEL_<NAME>_* env vars here and injected into each adapter's
|
||||||
|
// concrete Config. Adapters whose construction fails are skipped rather than
|
||||||
|
// aborting boot, matching the "no credentials → warn and continue" contract of
|
||||||
|
// the channel bridge.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildChannelAdapters constructs the four channel adapters from environment
|
||||||
|
// configuration and returns the successfully built subset. Adapter construction
|
||||||
|
// never dials the network; actual connection happens in the channel bridge.
|
||||||
|
func buildChannelAdapters(log *slog.Logger) []adapter.PlatformAdapter {
|
||||||
|
qq := qq_obv11.NewAdapter(qq_obv11.Config{
|
||||||
|
Mode: envString("CHANNEL_QQ_OBV11_MODE", "server"),
|
||||||
|
ListenAddr: os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"),
|
||||||
|
RemoteURL: os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"),
|
||||||
|
AccessToken: os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"),
|
||||||
|
SelfID: os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"),
|
||||||
|
Logger: log,
|
||||||
|
})
|
||||||
|
|
||||||
|
weixin, werr := weixin_ilink.New(weixin_ilink.Config{
|
||||||
|
BaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"),
|
||||||
|
CDNBaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"),
|
||||||
|
BotToken: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"),
|
||||||
|
BotID: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"),
|
||||||
|
OwnerID: os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"),
|
||||||
|
}, nil, log)
|
||||||
|
if werr != nil {
|
||||||
|
// A blank config defaults to a valid base URL, so this only fires on an
|
||||||
|
// explicitly invalid one; skip the channel rather than fail startup.
|
||||||
|
log.Warn("weixin-ilink adapter build skipped", "error", werr)
|
||||||
|
weixin = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tg := telegram.New(telegram.Config{
|
||||||
|
BotToken: os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"),
|
||||||
|
WebhookURL: os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
|
||||||
|
}, telegram.WithLogger(log))
|
||||||
|
|
||||||
|
// webhook.WithLogger does not tag provenance, so wrap base with the module
|
||||||
|
// source here to satisfy the logging rule.
|
||||||
|
wh := webhook.New(
|
||||||
|
webhook.WithConfig(webhook.Config{
|
||||||
|
EndpointPath: os.Getenv("CHANNEL_WEBHOOK_ENDPOINT_PATH"),
|
||||||
|
ChannelID: os.Getenv("CHANNEL_WEBHOOK_CHANNEL_ID"),
|
||||||
|
ChannelName: os.Getenv("CHANNEL_WEBHOOK_CHANNEL_NAME"),
|
||||||
|
Secret: os.Getenv("CHANNEL_WEBHOOK_SECRET"),
|
||||||
|
SignatureHeader: os.Getenv("CHANNEL_WEBHOOK_SIGNATURE_HEADER"),
|
||||||
|
}),
|
||||||
|
webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")),
|
||||||
|
)
|
||||||
|
|
||||||
|
out := []adapter.PlatformAdapter{qq, tg, wh}
|
||||||
|
if weixin != nil {
|
||||||
|
out = append(out, weixin)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// envString returns the value of key, or fallback when the variable is unset or
|
||||||
|
// blank.
|
||||||
|
func envString(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
+67
-57
@@ -26,6 +26,7 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||||||
@@ -41,12 +42,9 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/basic"
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/basic"
|
||||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||||
)
|
)
|
||||||
@@ -70,28 +68,30 @@ const (
|
|||||||
// app holds every subsystem the single Cyrene binary assembles. It is built by
|
// app holds every subsystem the single Cyrene binary assembles. It is built by
|
||||||
// newApp and torn down by close on shutdown.
|
// newApp and torn down by close on shutdown.
|
||||||
type app struct {
|
type app struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
bus bus.Bus
|
bus bus.Bus
|
||||||
personas *personareg.Registry
|
personas *personareg.Registry
|
||||||
caps *registry.Registry
|
caps *registry.Registry
|
||||||
dispatcher *tool.Dispatcher
|
dispatcher *tool.Dispatcher
|
||||||
memStore *memory.Store
|
memStore *memory.Store
|
||||||
retriever *memory.Retriever
|
retriever *memory.Retriever
|
||||||
loop *loop.Loop
|
projStore *projectindex.Store
|
||||||
scheduler *orchestration.Scheduler
|
loop *loop.Loop
|
||||||
tasks *task.Manager
|
scheduler *orchestration.Scheduler
|
||||||
execProv exec.Provider
|
tasks *task.Manager
|
||||||
iot *iot.Manager
|
execProv exec.Provider
|
||||||
voice *voice.Chain
|
iot *iot.Manager
|
||||||
mcpRegs []*mcp.Registry
|
voice *voice.Chain
|
||||||
sessions *session.SessionManager
|
mcpRegs []*mcp.Registry
|
||||||
automation *automations.Service
|
sessions *session.SessionManager
|
||||||
backthink *backthink.Backthink
|
automation *automations.Service
|
||||||
vindex vector.Index
|
backthink *backthink.Backthink
|
||||||
owner *user.OwnerProfile
|
vindex vector.Index
|
||||||
gw *gateway.Server
|
owner *user.OwnerProfile
|
||||||
|
gw *gateway.Server
|
||||||
|
channelBridge *channelbridge.Bridge
|
||||||
}
|
}
|
||||||
|
|
||||||
// newApp assembles the full Cyrene runtime from cfg and log. It opens the
|
// newApp assembles the full Cyrene runtime from cfg and log. It opens the
|
||||||
@@ -237,6 +237,7 @@ func (a *app) wireMemory(ctx context.Context) error {
|
|||||||
a.log.Warn("project index unavailable", "error", err)
|
a.log.Warn("project index unavailable", "error", err)
|
||||||
projStore = nil
|
projStore = nil
|
||||||
}
|
}
|
||||||
|
a.projStore = projStore
|
||||||
|
|
||||||
opts := []memory.RetrieverOption{memory.WithEmbedder(embedder)}
|
opts := []memory.RetrieverOption{memory.WithEmbedder(embedder)}
|
||||||
if a.vindex != nil {
|
if a.vindex != nil {
|
||||||
@@ -398,34 +399,42 @@ func (a *app) wireAuxiliary() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// wireChannels constructs and registers the four IM channel adapters so the
|
// wireChannels constructs the four IM channel adapters from environment
|
||||||
// admin API can enumerate them. They are registered but not connected: real
|
// configuration, registers them so the admin API can enumerate them, and wires
|
||||||
// platform credentials are supplied per-deployment, and connecting with empty
|
// them into the channel bridge so a configured adapter connects and routes
|
||||||
// config would dial external networks.
|
// inbound messages through session→actor→loop→reply. Adapters without
|
||||||
|
// credentials stay disconnected (logged, never fatal): connecting with an empty
|
||||||
|
// config would either fail or, for weixin-ilink, trigger a blocking scan-code
|
||||||
|
// login.
|
||||||
func (a *app) wireChannels() error {
|
func (a *app) wireChannels() error {
|
||||||
channels := []struct {
|
adapters := buildChannelAdapters(a.log)
|
||||||
name string
|
for _, ch := range adapters {
|
||||||
value any
|
|
||||||
policy registry.ToolPolicy
|
|
||||||
}{
|
|
||||||
{"qq-obv11", qq_obv11.NewAdapter(qq_obv11.Config{Logger: a.log}), registry.ToolPolicy{}},
|
|
||||||
{"weixin-ilink", mustWeixin(a.log), registry.ToolPolicy{}},
|
|
||||||
{"telegram", telegram.New(telegram.Config{}, telegram.WithLogger(a.log)), registry.ToolPolicy{}},
|
|
||||||
{"webhook", webhook.New(webhook.WithLogger(a.log)), registry.ToolPolicy{}},
|
|
||||||
}
|
|
||||||
for _, ch := range channels {
|
|
||||||
capp := registry.Capability{
|
capp := registry.Capability{
|
||||||
Name: ch.name,
|
Name: ch.Name(),
|
||||||
Kind: registry.KindChannel,
|
Kind: registry.KindChannel,
|
||||||
Description: ch.name,
|
Description: ch.Name(),
|
||||||
Policy: ch.policy,
|
Value: ch,
|
||||||
Value: ch.value,
|
|
||||||
}
|
}
|
||||||
if err := a.caps.Register(capp); err != nil {
|
if err := a.caps.Register(capp); err != nil {
|
||||||
a.log.Warn("channel register skipped", "channel", ch.name, "error", err)
|
a.log.Warn("channel register skipped", "channel", ch.Name(), "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.log.Info("channel adapters registered", "count", len(channels))
|
|
||||||
|
bridge, berr := channelbridge.New(channelbridge.Options{
|
||||||
|
Log: logger.WithSource(a.log, "services/channelbridge"),
|
||||||
|
Loop: a.loop,
|
||||||
|
Sessions: a.sessions,
|
||||||
|
Owner: a.owner,
|
||||||
|
})
|
||||||
|
if berr != nil {
|
||||||
|
return fmt.Errorf("channel bridge: %w", berr)
|
||||||
|
}
|
||||||
|
for _, ch := range adapters {
|
||||||
|
bridge.Add(ch)
|
||||||
|
}
|
||||||
|
bridge.Start()
|
||||||
|
a.channelBridge = bridge
|
||||||
|
a.log.Info("channel adapters registered", "count", len(adapters))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +444,13 @@ func (a *app) wireChannels() error {
|
|||||||
func (a *app) wireFrontend() error {
|
func (a *app) wireFrontend() error {
|
||||||
jwt := auth.New(a.cfg.Auth)
|
jwt := auth.New(a.cfg.Auth)
|
||||||
apiKeys := auth.NewAPIKey(a.cfg.Auth)
|
apiKeys := auth.NewAPIKey(a.cfg.Auth)
|
||||||
|
ragStore := knowledge.New(knowledge.Deps{
|
||||||
|
Projects: a.projStore,
|
||||||
|
Retriever: a.retriever,
|
||||||
|
DefaultPersona: a.defaultPersonaID(),
|
||||||
|
VectorOn: a.vindex != nil,
|
||||||
|
Log: a.log,
|
||||||
|
})
|
||||||
gw, err := gateway.New(a.cfg, gateway.Deps{
|
gw, err := gateway.New(a.cfg, gateway.Deps{
|
||||||
Log: logger.WithSource(a.log, "gateway"),
|
Log: logger.WithSource(a.log, "gateway"),
|
||||||
Auth: jwt,
|
Auth: jwt,
|
||||||
@@ -450,6 +466,7 @@ func (a *app) wireFrontend() error {
|
|||||||
Automations: a.automation,
|
Automations: a.automation,
|
||||||
Users: apiKeys,
|
Users: apiKeys,
|
||||||
Backthink: a.backthink,
|
Backthink: a.backthink,
|
||||||
|
RAG: ragStore,
|
||||||
Streaming: true,
|
Streaming: true,
|
||||||
Frontend: resolveFrontendFS(a.log),
|
Frontend: resolveFrontendFS(a.log),
|
||||||
})
|
})
|
||||||
@@ -502,6 +519,9 @@ func (a *app) close(ctx context.Context) {
|
|||||||
if a.automation != nil {
|
if a.automation != nil {
|
||||||
_ = a.automation.Stop(ctx)
|
_ = a.automation.Stop(ctx)
|
||||||
}
|
}
|
||||||
|
if a.channelBridge != nil {
|
||||||
|
a.channelBridge.Stop(ctx)
|
||||||
|
}
|
||||||
if a.scheduler != nil {
|
if a.scheduler != nil {
|
||||||
_ = a.scheduler.CancelAll(ctx)
|
_ = a.scheduler.CancelAll(ctx)
|
||||||
}
|
}
|
||||||
@@ -545,13 +565,3 @@ func resolveSQLitePath(cfg *config.Config, dataDir string) string {
|
|||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// mustWeixin builds the weixin-ilink adapter, panicking on an invalid config
|
|
||||||
// (which should never happen with defaults).
|
|
||||||
func mustWeixin(log *slog.Logger) *weixin_ilink.Adapter {
|
|
||||||
adapter, err := weixin_ilink.New(weixin_ilink.Config{}, nil, log)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("weixin-ilink adapter: %v", err))
|
|
||||||
}
|
|
||||||
return adapter
|
|
||||||
}
|
|
||||||
@@ -1,37 +1,60 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useAsyncData } from "../../lib/useAsyncData";
|
||||||
|
import { adminDeleteRag, adminListRag, adminSearchRag } from "../../lib/api";
|
||||||
|
import type { AdminRagDoc, AdminRagHit } from "../../lib/types";
|
||||||
import { useAuditStore } from "../audit";
|
import { useAuditStore } from "../audit";
|
||||||
import { Notice, PageHeader } from "../components";
|
import { Notice, PageHeader } from "../components";
|
||||||
import { Button, Surface, Tag } from "../../components/primitives";
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
|
|
||||||
interface DocRow {
|
const WRITE_M10 = "上传/分块/嵌入为后端 M10+ 提供;当前只读列文档与检索,删除接口在 M10 落地。";
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
chunks: number;
|
|
||||||
embedded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const docs: DocRow[] = [
|
|
||||||
{ id: "1", name: "owner_identity.md", chunks: 1, embedded: false },
|
|
||||||
{ id: "2", name: "开发时间线.md", chunks: 6, embedded: false },
|
|
||||||
{ id: "3", name: "design/10-后台管理面板.md", chunks: 2, embedded: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function RagModule(): React.ReactNode {
|
export function RagModule(): React.ReactNode {
|
||||||
const log = useAuditStore((s) => s.log);
|
const log = useAuditStore((s) => s.log);
|
||||||
|
const { data, loading, error, reload } = useAsyncData(() => adminListRag(), []);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [hits, setHits] = useState<string[]>([]);
|
const [hits, setHits] = useState<AdminRagHit[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const docs: AdminRagDoc[] = data?.documents ?? [];
|
||||||
|
|
||||||
|
const search = async () => {
|
||||||
|
log("rag.search", "ok");
|
||||||
|
setSearching(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const res = await adminSearchRag(query.trim());
|
||||||
|
setHits(res.hits);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : "检索失败";
|
||||||
|
setNotice(msg);
|
||||||
|
setHits([]);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (d: AdminRagDoc) => {
|
||||||
|
log("rag.delete", "ok");
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
await adminDeleteRag(d.id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setNotice(err instanceof Error ? err.message : WRITE_M10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="知识库 RAG"
|
title="知识库 RAG"
|
||||||
subtitle="文档列表(分块/嵌入状态) + 索引 + 检索测试"
|
subtitle="GET /api/v1/admin/rag · 文档列表(分块/嵌入状态) + 检索测试"
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
log("rag.upload", "ok");
|
log("rag.upload", "ok");
|
||||||
alert("上传文档(占位):后端 M10+ 提供分块/嵌入。");
|
setNotice(WRITE_M10);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
上传文档
|
上传文档
|
||||||
@@ -39,30 +62,50 @@ export function RagModule(): React.ReactNode {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{notice ? <Notice tone="warn">{notice}</Notice> : null}
|
||||||
|
|
||||||
<Surface className="space-y-2">
|
<Surface className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-sm font-semibold">文档列表</h3>
|
<h3 className="text-sm font-semibold">文档列表</h3>
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">{docs.length} 篇</span>
|
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||||
|
{loading ? "加载中…" : `${docs.length} 篇`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{docs.map((d) => (
|
{loading ? (
|
||||||
<div key={d.id} className="flex items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm">
|
<p className="text-xs text-[var(--mu-text-lo)]">正在从项目索引加载知识文档…</p>
|
||||||
<span className="font-medium">{d.name}</span>
|
) : error ? (
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">{d.chunks} 分块</span>
|
<p className="text-xs text-[var(--mu-text-lo)]">{error}</p>
|
||||||
<span className="ml-auto">
|
) : docs.length === 0 ? (
|
||||||
<Tag tone={d.embedded ? "ok" : "warn"}>{d.embedded ? "已嵌入" : "待嵌入"}</Tag>
|
<p className="text-xs text-[var(--mu-text-lo)]">暂无知识文档(项目索引为空)。</p>
|
||||||
</span>
|
) : (
|
||||||
<Button
|
docs.map((d) => (
|
||||||
size="sm"
|
<div
|
||||||
variant="ghost"
|
key={d.id}
|
||||||
onClick={() => {
|
className="flex items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm"
|
||||||
log("rag.delete", "ok");
|
|
||||||
alert(`删除 ${d.name}(占位)`);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
删除
|
<div className="min-w-0">
|
||||||
</Button>
|
<span className="font-medium">{d.name}</span>
|
||||||
</div>
|
{d.desc ? (
|
||||||
))}
|
<span className="ml-2 truncate text-xs text-[var(--mu-text-lo)]">{d.desc}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--mu-text-lo)]">{d.chunks} 分块</span>
|
||||||
|
<span className="ml-auto">
|
||||||
|
<Tag tone={d.embedded ? "ok" : "warn"}>{d.embedded ? "已嵌入" : "待嵌入"}</Tag>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirm(`删除 ${d.name}?`)) return;
|
||||||
|
void remove(d);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
<Surface className="space-y-3">
|
<Surface className="space-y-3">
|
||||||
@@ -71,29 +114,45 @@ export function RagModule(): React.ReactNode {
|
|||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="输入查询…"
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && query.trim()) void search();
|
||||||
|
}}
|
||||||
|
placeholder="输入查询(命中项目索引或项目记忆)…"
|
||||||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button variant="primary" onClick={() => void search()} disabled={searching || !query.trim()}>
|
||||||
variant="primary"
|
{searching ? "检索中…" : "检索"}
|
||||||
onClick={() => {
|
|
||||||
log("rag.search", "ok");
|
|
||||||
setHits(query ? [`片段 1(命中 "${query}")`, `片段 2(命中 "${query}")`] : []);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
检索
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{hits.length > 0 ? (
|
{hits.length > 0 ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{hits.map((h) => (
|
{hits.map((h, i) => (
|
||||||
<div key={h} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">{h}</div>
|
<div
|
||||||
|
key={`${h.docId}-${h.chapter ?? "mem"}-${i}`}
|
||||||
|
className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<span className="font-medium text-[var(--mu-text)]">{h.docName}</span>
|
||||||
|
{h.chapter ? (
|
||||||
|
<span className="text-[var(--mu-text-lo)]">#{h.chapter}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-auto text-[var(--mu-text-lo)]">
|
||||||
|
{h.semantic ? "语义" : "关键词"} · {h.score.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-[var(--mu-text-mid)]">{h.text}</p>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : hits.length === 0 && query.trim() && !searching ? (
|
||||||
|
<p className="text-xs text-[var(--mu-text-lo)]">无命中。</p>
|
||||||
) : null}
|
) : null}
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
<Notice>分块/嵌入状态、向量索引与语义检索为后端 M10+ 提供;配置了 pgvector 增强后必须真实走语义路径(非空壳)。</Notice>
|
<Notice>
|
||||||
|
分块/嵌入状态与语义检索:配置了 pgvector / SQLite 向量增强后,后端经真实语义/向量路径返回(非空壳);
|
||||||
|
未配置时走项目索引关键词 + 行上下文。上传/删除为后端 M10 写操作。
|
||||||
|
</Notice>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
AdminIoTDevice,
|
AdminIoTDevice,
|
||||||
AdminLlmConfig,
|
AdminLlmConfig,
|
||||||
AdminPluginRecord,
|
AdminPluginRecord,
|
||||||
|
AdminRagHit,
|
||||||
|
AdminRagResponse,
|
||||||
AdminUserRecord,
|
AdminUserRecord,
|
||||||
ApiErrorShape,
|
ApiErrorShape,
|
||||||
ChatSyncResult,
|
ChatSyncResult,
|
||||||
@@ -244,3 +246,26 @@ export function adminGetLlm(): Promise<AdminLlmConfig> {
|
|||||||
export function adminListAutomations(): Promise<AdminAutomationRecord[]> {
|
export function adminListAutomations(): Promise<AdminAutomationRecord[]> {
|
||||||
return request<AdminAutomationRecord[]>("/admin/automations");
|
return request<AdminAutomationRecord[]>("/admin/automations");
|
||||||
}
|
}
|
||||||
|
/** GET /api/v1/admin/rag — lists knowledge documents; when query is present the
|
||||||
|
* backend also returns matching hits. */
|
||||||
|
export function adminListRag(query?: string): Promise<AdminRagResponse> {
|
||||||
|
const qs = query ? `?query=${encodeURIComponent(query)}` : "";
|
||||||
|
return request<AdminRagResponse>(`/admin/rag${qs}`);
|
||||||
|
}
|
||||||
|
/** POST /api/v1/admin/rag/query — runs a real knowledge-base query. */
|
||||||
|
export function adminSearchRag(
|
||||||
|
query: string
|
||||||
|
): Promise<{ query: string; hits: AdminRagHit[] }> {
|
||||||
|
return request<{ query: string; hits: AdminRagHit[] }>("/admin/rag/query", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** POST /api/v1/admin/rag/delete — deletes a document. The backend returns a
|
||||||
|
* clear 501 until M10 lands the write path (upload/chunk/embed/delete). */
|
||||||
|
export function adminDeleteRag(id: string): Promise<{ id: string }> {
|
||||||
|
return request<{ id: string }>("/admin/rag/delete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -249,6 +249,31 @@ export interface AdminLogRecord {
|
|||||||
msg: string;
|
msg: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A knowledge-base document (GET /api/v1/admin/rag). */
|
||||||
|
export interface AdminRagDoc {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
desc?: string;
|
||||||
|
chunks: number;
|
||||||
|
embedded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One RAG retrieval hit (GET /api/v1/admin/rag?query= / POST /rag/query). */
|
||||||
|
export interface AdminRagHit {
|
||||||
|
docId: string;
|
||||||
|
docName: string;
|
||||||
|
chapter?: string;
|
||||||
|
text: string;
|
||||||
|
score: number;
|
||||||
|
semantic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GET /api/v1/admin/rag response shape. */
|
||||||
|
export interface AdminRagResponse {
|
||||||
|
documents: AdminRagDoc[];
|
||||||
|
hits?: AdminRagHit[];
|
||||||
|
}
|
||||||
|
|
||||||
/** One audit entry: who / what / when / result. */
|
/** One audit entry: who / what / when / result. */
|
||||||
export interface AuditRecord {
|
export interface AuditRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
// Package knowledge implements the RAG knowledge-base data source described in
|
||||||
|
// DevDocs/design/10-后台管理面板.md §3.12 (module 8 知识库 RAG) and the
|
||||||
|
// knowledge/memory separation in DevDocs/design/03-记忆.md §3 (③ 知识库 =
|
||||||
|
// 参考资料, 记忆 = 经历).
|
||||||
|
//
|
||||||
|
// It reads the project index (项目索引 → 项目描述 → 章节内容) as the set of knowledge
|
||||||
|
// documents and answers searches over it with line-level context. When a semantic
|
||||||
|
// retriever is wired, it also folds in project-linked long-term memories as
|
||||||
|
// semantic hits, ranked by the retriever's real keyword × semantic × decay path —
|
||||||
|
// so a configured vector index (pgvector / SQLite vector) is genuinely used and
|
||||||
|
// never a shell.
|
||||||
|
//
|
||||||
|
// The store is read-only for this milestone: upload/chunk/embed and delete land
|
||||||
|
// in M10. The write methods return ErrWriteUnsupported so the admin handler can
|
||||||
|
// answer honestly instead of fabricating a success.
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrWriteUnsupported is returned by the write operations. Upload/chunk/embed and
|
||||||
|
// delete are milestone M10; the admin handler maps it to a clear 501 so the
|
||||||
|
// frontend can explain rather than show a placeholder alert.
|
||||||
|
var ErrWriteUnsupported = errors.New("knowledge: write operations (upload/chunk/embed/delete) land in M10")
|
||||||
|
|
||||||
|
// defaultTopK bounds the semantic retrieval path.
|
||||||
|
const defaultTopK = 8
|
||||||
|
|
||||||
|
// Retriever is the subset of the memory retriever the knowledge store uses for
|
||||||
|
// semantic ranking. *memory.Retriever implements it; nil means no semantic path.
|
||||||
|
type Retriever interface {
|
||||||
|
Retrieve(ctx context.Context, personaID, query string, topK int) ([]memory.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doc is one knowledge-base document backing a project-index entry.
|
||||||
|
type Doc struct {
|
||||||
|
// ID is the project index id.
|
||||||
|
ID string
|
||||||
|
// Name is the project title (the document's display name).
|
||||||
|
Name string
|
||||||
|
// Desc is the project description (layer 2 of the hierarchy).
|
||||||
|
Desc string
|
||||||
|
// Chunks is the number of chapters the document is split into.
|
||||||
|
Chunks int
|
||||||
|
// Embedded reports whether a persistent vector index is wired for this
|
||||||
|
// deployment. This is the deployment-level semantic capability; per-document
|
||||||
|
// embedding state is not tracked yet (M10).
|
||||||
|
Embedded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit is one retrieval result surfaced to the admin panel.
|
||||||
|
type Hit struct {
|
||||||
|
// DocID is the owning project index id.
|
||||||
|
DocID string
|
||||||
|
// DocName is the owning project title.
|
||||||
|
DocName string
|
||||||
|
// Chapter is the chapter title that matched (empty for a semantic/memory hit).
|
||||||
|
Chapter string
|
||||||
|
// Text is the matched content: a chapter's located line(s) with context, or
|
||||||
|
// the memory content for a semantic hit.
|
||||||
|
Text string
|
||||||
|
// Score is the relevance score (higher = more relevant).
|
||||||
|
Score float64
|
||||||
|
// Semantic reports whether the hit was ranked by the semantic/vector path.
|
||||||
|
Semantic bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deps carries the construction inputs. Every field is nil-tolerant so a store
|
||||||
|
// without a project index or retriever degrades to read-only empty answers.
|
||||||
|
type Deps struct {
|
||||||
|
// Projects is the project index backing the document list and text search.
|
||||||
|
// Optional; when nil the document list and text search return empty.
|
||||||
|
Projects *projectindex.Store
|
||||||
|
// Retriever ranks project-linked memories semantically. Optional; the
|
||||||
|
// semantic path runs only when both Retriever and DefaultPersona are set.
|
||||||
|
Retriever Retriever
|
||||||
|
// DefaultPersona scopes semantic retrieval to one persona. Optional.
|
||||||
|
DefaultPersona string
|
||||||
|
// VectorOn reports whether a persistent vector index (pgvector / SQLite
|
||||||
|
// vector) is wired, so Doc.Embedded is honest and never a shell.
|
||||||
|
VectorOn bool
|
||||||
|
// Log is the base logger. Nil uses a discard logger tagged with this package.
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is the RAG data source. It is stateless and safe for concurrent use.
|
||||||
|
type Store struct {
|
||||||
|
projects *projectindex.Store
|
||||||
|
retriever Retriever
|
||||||
|
defaultPersona string
|
||||||
|
vectorOn bool
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Store. Availability degrades gracefully: a nil project index means
|
||||||
|
// no documents, a nil retriever means keyword/line search only.
|
||||||
|
func New(d Deps) *Store {
|
||||||
|
log := d.Log
|
||||||
|
if log == nil {
|
||||||
|
log = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
return &Store{
|
||||||
|
projects: d.Projects,
|
||||||
|
retriever: d.Retriever,
|
||||||
|
defaultPersona: strings.TrimSpace(d.DefaultPersona),
|
||||||
|
vectorOn: d.VectorOn,
|
||||||
|
log: logger.WithSource(log, "runtime/knowledge"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDocuments enumerates the knowledge-base documents (project-index entries).
|
||||||
|
// A nil project index returns an empty, non-nil slice.
|
||||||
|
func (s *Store) ListDocuments(ctx context.Context) ([]Doc, error) {
|
||||||
|
out := make([]Doc, 0)
|
||||||
|
if s.projects == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
projects, err := s.projects.ListProjects(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("knowledge: list projects: %w", err)
|
||||||
|
}
|
||||||
|
for i := range projects {
|
||||||
|
chapters, cerr := s.projects.ListChapters(ctx, projects[i].ID)
|
||||||
|
if cerr != nil {
|
||||||
|
s.log.Error("list chapters", "project", projects[i].ID, "err", cerr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Doc{
|
||||||
|
ID: projects[i].ID,
|
||||||
|
Name: projects[i].Title,
|
||||||
|
Desc: projects[i].Desc,
|
||||||
|
Chunks: len(chapters),
|
||||||
|
Embedded: s.vectorOn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search runs the knowledge-base search over the project index (line-level,
|
||||||
|
// hierarchy-aware) and, when a semantic retriever is configured, folds in
|
||||||
|
// project-linked memories ranked by the real vector path. A blank query returns
|
||||||
|
// no hits.
|
||||||
|
func (s *Store) Search(ctx context.Context, query string) ([]Hit, error) {
|
||||||
|
hits := make([]Hit, 0)
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
if s.projects != nil {
|
||||||
|
textHits, err := s.searchProjects(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hits = append(hits, textHits...)
|
||||||
|
}
|
||||||
|
if s.retriever != nil && s.defaultPersona != "" {
|
||||||
|
semHits, err := s.searchSemantic(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("semantic search", "err", err)
|
||||||
|
} else {
|
||||||
|
hits = append(hits, semHits...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchProjects walks every project and returns chapter/item hits via
|
||||||
|
// projectindex.Search (keyword + line context). A project deleted between the
|
||||||
|
// list and the search is skipped rather than failing the whole search.
|
||||||
|
func (s *Store) searchProjects(ctx context.Context, query string) ([]Hit, error) {
|
||||||
|
projects, err := s.projects.ListProjects(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("knowledge: list projects: %w", err)
|
||||||
|
}
|
||||||
|
hits := make([]Hit, 0)
|
||||||
|
for i := range projects {
|
||||||
|
res, err := s.projects.Search(ctx, projects[i].ID, query)
|
||||||
|
if errors.Is(err, projectindex.ErrProjectNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("knowledge: search project %q: %w", projects[i].ID, err)
|
||||||
|
}
|
||||||
|
if res.Project != nil {
|
||||||
|
hits = append(hits, Hit{
|
||||||
|
DocID: res.Project.ID,
|
||||||
|
DocName: res.Project.Title,
|
||||||
|
Text: describeProject(res.Project),
|
||||||
|
Score: float64(len(res.Chapters) + 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, ch := range res.Chapters {
|
||||||
|
hits = append(hits, chapterHit(projects[i], ch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchSemantic ranks memories via the retriever's real retrieval path and maps
|
||||||
|
// each project-linked result to a Hit (marked Semantic so the panel can show it).
|
||||||
|
func (s *Store) searchSemantic(ctx context.Context, query string) ([]Hit, error) {
|
||||||
|
results, err := s.retriever.Retrieve(ctx, s.defaultPersona, query, defaultTopK)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("knowledge: retrieve: %w", err)
|
||||||
|
}
|
||||||
|
hits := make([]Hit, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Project == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hits = append(hits, Hit{
|
||||||
|
DocID: r.Project.ID,
|
||||||
|
DocName: r.Project.Title,
|
||||||
|
Text: r.Item.Content,
|
||||||
|
Score: r.Score,
|
||||||
|
Semantic: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chapterHit maps a matched chapter to a Hit. Text is the located line plus its
|
||||||
|
// surrounding context (projectindex.LineMatch.Context), and Score reflects how
|
||||||
|
// many lines in the chapter matched.
|
||||||
|
func chapterHit(p projectindex.Project, ch projectindex.ChapterHit) Hit {
|
||||||
|
text := ""
|
||||||
|
if len(ch.Matches) > 0 {
|
||||||
|
text = ch.Matches[0].Context
|
||||||
|
}
|
||||||
|
return Hit{
|
||||||
|
DocID: p.ID,
|
||||||
|
DocName: p.Title,
|
||||||
|
Chapter: ch.Chapter.Title,
|
||||||
|
Text: text,
|
||||||
|
Score: float64(len(ch.Matches)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeProject summarizes a project-index hit (title/desc match) for the panel.
|
||||||
|
func describeProject(p *projectindex.Project) string {
|
||||||
|
if p.Desc != "" {
|
||||||
|
return p.Desc
|
||||||
|
}
|
||||||
|
return p.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexDocument is not implemented in this milestone. It returns
|
||||||
|
// ErrWriteUnsupported so the handler answers honestly (upload/chunk/embed = M10).
|
||||||
|
func (s *Store) IndexDocument(_ context.Context, _ Doc) error {
|
||||||
|
return ErrWriteUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDocument is not implemented in this milestone. It returns
|
||||||
|
// ErrWriteUnsupported so the handler answers honestly (delete = M10).
|
||||||
|
func (s *Store) DeleteDocument(_ context.Context, _ string) error {
|
||||||
|
return ErrWriteUnsupported
|
||||||
|
}
|
||||||
@@ -105,6 +105,32 @@ func discardLogger(log *slog.Logger) *slog.Logger {
|
|||||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListProjects returns every project index entry in insertion order (created_at
|
||||||
|
// ASC, id ASC). An empty (non-nil) slice is returned when there are none, so
|
||||||
|
// callers can range over it unconditionally. A read error is wrapped with %w so
|
||||||
|
// callers can use errors.Is against the package sentinels.
|
||||||
|
func (s *Store) ListProjects(ctx context.Context) ([]Project, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, title, desc, created_at, updated_at
|
||||||
|
FROM project ORDER BY created_at ASC, id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("projectindex: list projects: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
projects := make([]Project, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanProject(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("projectindex: scan project: %w", err)
|
||||||
|
}
|
||||||
|
projects = append(projects, *p)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("projectindex: iterate projects: %w", err)
|
||||||
|
}
|
||||||
|
return projects, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Schema DDL, applied idempotently by New. The two tables model the three-layer
|
// Schema DDL, applied idempotently by New. The two tables model the three-layer
|
||||||
// hierarchy: the project row is the index entry (layer 1) plus its description
|
// hierarchy: the project row is the index entry (layer 1) plus its description
|
||||||
// (layer 2), and project_chapter holds the chapter content (layer 3).
|
// (layer 2), and project_chapter holds the chapter content (layer 3).
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
// Package channelbridge wires the registered IM channel adapters into the Cyrene
|
||||||
|
// runtime: it connects each configured adapter, installs the inbound message
|
||||||
|
// handler, and routes every normalized inbound message through the
|
||||||
|
// channel→session→actor→loop→reply pipeline (DevDocs/design/08 §0-§2).
|
||||||
|
//
|
||||||
|
// The bridge is deliberately generic over adapter.PlatformAdapter. It owns the
|
||||||
|
// per-platform Connect lifecycle and the inbound→reply path; the concrete
|
||||||
|
// adapters themselves (and their configuration, sourced from env) are assembled
|
||||||
|
// in cmd/cyrene/wire.go and handed in via Add. It never reads message text to
|
||||||
|
// decide identity — that is ResolveActor's job (actor metadata + owner whitelist,
|
||||||
|
// a project red line).
|
||||||
|
package channelbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Knob constants. Compile-time so no bare magic number appears in the run path
|
||||||
|
// (project rule §10).
|
||||||
|
const (
|
||||||
|
// connectTimeout bounds a single adapter Connect so a hung dial cannot stall
|
||||||
|
// boot (a failed connect logs a warning and is skipped, never fatal).
|
||||||
|
connectTimeout = 10 * time.Second
|
||||||
|
// maxConcurrent bounds how many inbound messages run the agent loop at once,
|
||||||
|
// so a burst of channel messages cannot spawn unbounded goroutines.
|
||||||
|
maxConcurrent = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNilLoop is returned when the bridge is built without a chat runner.
|
||||||
|
var ErrNilLoop = errors.New("channelbridge: nil loop runner")
|
||||||
|
|
||||||
|
// ErrNilSessions is returned when the bridge is built without a session store.
|
||||||
|
var ErrNilSessions = errors.New("channelbridge: nil session store")
|
||||||
|
|
||||||
|
// Chatter runs a single agent turn. *loop.Loop is the production implementation;
|
||||||
|
// tests inject a stub.
|
||||||
|
type Chatter interface {
|
||||||
|
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStore resolves the channel↔session binding for an inbound message.
|
||||||
|
// *session.SessionManager is the production implementation.
|
||||||
|
type SessionStore interface {
|
||||||
|
GetOrCreate(ctx context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options carries the bridge's injected dependencies. Loop and Sessions are
|
||||||
|
// required; Owner may be nil (then no account is recognized as owner, the safe
|
||||||
|
// default-deny posture from design/11 §0).
|
||||||
|
type Options struct {
|
||||||
|
Log *slog.Logger
|
||||||
|
Loop Chatter
|
||||||
|
Sessions SessionStore
|
||||||
|
Owner *user.OwnerProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
|
||||||
|
// is safe for concurrent use.
|
||||||
|
type Bridge struct {
|
||||||
|
log *slog.Logger
|
||||||
|
run Chatter
|
||||||
|
sessions SessionStore
|
||||||
|
owner *user.OwnerProfile
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
adapters map[string]adapter.PlatformAdapter
|
||||||
|
connected map[string]bool
|
||||||
|
closed bool
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
sem chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Bridge from opts. It returns an error when a required dependency
|
||||||
|
// is missing so the caller fails fast rather than silently dropping messages.
|
||||||
|
func New(opts Options) (*Bridge, error) {
|
||||||
|
if opts.Loop == nil {
|
||||||
|
return nil, ErrNilLoop
|
||||||
|
}
|
||||||
|
if opts.Sessions == nil {
|
||||||
|
return nil, ErrNilSessions
|
||||||
|
}
|
||||||
|
log := opts.Log
|
||||||
|
if log == nil {
|
||||||
|
log = logger.New(logger.Options{Out: nil})
|
||||||
|
}
|
||||||
|
return &Bridge{
|
||||||
|
log: logger.WithSource(log, "services/channelbridge"),
|
||||||
|
run: opts.Loop,
|
||||||
|
sessions: opts.Sessions,
|
||||||
|
owner: opts.Owner,
|
||||||
|
adapters: make(map[string]adapter.PlatformAdapter),
|
||||||
|
connected: make(map[string]bool),
|
||||||
|
cancel: func() {},
|
||||||
|
sem: make(chan struct{}, maxConcurrent),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add registers an adapter by its Name. It is safe to call before Start; adding
|
||||||
|
// after Start has no effect on already-running adapters.
|
||||||
|
func (b *Bridge) Add(a adapter.PlatformAdapter) {
|
||||||
|
if a == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.adapters[a.Name()] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start connects every configured adapter, installs the inbound handler, and
|
||||||
|
// launches the read loops. Adapters without credentials (or whose Connect fails)
|
||||||
|
// are logged and left disconnected; they never fail startup. Start blocks only
|
||||||
|
// long enough to connect (bounded by connectTimeout per adapter) and returns.
|
||||||
|
func (b *Bridge) Start() {
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.closed {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.ctx, b.cancel = context.WithCancel(context.Background())
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
for _, a := range b.snapshot() {
|
||||||
|
if !b.configured(a) {
|
||||||
|
b.log.Warn("channel skipped: no credentials", "channel", a.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
connCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||||
|
err := a.Connect(connCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("channel connect failed, leaving disconnected",
|
||||||
|
"channel", a.Name(), "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
b.connected[a.Name()] = true
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
a.SetMessageHandler(b.onMessage)
|
||||||
|
b.wg.Add(1)
|
||||||
|
go b.readLoop(a)
|
||||||
|
b.log.Info("channel connected", "channel", a.Name())
|
||||||
|
}
|
||||||
|
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
|
||||||
|
"registered", len(b.snapshot()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop cancels the read loops, disconnects every adapter and waits for in-flight
|
||||||
|
// message processing to finish. It is idempotent.
|
||||||
|
func (b *Bridge) Stop(ctx context.Context) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.closed {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.closed = true
|
||||||
|
cancel := b.cancel
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
for name, a := range b.snapshot() {
|
||||||
|
if err := a.Disconnect(ctx); err != nil {
|
||||||
|
b.log.Warn("channel disconnect", "channel", name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.wg.Wait()
|
||||||
|
b.log.Info("channel bridge stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected reports whether the named adapter is currently connected.
|
||||||
|
func (b *Bridge) IsConnected(name string) bool {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
return b.connected[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectedCount reports how many adapters are currently connected.
|
||||||
|
func (b *Bridge) ConnectedCount() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
n := 0
|
||||||
|
for _, ok := range b.connected {
|
||||||
|
if ok {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot returns a copy of the registered adapters for a lock-free iteration.
|
||||||
|
func (b *Bridge) snapshot() []adapter.PlatformAdapter {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
out := make([]adapter.PlatformAdapter, 0, len(b.adapters))
|
||||||
|
for _, a := range b.adapters {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// configured reports whether the adapter carries enough configuration to attempt
|
||||||
|
// a Connect. It inspects the adapter's own Config() snapshot for the credential
|
||||||
|
// each platform needs, so a bare (empty) adapter is skipped rather than dialed.
|
||||||
|
// This is what keeps weixin-ilink from falling into its blocking QR-login flow
|
||||||
|
// when no bot token is present.
|
||||||
|
func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
|
||||||
|
cfg := a.Config()
|
||||||
|
switch a.Name() {
|
||||||
|
case "telegram", "weixin-ilink":
|
||||||
|
return cfgStr(cfg, "bot_token") != ""
|
||||||
|
case "qq-obv11":
|
||||||
|
if cfgStr(cfg, "mode") == "client" {
|
||||||
|
return cfgStr(cfg, "remoteURL") != ""
|
||||||
|
}
|
||||||
|
return cfgStr(cfg, "listenAddr") != ""
|
||||||
|
case "webhook":
|
||||||
|
// Inbound-only: no external credential is required to hold the session.
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop runs an adapter's blocking read path until the bridge stops. The emit
|
||||||
|
// sink is wired to onMessage so poll-style adapters (Telegram getUpdates, weixin
|
||||||
|
// long poll) deliver through the same pipeline as push-style adapters.
|
||||||
|
func (b *Bridge) readLoop(a adapter.PlatformAdapter) {
|
||||||
|
defer b.wg.Done()
|
||||||
|
ctx := b.ctx
|
||||||
|
a.ReadMessages(ctx, func(msg adapter.UnifiedMessage) {
|
||||||
|
b.onMessage(ctx, msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// onMessage is the adapter-facing inbound callback. It runs the pipeline in a
|
||||||
|
// bounded goroutine so the adapter's read loop never blocks on a slow agent turn.
|
||||||
|
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.closed {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if err := msg.Validate(); err != nil {
|
||||||
|
b.log.Warn("inbound message invalid, dropped", "platform", msg.Platform, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case b.sem <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the worker under the same lock that Stop uses to set closed, so a
|
||||||
|
// concurrent Stop either sees our Add before it waits, or we observe closed
|
||||||
|
// and bail without leaking a goroutine.
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.closed {
|
||||||
|
b.mu.Unlock()
|
||||||
|
<-b.sem
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.wg.Add(1)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer b.wg.Done()
|
||||||
|
defer func() { <-b.sem }()
|
||||||
|
b.process(ctx, msg)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// process runs the full inbound pipeline: resolve the session/actor, drive the
|
||||||
|
// agent loop with the chat text, and send the loop's reply back through the
|
||||||
|
// channel. Identity is resolved structurally by the session layer; the bridge
|
||||||
|
// never reads message content to decide who the sender is.
|
||||||
|
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||||
|
text := strings.TrimSpace(msg.Content)
|
||||||
|
if text == "" {
|
||||||
|
b.log.Debug("inbound message has no chat text, skipped",
|
||||||
|
"platform", msg.Platform, "channel", msg.ChannelID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("session resolve failed", "platform", msg.Platform,
|
||||||
|
"channel", msg.ChannelID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.log.Debug("inbound → session",
|
||||||
|
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||||
|
"session", sess.SessionID, "actor_role", sess.ActorRole,
|
||||||
|
"env", sess.Env(), "mem_writable", sess.MemWritable)
|
||||||
|
|
||||||
|
res, err := b.run.Run(ctx, sess.SessionID, []model.Message{
|
||||||
|
model.TextMessage(model.RoleUser, text),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("agent run failed", "platform", msg.Platform,
|
||||||
|
"session", sess.SessionID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := strings.TrimSpace(res.Text)
|
||||||
|
if reply == "" {
|
||||||
|
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sess.SessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := b.sendReply(ctx, msg, reply); err != nil {
|
||||||
|
if errors.Is(err, adapter.ErrUnsupported) {
|
||||||
|
b.log.Debug("channel does not support outbound reply, skipped",
|
||||||
|
"platform", msg.Platform, "session", sess.SessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendReply delivers the loop's text back to the channel's source conversation.
|
||||||
|
// It mirrors the inbound message's channel targeting fields so the adapter
|
||||||
|
// resolves the same peer the message came from.
|
||||||
|
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, text string) error {
|
||||||
|
a := b.adapter(in.Platform)
|
||||||
|
if a == nil {
|
||||||
|
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
|
||||||
|
}
|
||||||
|
out := adapter.UnifiedMessage{
|
||||||
|
Platform: in.Platform,
|
||||||
|
ChannelType: in.ChannelType,
|
||||||
|
ChannelID: in.ChannelID,
|
||||||
|
GroupID: in.GroupID,
|
||||||
|
GroupName: in.GroupName,
|
||||||
|
SenderID: in.SenderID,
|
||||||
|
Sender: in.Sender,
|
||||||
|
Content: text,
|
||||||
|
ContentType: adapter.ContentText,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{out}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adapter returns the registered adapter for a platform name, or nil.
|
||||||
|
func (b *Bridge) adapter(platform string) adapter.PlatformAdapter {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
return b.adapters[platform]
|
||||||
|
}
|
||||||
|
|
||||||
|
// cfgStr reads a string field from an adapter config map, returning "" when
|
||||||
|
// absent.
|
||||||
|
func cfgStr(m map[string]any, key string) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := m[key].(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package channelbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeAdapter is a minimal PlatformAdapter that records outbound sends so the
|
||||||
|
// bridge's reply path can be asserted without network I/O.
|
||||||
|
type fakeAdapter struct {
|
||||||
|
name string
|
||||||
|
connected bool
|
||||||
|
sends []adapter.UnifiedMessage
|
||||||
|
handler adapter.MessageHandler
|
||||||
|
sendErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAdapter) Name() string { return f.name }
|
||||||
|
func (f *fakeAdapter) Config() map[string]any {
|
||||||
|
return map[string]any{"bot_token": "configured"}
|
||||||
|
}
|
||||||
|
func (f *fakeAdapter) Connect(context.Context) error { f.connected = true; return nil }
|
||||||
|
func (f *fakeAdapter) Disconnect(context.Context) error {
|
||||||
|
f.connected = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeAdapter) IsConnected() bool { return f.connected }
|
||||||
|
func (f *fakeAdapter) HealthCheck(context.Context) error { return nil }
|
||||||
|
func (f *fakeAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
||||||
|
func (f *fakeAdapter) SendMessage(_ context.Context, resp adapter.UnifiedResponse) error {
|
||||||
|
for _, m := range resp.Messages {
|
||||||
|
f.sends = append(f.sends, m)
|
||||||
|
}
|
||||||
|
return f.sendErr
|
||||||
|
}
|
||||||
|
func (f *fakeAdapter) SendProactive(context.Context, adapter.UnifiedMessage) error { return nil }
|
||||||
|
func (f *fakeAdapter) SetMessageHandler(fn adapter.MessageHandler) { f.handler = fn }
|
||||||
|
func (f *fakeAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
|
||||||
|
func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil }
|
||||||
|
|
||||||
|
// stubChatter records the session id and messages passed to the agent loop.
|
||||||
|
type stubChatter struct {
|
||||||
|
sessionID string
|
||||||
|
messages []model.Message
|
||||||
|
res *loop.Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubChatter) Run(_ context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
|
||||||
|
s.sessionID = sessionID
|
||||||
|
s.messages = msgs
|
||||||
|
return s.res, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubSessions records the inbound message and returns a fixed session.
|
||||||
|
type stubSessions struct {
|
||||||
|
msg adapter.UnifiedMessage
|
||||||
|
owner *user.OwnerProfile
|
||||||
|
sess session.Session
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error) {
|
||||||
|
s.msg = msg
|
||||||
|
s.owner = owner
|
||||||
|
if s.err != nil {
|
||||||
|
return session.Session{}, s.err
|
||||||
|
}
|
||||||
|
return s.sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func directMessage() adapter.UnifiedMessage {
|
||||||
|
return adapter.UnifiedMessage{
|
||||||
|
Platform: "telegram",
|
||||||
|
ChannelID: "100",
|
||||||
|
ChannelType: adapter.ChannelDirect,
|
||||||
|
SenderID: "100",
|
||||||
|
Content: "hello",
|
||||||
|
ContentType: adapter.ContentText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestBridge(t *testing.T) (*Bridge, *stubChatter, *stubSessions, *fakeAdapter) {
|
||||||
|
t.Helper()
|
||||||
|
chat := &stubChatter{res: &loop.Result{Text: "hi back"}}
|
||||||
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
||||||
|
ad := &fakeAdapter{name: "telegram"}
|
||||||
|
|
||||||
|
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
b.Add(ad)
|
||||||
|
return b, chat, sess, ad
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessRunsPipelineAndReplies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
b, chat, sess, ad := newTestBridge(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
b.process(ctx, directMessage())
|
||||||
|
|
||||||
|
if sess.msg.SenderID != "100" {
|
||||||
|
t.Errorf("resolved sender = %q, want 100", sess.msg.SenderID)
|
||||||
|
}
|
||||||
|
if chat.sessionID != "sess-1" {
|
||||||
|
t.Errorf("loop session id = %q, want sess-1", chat.sessionID)
|
||||||
|
}
|
||||||
|
if len(chat.messages) != 1 || chat.messages[0].Role != model.RoleUser {
|
||||||
|
t.Fatalf("loop messages = %+v, want single user message", chat.messages)
|
||||||
|
}
|
||||||
|
if got := chat.messages[0].Content[0].Text; got != "hello" {
|
||||||
|
t.Errorf("loop text = %q, want hello", got)
|
||||||
|
}
|
||||||
|
if len(ad.sends) != 1 {
|
||||||
|
t.Fatalf("send count = %d, want 1", len(ad.sends))
|
||||||
|
}
|
||||||
|
reply := ad.sends[0]
|
||||||
|
if reply.Content != "hi back" || reply.ContentType != adapter.ContentText {
|
||||||
|
t.Errorf("reply = %+v, want text 'hi back'", reply)
|
||||||
|
}
|
||||||
|
if reply.ChannelID != "100" || reply.Platform != "telegram" {
|
||||||
|
t.Errorf("reply target = %+v, want platform telegram channel 100", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessSkipsEmptyText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
b, chat, _, ad := newTestBridge(t)
|
||||||
|
msg := directMessage()
|
||||||
|
msg.Content = " "
|
||||||
|
|
||||||
|
b.process(context.Background(), msg)
|
||||||
|
|
||||||
|
if chat.sessionID != "" {
|
||||||
|
t.Errorf("loop ran for empty content, session = %q", chat.sessionID)
|
||||||
|
}
|
||||||
|
if len(ad.sends) != 0 {
|
||||||
|
t.Errorf("send count = %d, want 0 for empty content", len(ad.sends))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessLoopErrorDoesNotSend(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
b, chat, _, ad := newTestBridge(t)
|
||||||
|
chat.res = nil
|
||||||
|
chat.err = context.DeadlineExceeded
|
||||||
|
|
||||||
|
b.process(context.Background(), directMessage())
|
||||||
|
|
||||||
|
if chat.sessionID != "sess-1" {
|
||||||
|
t.Errorf("loop should still run for valid text, session = %q", chat.sessionID)
|
||||||
|
}
|
||||||
|
if len(ad.sends) != 0 {
|
||||||
|
t.Errorf("send count = %d, want 0 on loop error", len(ad.sends))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
b, chat, _, ad := newTestBridge(t)
|
||||||
|
msg := adapter.UnifiedMessage{
|
||||||
|
Platform: "telegram",
|
||||||
|
ChannelID: "-20",
|
||||||
|
ChannelType: adapter.ChannelGroup,
|
||||||
|
GroupID: "-20",
|
||||||
|
GroupName: "Gang",
|
||||||
|
SenderID: "9",
|
||||||
|
Content: "hello all",
|
||||||
|
ContentType: adapter.ContentText,
|
||||||
|
}
|
||||||
|
chat.res = &loop.Result{Text: "group ack"}
|
||||||
|
|
||||||
|
b.process(context.Background(), msg)
|
||||||
|
|
||||||
|
if len(ad.sends) != 1 {
|
||||||
|
t.Fatalf("send count = %d, want 1", len(ad.sends))
|
||||||
|
}
|
||||||
|
reply := ad.sends[0]
|
||||||
|
if reply.ChannelType != adapter.ChannelGroup || reply.GroupID != "-20" {
|
||||||
|
t.Errorf("group reply target = %+v, want group -20", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessEmptyReplySendsNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
b, chat, _, ad := newTestBridge(t)
|
||||||
|
chat.res = &loop.Result{Text: " "}
|
||||||
|
|
||||||
|
b.process(context.Background(), directMessage())
|
||||||
|
|
||||||
|
if len(ad.sends) != 0 {
|
||||||
|
t.Errorf("send count = %d, want 0 for empty reply", len(ad.sends))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -251,6 +253,150 @@ func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminRagDoc is the wire shape of one knowledge-base document (project-index
|
||||||
|
// entry) in the admin RAG list.
|
||||||
|
type AdminRagDoc struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Desc string `json:"desc,omitempty"`
|
||||||
|
Chunks int `json:"chunks"`
|
||||||
|
Embedded bool `json:"embedded"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRagHit is the wire shape of one RAG retrieval hit.
|
||||||
|
type AdminRagHit struct {
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
DocName string `json:"docName"`
|
||||||
|
Chapter string `json:"chapter,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Semantic bool `json:"semantic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRagResponse is the wire shape of GET /api/v1/admin/rag. Hits is present
|
||||||
|
// only when a query parameter was supplied, so callers distinguish "no search
|
||||||
|
// requested" from "searched and found nothing".
|
||||||
|
type AdminRagResponse struct {
|
||||||
|
Documents []AdminRagDoc `json:"documents"`
|
||||||
|
Hits []AdminRagHit `json:"hits,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ragQueryRequest is the POST /api/v1/admin/rag/query body.
|
||||||
|
type ragQueryRequest struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ragDeleteRequest is the POST /api/v1/admin/rag/delete body.
|
||||||
|
type ragDeleteRequest struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminListRag lists the knowledge-base documents and, when ?query= is present,
|
||||||
|
// returns the matching hits too. Without a wired RagStore it degrades to an empty
|
||||||
|
// list (backward compatible — never a 500).
|
||||||
|
func (s *Server) adminListRag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out := AdminRagResponse{Documents: make([]AdminRagDoc, 0)}
|
||||||
|
if s.rag == nil {
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
docs, err := s.rag.ListDocuments(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("list rag", "err", err)
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_failed", "failed to list knowledge documents"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Documents = ragDocsFrom(docs)
|
||||||
|
if query := strings.TrimSpace(r.URL.Query().Get("query")); query != "" {
|
||||||
|
hits, herr := s.rag.Search(r.Context(), query)
|
||||||
|
if herr != nil {
|
||||||
|
s.log.Error("search rag", "err", herr)
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_search_failed", "failed to search knowledge base"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Hits = ragHitsFrom(hits)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSearchRag runs a knowledge-base query and returns the matching hits.
|
||||||
|
func (s *Server) adminSearchRag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.rag == nil {
|
||||||
|
s.auditClaims(r, "rag.query", "error")
|
||||||
|
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ragQueryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Query) == "" {
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "query is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hits, err := s.rag.Search(r.Context(), req.Query)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("search rag", "err", err)
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_search_failed", "failed to search knowledge base"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.auditClaims(r, "rag.query", "ok")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "hits": ragHitsFrom(hits)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDeleteRag answers the document delete honestly: write operations
|
||||||
|
// (upload/chunk/embed/delete) are milestone M10, so it returns a clear 501 rather
|
||||||
|
// than a fabricated success. The route is audited as an admin write.
|
||||||
|
func (s *Server) adminDeleteRag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.rag == nil {
|
||||||
|
s.auditClaims(r, "rag.delete", "error")
|
||||||
|
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ragDeleteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ID) == "" {
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.rag.DeleteDocument(r.Context(), req.ID); err != nil {
|
||||||
|
s.auditClaims(r, "rag.delete", "error")
|
||||||
|
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库写操作(上传/分块/嵌入/删除)由 M10 后端提供", HTTPCode: http.StatusNotImplemented})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.auditClaims(r, "rag.delete", "ok")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"id": req.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ragDocsFrom maps knowledge docs to their admin wire shape.
|
||||||
|
func ragDocsFrom(docs []knowledge.Doc) []AdminRagDoc {
|
||||||
|
out := make([]AdminRagDoc, 0, len(docs))
|
||||||
|
for _, d := range docs {
|
||||||
|
out = append(out, AdminRagDoc{ID: d.ID, Name: d.Name, Desc: d.Desc, Chunks: d.Chunks, Embedded: d.Embedded})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ragHitsFrom maps knowledge hits to their admin wire shape.
|
||||||
|
func ragHitsFrom(hits []knowledge.Hit) []AdminRagHit {
|
||||||
|
out := make([]AdminRagHit, 0, len(hits))
|
||||||
|
for _, h := range hits {
|
||||||
|
out = append(out, AdminRagHit{
|
||||||
|
DocID: h.DocID,
|
||||||
|
DocName: h.DocName,
|
||||||
|
Chapter: h.Chapter,
|
||||||
|
Text: h.Text,
|
||||||
|
Score: h.Score,
|
||||||
|
Semantic: h.Semantic,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// adminSetPolicy upserts the reachability markers for a capability. It is the
|
// adminSetPolicy upserts the reachability markers for a capability. It is the
|
||||||
// audited admin write example (owner-only, enforced by the route registration).
|
// audited admin write example (owner-only, enforced by the route registration).
|
||||||
func (s *Server) adminSetPolicy(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) adminSetPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
@@ -188,6 +189,21 @@ type stubMemories struct {
|
|||||||
|
|
||||||
func (s *stubMemories) List(ctx context.Context) ([]memory.MemoryItem, error) { return s.list, s.err }
|
func (s *stubMemories) List(ctx context.Context) ([]memory.MemoryItem, error) { return s.list, s.err }
|
||||||
|
|
||||||
|
// stubRag mimics RagStore, returning domain docs/hits the gateway maps to its
|
||||||
|
// admin wire shape.
|
||||||
|
type stubRag struct {
|
||||||
|
docs []knowledge.Doc
|
||||||
|
hits []knowledge.Hit
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRag) ListDocuments(ctx context.Context) ([]knowledge.Doc, error) { return s.docs, s.err }
|
||||||
|
func (s *stubRag) Search(ctx context.Context, query string) ([]knowledge.Hit, error) {
|
||||||
|
return s.hits, s.err
|
||||||
|
}
|
||||||
|
func (s *stubRag) IndexDocument(ctx context.Context, doc knowledge.Doc) error { return s.err }
|
||||||
|
func (s *stubRag) DeleteDocument(ctx context.Context, id string) error { return s.err }
|
||||||
|
|
||||||
// stubIoT mimics IoTStore.
|
// stubIoT mimics IoTStore.
|
||||||
type stubIoT struct {
|
type stubIoT struct {
|
||||||
list []iot.Device
|
list []iot.Device
|
||||||
@@ -1219,3 +1235,132 @@ func reqWithOrigin(origin string) *http.Request {
|
|||||||
func reqWithQuery(k, v string) *http.Request {
|
func reqWithQuery(k, v string) *http.Request {
|
||||||
return httptest.NewRequest(http.MethodGet, "/ws/chat?"+k+"="+v, nil)
|
return httptest.NewRequest(http.MethodGet, "/ws/chat?"+k+"="+v, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- RAG knowledge base ----------------------------------------------------
|
||||||
|
|
||||||
|
func TestAdminRagListsAndSearches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
docs := []knowledge.Doc{{ID: "p1", Name: "开发时间线.md", Desc: "milestones", Chunks: 3, Embedded: true}}
|
||||||
|
hits := []knowledge.Hit{{DocID: "p1", DocName: "开发时间线.md", Chapter: "M4", Text: "RAG 检索测试", Score: 1.0}}
|
||||||
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{docs: docs, hits: hits}})
|
||||||
|
_ = srv
|
||||||
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||||
|
|
||||||
|
// List only.
|
||||||
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var list struct {
|
||||||
|
Documents []AdminRagDoc `json:"documents"`
|
||||||
|
Hits []AdminRagHit `json:"hits"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil {
|
||||||
|
t.Fatalf("decode list: %v (body=%s)", err, body)
|
||||||
|
}
|
||||||
|
if len(list.Documents) != 1 || list.Documents[0].Name != "开发时间线.md" {
|
||||||
|
t.Fatalf("documents = %+v, want one 开发时间线.md", list.Documents)
|
||||||
|
}
|
||||||
|
if list.Hits != nil {
|
||||||
|
t.Fatalf("hits = %+v, want omitted without query", list.Hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List + search via ?query=.
|
||||||
|
resp2, body2 := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag?query=rag", nil, tok)
|
||||||
|
if resp2.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("search status = %d, want 200 (body=%s)", resp2.StatusCode, body2)
|
||||||
|
}
|
||||||
|
var search struct {
|
||||||
|
Documents []AdminRagDoc `json:"documents"`
|
||||||
|
Hits []AdminRagHit `json:"hits"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(decodeBody(t, body2)["data"], &search); err != nil {
|
||||||
|
t.Fatalf("decode search: %v (body=%s)", err, body2)
|
||||||
|
}
|
||||||
|
if len(search.Hits) != 1 || search.Hits[0].DocName != "开发时间线.md" {
|
||||||
|
t.Fatalf("hits = %+v, want one hit", search.Hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminRagQueryPost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
hits := []knowledge.Hit{{DocID: "p1", DocName: "开发时间线.md", Text: "RAG 检索测试", Score: 1.0}}
|
||||||
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{hits: hits}})
|
||||||
|
_ = srv
|
||||||
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||||
|
|
||||||
|
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "rag"}, tok)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Hits []AdminRagHit `json:"hits"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||||
|
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||||
|
}
|
||||||
|
if res.Query != "rag" || len(res.Hits) != 1 {
|
||||||
|
t.Fatalf("response = %+v, want query=rag with 1 hit", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminRagEmptyWhenUnwired(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||||
|
_ = srv
|
||||||
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||||
|
|
||||||
|
// List degrades to empty (never a 500).
|
||||||
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var list struct {
|
||||||
|
Documents []AdminRagDoc `json:"documents"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil {
|
||||||
|
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||||
|
}
|
||||||
|
if len(list.Documents) != 0 {
|
||||||
|
t.Fatalf("documents = %+v, want empty", list.Documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query reports a clear 501 (backend not implemented).
|
||||||
|
resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "rag"}, tok)
|
||||||
|
if resp2.StatusCode != http.StatusNotImplemented {
|
||||||
|
t.Fatalf("query status = %d, want 501 (body=%s)", resp2.StatusCode, body2)
|
||||||
|
}
|
||||||
|
wantErrorShape(t, body2)
|
||||||
|
if !strings.Contains(string(body2), "not_implemented") {
|
||||||
|
t.Fatalf("error body = %s, want not_implemented code", body2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminRagDeleteNotImplemented(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{err: knowledge.ErrWriteUnsupported}})
|
||||||
|
_ = srv
|
||||||
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||||
|
|
||||||
|
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/delete", map[string]any{"id": "p1"}, tok)
|
||||||
|
if resp.StatusCode != http.StatusNotImplemented {
|
||||||
|
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
wantErrorShape(t, body)
|
||||||
|
if !strings.Contains(string(body), "M10") {
|
||||||
|
t.Fatalf("error body = %s, want M10 write message", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminRagDeleteRequiresID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{}})
|
||||||
|
_ = srv
|
||||||
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||||
|
|
||||||
|
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/delete", map[string]any{"id": " "}, tok)
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
@@ -132,6 +133,23 @@ type BackthinkStore interface {
|
|||||||
List(ctx context.Context, limit int) ([]backthink.Record, error)
|
List(ctx context.Context, limit int) ([]backthink.Record, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8).
|
||||||
|
// *runtime/knowledge.Store is the production implementation. Every method is
|
||||||
|
// read-only for this milestone: IndexDocument/DeleteDocument return a clear
|
||||||
|
// "write lands in M10" error rather than a fabricated success.
|
||||||
|
type RagStore interface {
|
||||||
|
// ListDocuments enumerates the knowledge-base documents (project-index
|
||||||
|
// entries).
|
||||||
|
ListDocuments(ctx context.Context) ([]knowledge.Doc, error)
|
||||||
|
// Search answers a knowledge-base query (line-level hierarchy + optional
|
||||||
|
// semantic/vector ranking).
|
||||||
|
Search(ctx context.Context, query string) ([]knowledge.Hit, error)
|
||||||
|
// IndexDocument uploads/chunks/embeds a document. M10; returns an error.
|
||||||
|
IndexDocument(ctx context.Context, doc knowledge.Doc) error
|
||||||
|
// DeleteDocument removes a document. M10; returns an error.
|
||||||
|
DeleteDocument(ctx context.Context, id string) error
|
||||||
|
}
|
||||||
|
|
||||||
// Deps carries the gateway's injected dependencies. Every field is nil-tolerant
|
// Deps carries the gateway's injected dependencies. Every field is nil-tolerant
|
||||||
// except Auth, which is required (authentication backs login and every RBAC
|
// except Auth, which is required (authentication backs login and every RBAC
|
||||||
// decision). Registry/Loop are satisfied by the concrete *capability.Registry and
|
// decision). Registry/Loop are satisfied by the concrete *capability.Registry and
|
||||||
@@ -170,6 +188,9 @@ type Deps struct {
|
|||||||
Users UserStore
|
Users UserStore
|
||||||
// Backthink lists background-think records. Optional.
|
// Backthink lists background-think records. Optional.
|
||||||
Backthink BackthinkStore
|
Backthink BackthinkStore
|
||||||
|
// RAG backs the admin knowledge-base RAG module. Optional; list/search
|
||||||
|
// return empty when unset, and write endpoints report a clear 501.
|
||||||
|
RAG RagStore
|
||||||
// Streaming marks the injected Loop as streaming-capable (its OnStream is
|
// Streaming marks the injected Loop as streaming-capable (its OnStream is
|
||||||
// wired to publish ws.stream_chunk). When set the hub skips its own single
|
// wired to publish ws.stream_chunk). When set the hub skips its own single
|
||||||
// final stream_chunk emission, since deltas already stream through OnStream.
|
// final stream_chunk emission, since deltas already stream through OnStream.
|
||||||
@@ -197,6 +218,7 @@ type Server struct {
|
|||||||
automations AutomationStore
|
automations AutomationStore
|
||||||
users UserStore
|
users UserStore
|
||||||
backthink BackthinkStore
|
backthink BackthinkStore
|
||||||
|
rag RagStore
|
||||||
voice VoiceService
|
voice VoiceService
|
||||||
streaming bool
|
streaming bool
|
||||||
frontend fs.FS
|
frontend fs.FS
|
||||||
@@ -229,6 +251,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
|||||||
automations: deps.Automations,
|
automations: deps.Automations,
|
||||||
users: deps.Users,
|
users: deps.Users,
|
||||||
backthink: deps.Backthink,
|
backthink: deps.Backthink,
|
||||||
|
rag: deps.RAG,
|
||||||
streaming: deps.Streaming,
|
streaming: deps.Streaming,
|
||||||
frontend: deps.Frontend,
|
frontend: deps.Frontend,
|
||||||
log: sourcedLog(deps.Log),
|
log: sourcedLog(deps.Log),
|
||||||
@@ -269,6 +292,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("GET /api/v1/admin/automations", adminAuth(http.HandlerFunc(s.adminListAutomations)))
|
mux.Handle("GET /api/v1/admin/automations", adminAuth(http.HandlerFunc(s.adminListAutomations)))
|
||||||
mux.Handle("GET /api/v1/admin/users", adminAuth(http.HandlerFunc(s.adminListUsers)))
|
mux.Handle("GET /api/v1/admin/users", adminAuth(http.HandlerFunc(s.adminListUsers)))
|
||||||
mux.Handle("GET /api/v1/admin/backthink", adminAuth(http.HandlerFunc(s.adminListBackthink)))
|
mux.Handle("GET /api/v1/admin/backthink", adminAuth(http.HandlerFunc(s.adminListBackthink)))
|
||||||
|
mux.Handle("GET /api/v1/admin/rag", adminAuth(http.HandlerFunc(s.adminListRag)))
|
||||||
|
mux.Handle("POST /api/v1/admin/rag/query", adminAuth(http.HandlerFunc(s.adminSearchRag)))
|
||||||
|
mux.Handle("POST /api/v1/admin/rag/delete", adminAuth(http.HandlerFunc(s.adminDeleteRag)))
|
||||||
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
|
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
|
||||||
|
|
||||||
var h http.Handler = mux
|
var h http.Handler = mux
|
||||||
|
|||||||
Reference in new issue
Block a user