diff --git a/client/system.go b/client/system.go index 44cd25e16..392980444 100644 --- a/client/system.go +++ b/client/system.go @@ -19,6 +19,8 @@ type SystemInfo struct { MinioEnabled bool `json:"minio_enabled,omitempty"` DBVersion string `json:"db_version,omitempty"` DBMigrationError string `json:"db_migration_error,omitempty"` + StartedAt string `json:"started_at,omitempty"` + UptimeSeconds int64 `json:"uptime_seconds,omitempty"` } // ParserEngine represents a document parser engine diff --git a/cmd/server/main.go b/cmd/server/main.go index 1e6789abe..fc7efdc14 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -53,6 +53,7 @@ func main() { // Print the env banner before container build so operators see what // config landed even when DB / storage init fails. runtime.LogStartupEnv(context.Background()) + runtime.MarkServerStarted() // Build dependency injection container c := container.BuildContainer(runtime.GetContainer()) diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts index cf4de5dc2..4c0474cdf 100644 --- a/frontend/src/api/system/index.ts +++ b/frontend/src/api/system/index.ts @@ -15,6 +15,10 @@ export interface SystemInfo { * When non-empty, the system info view should surface a troubleshooting * banner (see docs/migration-troubleshooting.md). */ db_migration_error?: string + /** Server process boot time (RFC3339, UTC). */ + started_at?: string + /** Seconds since process start. */ + uptime_seconds?: number } export interface PlaceholderDefinition { diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 0b5264aca..d75956641 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -2984,6 +2984,14 @@ export default { buildTimeDescription: 'Time when the system was built', goVersionLabel: 'Go Version', goVersionDescription: 'Go language version used by the backend', + startedAtLabel: 'Service started at', + startedAtDescription: 'When the current backend process last started', + uptimeLabel: 'Uptime', + uptimeDescription: 'How long the service has been running since this start', + uptimeDays: '{n}d', + uptimeHours: '{n}h', + uptimeMinutes: '{n}m', + uptimeSeconds: '{n}s', dbVersionLabel: 'Database Version', dbVersionDescription: 'Current database migration version', dbMigrationFailedTag: 'Migration failed', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 8a6ba1318..2051f1011 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -2216,6 +2216,14 @@ export default { buildTimeDescription: "시스템이 빌드된 시간", goVersionLabel: "Go 버전", goVersionDescription: "백엔드에서 사용하는 Go 언어 버전", + startedAtLabel: "서비스 시작 시각", + startedAtDescription: "현재 백엔드 프로세스가 마지막으로 시작된 시각", + uptimeLabel: "가동 시간", + uptimeDescription: "이번 시작 이후 연속 실행된 시간", + uptimeDays: "{n}일", + uptimeHours: "{n}시간", + uptimeMinutes: "{n}분", + uptimeSeconds: "{n}초", dbVersionLabel: "데이터베이스 버전", dbVersionDescription: "현재 데이터베이스 마이그레이션 버전", dbMigrationFailedTag: "마이그레이션 실패", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 4d94923cb..97e60c58f 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -1935,6 +1935,14 @@ export default { buildTimeDescription: 'Время, когда система была собрана', goVersionLabel: 'Версия Go', goVersionDescription: 'Версия языка Go, используемая backend', + startedAtLabel: 'Время запуска сервиса', + startedAtDescription: 'Момент последнего запуска текущего процесса backend', + uptimeLabel: 'Время работы', + uptimeDescription: 'Непрерывное время работы с момента этого запуска', + uptimeDays: '{n} д', + uptimeHours: '{n} ч', + uptimeMinutes: '{n} мин', + uptimeSeconds: '{n} с', dbVersionLabel: 'Версия базы данных', dbVersionDescription: 'Текущая версия миграции базы данных', dbMigrationFailedTag: 'Миграция не удалась', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 13483f93e..e755d8bf9 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2202,6 +2202,14 @@ export default { buildTimeDescription: "系统构建的时间", goVersionLabel: "Go 版本", goVersionDescription: "后端使用的 Go 语言版本", + startedAtLabel: "服务启动时间", + startedAtDescription: "当前后端进程最近一次启动的时刻", + uptimeLabel: "运行时长", + uptimeDescription: "自本次启动以来的连续运行时间", + uptimeDays: "{n} 天", + uptimeHours: "{n} 小时", + uptimeMinutes: "{n} 分钟", + uptimeSeconds: "{n} 秒", dbVersionLabel: "数据库版本", dbVersionDescription: "当前数据库迁移版本号", dbMigrationFailedTag: "迁移失败", diff --git a/frontend/src/views/settings/SystemInfo.vue b/frontend/src/views/settings/SystemInfo.vue index e57f4efba..156ba2640 100644 --- a/frontend/src/views/settings/SystemInfo.vue +++ b/frontend/src/views/settings/SystemInfo.vue @@ -87,6 +87,28 @@ + +
+
+ +

