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)
+ }
+}