Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,9 @@ scrape job when Prometheus is shared.

Online recall-quality cards use `densemem_recall_feedback_total` and
`densemem_recall_feedback_quality_score`. They stay at zero until
`RECALL_FEEDBACK_ENABLED=true` registers the `submit_recall_feedback` tool and a
host LLM submits compact feedback for `recall_memory` results. Normal production
recall traffic still contributes request volume, result count, and latency.
recall feedback is enabled from the control portal config panel and a host LLM
submits compact feedback for `recall_memory` results. Normal production recall
traffic still contributes request volume, result count, and latency.

For the disposable demo image, keep the control portal disabled and use the
demo telemetry overlay instead:
Expand Down
49 changes: 47 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
</p>

<p align="center">
<a href="https://zenodo.org/records/20469578"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.20469578.svg" alt="DOI: 10.5281/zenodo.20469578" /></a>
<a href="https://zenodo.org/records/20519039"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.20519039.svg" alt="DOI: 10.5281/zenodo.20519039" /></a>
</p>

Dense-Mem 是一层给 MCP 客户端使用的持久记忆服务。它把来源、claims、facts、
Expand All @@ -50,7 +50,7 @@ Dense-Mem 负责把记忆状态存稳、管住,并返回可以解释给用户

Dense-Mem 也是这篇研究预印本的一部分:
[Governed Enterprise AI Memory Beyond RAG: From Vector Retrieval to Permissioned
Knowledge Graphs](https://zenodo.org/records/20469578)。
Knowledge Graphs](https://zenodo.org/records/20519039)。

## 项目介绍视频

Expand Down Expand Up @@ -123,6 +123,51 @@ URL、model 和 dimensions 提供 OpenAI 默认值:`https://api.openai.com/v1`
`text-embedding-3-small`、`1536`。因此最小本地部署只需要补上 `AI_API_KEY`。
如果切换到其他 embedding provider 或 model,请一起覆盖这些配置。

### Telemetry Overlay

Prometheus telemetry 默认关闭,是可选功能。要为 `/ui` 应用和 control portal
dashboards 收集 usage、performance、verifier token、embedding token、recall
和 promotion metrics,可以把基础 stack 和 telemetry overlay 一起启动:

```bash
curl -fsSLo prometheus.yml \
https://raw.githubusercontent.com/markhuangai/dense-mem/main/examples/prometheus.yml
curl -fsSLo docker-compose.telemetry.yml \
https://raw.githubusercontent.com/markhuangai/dense-mem/main/examples/docker-compose.telemetry.yml

export TELEMETRY_SCRAPE_TOKEN="$(openssl rand -hex 32)"
docker compose -f docker-compose.yml -f docker-compose.telemetry.yml up -d
```

这个 overlay 会在 `127.0.0.1:9090` 启动 Prometheus,保留 30 天样本,把
`TELEMETRY_SCRAPE_TOKEN` 作为 scrape secret 传给 Prometheus,并让 Dense-Mem
使用 `http://prometheus:9090` 查询 telemetry。它也会设置
`TELEMETRY_PROMETHEUS_JOB=dense-mem`,这样在共享 Prometheus 时 dashboard
只查询 `dense-mem` scrape job。

在线 recall quality 卡片使用 `densemem_recall_feedback_total` 和
`densemem_recall_feedback_quality_score`。在 control portal config panel
开启 recall feedback,并且宿主 LLM 为 `recall_memory` 结果提交 compact
feedback 之前,这些卡片会保持为零。正常生产 recall 流量仍然会贡献请求量、
结果数和延迟指标。

对于一次性 demo image,保持 control portal 关闭,改用 demo telemetry
overlay:

```bash
curl -fsSLo prometheus.demo.yml \
https://raw.githubusercontent.com/markhuangai/dense-mem/main/examples/prometheus.demo.yml
curl -fsSLo docker-compose.demo.telemetry.yml \
https://raw.githubusercontent.com/markhuangai/dense-mem/main/examples/docker-compose.demo.telemetry.yml

export TELEMETRY_SCRAPE_TOKEN="$(openssl rand -hex 32)"
docker compose -f docker-compose.yml -f docker-compose.demo.telemetry.yml up -d
```

demo overlay 会在私有 Compose network 上 scrape `demo:8091`,并设置
`TELEMETRY_PROMETHEUS_JOB=dense-mem-demo`。不要把这个 metrics listener
公开到公网。

## 能力对比

| 能力 | Dense-Mem | 文件记忆 | Vector DB | 通用 MCP memory |
Expand Down
17 changes: 9 additions & 8 deletions cmd/demo-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ type scopedReaderAdapter struct {
inner neo4j.ScopedReader
}

const startupTimeout = 5 * time.Minute

func (a *scopedReaderAdapter) ScopedRead(ctx context.Context, profileID string, query string, params map[string]any) (any, []map[string]any, error) {
summary, rows, err := a.inner.ScopedRead(ctx, profileID, query, params)
return summary, rows, err
Expand Down Expand Up @@ -108,9 +110,8 @@ func main() {
validation.SetEmbeddingDimensions(cfg.GetEmbeddingDimensions())
middleware.SetAuthVerificationConcurrency(cfg.AuthVerifyMaxConcurrency)

// Create root context with timeout for startup. A cold database may need
// time to apply migrations before schema-dependent checks can run.
startupCtx, startupCancel := context.WithTimeout(context.Background(), 60*time.Second)
// A cold Neo4j instance can need several minutes to create schema indexes.
startupCtx, startupCancel := context.WithTimeout(context.Background(), startupTimeout)
defer startupCancel()

// Initialize Postgres connection (REQUIRED for production)
Expand Down Expand Up @@ -488,7 +489,7 @@ func main() {
FragmentList: fragmentListSvc,
Recall: recallRegistrySvc,
Metrics: discoverabilityMetrics,
RecallFeedbackEnabled: cfg.GetRecallFeedbackEnabled(),
RecallFeedbackConfig: appConfigService,
KeywordSearch: keywordSearchService,
SemanticSearch: semanticSearchService,
GraphQuery: graphQueryService,
Expand Down Expand Up @@ -554,10 +555,10 @@ func main() {
factRetractHandler := handler.NewFactRetractHandler(factRetractSvc)
communityReadHandler := handler.NewCommunityReadHandler(communityGetSvc)
communityListHandler := handler.NewCommunityListHandler(communityListSvc)
toolCatalogHandler := handler.NewToolCatalogHandler(toolRegistry)
toolReadHandler := handler.NewToolReadHandler(toolRegistry)
toolExecuteHandler := handler.NewToolExecuteHandler(toolRegistry)
mcpHandler := handler.NewMCPHandlerWithLifecycle(toolRegistry, logger, streamLifecycle)
toolCatalogHandler := handler.NewToolCatalogHandlerWithRuntimeConfig(toolRegistry, appConfigService)
toolReadHandler := handler.NewToolReadHandlerWithRuntimeConfig(toolRegistry, appConfigService)
toolExecuteHandler := handler.NewToolExecuteHandlerWithRuntimeConfig(toolRegistry, appConfigService)
mcpHandler := handler.NewMCPHandlerWithLifecycleAndRuntimeConfig(toolRegistry, logger, streamLifecycle, appConfigService)
openAPIAISafeHandler := handler.NewOpenAPIHandler(openAPIGen, openapi.SpecVariantAISafe)
openAPIFullHandler := handler.NewOpenAPIHandler(openAPIGen, openapi.SpecVariantFull)

Expand Down
5 changes: 3 additions & 2 deletions cmd/migrate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/markhuangai/dense-mem/internal/storage/postgres"
)

const migrationTimeout = 5 * time.Minute

func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: %s <up|down|status|backfill-neo4j>\n", os.Args[0])
Expand All @@ -36,8 +38,7 @@ func main() {
log.Fatalf("Failed to load config: %v", err)
}

// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout)
defer cancel()

// Execute command
Expand Down
17 changes: 9 additions & 8 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ type scopedReaderAdapter struct {
inner neo4j.ScopedReader
}

const startupTimeout = 5 * time.Minute

func (a *scopedReaderAdapter) ScopedRead(ctx context.Context, profileID string, query string, params map[string]any) (any, []map[string]any, error) {
summary, rows, err := a.inner.ScopedRead(ctx, profileID, query, params)
return summary, rows, err
Expand Down Expand Up @@ -101,9 +103,8 @@ func main() {
validation.SetEmbeddingDimensions(cfg.GetEmbeddingDimensions())
middleware.SetAuthVerificationConcurrency(cfg.AuthVerifyMaxConcurrency)

// Create root context with timeout for startup. A cold database may need
// time to apply migrations before schema-dependent checks can run.
startupCtx, startupCancel := context.WithTimeout(context.Background(), 60*time.Second)
// A cold Neo4j instance can need several minutes to create schema indexes.
startupCtx, startupCancel := context.WithTimeout(context.Background(), startupTimeout)
defer startupCancel()

// Initialize Postgres connection (REQUIRED for production)
Expand Down Expand Up @@ -436,7 +437,7 @@ func main() {
FragmentList: fragmentListSvc,
Recall: recallRegistrySvc,
Metrics: discoverabilityMetrics,
RecallFeedbackEnabled: cfg.GetRecallFeedbackEnabled(),
RecallFeedbackConfig: appConfigService,
KeywordSearch: keywordSearchService,
SemanticSearch: semanticSearchService,
GraphQuery: graphQueryService,
Expand Down Expand Up @@ -502,10 +503,10 @@ func main() {
factRetractHandler := handler.NewFactRetractHandler(factRetractSvc)
communityReadHandler := handler.NewCommunityReadHandler(communityGetSvc)
communityListHandler := handler.NewCommunityListHandler(communityListSvc)
toolCatalogHandler := handler.NewToolCatalogHandler(toolRegistry)
toolReadHandler := handler.NewToolReadHandler(toolRegistry)
toolExecuteHandler := handler.NewToolExecuteHandler(toolRegistry)
mcpHandler := handler.NewMCPHandlerWithLifecycle(toolRegistry, logger, streamLifecycle)
toolCatalogHandler := handler.NewToolCatalogHandlerWithRuntimeConfig(toolRegistry, appConfigService)
toolReadHandler := handler.NewToolReadHandlerWithRuntimeConfig(toolRegistry, appConfigService)
toolExecuteHandler := handler.NewToolExecuteHandlerWithRuntimeConfig(toolRegistry, appConfigService)
mcpHandler := handler.NewMCPHandlerWithLifecycleAndRuntimeConfig(toolRegistry, logger, streamLifecycle, appConfigService)
openAPIAISafeHandler := handler.NewOpenAPIHandler(openAPIGen, openapi.SpecVariantAISafe)
openAPIFullHandler := handler.NewOpenAPIHandler(openAPIGen, openapi.SpecVariantFull)

Expand Down
7 changes: 0 additions & 7 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ type Config struct {
ClaimWriteRateLimit int
ClaimReadRateLimit int
RecallValidatedClaimWeight float64
RecallFeedbackEnabled bool
PromoteTxTimeoutSeconds int
SkillPackImportHistoryDays int
AICommunityMaxNodes int
Expand Down Expand Up @@ -155,7 +154,6 @@ func (c *Config) GetAIVerifierMaxConcurrency() int { return c.AIVerifierMa
func (c *Config) GetClaimWriteRateLimit() int { return c.ClaimWriteRateLimit }
func (c *Config) GetClaimReadRateLimit() int { return c.ClaimReadRateLimit }
func (c *Config) GetRecallValidatedClaimWeight() float64 { return c.RecallValidatedClaimWeight }
func (c *Config) GetRecallFeedbackEnabled() bool { return c.RecallFeedbackEnabled }
func (c *Config) GetPromoteTxTimeoutSeconds() int { return c.PromoteTxTimeoutSeconds }
func (c *Config) GetSkillPackImportHistoryDays() int { return c.SkillPackImportHistoryDays }
func (c *Config) GetAICommunityMaxNodes() int { return c.AICommunityMaxNodes }
Expand Down Expand Up @@ -400,11 +398,6 @@ func Load() (Config, error) {
return cfg, err
}

cfg.RecallFeedbackEnabled, err = parseBoolOrDefault("RECALL_FEEDBACK_ENABLED", false)
if err != nil {
return cfg, err
}

cfg.PromoteTxTimeoutSeconds, err = parseIntOrDefault("PROMOTE_TX_TIMEOUT_SECONDS", 10)
if err != nil {
return cfg, err
Expand Down
19 changes: 0 additions & 19 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ func clearEnv() {
"CLAIM_WRITE_RATE_LIMIT",
"CLAIM_READ_RATE_LIMIT",
"RECALL_VALIDATED_CLAIM_WEIGHT",
"RECALL_FEEDBACK_ENABLED",
"PROMOTE_TX_TIMEOUT_SECONDS",
"AI_COMMUNITY_MAX_NODES",
"CONTROL_HTTP_ADDR",
Expand Down Expand Up @@ -134,9 +133,6 @@ func TestLoadDefaults(t *testing.T) {
if cfg.TelemetryPrometheusJob != "" {
t.Errorf("TelemetryPrometheusJob default = %q, want empty", cfg.TelemetryPrometheusJob)
}
if cfg.GetRecallFeedbackEnabled() {
t.Error("GetRecallFeedbackEnabled() default = true, want false")
}
}

func TestLoadTelemetryConfig(t *testing.T) {
Expand Down Expand Up @@ -193,21 +189,6 @@ func TestLoadTelemetryConfigRequiresScrapeToken(t *testing.T) {
}
}

func TestLoadRecallFeedbackConfig(t *testing.T) {
clearEnv()
setRequiredEnv()
os.Setenv("RECALL_FEEDBACK_ENABLED", "true")

cfg, err := Load()
if err != nil {
t.Fatalf("Load() returned unexpected error: %v", err)
}

if !cfg.GetRecallFeedbackEnabled() {
t.Fatal("GetRecallFeedbackEnabled() = false, want true")
}
}

func TestLoadValidation_MissingPostgresDSN(t *testing.T) {
clearEnv()
os.Setenv("NEO4J_URI", "bolt://localhost:7687")
Expand Down
23 changes: 23 additions & 0 deletions internal/domain/app_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const (
AppConfigCommunityDetectionJitterSeconds = "COMMUNITY_DETECTION_JITTER_SECONDS"

AppConfigOperationLogRetentionDays = "OPERATION_LOG_RETENTION_DAYS"

AppConfigRecallFeedbackEnabled = "RECALL_FEEDBACK_ENABLED"
)

type AppConfigEntry struct {
Expand Down Expand Up @@ -89,3 +91,24 @@ type OperationLogConfigItem struct {
type OperationLogRuntimeConfig struct {
RetentionDays int `json:"retention_days"`
}

// RecallFeedbackConfigSettings is the editable runtime configuration for
// host-LLM recall feedback telemetry.
type RecallFeedbackConfigSettings struct {
UpdateTime string `json:"update_time"`
Items []RecallFeedbackConfigItem `json:"items"`
Effective RecallFeedbackRuntimeConfig `json:"effective"`
}

// RecallFeedbackConfigItem is one control-panel editable recall feedback config entry.
type RecallFeedbackConfigItem struct {
Key string `json:"key"`
Value string `json:"value"`
EffectiveValue string `json:"effective_value"`
UpdatedAt time.Time `json:"updated_at"`
}

// RecallFeedbackRuntimeConfig is the effective recall feedback runtime config.
type RecallFeedbackRuntimeConfig struct {
Enabled bool `json:"enabled"`
}
2 changes: 2 additions & 0 deletions internal/http/control_portal.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ func NewControlPortalServerWithMetricsAndTelemetry(
api.PATCH("/config/community-detection", control.updateCommunityDetectionConfig)
api.GET("/config/operation-logs", control.getOperationLogConfig)
api.PATCH("/config/operation-logs", control.updateOperationLogConfig)
api.GET("/config/recall-feedback", control.getRecallFeedbackConfig)
api.PATCH("/config/recall-feedback", control.updateRecallFeedbackConfig)
}
if telemetry.SSO != nil {
api.GET("/sso/providers", control.listSSOProviders)
Expand Down
63 changes: 63 additions & 0 deletions internal/http/control_portal_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,39 @@ func (h *controlPortalHandler) updateOperationLogConfig(c echo.Context) error {
return c.JSON(nethttp.StatusOK, map[string]any{"data": toControlOperationLogConfig(settings)})
}

func (h *controlPortalHandler) getRecallFeedbackConfig(c echo.Context) error {
if h.appConfig == nil {
return httperr.New(httperr.SERVICE_UNAVAILABLE, "app config service unavailable")
}
settings, err := h.appConfig.GetRecallFeedbackSettings(c.Request().Context())
if err != nil {
return err
}
return c.JSON(nethttp.StatusOK, map[string]any{"data": toControlRecallFeedbackConfig(settings)})
}

func (h *controlPortalHandler) updateRecallFeedbackConfig(c echo.Context) error {
if h.appConfig == nil {
return httperr.New(httperr.SERVICE_UNAVAILABLE, "app config service unavailable")
}
var body controlRecallFeedbackConfigRequest
if err := c.Bind(&body); err != nil {
return httperr.New(httperr.VALIDATION_ERROR, "malformed JSON body")
}
values := make(map[string]string, len(body.Items))
for _, item := range body.Items {
values[item.Key] = item.Value
}
settings, err := h.appConfig.UpdateRecallFeedbackSettings(c.Request().Context(), values, "control", c.RealIP(), "")
if err != nil {
if errors.Is(err, service.ErrInvalidAppConfig) {
return httperr.New(httperr.VALIDATION_ERROR, err.Error())
}
return err
}
return c.JSON(nethttp.StatusOK, map[string]any{"data": toControlRecallFeedbackConfig(settings)})
}

type controlGeneralConfigRequest struct {
Items []controlSSOConfigItemRequest `json:"items"`
}
Expand All @@ -197,6 +230,10 @@ type controlOperationLogConfigRequest struct {
Items []controlSSOConfigItemRequest `json:"items"`
}

type controlRecallFeedbackConfigRequest struct {
Items []controlSSOConfigItemRequest `json:"items"`
}

type controlSSOConfigItemRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Expand Down Expand Up @@ -231,6 +268,12 @@ type controlOperationLogConfigResponse struct {
Effective domain.OperationLogRuntimeConfig `json:"effective"`
}

type controlRecallFeedbackConfigResponse struct {
UpdateTime string `json:"update_time"`
Items []controlSSOConfigItemResponse `json:"items"`
Effective domain.RecallFeedbackRuntimeConfig `json:"effective"`
}

type controlSSOConfigItemResponse struct {
Key string `json:"key"`
Value string `json:"value"`
Expand Down Expand Up @@ -336,3 +379,23 @@ func toControlOperationLogConfig(settings *domain.OperationLogConfigSettings) co
Effective: settings.Effective,
}
}

func toControlRecallFeedbackConfig(settings *domain.RecallFeedbackConfigSettings) controlRecallFeedbackConfigResponse {
if settings == nil {
return controlRecallFeedbackConfigResponse{Items: []controlSSOConfigItemResponse{}}
}
items := make([]controlSSOConfigItemResponse, 0, len(settings.Items))
for _, item := range settings.Items {
items = append(items, controlSSOConfigItemResponse{
Key: item.Key,
Value: item.Value,
EffectiveValue: item.EffectiveValue,
UpdatedAt: item.UpdatedAt.Format(time.RFC3339),
})
}
return controlRecallFeedbackConfigResponse{
UpdateTime: settings.UpdateTime,
Items: items,
Effective: settings.Effective,
}
}
Loading
Loading