{{ $t('system.startedAtDescription') }}

+
+
+ {{ formatStartedAt(systemInfo.started_at) }} +
+
+ + +
+
+ +

{{ $t('system.uptimeDescription') }}

+
+
+ {{ formatUptime(displayUptimeSeconds) }} +
+
+
@@ -170,11 +192,11 @@ diff --git a/internal/agent/tools/grep_chunks.go b/internal/agent/tools/grep_chunks.go index 4fcfcbeca..0e261a60a 100644 --- a/internal/agent/tools/grep_chunks.go +++ b/internal/agent/tools/grep_chunks.go @@ -169,6 +169,10 @@ func (t *GrepChunksTool) Execute(ctx context.Context, args json.RawMessage) (*ty } sort.Slice(finalResults, func(i, j int) bool { + // Title matches rank above everything else (see chunkWithTitle.TitleMatch). + if finalResults[i].TitleMatch != finalResults[j].TitleMatch { + return finalResults[i].TitleMatch + } if finalResults[i].MatchedPatterns != finalResults[j].MatchedPatterns { return finalResults[i].MatchedPatterns > finalResults[j].MatchedPatterns } @@ -213,7 +217,13 @@ type chunkWithTitle struct { KnowledgeTitle string `json:"knowledge_title" gorm:"column:knowledge_title"` MatchScore float64 `json:"match_score" gorm:"column:match_score"` MatchedPatterns int `json:"matched_patterns"` - TotalChunkCount int `json:"total_chunk_count" gorm:"column:total_chunk_count"` + // TitleMatch is true when the query regex matches the owning knowledge's + // TITLE (not just chunk body). A doc literally titled "图片素材" is the most + // on-topic hit for the query "图片素材", yet its body may mention the term far + // less than long FAQ docs that repeat it — so title hits are floated to the + // very top of both the per-chunk and per-knowledge ordering. + TitleMatch bool `json:"title_match"` + TotalChunkCount int `json:"total_chunk_count" gorm:"column:total_chunk_count"` } // regexOperatorForDialect returns the SQL operator used to apply a POSIX @@ -282,8 +292,12 @@ func (t *GrepChunksTool) searchChunks( var regexConditions []string var regexArgs []interface{} for _, q := range queries { - regexConditions = append(regexConditions, fmt.Sprintf("chunks.content %s ?", regexOp)) - regexArgs = append(regexArgs, q) + // Match the regex against either the chunk body OR the owning + // knowledge's title, so a doc whose title matches (e.g. titled + // "图片素材") surfaces even when its body rarely repeats the term. + regexConditions = append(regexConditions, + fmt.Sprintf("(chunks.content %s ? OR knowledges.title %s ?)", regexOp, regexOp)) + regexArgs = append(regexArgs, q, q) } query = query.Where("("+strings.Join(regexConditions, " OR ")+")", regexArgs...) @@ -415,6 +429,7 @@ type knowledgeAggregation struct { KnowledgeID string `json:"knowledge_id"` KnowledgeBaseID string `json:"knowledge_base_id"` KnowledgeTitle string `json:"knowledge_title"` + TitleMatch bool `json:"title_match"` ChunkHitCount int `json:"chunk_hit_count"` TotalChunkCount int `json:"total_chunk_count"` PatternCounts map[string]int `json:"pattern_counts"` @@ -465,6 +480,9 @@ func (t *GrepChunksTool) aggregateByKnowledge( entry := aggregated[knowledgeID] entry.ChunkHitCount++ + if chunk.TitleMatch { + entry.TitleMatch = true + } occurrences := countRegexHits(chunk.Content, compiled, queryKeys) for _, q := range queryKeys { @@ -490,6 +508,11 @@ func (t *GrepChunksTool) aggregateByKnowledge( } sort.Slice(resultSlice, func(i, j int) bool { + // A knowledge whose TITLE matches the query is the most on-topic hit + // and always ranks first, regardless of body keyword frequency. + if resultSlice[i].TitleMatch != resultSlice[j].TitleMatch { + return resultSlice[i].TitleMatch + } if resultSlice[i].DistinctPatterns != resultSlice[j].DistinctPatterns { return resultSlice[i].DistinctPatterns > resultSlice[j].DistinctPatterns } @@ -504,6 +527,20 @@ func (t *GrepChunksTool) aggregateByKnowledge( return resultSlice } +// regexMatchesAny reports whether text matches at least one of the compiled +// patterns. Used to flag title hits without counting occurrences. +func regexMatchesAny(text string, compiled []*regexp.Regexp) bool { + if text == "" || len(compiled) == 0 { + return false + } + for _, re := range compiled { + if re != nil && re.MatchString(text) { + return true + } + } + return false +} + // countRegexHits returns the total number of matches per (compiled) pattern // within content, keyed by the original (uncompiled) pattern string. func countRegexHits(content string, compiled []*regexp.Regexp, patterns []string) map[string]int { @@ -656,6 +693,20 @@ func (t *GrepChunksTool) scoreChunks( for i := range results { scored[i] = results[i] score, patternCount := t.calculateMatchScore(results[i].Content, compiled) + // Title-aware boost: when the owning knowledge's TITLE matches the + // query, treat the chunk as highly relevant regardless of how often + // the body repeats the term. The boost keeps such chunks alive through + // MMR selection; TitleMatch is the primary sort key downstream so they + // also land at the very top of the final ordering. + if regexMatchesAny(results[i].KnowledgeTitle, compiled) { + scored[i].TitleMatch = true + score = math.Min(score+0.5, 1.0) + if patternCount == 0 { + // Title-only recall (body never matched the regex) still counts + // as one matched pattern so it isn't sorted below true zeros. + patternCount = 1 + } + } scored[i].MatchScore = score scored[i].MatchedPatterns = patternCount } diff --git a/internal/handler/system.go b/internal/handler/system.go index 5ee2ac404..9b719d7f8 100644 --- a/internal/handler/system.go +++ b/internal/handler/system.go @@ -11,6 +11,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/Tencent/WeKnora/internal/application/repository" "github.com/Tencent/WeKnora/internal/application/service" @@ -19,6 +20,7 @@ import ( "github.com/Tencent/WeKnora/internal/database" "github.com/Tencent/WeKnora/internal/infrastructure/docparser" "github.com/Tencent/WeKnora/internal/logger" + "github.com/Tencent/WeKnora/internal/runtime" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" secutils "github.com/Tencent/WeKnora/internal/utils" @@ -121,6 +123,10 @@ type GetSystemInfoResponse struct { // succeeded; non-empty values let the frontend surface a troubleshooting // banner instead of silently hiding the DB version row (see issue #1319). DBMigrationError string `json:"db_migration_error,omitempty"` + // StartedAt is the server process boot time (RFC3339, UTC). + StartedAt string `json:"started_at,omitempty"` + // UptimeSeconds is seconds elapsed since process start. + UptimeSeconds int64 `json:"uptime_seconds,omitempty"` } // 编译时注入的版本信息 @@ -172,6 +178,13 @@ func (h *SystemHandler) GetSystemInfo(c *gin.Context) { dbVersion = "unknown" } + var startedAt string + var uptimeSec int64 + if boot := runtime.ServerStartedAt(); !boot.IsZero() { + startedAt = boot.UTC().Format(time.RFC3339) + uptimeSec = int64(runtime.ServerUptime().Seconds()) + } + response := GetSystemInfoResponse{ Version: Version, Edition: Edition, @@ -184,6 +197,8 @@ func (h *SystemHandler) GetSystemInfo(c *gin.Context) { MinioEnabled: minioEnabled, DBVersion: dbVersion, DBMigrationError: dbMigrationErr, + StartedAt: startedAt, + UptimeSeconds: uptimeSec, } logger.Info(ctx, "System info retrieved successfully") diff --git a/internal/runtime/server.go b/internal/runtime/server.go new file mode 100644 index 000000000..778ff74d6 --- /dev/null +++ b/internal/runtime/server.go @@ -0,0 +1,27 @@ +package runtime + +import "time" + +// serverStartedAt is set once at process boot (see MarkServerStarted). +var serverStartedAt time.Time + +// MarkServerStarted records the instant the WeKnora server process started. +// Call once from main() before heavy init so uptime reflects the full process +// lifetime, not merely when the HTTP listener binds. +func MarkServerStarted() { + serverStartedAt = time.Now().UTC() +} + +// ServerStartedAt returns the boot instant in UTC. Zero when MarkServerStarted +// was not called (e.g. some tests); callers should treat zero as unknown. +func ServerStartedAt() time.Time { + return serverStartedAt +} + +// ServerUptime returns elapsed time since MarkServerStarted. Zero when unset. +func ServerUptime() time.Duration { + if serverStartedAt.IsZero() { + return 0 + } + return time.Since(serverStartedAt) +} diff --git a/internal/runtime/server_test.go b/internal/runtime/server_test.go new file mode 100644 index 000000000..b5992eaa5 --- /dev/null +++ b/internal/runtime/server_test.go @@ -0,0 +1,18 @@ +package runtime + +import ( + "testing" + "time" +) + +func TestMarkServerStartedAndUptime(t *testing.T) { + MarkServerStarted() + boot := ServerStartedAt() + if boot.IsZero() { + t.Fatal("ServerStartedAt should be set after MarkServerStarted") + } + time.Sleep(2 * time.Millisecond) + if got := ServerUptime(); got <= 0 { + t.Fatalf("ServerUptime() = %v, want > 0", got) + } +}