diff --git a/app-go/app-auth/internal/service/auth_service.go b/app-go/app-auth/internal/service/auth_service.go index c2ee197..b22a244 100644 --- a/app-go/app-auth/internal/service/auth_service.go +++ b/app-go/app-auth/internal/service/auth_service.go @@ -6,6 +6,7 @@ import ( "log/slog" "regexp" "strings" + "sync" "time" "app-auth/internal/config" @@ -103,20 +104,40 @@ type AuthService struct { oauth *repo.OAuthRepo devices *repo.DeviceRepo rbac *repo.RBACRepo + + // bilibili binding status is resolved with a live api.bilibili.com call, + // which can take seconds on some networks. The account page must not block + // on it: cache per uid for a short TTL and hard-cap the upstream call. + biliMu sync.Mutex + biliCache map[int64]biliCacheEntry +} + +type biliCacheEntry struct { + status BilibiliStatus + at time.Time } +const ( + // short enough that a fresh bind shows up quickly, long enough that repeat + // account-page opens do not hammer the B站 API + biliStatusTTL = 60 * time.Second + // the live check is best-effort: beyond this the page gets a fallback status + biliStatusTimeout = 2500 * time.Millisecond +) + func NewAuthService(db *gorm.DB, cfg *config.Config, cipher *security.Cipher, sf *security.Snowflake, tokens *TokenService, rbac *RBACService) *AuthService { return &AuthService{ - db: db, - cfg: cfg, - cipher: cipher, - sf: sf, - tokens: tokens, - users: repo.NewUserRepo(), - oauth: repo.NewOAuthRepo(), - devices: repo.NewDeviceRepo(), - rbac: rbac.repoRef(), + db: db, + cfg: cfg, + cipher: cipher, + sf: sf, + tokens: tokens, + users: repo.NewUserRepo(), + oauth: repo.NewOAuthRepo(), + devices: repo.NewDeviceRepo(), + rbac: rbac.repoRef(), + biliCache: make(map[int64]biliCacheEntry), } } @@ -218,6 +239,8 @@ func (s *AuthService) EnsureUserFromOAuth(provider, providerUID string, data *Pr slog.Info("[USER] created user", "uid", uid, "provider", provider) } + s.invalidateBiliStatus(uid) + token, err := s.tokens.Issue(context.Background(), uid, deviceID, ip, userAgent) if err != nil { return 0, "", err @@ -229,6 +252,7 @@ func (s *AuthService) EnsureUserFromOAuth(provider, providerUID string, data *Pr // user. Refuses bindings owned by another user. func (s *AuthService) BindOAuthToUser(uid int64, provider, providerUID string, data *ProviderData, profile *ProfilePatch) error { + s.invalidateBiliStatus(uid) // a bind/refresh must not serve a stale cached status user, err := s.users.GetByUID(s.db, uid) if err != nil { return err @@ -788,12 +812,54 @@ func (s *AuthService) GetSecurityInfo(uid int64, passport *BilibiliPassport) (*S "is_primary": primary, }) if b.Provider == "bilibili" { - out.Bilibili = s.bilibiliStatus(&b, passport) + out.Bilibili = s.bilibiliStatusCached(uid, &b, passport) } } return out, nil } +// bilibiliStatusCached wraps the live B站 check with a per-uid TTL cache and a +// hard deadline. GET /auth/security is on the account page's critical path; the +// upstream call was observed taking 2–8s (api.bilibili.com reachability), which +// made the whole page wait on it. +func (s *AuthService) bilibiliStatusCached(uid int64, b *model.UserOAuth, passport *BilibiliPassport) BilibiliStatus { + s.biliMu.Lock() + if entry, ok := s.biliCache[uid]; ok && time.Since(entry.at) < biliStatusTTL { + s.biliMu.Unlock() + return entry.status + } + s.biliMu.Unlock() + + ch := make(chan BilibiliStatus, 1) + go func() { ch <- s.bilibiliStatus(b, passport) }() + + select { + case st := <-ch: + s.biliMu.Lock() + if len(s.biliCache) > 5000 { + s.biliCache = make(map[int64]biliCacheEntry) + } + s.biliCache[uid] = biliCacheEntry{status: st, at: time.Now()} + s.biliMu.Unlock() + return st + case <-time.After(biliStatusTimeout): + st := BilibiliStatus{Bound: true, Valid: false, Message: "B站状态检查超时,请稍后重试"} + if isDigits(b.ProviderUID) { + mid := parseInt64(b.ProviderUID) + st.Mid = &mid + } + // not cached: the next open retries the live check + return st + } +} + +// invalidateBiliStatus drops the cached binding status (called on bind/unbind). +func (s *AuthService) invalidateBiliStatus(uid int64) { + s.biliMu.Lock() + delete(s.biliCache, uid) + s.biliMu.Unlock() +} + func (s *AuthService) bilibiliStatus(b *model.UserOAuth, passport *BilibiliPassport) BilibiliStatus { status := BilibiliStatus{Bound: true, Message: "B站登录已失效,请重新扫码"} if isDigits(b.ProviderUID) { diff --git a/app-go/app-cloud/internal/dto/cloud_ai.go b/app-go/app-cloud/internal/dto/cloud_ai.go index 622c4e9..eeda412 100644 --- a/app-go/app-cloud/internal/dto/cloud_ai.go +++ b/app-go/app-cloud/internal/dto/cloud_ai.go @@ -52,3 +52,34 @@ type SemanticSearchResponse struct { type SimilarResponse struct { Items []SemanticHit `json:"items"` } + +// KGDocumentsItem is one parsed cloud document listed for cloud-driven KG +// builds. The body text is deliberately absent (fetch it per document); the +// listing must stay small even for users with thousands of documents. +type KGDocumentsItem struct { + UploadUUID string `json:"upload_uuid"` + Title string `json:"title"` + ContentHash string `json:"content_hash"` + // SizeChars is the document's character count (0 = recorded size unknown, + // i.e. parsed before size_chars was persisted). The per-document endpoint + // always reports the exact value. + SizeChars int `json:"size_chars"` +} + +// KGDocumentsResponse is one page of the KG document listing. next_cursor is +// the last upload_uuid of a full page and null on the last page. +type KGDocumentsResponse struct { + Items []KGDocumentsItem `json:"items"` + NextCursor *string `json:"next_cursor"` +} + +// KGDocumentResponse is one parsed document with its body text (truncated +// server-side to the KG extraction budget). size_chars counts the full text, +// not the returned slice, so callers can tell truncated documents apart. +type KGDocumentResponse struct { + UploadUUID string `json:"upload_uuid"` + Title string `json:"title"` + ContentHash string `json:"content_hash"` + SizeChars int `json:"size_chars"` + Text string `json:"text"` +} diff --git a/app-go/app-cloud/internal/infra/mongostore/mongostore.go b/app-go/app-cloud/internal/infra/mongostore/mongostore.go index 08dd19c..d8b82f1 100644 --- a/app-go/app-cloud/internal/infra/mongostore/mongostore.go +++ b/app-go/app-cloud/internal/infra/mongostore/mongostore.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" "time" + "unicode/utf8" "app-cloud/internal/config" @@ -100,6 +101,127 @@ func (m *MongoStore) DocumentPreview(ctx context.Context, uid int64, uploadUUID return cleaned[offset:end], total, doc.DocMeta } +// ParsedDocumentMeta is one parsed drive document listed for cloud +// knowledge-graph builds: identity and content version only, never the body. +type ParsedDocumentMeta struct { + UploadUUID string + Title string + ContentHash string + // SizeChars is the character count recorded when the text was persisted. + // The listing deliberately excludes `content`, so no body text is loaded + // (or measured) on the read path; documents written before size_chars + // existed report 0. The per-document endpoint serves the exact value. + SizeChars int +} + +// ParsedDocument is one parsed drive document including its full text. +type ParsedDocument struct { + UploadUUID string + Title string + ContentHash string + SizeChars int + Text string +} + +// ListParsedDocuments pages one user's parsed documents that have usable body +// text, ordered by upload_uuid ascending; afterUploadUUID (empty = first page) +// is the exclusive lower bound, matching the upload_uuid cursor semantics of +// the KG M2M listing. The projection skips `content`: a 100-document page must +// not drag megabytes of text over the wire — size_chars is read from the value +// persisted by UpsertParsedDocument instead. +func (m *MongoStore) ListParsedDocuments(ctx context.Context, uid int64, + afterUploadUUID string, limit int) ([]ParsedDocumentMeta, error) { + if m == nil || m.db == nil || limit <= 0 { + // Disabled store / no page size: nil-safe empty page, like the other + // readers (the HTTP layer clamps limit into 1..100). + return nil, nil + } + filter := bson.M{ + "uid": uid, + "content": usableTextFilter(), + "content_hash": usableTextFilter(), + } + if afterUploadUUID != "" { + filter["upload_uuid"] = bson.M{"$gt": afterUploadUUID} + } + cur, err := m.db.Collection("cloud_drive_documents").Find(ctx, filter, + options.Find(). + SetSort(bson.M{"upload_uuid": 1}). + SetLimit(int64(limit)). + SetProjection(bson.M{ + "upload_uuid": 1, "title": 1, "content_hash": 1, "size_chars": 1, "_id": 0, + }), + ) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + out := make([]ParsedDocumentMeta, 0, limit) + for cur.Next(ctx) { + var row struct { + UploadUUID string `bson:"upload_uuid"` + Title string `bson:"title"` + ContentHash string `bson:"content_hash"` + SizeChars int `bson:"size_chars"` + } + if err := cur.Decode(&row); err != nil { + return nil, err + } + out = append(out, ParsedDocumentMeta{ + UploadUUID: row.UploadUUID, + Title: row.Title, + ContentHash: row.ContentHash, + SizeChars: row.SizeChars, + }) + } + return out, cur.Err() +} + +// GetParsedDocument returns one user's parsed document with its full text. +// (nil, nil) means "absent, no body text, or no content hash" — the HTTP layer +// maps that to 404 without distinguishing the cases (same as the drive detail +// contract). A nil/disabled store behaves identically. +func (m *MongoStore) GetParsedDocument(ctx context.Context, uid int64, + uploadUUID string) (*ParsedDocument, error) { + if m == nil || m.db == nil || uploadUUID == "" { + return nil, nil + } + var row struct { + UploadUUID string `bson:"upload_uuid"` + Title string `bson:"title"` + ContentHash string `bson:"content_hash"` + Content string `bson:"content"` + SizeChars int `bson:"size_chars"` + } + err := m.db.Collection("cloud_drive_documents").FindOne(ctx, + bson.M{"upload_uuid": uploadUUID, "uid": uid, + "content": usableTextFilter(), "content_hash": usableTextFilter()}, + options.FindOne().SetProjection(bson.M{ + "upload_uuid": 1, "title": 1, "content_hash": 1, "content": 1, + "size_chars": 1, "_id": 0, + }), + ).Decode(&row) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &ParsedDocument{ + UploadUUID: row.UploadUUID, + Title: row.Title, + ContentHash: row.ContentHash, + SizeChars: row.SizeChars, + Text: row.Content, + }, nil +} + +// usableTextFilter matches a string field that exists and is neither null nor +// empty (the "parsed正文可用" gate shared by the KG reads). +func usableTextFilter() bson.M { + return bson.M{"$exists": true, "$nin": []any{nil, ""}} +} + // UpsertParsedDocument stores the parsed full text (pipeline phase 1). func (m *MongoStore) UpsertParsedDocument(ctx context.Context, uploadUUID string, uid int64, title, sourceType, contentSource, content, contentHash string, docMeta map[string]any) error { @@ -116,8 +238,13 @@ func (m *MongoStore) UpsertParsedDocument(ctx context.Context, uploadUUID string "content_source": contentSource, "content": content, "content_hash": contentHash, - "doc_meta": docMeta, - "updated_at": time.Now().UTC(), + // size_chars is persisted so the KG listing can report a size + // without loading (or even transferring) the body text. Rune count + // keeps it consistent with the summary size_chars convention + // (one CJK character = 1), unlike len() which counts UTF-8 bytes. + "size_chars": utf8.RuneCountInString(content), + "doc_meta": docMeta, + "updated_at": time.Now().UTC(), }}, options.UpdateOne().SetUpsert(true), ) diff --git a/app-go/app-cloud/internal/router/cloud_ai.go b/app-go/app-cloud/internal/router/cloud_ai.go index 22f06db..4338706 100644 --- a/app-go/app-cloud/internal/router/cloud_ai.go +++ b/app-go/app-cloud/internal/router/cloud_ai.go @@ -1,20 +1,25 @@ package router // Cloud document intelligence endpoints: per-file summary (owner + M2M), -// folder-scoped semantic search and similar files. +// folder-scoped semantic search and similar files, plus the KG document reads +// (listing + body text) that app-ts uses to build cloud-drive knowledge points. // // The owner endpoints read the gateway-injected X-Uid like every other -// /cloud/* route. The /internal/cloud/{summary,similar} endpoints exist for +// /cloud/* route. The /internal/cloud/{summary,similar,kg/*} endpoints exist for // service-to-service use (app-ts tools) and authenticate with the shared // internal.api_key instead of a user session; an unconfigured key fails // closed. The legacy /internal/cloud/reconcile endpoint keeps its own // deployment posture and is deliberately not covered here. import ( + "context" "errors" "net/http" + "strconv" + "unicode/utf8" "app-cloud/internal/dto" + "app-cloud/internal/infra/mongostore" "app-cloud/internal/middleware" "app-cloud/internal/service" @@ -35,6 +40,11 @@ func registerInternalCloudAI(e *gin.Engine, d Deps) { internal.Use(middleware.RequireInternalKey(d.Cfg), middleware.RequireInternalUID()) internal.GET("/summary", middleware.RequireCapability(d.Summary != nil, "摘要服务暂不可用"), internalSummary(d)) internal.GET("/similar", middleware.RequireCapability(d.Search != nil, "语义检索暂不可用"), internalSimilar(d)) + // KG cloud-document reads (app-ts → cloud-drive knowledge points). No + // capability gate: a disabled Mongo store degrades to "no documents" / + // 404 exactly like the other nil-safe Mongo readers. + internal.GET("/kg/documents", KGDocumentsHandler(d.Mongo)) + internal.GET("/kg/document", KGDocumentHandler(d.Mongo)) } // ── owner-facing ───────────────────────────────────────────────────── @@ -146,6 +156,128 @@ func internalSimilar(d Deps) gin.HandlerFunc { } } +// ── machine-to-machine: KG cloud-document reads ───────────────────── + +// KGDocumentReader is the read port over parsed drive documents used by the +// cloud knowledge-graph M2M face; *mongostore.MongoStore is the production +// implementation and stays nil-safe. The interface (and the two handler +// constructors below) are exported so the black-box HTTP suite can exercise +// the contract without a live MongoDB. +type KGDocumentReader interface { + ListParsedDocuments(ctx context.Context, uid int64, afterUploadUUID string, + limit int) ([]mongostore.ParsedDocumentMeta, error) + GetParsedDocument(ctx context.Context, uid int64, + uploadUUID string) (*mongostore.ParsedDocument, error) +} + +const ( + // kgDocumentsDefaultLimit / kgDocumentsMaxLimit bound one listing page: + // app-ts walks the drive in upload_uuid order and a page of IDs is cheap, + // while 100 is the most the KG build orchestration should have to buffer. + kgDocumentsDefaultLimit = 50 + kgDocumentsMaxLimit = 100 + // kgDocumentMaxChars caps the body returned per document. One LLM + // extraction window never needs more, and a runaway multi-MB text must not + // bloat the M2M response. Counted in characters, like size_chars. + kgDocumentMaxChars = 200_000 +) + +// KGDocumentsHandler — GET /internal/cloud/kg/documents?uid=&limit=&cursor= +// Lists the user's parsed documents (identity + content hash only, no body), +// ordered by upload_uuid; a full page returns the last uuid as next_cursor. +func KGDocumentsHandler(reader KGDocumentReader) gin.HandlerFunc { + return func(c *gin.Context) { + uid := middleware.UIDFrom(c) + limit := clampInt(c.DefaultQuery("limit", strconv.Itoa(kgDocumentsDefaultLimit)), + 1, kgDocumentsMaxLimit) + cursor := trim(c.Query("cursor")) + + metas, err := reader.ListParsedDocuments(c.Request.Context(), uid, cursor, limit) + if err != nil { + apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") + return + } + items := make([]dto.KGDocumentsItem, 0, len(metas)) + for _, meta := range metas { + items = append(items, dto.KGDocumentsItem{ + UploadUUID: meta.UploadUUID, + Title: meta.Title, + ContentHash: meta.ContentHash, + SizeChars: meta.SizeChars, + }) + } + // next_cursor is only meaningful when the page is full: a short page + // proves there are no further upload_uuid values. A full page may still + // be the last one — the next call then simply returns zero items. + var nextCursor *string + if len(metas) == limit { + last := metas[len(metas)-1].UploadUUID + nextCursor = &last + } + c.JSON(http.StatusOK, dto.KGDocumentsResponse{Items: items, NextCursor: nextCursor}) + } +} + +// KGDocumentHandler — GET /internal/cloud/kg/document?uid=&upload_uuid= +// Returns one parsed document body (truncated to kgDocumentMaxChars) plus the +// content hash that versions it. +func KGDocumentHandler(reader KGDocumentReader) gin.HandlerFunc { + return func(c *gin.Context) { + uid := middleware.UIDFrom(c) + uploadUUID := trim(c.Query("upload_uuid")) + if uploadUUID == "" { + apiErr(c, http.StatusUnprocessableEntity, "缺少 upload_uuid 参数") + return + } + doc, err := reader.GetParsedDocument(c.Request.Context(), uid, uploadUUID) + if err != nil { + apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") + return + } + if doc == nil { + // Absent document or no usable body text — indistinguishable by + // contract (the caller only needs "not extractable"). + apiErr(c, http.StatusNotFound, "Document not found") + return + } + sizeChars := doc.SizeChars + if sizeChars <= 0 { + // Documents parsed before size_chars was persisted: compute the + // exact count from the text we already loaded. + sizeChars = utf8.RuneCountInString(doc.Text) + } + docUUID := doc.UploadUUID + if docUUID == "" { + docUUID = uploadUUID + } + c.JSON(http.StatusOK, dto.KGDocumentResponse{ + UploadUUID: docUUID, + Title: doc.Title, + ContentHash: doc.ContentHash, + SizeChars: sizeChars, + Text: truncateChars(doc.Text, kgDocumentMaxChars), + }) + } +} + +// truncateChars returns at most max characters (runes) of s. The limit counts +// characters, so byte slicing would both cut the budget short for CJK text and +// split a multi-byte rune into invalid UTF-8; walking rune boundaries avoids +// that without allocating a []rune copy of the whole text. +func truncateChars(s string, max int) string { + if max <= 0 { + return "" + } + seen := 0 + for idx := range s { + if seen == max { + return s[:idx] + } + seen++ + } + return s +} + // ── shared helpers ─────────────────────────────────────────────────── // writeSummary answers the summary payload (available / stale / model / diff --git a/app-go/app-cloud/tests/kg_documents_test.go b/app-go/app-cloud/tests/kg_documents_test.go new file mode 100644 index 0000000..0c81a42 --- /dev/null +++ b/app-go/app-cloud/tests/kg_documents_test.go @@ -0,0 +1,315 @@ +package tests + +// HTTP-level guards for the KG cloud-document M2M reads +// (GET /internal/cloud/kg/documents and /internal/cloud/kg/document): auth, +// parameter validation, cursor/limit semantics, truncation and wire shape. +// The handlers take a KGDocumentReader, so no live MongoDB (or any other +// backing service) is needed — the whole contract is exercised through the +// real Gin middleware chain. + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "unicode/utf8" + + "app-cloud/internal/config" + "app-cloud/internal/infra/mongostore" + "app-cloud/internal/middleware" + "app-cloud/internal/router" + + "github.com/gin-gonic/gin" +) + +// fakeKGReader is an in-memory KGDocumentReader (records the arguments so the +// cursor/limit contract is asserted, not just the response body). +type fakeKGReader struct { + metas []mongostore.ParsedDocumentMeta + doc *mongostore.ParsedDocument + + listErr error + getErr error + + calls int + gotUID int64 + gotCursor string + gotLimit int + gotUUID string +} + +func (f *fakeKGReader) ListParsedDocuments(_ context.Context, uid int64, afterUploadUUID string, + limit int) ([]mongostore.ParsedDocumentMeta, error) { + f.calls++ + f.gotUID, f.gotCursor, f.gotLimit = uid, afterUploadUUID, limit + if f.listErr != nil { + return nil, f.listErr + } + return f.metas, nil +} + +func (f *fakeKGReader) GetParsedDocument(_ context.Context, uid int64, + uploadUUID string) (*mongostore.ParsedDocument, error) { + f.calls++ + f.gotUID, f.gotUUID = uid, uploadUUID + if f.getErr != nil { + return nil, f.getErr + } + return f.doc, nil +} + +// newKGEngine mounts the two KG handlers on the real /internal/cloud middleware +// chain (apikey + uid validation) without booting the rest of the service. +func newKGEngine(internalKey string, reader router.KGDocumentReader) *gin.Engine { + gin.SetMode(gin.TestMode) + cfg := &config.Config{} + cfg.Internal.APIKey = internalKey + e := gin.New() + internal := e.Group("/internal/cloud") + internal.Use(middleware.RequireInternalKey(cfg), middleware.RequireInternalUID()) + internal.GET("/kg/documents", router.KGDocumentsHandler(reader)) + internal.GET("/kg/document", router.KGDocumentHandler(reader)) + return e +} + +func kgHeaders() map[string]string { return map[string]string{"apikey": "internal-secret"} } + +// ── registration on the production engine ────────────────────────── + +func TestKGDocumentsRoutesRegistered(t *testing.T) { + cfg := &config.Config{} + cfg.Internal.APIKey = "internal-secret" + e := router.New(router.Deps{Cfg: cfg}) // nil-safe Mongo store → empty page / 404 + + code, body := doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents?uid=7", "", kgHeaders()) + if code != http.StatusOK { + t.Fatalf("list status = %d, body = %v", code, body) + } + assertKeys(t, body, "items", "next_cursor") + if items, ok := body["items"].([]any); !ok || len(items) != 0 { + t.Fatalf("items must be [] with a disabled store: %v", body["items"]) + } + if body["next_cursor"] != nil { + t.Fatalf("next_cursor must be null for an empty page: %v", body["next_cursor"]) + } + + code, body = doJSON(t, e, http.MethodGet, "/internal/cloud/kg/document?uid=7", "", kgHeaders()) + if code != http.StatusUnprocessableEntity || body["detail"] != "缺少 upload_uuid 参数" { + t.Fatalf("missing upload_uuid = %d %v", code, body) + } + + code, body = doJSON(t, e, http.MethodGet, + "/internal/cloud/kg/document?uid=7&upload_uuid=u1", "", kgHeaders()) + if code != http.StatusNotFound || body["detail"] != "Document not found" { + t.Fatalf("unknown document with a disabled store = %d %v", code, body) + } +} + +func TestKGDocumentsAuthAndUIDValidation(t *testing.T) { + // Unconfigured key fails closed before any credential comparison. + noKey := newKGEngine("", &fakeKGReader{}) + if code, _ := doJSON(t, noKey, http.MethodGet, "/internal/cloud/kg/documents?uid=7", "", nil); code != http.StatusServiceUnavailable { + t.Fatalf("unconfigured internal key must be 503, got %d", code) + } + + reader := &fakeKGReader{} + e := newKGEngine("internal-secret", reader) + if code, _ := doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents?uid=7", "", nil); code != http.StatusUnauthorized { + t.Fatalf("missing apikey must be 401, got %d", code) + } + if code, _ := doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents?uid=7", "", + map[string]string{"apikey": "wrong"}); code != http.StatusUnauthorized { + t.Fatalf("wrong apikey must be 401, got %d", code) + } + if code, body := doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents", "", + kgHeaders()); code != http.StatusUnprocessableEntity || body["detail"] != "缺少 uid 参数" { + t.Fatalf("missing uid must be 422, got %d %v", code, body) + } + if code, body := doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents?uid=abc", "", + kgHeaders()); code != http.StatusUnprocessableEntity || body["detail"] != "uid 不合法" { + t.Fatalf("invalid uid must be 422, got %d %v", code, body) + } + if reader.calls != 0 { + t.Fatalf("handlers must not run before auth/uid validation, calls = %d", reader.calls) + } + // Bearer form is accepted like the other internal endpoints. + bearer := map[string]string{"Authorization": "Bearer internal-secret"} + if code, _ := doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents?uid=7", "", bearer); code != http.StatusOK { + t.Fatalf("Bearer apikey must pass, got %d", code) + } +} + +// ── listing ───────────────────────────────────────────────────────── + +func TestKGDocumentsListingPagination(t *testing.T) { + metas := make([]mongostore.ParsedDocumentMeta, 0, 50) + for i := 0; i < 50; i++ { + metas = append(metas, mongostore.ParsedDocumentMeta{ + UploadUUID: "u" + pad2(i), + Title: "文档", + ContentHash: "h", + SizeChars: 100 + i, + }) + } + reader := &fakeKGReader{metas: metas} + e := newKGEngine("internal-secret", reader) + + // Default page: limit 50, no cursor, full page → next_cursor = last uuid. + code, body := doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents?uid=7", "", kgHeaders()) + if code != http.StatusOK { + t.Fatalf("status = %d, body = %v", code, body) + } + if reader.gotUID != 7 || reader.gotLimit != 50 || reader.gotCursor != "" { + t.Fatalf("store args = uid %d limit %d cursor %q", reader.gotUID, reader.gotLimit, reader.gotCursor) + } + assertKeys(t, body, "items", "next_cursor") + items, _ := body["items"].([]any) + if len(items) != 50 { + t.Fatalf("items = %d", len(items)) + } + assertKeys(t, items[0].(map[string]any), "upload_uuid", "title", "content_hash", "size_chars") + if items[0].(map[string]any)["size_chars"] != float64(100) { + t.Fatalf("size_chars not forwarded: %v", items[0]) + } + if body["next_cursor"] != "u49" { + t.Fatalf("next_cursor = %v, want u49", body["next_cursor"]) + } + + // Cursor is forwarded as the exclusive lower bound. + reader.metas = metas[:2] + code, body = doJSON(t, e, http.MethodGet, + "/internal/cloud/kg/documents?uid=7&limit=2&cursor=u07", "", kgHeaders()) + if code != http.StatusOK { + t.Fatalf("status = %d, body = %v", code, body) + } + if reader.gotCursor != "u07" || reader.gotLimit != 2 { + t.Fatalf("store args = cursor %q limit %d", reader.gotCursor, reader.gotLimit) + } + if body["next_cursor"] != "u01" { + t.Fatalf("next_cursor = %v, want u01 (full page)", body["next_cursor"]) + } + + // Short page = last page → null cursor. + reader.metas = metas[:1] + _, body = doJSON(t, e, http.MethodGet, + "/internal/cloud/kg/documents?uid=7&limit=2&cursor=u07", "", kgHeaders()) + if body["next_cursor"] != nil { + t.Fatalf("short page must end pagination: %v", body["next_cursor"]) + } + + // limit is clamped into 1..100. + reader.metas = nil + _, _ = doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents?uid=7&limit=1000", "", kgHeaders()) + if reader.gotLimit != 100 { + t.Fatalf("limit 1000 clamped to %d, want 100", reader.gotLimit) + } + _, _ = doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents?uid=7&limit=0", "", kgHeaders()) + if reader.gotLimit != 1 { + t.Fatalf("limit 0 clamped to %d, want 1", reader.gotLimit) + } +} + +func TestKGDocumentsListingErrorIs500(t *testing.T) { + e := newKGEngine("internal-secret", &fakeKGReader{listErr: errors.New("mongo down")}) + if code, body := doJSON(t, e, http.MethodGet, "/internal/cloud/kg/documents?uid=7", "", kgHeaders()); code != http.StatusInternalServerError { + t.Fatalf("store failure must be 500, got %d %v", code, body) + } +} + +// ── per-document read ────────────────────────────────────────────── + +func TestKGDocumentBodyTruncation(t *testing.T) { + // 200001 CJK characters: one over the budget, each 3 bytes in UTF-8. + full := strings.Repeat("云", 200001) + reader := &fakeKGReader{doc: &mongostore.ParsedDocument{ + UploadUUID: "u1", Title: "云文档", ContentHash: "h1", SizeChars: 0, Text: full, + }} + e := newKGEngine("internal-secret", reader) + + code, body := doJSON(t, e, http.MethodGet, + "/internal/cloud/kg/document?uid=7&upload_uuid=u1", "", kgHeaders()) + if code != http.StatusOK { + t.Fatalf("status = %d, body = %v", code, body) + } + if reader.gotUID != 7 || reader.gotUUID != "u1" { + t.Fatalf("store args = uid %d uuid %q", reader.gotUID, reader.gotUUID) + } + assertKeys(t, body, "upload_uuid", "title", "content_hash", "size_chars", "text") + text, _ := body["text"].(string) + if utf8.RuneCountInString(text) != 200000 { + t.Fatalf("text runes = %d, want 200000", utf8.RuneCountInString(text)) + } + if !utf8.ValidString(text) || strings.ContainsRune(text, utf8.RuneError) { + t.Fatalf("truncation split a multi-byte rune") + } + if !strings.HasPrefix(text, "云") { + t.Fatalf("text content changed at the head") + } + // size_chars describes the FULL text (0 stored → computed from the body), + // so callers can detect the truncation. + if body["size_chars"] != float64(200001) { + t.Fatalf("size_chars = %v, want full 200001", body["size_chars"]) + } + + // Exactly at the budget: returned verbatim, still 200000. + reader.doc = &mongostore.ParsedDocument{ + UploadUUID: "u1", ContentHash: "h1", SizeChars: 200000, + Text: strings.Repeat("云", 200000), + } + _, body = doJSON(t, e, http.MethodGet, + "/internal/cloud/kg/document?uid=7&upload_uuid=u1", "", kgHeaders()) + if got := utf8.RuneCountInString(body["text"].(string)); got != 200000 { + t.Fatalf("exact-budget text runes = %d", got) + } + if body["size_chars"] != float64(200000) { + t.Fatalf("stored size_chars not forwarded: %v", body["size_chars"]) + } + + // Short document: body kept whole, size computed when not persisted. + reader.doc = &mongostore.ParsedDocument{UploadUUID: "u2", Title: "短", ContentHash: "h2", + Text: " 中文 body "} + _, body = doJSON(t, e, http.MethodGet, + "/internal/cloud/kg/document?uid=7&upload_uuid=u2", "", kgHeaders()) + if body["text"] != " 中文 body " || body["size_chars"] != float64(utf8.RuneCountInString(" 中文 body ")) { + t.Fatalf("short document payload = %v", body) + } + if body["upload_uuid"] != "u2" || body["title"] != "短" || body["content_hash"] != "h2" { + t.Fatalf("document identity wrong: %v", body) + } +} + +func TestKGDocumentMissingAndErrors(t *testing.T) { + // No parsed document for that uid → 404 (absence and empty text are not + // distinguished by contract). + e := newKGEngine("internal-secret", &fakeKGReader{}) + code, body := doJSON(t, e, http.MethodGet, + "/internal/cloud/kg/document?uid=7&upload_uuid=missing", "", kgHeaders()) + if code != http.StatusNotFound || body["detail"] != "Document not found" { + t.Fatalf("missing document = %d %v", code, body) + } + + // Store failure → 500, never a misleading 404. + e = newKGEngine("internal-secret", &fakeKGReader{getErr: errors.New("mongo down")}) + if code, _ := doJSON(t, e, http.MethodGet, + "/internal/cloud/kg/document?uid=7&upload_uuid=u1", "", kgHeaders()); code != http.StatusInternalServerError { + t.Fatalf("store failure must be 500, got %d", code) + } + + // uid comes from the middleware, not from a handler-level query parse. + reader := &fakeKGReader{doc: &mongostore.ParsedDocument{UploadUUID: "u1", Text: "x"}} + e = newKGEngine("internal-secret", reader) + _, _ = doJSON(t, e, http.MethodGet, + "/internal/cloud/kg/document?uid=42&upload_uuid=u1", "", kgHeaders()) + if reader.gotUID != 42 { + t.Fatalf("uid from middleware = %d, want 42", reader.gotUID) + } +} + +// pad2 zero-pads a small int for stable uuid ordering. +func pad2(n int) string { + if n < 10 { + return "0" + string(rune('0'+n)) + } + return string(rune('0'+n/10)) + string(rune('0'+n%10)) +} diff --git a/app-go/app-kg/README.md b/app-go/app-kg/README.md index 316e37b..b673c54 100644 --- a/app-go/app-kg/README.md +++ b/app-go/app-kg/README.md @@ -1,27 +1,42 @@ # app-kg —— KG 图存储服务(Go + gRPC) > 知识图谱域的存储权威:Neo4j 全部 Cypher、`video.kg_status/kg_version` 记账两列唯一写者、 -> 收藏夹 → bvids 解析(只读)。AI 抽取/编排完全在 app-ts,两者经 gRPC 通信(proto/kg/v1/kg.proto,21 个 RPC)。 +> 收藏夹 → bvids 解析(只读)、云盘文档知识点证据(CloudDoc 源节点)。AI 抽取/编排完全在 app-ts, +> 两者经 gRPC 通信(proto/kg/v1/kg.proto,24 个 RPC)。 > **gRPC 不进 APISIX**:app-ts → app-kg 内网直连(compose mind-base-net)。 ## 端口 | 端口 | 协议 | 说明 | |------|------|------| -| :8013 | gRPC | 记账(ResolveScope/BeginPage/MarkPageDone/…)+ 图写(UpsertEntities/LinkAppearances/…)+ 图读(Neighbors/FetchEvidence/Stats/…) | -| :8014 | HTTP | `/health` 探活(调试口仅回环) | +| :8013 | gRPC | 记账(ResolveScope/BeginPage/MarkPageDone/…)+ 图写(UpsertEntities/LinkAppearances/UpsertRelations/…)+ 云盘知识点(LinkCloudDocAppearances/DeleteCloudDocAppearances/FetchCloudDocEvidence)+ 图读(Neighbors/FetchEvidence/Stats/…) | +| :8014 | HTTP | `/health` 探活 + `/internal/kg/cloud-doc/*`(app-cloud 内部 JSON 路由;调试口仅回环) | ## 数据所有权 -Neo4j(Entity/Video/RELATES/APPEARS_IN);MySQL `video.kg_status`/`kg_version`(唯一写者)。 +Neo4j(Entity/Video/CloudDoc/RELATES/APPEARS_IN);MySQL `video.kg_status`/`kg_version`(唯一写者)。 +知识点有两个来源、共用同一张 Entity/RELATES 图:收藏夹视频分P(证据边指向 `(:Video {bvid})`) +与云盘文档(证据边指向 `(:CloudDoc {uid, upload_uuid, title, content_hash})`,节点复用云盘 L1 路由图)。 归一化契约:`name_lower` 与 `eid`(sha1 前 16 位)由 app-ts 计算后传入,app-kg 不做归一化 -——graph 包守护测试钉死。实体向量在 Milvus `mind_base_ts.kg_entities`(app-ts 所有)。 +——tests 包守护测试钉死。实体向量在 Milvus `mind_base_ts.kg_entities`(app-ts 所有)。 + +## 包结构(infra → repo → service → transport) + +| 包 | 职责 | +|----|------| +| `internal/infra/{mysql,neo4j}` | 数据库初始化与连接配置(DSN/连接池、driver 生命周期、唯一约束、Ping;零业务、零查询) | +| `internal/repo/{mysql,neo4j}` | 数据库操作:SQL / Cypher(`video.kg_*` 记账、scope 解析、图增删查、cloud-doc 路由图、云盘知识点证据 `cloud_kg.go`) | +| `internal/service` | 业务规则:记账状态机、证据 title join、stats 组合、入参校验(不碰驱动/协议) | +| `internal/transport/{grpc,http}` | 协议边界:proto ↔ service 映射(gRPC)、健康检查 + cloud-doc JSON(HTTP) | +| `internal/config` | YAML/env 唯一入口(嵌入 infra 的连接配置结构) | +| `tests/` | **全部 Go 测试的独立 tests 包**(不与被测包混放):分层依赖方向守护、Cypher/SQL 契约守护、config 映射、service 校验、transport 错误映射 | ## 运行 ```bash go run ./app-go/app-kg # 依赖 Neo4j + MySQL docker compose up -d app-kg # compose profile ["", "kg", "full"] +go test ./tests # 全部 Go 测试在独立 tests 包(结构守护 + 契约测试,无需真实 DB) ``` -契约:`proto/kg/v1/kg.proto`(Go 产物已提交)。规范详见 AGENTS.md §2.18、docs/app-kg.md。 +契约:`proto/kg/v1/kg.proto`(Go 产物已提交)。规范详见 AGENTS.md §2.18、docs/app-kg.md。 \ No newline at end of file diff --git a/app-go/app-kg/internal/config/config.go b/app-go/app-kg/internal/config/config.go index 112bd1d..6413393 100644 --- a/app-go/app-kg/internal/config/config.go +++ b/app-go/app-kg/internal/config/config.go @@ -1,9 +1,14 @@ // Package config loads app-kg configuration from embedded default.yaml + // APPKG__-prefixed env overrides + project-root .env. // +// The database connection config shapes (rdbms / neo4j) are declared by the +// infra packages, next to the code that initializes those connections; this +// package only embeds them and stays the SINGLE YAML/env entry point (no +// other package reads environment variables). +// // Secret-bearing settings are shared with the other services and read from -// the SAME env var names (KG__URI, KG__PASSWORD/NEO4J_PASSWORD, -// RDBMS__URL via compose), so one .env drives the whole stack. +// the SAME env var names (KG__URI, KG__PASSWORD/NEO4J_PASSWORD, RDBMS__URL via +// compose), so one .env drives the whole stack. package config import ( @@ -12,6 +17,9 @@ import ( "strconv" "strings" + mysqlinfra "app-kg/internal/infra/mysql" + neo4jinfra "app-kg/internal/infra/neo4j" + "github.com/joho/godotenv" "gopkg.in/yaml.v3" ) @@ -21,9 +29,11 @@ type Config struct { Server ServerConfig `yaml:"server"` Log LogConfig `yaml:"log"` - Timezone string `yaml:"timezone"` - RDBMS RDBMSConfig `yaml:"rdbms"` - Neo4j Neo4jConfig `yaml:"neo4j"` + Timezone string `yaml:"timezone"` + + // Database initialization config — owned by the infra packages. + RDBMS mysqlinfra.Config `yaml:"rdbms"` + Neo4j neo4jinfra.Config `yaml:"neo4j"` } type AppConfig struct { @@ -41,20 +51,6 @@ type ServerConfig struct { InternalToken string `yaml:"internal_token"` } -type RDBMSConfig struct { - URL string `yaml:"url"` - MaxOpenConns int `yaml:"max_open_conns"` - MaxIdleConns int `yaml:"max_idle_conns"` - ConnMaxLifetime int `yaml:"conn_max_lifetime"` -} - -type Neo4jConfig struct { - URI string `yaml:"uri"` - Username string `yaml:"username"` - Password string `yaml:"password"` - Database string `yaml:"database"` -} - type LogConfig struct { Level string `yaml:"level"` Format string `yaml:"format"` @@ -155,19 +151,11 @@ func applyEnvOverrides(cfg *Config) { // Validate fails loud on config that would silently break the service. // Neo4j is REQUIRED (this service is useless without the graph store); -// MySQL is required for page bookkeeping and scope resolution. +// MySQL is required for page bookkeeping and scope resolution. The per-infra +// validation rules live with the connection code they protect. func Validate(cfg *Config) error { - if strings.TrimSpace(cfg.Neo4j.URI) == "" { - return fmt.Errorf("neo4j.uri is required (KG__URI)") - } - if strings.TrimSpace(cfg.RDBMS.URL) == "" { - return fmt.Errorf("rdbms.url is required (APPKG__RDBMS__URL)") - } - if strings.TrimSpace(cfg.Neo4j.Database) == "" { - cfg.Neo4j.Database = "neo4j" - } - if strings.TrimSpace(cfg.Neo4j.Username) == "" { - cfg.Neo4j.Username = "neo4j" + if err := cfg.Neo4j.Validate(); err != nil { + return err } - return nil + return cfg.RDBMS.Validate() } diff --git a/app-go/app-kg/internal/db/db.go b/app-go/app-kg/internal/db/db.go deleted file mode 100644 index a2ae3e9..0000000 --- a/app-go/app-kg/internal/db/db.go +++ /dev/null @@ -1,81 +0,0 @@ -// Package db initializes the GORM MySQL connection against the SHARED bilirag -// database. -// -// Migration policy: app-kg owns NO tables. The video.kg_status/kg_version -// columns already exist (created by the Python backend via db/system.sql); -// this service is the唯一 writer of those two columns (the mirror of the -// app-bili red line "app-bili 禁止写 kg* 列"), and reads -// collection/favorite_folders/video rows read-only. GORM AutoMigrate is -// deliberately NOT used. -package db - -import ( - "fmt" - "strings" - "time" - - "app-kg/internal/logger" - - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -var DB *gorm.DB - -// Init opens MySQL. dsn may be SQLAlchemy-style (mysql+aiomysql://...). -// -// loc=UTC: the Python kg stack compares version/kg_version ints only — -// datetimes are not part of the kg contract; UTC keeps any incidental -// reads consistent with the Python backend's naive-UTC writes. -func Init(dsn string, maxOpen, maxIdle, maxLife int, debug bool) error { - gormDSN := toGormDSN(dsn) - d, err := gorm.Open(mysql.Open(gormDSN), &gorm.Config{Logger: logger.NewGORMLogger(debug)}) - if err != nil { - return fmt.Errorf("open mysql: %w", err) - } - sqlDB, err := d.DB() - if err != nil { - return fmt.Errorf("get *sql.DB: %w", err) - } - sqlDB.SetMaxOpenConns(maxOpen) - sqlDB.SetMaxIdleConns(maxIdle) - sqlDB.SetConnMaxLifetime(time.Duration(maxLife) * time.Second) - DB = d - return nil -} - -func Close() error { - if DB == nil { - return nil - } - sqlDB, err := DB.DB() - if err != nil { - return err - } - return sqlDB.Close() -} - -// toGormDSN converts mysql+aiomysql://user:pass@host:port/db -// to user:pass@tcp(host:port)/db?charset=utf8mb4&parseTime=True&loc=UTC. -func toGormDSN(sqlalchemyURL string) string { - s := sqlalchemyURL - for _, p := range []string{"mysql+aiomysql://", "mysql+pymysql://", "mysql://"} { - s = strings.TrimPrefix(s, p) - } - atIdx := strings.LastIndex(s, "@") - if atIdx < 0 { - return s + "?charset=utf8mb4&parseTime=True&loc=UTC" - } - userPass := s[:atIdx] - hostDB := s[atIdx+1:] - slashIdx := strings.Index(hostDB, "/") - if slashIdx < 0 { - return userPass + "@tcp(" + hostDB + ")/?charset=utf8mb4&parseTime=True&loc=UTC" - } - hostPort := hostDB[:slashIdx] - rest := hostDB[slashIdx+1:] - if qIdx := strings.Index(rest, "?"); qIdx >= 0 { - rest = rest[:qIdx] - } - return userPass + "@tcp(" + hostPort + ")/" + rest + "?charset=utf8mb4&parseTime=True&loc=UTC" -} diff --git a/app-go/app-kg/internal/db/pages.go b/app-go/app-kg/internal/db/pages.go deleted file mode 100644 index 36cde36..0000000 --- a/app-go/app-kg/internal/db/pages.go +++ /dev/null @@ -1,253 +0,0 @@ -// Page bookkeeping + scope resolution on the shared bilirag MySQL — port of -// the SQL slices of app/services/kg/service.py (process_page Phase 1/7, -// _select_pending_pages, stats pending count) and app/services/chat/scope.py -// (get_media_ids_for_uid / get_bvids_by_media_ids). -// -// Write surface is exactly two columns on `video` (kg_status, kg_version); -// everything else is read-only. All queries are parameterised. -package db - -import ( - "context" - "errors" -) - -// PageKey identifies one video page for title lookups. -type PageKey struct { - Bvid string - PageIndex int32 -} - -// PendingPage is one page selected for (re-)extraction. -type PendingPage struct { - Bvid string - Cid int64 - PageIndex int32 - PageTitle string - Version int64 -} - -// BeginResult mirrors the Python process_page Phase 1 outcome. -type BeginResult struct { - Found bool - AsrDone bool - Skip bool - Reason string - ContentVersion int64 - PageTitle string - PageIndex int32 -} - -// ErrPageNotFound is returned when the video row does not exist. -var ErrPageNotFound = errors.New("video page not found") - -// ResolveScope resolves favorite folders → bvids (dedup, order-stable). -// Empty folderIDs = all of the user's non-deleted folders. Port of -// chat/scope.get_media_ids_for_uid + get_bvids_by_media_ids. -func ResolveScope(ctx context.Context, uid int64, folderIDs []int64) ([]string, error) { - q := DB.WithContext(ctx).Raw( - "SELECT media_id FROM favorite_folders WHERE uid = ? AND deleted_at IS NULL", - uid, - ) - if len(folderIDs) > 0 { - q = DB.WithContext(ctx).Raw( - "SELECT media_id FROM favorite_folders WHERE uid = ? AND deleted_at IS NULL "+ - "AND media_id IN (?) ORDER BY updated_at DESC", - uid, folderIDs, - ) - } else { - q = DB.WithContext(ctx).Raw( - "SELECT media_id FROM favorite_folders WHERE uid = ? AND deleted_at IS NULL "+ - "ORDER BY updated_at DESC", - uid, - ) - } - var mediaIDs []int64 - if err := q.Scan(&mediaIDs).Error; err != nil { - return nil, err - } - seen := map[int64]bool{} - uniq := make([]int64, 0, len(mediaIDs)) - for _, mid := range mediaIDs { - if mid != 0 && !seen[mid] { - seen[mid] = true - uniq = append(uniq, mid) - } - } - if len(uniq) == 0 { - return nil, nil - } - var bvids []string - if err := DB.WithContext(ctx).Raw( - "SELECT bvid FROM collection WHERE media_id IN (?)", uniq, - ).Scan(&bvids).Error; err != nil { - return nil, err - } - seenBv := map[string]bool{} - out := make([]string, 0, len(bvids)) - for _, bv := range bvids { - if bv != "" && !seenBv[bv] { - seenBv[bv] = true - out = append(out, bv) - } - } - return out, nil -} - -// SelectPendingPages picks pages whose ASR is done and whose kg extraction -// is missing / failed / stale. Port of service._select_pending_pages. -func SelectPendingPages(ctx context.Context, bvids []string, limit int32) ([]PendingPage, error) { - if len(bvids) == 0 { - return nil, nil - } - var rows []PendingPage - err := DB.WithContext(ctx).Raw( - "SELECT bvid, cid, page_index, page_title, version FROM video "+ - "WHERE bvid IN (?) AND is_processed = 1 "+ - "AND (kg_version IS NULL OR kg_status = 'failed' OR kg_version <> version) "+ - "LIMIT ?", - bvids, limit, - ).Scan(&rows).Error - return rows, err -} - -// CountPendingPages is the scope-free stats counter (service.stats). -func CountPendingPages(ctx context.Context) (int64, error) { - var n int64 - err := DB.WithContext(ctx).Raw( - "SELECT COUNT(id) FROM video " + - "WHERE is_processed = 1 " + - "AND (kg_version IS NULL OR kg_status = 'failed' OR kg_version <> version)", - ).Scan(&n).Error - return n, err -} - -// BeginPage performs the idempotency check and marks the page processing. -// Port of process_page Phase 1: missing row / ASR not done → typed result; -// done-and-current (only when expectedVersion is unset) → skip=true; -// otherwise kg_status='processing'. -func BeginPage(ctx context.Context, bvid string, cid int64, expectedVersion *int64) (BeginResult, error) { - var row struct { - Bvid string - PageIndex int32 - PageTitle string - IsProcess bool - Version int64 - KgStatus *string - KgVersion *int64 - } - err := DB.WithContext(ctx).Raw( - "SELECT bvid, page_index, page_title, is_processed AS is_process, version, "+ - "kg_status, kg_version FROM video WHERE bvid = ? AND cid = ?", - bvid, cid, - ).Scan(&row).Error - if err != nil { - return BeginResult{}, err - } - if row.Bvid == "" { - return BeginResult{Found: false, Reason: "video page not found"}, ErrPageNotFound - } - - res := BeginResult{ - Found: true, - AsrDone: row.IsProcess, - PageTitle: row.PageTitle, - PageIndex: row.PageIndex, - } - if !row.IsProcess { - res.Reason = "ASR not done yet" - return res, nil - } - if expectedVersion != nil { - res.ContentVersion = *expectedVersion - } else { - res.ContentVersion = row.Version - } - if row.KgStatus != nil && *row.KgStatus == "done" && - row.KgVersion != nil && *row.KgVersion == res.ContentVersion && - expectedVersion == nil { - res.Skip = true - res.Reason = "up to date" - return res, nil - } - if err := SetStatus(ctx, bvid, cid, "processing"); err != nil { - return res, err - } - return res, nil -} - -// MarkPageDone sets kg_status='done' + kg_version=content_version (Phase 7). -func MarkPageDone(ctx context.Context, bvid string, cid int64, contentVersion int64) error { - return DB.WithContext(ctx).Exec( - "UPDATE video SET kg_status = 'done', kg_version = ? WHERE bvid = ? AND cid = ?", - contentVersion, bvid, cid, - ).Error -} - -// MarkPageFailed marks the page failed (retryable) — failure isolation path. -func MarkPageFailed(ctx context.Context, bvid string, cid int64) error { - return SetStatus(ctx, bvid, cid, "failed") -} - -// SetStatus moves a page's kg_status to an arbitrary value. The only -// permitted writers of these two columns in the whole stack. -func SetStatus(ctx context.Context, bvid string, cid int64, status string) error { - return DB.WithContext(ctx).Exec( - "UPDATE video SET kg_status = ? WHERE bvid = ? AND cid = ?", - status, bvid, cid, - ).Error -} - -// PageTitles returns (bvid, page_index) → page_title for evidence annotation -// (retriever._page_titles). Missing titles fall back at the caller/cypher -// layer to "P{n+1}". -func PageTitles(ctx context.Context, bvids []string) (map[PageKey]string, error) { - out := map[PageKey]string{} - if len(bvids) == 0 { - return out, nil - } - var rows []struct { - Bvid string - PageIndex int32 - PageTitle *string - } - if err := DB.WithContext(ctx).Raw( - "SELECT bvid, page_index, page_title FROM video WHERE bvid IN (?)", - bvids, - ).Scan(&rows).Error; err != nil { - return nil, err - } - for _, r := range rows { - title := "" - if r.PageTitle != nil { - title = *r.PageTitle - } - if title == "" { - title = "P" + itoa(int(r.PageIndex)+1) - } - out[PageKey{Bvid: r.Bvid, PageIndex: r.PageIndex}] = title - } - return out, nil -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - neg := n < 0 - if neg { - n = -n - } - var buf [20]byte - i := len(buf) - for n > 0 { - i-- - buf[i] = byte('0' + n%10) - n /= 10 - } - if neg { - i-- - buf[i] = '-' - } - return string(buf[i:]) -} diff --git a/app-go/app-kg/internal/graph/cloud_test.go b/app-go/app-kg/internal/graph/cloud_test.go deleted file mode 100644 index cee62ab..0000000 --- a/app-go/app-kg/internal/graph/cloud_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package graph - -import ( - "os" - "strings" - "testing" -) - -// readCloudSource loads cloud.go for structural assertions (no external deps). -func readCloudSource(t *testing.T) string { - t.Helper() - b, err := os.ReadFile("cloud.go") - if err != nil { - t.Fatalf("read cloud.go: %v", err) - } - return string(b) -} - -// Guard: every cloud-doc Cypher statement must scope on uid — multi-tenant -// isolation is enforced in Cypher, never by the caller. Also: the graph -// never normalizes names (name_lower arrives precomputed from the caller). -func TestCloudDocGraphUIDScoping(t *testing.T) { - for _, frag := range []string{ - "{uid: $uid, upload_uuid: $upload_uuid}", - "(t:CloudTopic {uid: $uid, name_lower: row.name_lower})", - "(en:CloudEntity {uid: $uid, name_lower: row.name_lower})", - "(t:CloudTopic {uid: $uid})", - "(en:CloudEntity {uid: $uid})", - } { - if !strings.Contains(frag, "$uid") { - t.Fatalf("fragment missing uid scope: %s", frag) - } - } - src := readCloudSource(t) - for _, banned := range []string{"sha1(", "toLower(row.name_lower)"} { - if strings.Contains(src, banned) { - t.Errorf("cloud graph Cypher contains banned normalization fragment %q", banned) - } - } -} - -// Guard: label separation — cloud operations must not touch the video KG -// labels (Entity/Video) and vice versa; ClearAll semantics stay intact. -func TestCloudDocGraphLabelSeparation(t *testing.T) { - src := readCloudSource(t) - for _, banned := range []string{":Entity ", ":Video ", "APPEARS_IN", "RELATES"} { - if strings.Contains(src, banned) { - t.Errorf("cloud graph must not touch video-KG labels/edge types: %q found", banned) - } - } -} diff --git a/app-go/app-kg/internal/graph/graph.go b/app-go/app-kg/internal/graph/graph.go deleted file mode 100644 index c8ecd99..0000000 --- a/app-go/app-kg/internal/graph/graph.go +++ /dev/null @@ -1,458 +0,0 @@ -// Package graph — the complete Neo4j data access for the KG domain, a -// method-by-method port of app/repository/kg_graph_repository.py. Cypher -// semantics must stay byte-compatible with the Python implementation: during -// the transition both stacks write the SAME graph (shared Neo4j, merge keys -// name_lower/bvid, deterministic eid = sha1(name_lower)[:16] computed by the -// CALLER — this package never normalizes names). -// -// Graph schema (unchanged): -// -// (:Entity {eid, name, name_lower, type, description, aliases, -// mention_count, created_at, updated_at}) -// (:Video {bvid}) -// (a:Entity)-[:RELATES {rel_type, weight, quotes}]->(b:Entity) -// (e:Entity)-[:APPEARS_IN {page_index, quote}]->(v:Video) -package graph - -import ( - "context" - "fmt" - "log/slog" - - "app-kg/internal/neo4jstore" -) - -// MaxRelationQuotes caps the evidence quotes kept per RELATES edge (parity -// with the Python MAX_RELATION_QUOTES). -const MaxRelationQuotes = 3 - -// Store executes Cypher against the configured database. -type Store struct { - st *neo4jstore.Store -} - -// New wires the graph store onto the neo4jstore handle. -func New(st *neo4jstore.Store) *Store { return &Store{st: st} } - -// run executes a query and collects all records as value maps. -func (g *Store) run(ctx context.Context, cypher string, params map[string]any) ([]map[string]any, error) { - session := g.st.NewSession(ctx) - defer session.Close(ctx) - result, err := session.Run(ctx, cypher, params) - if err != nil { - return nil, err - } - records, err := result.Collect(ctx) - if err != nil { - return nil, err - } - out := make([]map[string]any, 0, len(records)) - for _, rec := range records { - row := make(map[string]any, len(rec.Keys)) - for i, key := range rec.Keys { - row[key] = rec.Values[i] - } - out = append(out, row) - } - return out, nil -} - -// ── entity / video / evidence / relation writes ────────────────────── - -// UpsertEntities bulk-MERGEs entities (merge key name_lower). Description is -// only overwritten when the incoming value is non-empty (stub rows must not -// clear existing descriptions); mention_count increments per upsert; alias is -// appended when new. Returns all involved eids. -func (g *Store) UpsertEntities(ctx context.Context, rows []map[string]any) ([]string, error) { - if len(rows) == 0 { - return nil, nil - } - cypher := ` -UNWIND $rows AS row -MERGE (e:Entity {name_lower: row.name_lower}) -ON CREATE SET e.eid = row.eid, e.created_at = timestamp(), - e.aliases = [], e.mention_count = 0 -SET e.name = row.name, - e.updated_at = timestamp(), - e.type = CASE WHEN coalesce(row.type,'') <> '' - THEN row.type ELSE coalesce(e.type, 'other') END, - e.description = CASE WHEN coalesce(row.description,'') <> '' - THEN row.description ELSE coalesce(e.description,'') END, - e.mention_count = coalesce(e.mention_count, 0) + 1 -FOREACH (_ IN CASE WHEN coalesce(row.alias,'') <> '' - AND NOT row.alias IN coalesce(e.aliases, []) - THEN [1] ELSE [] END | - SET e.aliases = coalesce(e.aliases, []) + row.alias -) -RETURN DISTINCT e.eid AS eid` - res, err := g.run(ctx, cypher, map[string]any{"rows": rows}) - if err != nil { - return nil, err - } - eids := make([]string, 0, len(res)) - for _, r := range res { - eids = append(eids, asString(r["eid"])) - } - return eids, nil -} - -// LinkAppearances writes APPEARS_IN evidence edges for (bvid, page_index). -// Same-page duplicates are resolved to the shortest non-empty quote (more -// focused), mirroring the Python dedup. Returns the linked edge count. -func (g *Store) LinkAppearances(ctx context.Context, bvid string, pageIndex int32, appearances []map[string]any) (int64, error) { - dedup := map[string]string{} - var order []string - for _, a := range appearances { - eid, _ := a["eid"].(string) - if eid == "" { - continue - } - quote, _ := a["quote"].(string) - cur, ok := dedup[eid] - if !ok { - order = append(order, eid) - dedup[eid] = quote - continue - } - if quote != "" && (cur == "" || len(quote) < len(cur)) { - dedup[eid] = quote - } - } - if len(order) == 0 { - return 0, nil - } - rows := make([]map[string]any, 0, len(order)) - for _, eid := range order { - rows = append(rows, map[string]any{"eid": eid, "quote": dedup[eid]}) - } - cypher := ` -MERGE (v:Video {bvid: $bvid}) -WITH v -UNWIND $rows AS row -MATCH (e:Entity {eid: row.eid}) -MERGE (e)-[ev:APPEARS_IN {page_index: $page_index}]->(v) -ON CREATE SET ev.created_at = timestamp() -SET ev.quote = row.quote, ev.updated_at = timestamp() -RETURN count(ev) AS linked` - res, err := g.run(ctx, cypher, map[string]any{ - "bvid": bvid, "page_index": pageIndex, "rows": rows, - }) - if err != nil { - return 0, err - } - if len(res) == 0 { - return 0, nil - } - return asInt64(res[0]["linked"]), nil -} - -// UpsertRelations bulk-MERGEs RELATES edges: weight increments, quotes keep -// the most recent dedup samples within MaxRelationQuotes. -func (g *Store) UpsertRelations(ctx context.Context, relations []map[string]any) error { - seen := map[string]bool{} - rows := make([]map[string]any, 0, len(relations)) - for _, rel := range relations { - key := asString(rel["src_eid"]) + "|" + asString(rel["dst_eid"]) + "|" + asString(rel["rel_type"]) - if seen[key] { - continue - } - seen[key] = true - rows = append(rows, rel) - } - if len(rows) == 0 { - return nil - } - cypher := ` -UNWIND $rows AS row -MATCH (a:Entity {eid: row.src_eid}) -MATCH (b:Entity {eid: row.dst_eid}) -MERGE (a)-[r:RELATES {rel_type: row.rel_type}]->(b) -SET r.weight = coalesce(r.weight, 0) + 1, - r.updated_at = timestamp(), - r.quotes = CASE - WHEN coalesce(row.quote,'') = '' THEN coalesce(r.quotes, []) - WHEN row.quote IN coalesce(r.quotes, []) THEN r.quotes - WHEN size(coalesce(r.quotes, [])) < $max_quotes - THEN coalesce(r.quotes, []) + row.quote - ELSE coalesce(r.quotes, [])[1..] + row.quote - END` - _, err := g.run(ctx, cypher, map[string]any{"rows": rows, "max_quotes": MaxRelationQuotes}) - return err -} - -// ── deletion / cleanup ─────────────────────────────────────────────── - -// DeletePageEvidence removes all evidence edges of one page -// (delete-before-write). Returns the deleted edge count. -func (g *Store) DeletePageEvidence(ctx context.Context, bvid string, pageIndex int32) (int64, error) { - cypher := ` -MATCH (:Video {bvid: $bvid})<-[ev:APPEARS_IN]-(:Entity) -WHERE ev.page_index = $page_index -DELETE ev -RETURN count(ev) AS deleted` - res, err := g.run(ctx, cypher, map[string]any{"bvid": bvid, "page_index": pageIndex}) - if err != nil { - return 0, err - } - if len(res) == 0 { - return 0, nil - } - return asInt64(res[0]["deleted"]), nil -} - -// DeleteVideo cascades all graph traces of a video and collects orphan -// nodes. Returns {entitiesRemoved, videosRemoved}. -func (g *Store) DeleteVideo(ctx context.Context, bvid string) (entitiesRemoved, videosRemoved int64, err error) { - session := g.st.NewSession(ctx) - defer session.Close(ctx) - - runCount := func(cypher string, params map[string]any) (int64, error) { - result, err := session.Run(ctx, cypher, params) - if err != nil { - return 0, err - } - records, err := result.Collect(ctx) - if err != nil { - return 0, err - } - if len(records) == 0 { - return 0, nil - } - return asInt64(records[0].Values[0]), nil - } - - videosRemoved, err = runCount( - "MATCH (v:Video {bvid: $bvid}) DETACH DELETE v RETURN count(v) AS deleted", - map[string]any{"bvid": bvid}) - if err != nil { - return 0, 0, err - } - entitiesRemoved, err = runCount( - "MATCH (e:Entity) WHERE NOT (e)--() DELETE e RETURN count(e) AS removed", - nil) - if err != nil { - return 0, 0, err - } - videosRemovedExtra, err := runCount( - "MATCH (v:Video) WHERE NOT ()-[:APPEARS_IN]->(v) DELETE v RETURN count(v) AS removed", - nil) - if err != nil { - return 0, 0, err - } - videosRemoved += videosRemovedExtra - if entitiesRemoved != 0 || videosRemoved != 0 { - slog.Info("[KG_REPO] delete_video", "bvid", bvid, - "entities_removed", entitiesRemoved, "videos_removed", videosRemoved) - } - return entitiesRemoved, videosRemoved, nil -} - -// ClearAll wipes every Entity/Video node (dangerous; admin/test only). -// Nodes with other labels are untouched. -func (g *Store) ClearAll(ctx context.Context) error { - _, err := g.run(ctx, "MATCH (n) WHERE n:Entity OR n:Video DETACH DELETE n", nil) - if err != nil { - return err - } - slog.Warn("[KG_REPO] graph cleared (all Entity/Video nodes)") - return nil -} - -// ── retrieval reads ────────────────────────────────────────────────── - -// Neighbors is the one-hop BFS expansion: neighbour entities of the frontier -// eids with their connecting relation info. -func (g *Store) Neighbors(ctx context.Context, eids, excludeEids []string, limit int32) ([]map[string]any, error) { - if len(eids) == 0 { - return nil, nil - } - cypher := ` -UNWIND $eids AS eid -MATCH (e:Entity {eid: eid})-[r:RELATES]-(n:Entity) -WHERE n.eid <> eid AND NOT n.eid IN $exclude -RETURN DISTINCT n.eid AS eid, n.name AS name, n.type AS type, - n.description AS description, r.rel_type AS rel_type, - e.eid AS from_eid -LIMIT $limit` - return g.run(ctx, cypher, map[string]any{ - "eids": eids, "exclude": excludeEids, "limit": limit, - }) -} - -// FetchEvidence pulls APPEARS_IN evidence rows; empty bvids = no scope -// filter (admin/debug semantics, mirroring the Python repository). -func (g *Store) FetchEvidence(ctx context.Context, eids, bvids []string, limit int32) ([]map[string]any, error) { - if len(eids) == 0 { - return nil, nil - } - if bvids == nil { - bvids = []string{} - } - cypher := ` -UNWIND $eids AS eid -MATCH (e:Entity {eid: eid})-[ev:APPEARS_IN]->(v:Video) -WHERE size($bvids) = 0 OR v.bvid IN $bvids -RETURN e.eid AS eid, e.name AS name, e.type AS type, - v.bvid AS bvid, ev.page_index AS page_index, ev.quote AS quote -LIMIT $limit` - return g.run(ctx, cypher, map[string]any{ - "eids": eids, "bvids": bvids, "limit": limit, - }) -} - -// RelationsAmong returns the relation edges between subgraph members. -func (g *Store) RelationsAmong(ctx context.Context, eids []string) ([]map[string]any, error) { - if len(eids) < 2 { - return nil, nil - } - cypher := ` -UNWIND $eids AS eid -MATCH (a:Entity {eid: eid})-[r:RELATES]->(b:Entity) -WHERE b.eid IN $eids -RETURN DISTINCT a.eid AS src_eid, a.name AS src_name, - r.rel_type AS rel_type, b.eid AS dst_eid, b.name AS dst_name, - coalesce(r.weight, 1) AS weight, coalesce(r.quotes, []) AS quotes` - return g.run(ctx, cypher, map[string]any{"eids": eids}) -} - -// EntitiesByEids fetches entity details. -func (g *Store) EntitiesByEids(ctx context.Context, eids []string) ([]map[string]any, error) { - if len(eids) == 0 { - return nil, nil - } - cypher := ` -UNWIND $eids AS eid -MATCH (e:Entity {eid: eid}) -RETURN e.eid AS eid, e.name AS name, e.type AS type, - e.description AS description, coalesce(e.aliases, []) AS aliases` - return g.run(ctx, cypher, map[string]any{"eids": eids}) -} - -// EntityExposure aggregates APPEARS_IN page counts per entity within scope -// (blindspot map). Empty bvids returns nil — callers must pass a scope -// (different from FetchEvidence where empty means "no filter"). -func (g *Store) EntityExposure(ctx context.Context, bvids []string, limit int32) ([]map[string]any, error) { - if len(bvids) == 0 { - return nil, nil - } - cypher := ` -MATCH (e:Entity)-[ev:APPEARS_IN]->(v:Video) -WHERE v.bvid IN $bvids -WITH e, count(ev) AS pages, - collect({bvid: v.bvid, page_index: ev.page_index, quote: ev.quote})[..5] - AS evidence_sample -RETURN e.eid AS eid, e.name AS name, e.type AS type, - e.description AS description, - pages, evidence_sample -ORDER BY pages DESC -LIMIT $limit` - return g.run(ctx, cypher, map[string]any{"bvids": bvids, "limit": limit}) -} - -// EntityAppearances lists all appearances of one entity. -func (g *Store) EntityAppearances(ctx context.Context, eid string, bvids []string, limit int32) ([]map[string]any, error) { - if bvids == nil { - bvids = []string{} - } - cypher := ` -MATCH (e:Entity {eid: $eid})-[ev:APPEARS_IN]->(v:Video) -WHERE size($bvids) = 0 OR v.bvid IN $bvids -RETURN v.bvid AS bvid, ev.page_index AS page_index, - ev.quote AS quote -ORDER BY v.bvid, ev.page_index -LIMIT $limit` - return g.run(ctx, cypher, map[string]any{"eid": eid, "bvids": bvids, "limit": limit}) -} - -// TopEntities ranks head entities by RELATES degree (overview mode). -func (g *Store) TopEntities(ctx context.Context, limit int32) ([]map[string]any, error) { - cypher := ` -MATCH (e:Entity)-[r:RELATES]-() -RETURN e.eid AS eid, e.name AS name, e.type AS type, - e.description AS description, count(r) AS degree -ORDER BY degree DESC -LIMIT $limit` - return g.run(ctx, cypher, map[string]any{"limit": limit}) -} - -// EdgesAmong returns directed relation edges inside an entity set (undirected -// dedup is the caller's job). -func (g *Store) EdgesAmong(ctx context.Context, eids []string) ([]map[string]any, error) { - if len(eids) < 2 { - return nil, nil - } - cypher := ` -UNWIND $eids AS eid -MATCH (a:Entity {eid: eid})-[r:RELATES]->(b:Entity) -WHERE b.eid IN $eids -RETURN DISTINCT a.eid AS src_eid, b.eid AS dst_eid, - r.rel_type AS rel_type` - return g.run(ctx, cypher, map[string]any{"eids": eids}) -} - -// GraphStats holds the Neo4j-side counters (service.stats composition adds -// Milvus + pending counts at the caller). -type GraphStats struct { - Entities int64 - Relations int64 - Evidence int64 - Videos int64 -} - -// Stats returns the four graph counters. CALL () { ... } scope clauses are -// required on Neo4j 5.23+ (bare CALL {} deprecated) — same as Python. -func (g *Store) Stats(ctx context.Context) (GraphStats, error) { - cypher := ` -CALL () { - MATCH (e:Entity) RETURN count(e) AS entities -} -CALL () { - MATCH ()-[r:RELATES]->() RETURN count(r) AS relations -} -CALL () { - MATCH ()-[a:APPEARS_IN]->() RETURN count(a) as evidence -} -CALL () { - MATCH (v:Video) RETURN count(v) AS videos -} -RETURN entities, relations, evidence, videos` - res, err := g.run(ctx, cypher, nil) - if err != nil { - return GraphStats{}, err - } - var out GraphStats - if len(res) > 0 { - out.Entities = asInt64(res[0]["entities"]) - out.Relations = asInt64(res[0]["relations"]) - out.Evidence = asInt64(res[0]["evidence"]) - out.Videos = asInt64(res[0]["videos"]) - } - return out, nil -} - -// ── value coercion helpers ─────────────────────────────────────────── - -func asString(v any) string { - if s, ok := v.(string); ok { - return s - } - if v == nil { - return "" - } - return fmt.Sprintf("%v", v) -} - -func asInt64(v any) int64 { - switch n := v.(type) { - case int64: - return n - case int: - return int64(n) - case float64: - return int64(n) - case nil: - return 0 - default: - return 0 - } -} diff --git a/app-go/app-kg/internal/graph/graph_test.go b/app-go/app-kg/internal/graph/graph_test.go deleted file mode 100644 index 71c241a..0000000 --- a/app-go/app-kg/internal/graph/graph_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package graph - -import ( - "strings" - "testing" -) - -// Guard: every Cypher statement issued by this package must MERGE on the -// canonical keys (name_lower for Entity, bvid for Video) and never normalize -// names itself — normalization (name_lower/eid) is the CALLER's job and the -// single implementation lives in app-ts's resolver / Python's repository. -// This pins the transition-time dual-write contract with the Python -// KgGraphRepository. -func TestMergeKeysAndNoNormalization(t *testing.T) { - src := readSource(t) - checks := []struct { - name string - must []string - mustNot []string - }{ - { - name: "UpsertEntities", - must: []string{"MERGE (e:Entity {name_lower: row.name_lower})", "ON CREATE SET e.eid = row.eid"}, - mustNot: []string{ - // sha1/normalization must never happen in Cypher or Go code here - "sha1", "toLower(row.name_lower)", - }, - }, - { - name: "LinkAppearances", - must: []string{"MERGE (v:Video {bvid: $bvid})", "MERGE (e)-[ev:APPEARS_IN {page_index: $page_index}]->(v)"}, - }, - { - name: "UpsertRelations", - must: []string{"MERGE (a)-[r:RELATES {rel_type: row.rel_type}]->(b)"}, - }, - } - for _, c := range checks { - segment := segmentFor(t, src, c.name) - for _, m := range c.must { - if !strings.Contains(segment, m) { - t.Errorf("%s: Cypher missing canonical fragment %q", c.name, m) - } - } - for _, banned := range c.mustNot { - if strings.Contains(strings.ToLower(segment), strings.ToLower(banned)) { - t.Errorf("%s: Cypher contains banned fragment %q (normalization belongs to the caller)", c.name, banned) - } - } - } -} - -// Guard: the relation quote sliding window must stay at 3 (parity with the -// Python MAX_RELATION_QUOTES). -func TestMaxRelationQuotes(t *testing.T) { - if MaxRelationQuotes != 3 { - t.Fatalf("MaxRelationQuotes = %d, want 3 (parity with Python)", MaxRelationQuotes) - } -} - -func segmentFor(t *testing.T, src string, fn string) string { - t.Helper() - idx := strings.Index(src, "func (g *Store) "+fn+"(") - if idx < 0 { - t.Fatalf("method %s not found", fn) - } - rest := src[idx:] - end := strings.Index(rest, "\nfunc ") - if end < 0 { - end = len(rest) - } - return rest[:end] -} diff --git a/app-go/app-kg/internal/graph/source_test.go b/app-go/app-kg/internal/graph/source_test.go deleted file mode 100644 index 8b1be5b..0000000 --- a/app-go/app-kg/internal/graph/source_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package graph - -import ( - "os" - "strings" - "testing" -) - -// readSource loads graph.go for structural assertions (no external deps). -func readSource(t *testing.T) string { - t.Helper() - b, err := os.ReadFile("graph.go") - if err != nil { - t.Fatalf("read graph.go: %v", err) - } - return string(b) -} - -var _ = strings.Contains diff --git a/app-go/app-kg/internal/infra/mysql/config.go b/app-go/app-kg/internal/infra/mysql/config.go new file mode 100644 index 0000000..255d1fe --- /dev/null +++ b/app-go/app-kg/internal/infra/mysql/config.go @@ -0,0 +1,32 @@ +// Package mysql owns the MySQL/GORM initialization of app-kg: the connection +// settings (this file) and the connection itself (mysql.go). +// +// Migration policy: app-kg owns NO tables. The video.kg_status/kg_version +// columns already exist (created by the Python backend via db/system.sql); +// this service is their ONLY writer, and it reads collection/favorite_folders +// read-only. GORM AutoMigrate is deliberately NOT used — zero DDL here. +package mysql + +import ( + "errors" + "strings" +) + +// Config is the `rdbms:` block of default.yaml. It is declared in the infra +// layer — next to the code that consumes it — and embedded by +// internal/config, which remains the only place that loads YAML/env. +type Config struct { + URL string `yaml:"url"` + MaxOpenConns int `yaml:"max_open_conns"` + MaxIdleConns int `yaml:"max_idle_conns"` + ConnMaxLifetime int `yaml:"conn_max_lifetime"` +} + +// Validate fails loud on a missing DSN: the service cannot run without the +// shared bilirag MySQL (bookkeeping + scope resolution). +func (c Config) Validate() error { + if strings.TrimSpace(c.URL) == "" { + return errors.New("rdbms.url is required (APPKG__RDBMS__URL)") + } + return nil +} diff --git a/app-go/app-kg/internal/infra/mysql/mysql.go b/app-go/app-kg/internal/infra/mysql/mysql.go new file mode 100644 index 0000000..7e96ef0 --- /dev/null +++ b/app-go/app-kg/internal/infra/mysql/mysql.go @@ -0,0 +1,96 @@ +package mysql + +import ( + "context" + "fmt" + "strings" + "time" + + "app-kg/internal/logger" + + mysqlDriver "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// DB is the initialized GORM handle. Repos receive Gorm() and never touch the +// driver or the pool directly. +type DB struct { + db *gorm.DB +} + +// Open initializes the connection pool against the SHARED bilirag database. +// dsn may be SQLAlchemy-style (mysql+aiomysql://...). +// +// loc=UTC: the KG contract compares version/kg_version ints only — datetimes +// are not part of it; UTC keeps any incidental reads consistent with the +// Python backend's naive-UTC writes. +func Open(cfg Config, debug bool) (*DB, error) { + gormDSN := toGormDSN(cfg.URL) + d, err := gorm.Open(mysqlDriver.Open(gormDSN), &gorm.Config{Logger: logger.NewGORMLogger(debug)}) + if err != nil { + return nil, fmt.Errorf("open mysql: %w", err) + } + sqlDB, err := d.DB() + if err != nil { + return nil, fmt.Errorf("get *sql.DB: %w", err) + } + sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) + return &DB{db: d}, nil +} + +// Gorm exposes the handle for repositories. +func (d *DB) Gorm() *gorm.DB { + if d == nil { + return nil + } + return d.db +} + +// Close releases the pool. +func (d *DB) Close() error { + if d == nil || d.db == nil { + return nil + } + sqlDB, err := d.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +// Ping verifies the pool with a real round-trip (health probe). +func (d *DB) Ping(ctx context.Context) error { + if d == nil || d.db == nil { + return fmt.Errorf("mysql not connected") + } + pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + return d.db.WithContext(pingCtx).Exec("SELECT 1").Error +} + +// toGormDSN converts mysql+aiomysql://user:pass@host:port/db +// to user:pass@tcp(host:port)/db?charset=utf8mb4&parseTime=True&loc=UTC. +func toGormDSN(sqlalchemyURL string) string { + s := sqlalchemyURL + for _, p := range []string{"mysql+aiomysql://", "mysql+pymysql://", "mysql://"} { + s = strings.TrimPrefix(s, p) + } + atIdx := strings.LastIndex(s, "@") + if atIdx < 0 { + return s + "?charset=utf8mb4&parseTime=True&loc=UTC" + } + userPass := s[:atIdx] + hostDB := s[atIdx+1:] + slashIdx := strings.Index(hostDB, "/") + if slashIdx < 0 { + return userPass + "@tcp(" + hostDB + ")/?charset=utf8mb4&parseTime=True&loc=UTC" + } + hostPort := hostDB[:slashIdx] + rest := hostDB[slashIdx+1:] + if qIdx := strings.Index(rest, "?"); qIdx >= 0 { + rest = rest[:qIdx] + } + return userPass + "@tcp(" + hostPort + ")/" + rest + "?charset=utf8mb4&parseTime=True&loc=UTC" +} diff --git a/app-go/app-kg/internal/infra/neo4j/config.go b/app-go/app-kg/internal/infra/neo4j/config.go new file mode 100644 index 0000000..29989e5 --- /dev/null +++ b/app-go/app-kg/internal/infra/neo4j/config.go @@ -0,0 +1,34 @@ +// Package neo4j owns the Neo4j driver lifecycle of app-kg: the connection +// settings (this file), the driver/session plumbing, and the uniqueness +// constraints the graph depends on (neo4j.go). +package neo4j + +import ( + "fmt" + "strings" +) + +// Config is the `neo4j:` block of default.yaml. Declared in the infra layer +// next to the code that consumes it; internal/config embeds it and stays the +// only YAML/env entry point. +type Config struct { + URI string `yaml:"uri"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Database string `yaml:"database"` +} + +// Validate fails loud on a missing URI (this service is useless without the +// graph store) and applies the protocol defaults for username/database. +func (c *Config) Validate() error { + if strings.TrimSpace(c.URI) == "" { + return fmt.Errorf("neo4j.uri is required (KG__URI)") + } + if strings.TrimSpace(c.Database) == "" { + c.Database = "neo4j" + } + if strings.TrimSpace(c.Username) == "" { + c.Username = "neo4j" + } + return nil +} diff --git a/app-go/app-kg/internal/neo4jstore/neo4jstore.go b/app-go/app-kg/internal/infra/neo4j/neo4j.go similarity index 51% rename from app-go/app-kg/internal/neo4jstore/neo4jstore.go rename to app-go/app-kg/internal/infra/neo4j/neo4j.go index 817760e..34e7597 100644 --- a/app-go/app-kg/internal/neo4jstore/neo4jstore.go +++ b/app-go/app-kg/internal/infra/neo4j/neo4j.go @@ -1,27 +1,25 @@ -// Package neo4jstore owns the Neo4j driver lifecycle and the uniqueness -// constraints (port of app/infra/neo4j.py init/ensure_constraints/close). -package neo4jstore +package neo4j import ( "context" "fmt" "log/slog" - "strings" "time" - "github.com/neo4j/neo4j-go-driver/v5/neo4j" + neo4jDriver "github.com/neo4j/neo4j-go-driver/v5/neo4j" ) -// Store wraps the driver + database name. +// Store wraps the driver + database name. Repositories open sessions from it; +// nothing outside infra touches the driver. type Store struct { - driver neo4j.DriverWithContext + driver neo4jDriver.DriverWithContext database string } -// Connect opens the driver and verifies connectivity. Uri empty → error is -// returned by the caller (config validation already guarantees non-empty). -func Connect(ctx context.Context, uri, username, password, database string) (*Store, error) { - driver, err := neo4j.NewDriverWithContext(uri, neo4j.BasicAuth(username, password, "")) +// Connect opens the driver and verifies connectivity (config validation +// already guarantees a non-empty URI). +func Connect(ctx context.Context, cfg Config) (*Store, error) { + driver, err := neo4jDriver.NewDriverWithContext(cfg.URI, neo4jDriver.BasicAuth(cfg.Username, cfg.Password, "")) if err != nil { return nil, fmt.Errorf("create neo4j driver: %w", err) } @@ -31,10 +29,10 @@ func Connect(ctx context.Context, uri, username, password, database string) (*St _ = driver.Close(ctx) return nil, fmt.Errorf("neo4j connectivity check failed: %w", err) } - return &Store{driver: driver, database: database}, nil + return &Store{driver: driver, database: cfg.Database}, nil } -// Close disconnects. +// Close disconnects (nil-safe). func (s *Store) Close(ctx context.Context) error { if s == nil || s.driver == nil { return nil @@ -42,29 +40,13 @@ func (s *Store) Close(ctx context.Context) error { return s.driver.Close(ctx) } -// Driver exposes the driver for the graph repository. -func (s *Store) Driver() neo4j.DriverWithContext { - if s == nil { - return nil - } - return s.driver -} - -// Database returns the configured database name. -func (s *Store) Database() string { - if s == nil { - return "neo4j" - } - return s.database -} - // NewSession opens a session bound to the configured database. -func (s *Store) NewSession(ctx context.Context) neo4j.SessionWithContext { - return s.driver.NewSession(ctx, neo4j.SessionConfig{DatabaseName: s.database}) +func (s *Store) NewSession(ctx context.Context) neo4jDriver.SessionWithContext { + return s.driver.NewSession(ctx, neo4jDriver.SessionConfig{DatabaseName: s.database}) } // EnsureConstraints creates the same uniqueness constraints the Python -// backend declares (idempotent). Port of infra/neo4j.ensure_constraints. +// backend declared (idempotent). Called once at boot. func (s *Store) EnsureConstraints(ctx context.Context) error { stmts := []string{ "CREATE CONSTRAINT kg_entity_eid IF NOT EXISTS FOR (e:Entity) REQUIRE e.eid IS UNIQUE", @@ -82,7 +64,7 @@ func (s *Store) EnsureConstraints(ctx context.Context) error { return nil } -// Ping measures driver-level connectivity latency (health endpoint). +// Ping measures driver-level connectivity (health probe). func (s *Store) Ping(ctx context.Context) error { if s == nil || s.driver == nil { return fmt.Errorf("neo4j not connected") @@ -91,11 +73,3 @@ func (s *Store) Ping(ctx context.Context) error { defer cancel() return s.driver.VerifyConnectivity(pingCtx) } - -// SanitizeDatabase guards against empty database names at call sites. -func SanitizeDatabase(name string) string { - if strings.TrimSpace(name) == "" { - return "neo4j" - } - return name -} diff --git a/app-go/app-kg/internal/kgpb/kg.pb.go b/app-go/app-kg/internal/kgpb/kg.pb.go index d58f113..e52b205 100644 --- a/app-go/app-kg/internal/kgpb/kg.pb.go +++ b/app-go/app-kg/internal/kgpb/kg.pb.go @@ -5,6 +5,12 @@ // video.kg_status/kg_version 记账 + 收藏夹 scope 解析。本服务不感知 // uid 之外的任何业务语义,不发起任何 LLM/embedding 调用。 // +// 知识点来源(source)有两类,统一进同一张 Entity/RELATES 知识图: +// - 收藏夹视频分P:(:Video {bvid}),证据边 APPEARS_IN {page_index, quote} +// - 云盘文档:(:CloudDoc {uid, upload_uuid, title, content_hash})(节点由 +// 云盘 L1 路由图创建/复用),证据边 APPEARS_IN {quote} +// 因此云盘文档的知识点与视频共用一个实体/关系空间,可跨来源检索与关联。 +// // 过渡期共享存储:Python KgGraphRepository 与本服务双写同一 Neo4j // (同 schema、确定性 eid=sha1(name_lower)[:16],merge 键 name_lower)。 // name_lower 与 eid 由调用方(app-ts resolver / Python repository) @@ -14,7 +20,7 @@ // versions: // protoc-gen-go v1.36.12 // protoc v6.31.1 -// source: proto/kg/v1/kg.proto +// source: kg/v1/kg.proto package kgpb @@ -43,7 +49,7 @@ type ResolveScopeRequest struct { func (x *ResolveScopeRequest) Reset() { *x = ResolveScopeRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[0] + mi := &file_kg_v1_kg_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -55,7 +61,7 @@ func (x *ResolveScopeRequest) String() string { func (*ResolveScopeRequest) ProtoMessage() {} func (x *ResolveScopeRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[0] + mi := &file_kg_v1_kg_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -68,7 +74,7 @@ func (x *ResolveScopeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResolveScopeRequest.ProtoReflect.Descriptor instead. func (*ResolveScopeRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{0} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{0} } func (x *ResolveScopeRequest) GetUid() int64 { @@ -94,7 +100,7 @@ type ResolveScopeResponse struct { func (x *ResolveScopeResponse) Reset() { *x = ResolveScopeResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[1] + mi := &file_kg_v1_kg_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -106,7 +112,7 @@ func (x *ResolveScopeResponse) String() string { func (*ResolveScopeResponse) ProtoMessage() {} func (x *ResolveScopeResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[1] + mi := &file_kg_v1_kg_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -119,7 +125,7 @@ func (x *ResolveScopeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResolveScopeResponse.ProtoReflect.Descriptor instead. func (*ResolveScopeResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{1} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{1} } func (x *ResolveScopeResponse) GetBvids() []string { @@ -139,7 +145,7 @@ type SelectPendingPagesRequest struct { func (x *SelectPendingPagesRequest) Reset() { *x = SelectPendingPagesRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[2] + mi := &file_kg_v1_kg_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -151,7 +157,7 @@ func (x *SelectPendingPagesRequest) String() string { func (*SelectPendingPagesRequest) ProtoMessage() {} func (x *SelectPendingPagesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[2] + mi := &file_kg_v1_kg_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -164,7 +170,7 @@ func (x *SelectPendingPagesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectPendingPagesRequest.ProtoReflect.Descriptor instead. func (*SelectPendingPagesRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{2} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{2} } func (x *SelectPendingPagesRequest) GetBvids() []string { @@ -194,7 +200,7 @@ type PendingPage struct { func (x *PendingPage) Reset() { *x = PendingPage{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[3] + mi := &file_kg_v1_kg_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -206,7 +212,7 @@ func (x *PendingPage) String() string { func (*PendingPage) ProtoMessage() {} func (x *PendingPage) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[3] + mi := &file_kg_v1_kg_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -219,7 +225,7 @@ func (x *PendingPage) ProtoReflect() protoreflect.Message { // Deprecated: Use PendingPage.ProtoReflect.Descriptor instead. func (*PendingPage) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{3} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{3} } func (x *PendingPage) GetBvid() string { @@ -266,7 +272,7 @@ type SelectPendingPagesResponse struct { func (x *SelectPendingPagesResponse) Reset() { *x = SelectPendingPagesResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[4] + mi := &file_kg_v1_kg_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -278,7 +284,7 @@ func (x *SelectPendingPagesResponse) String() string { func (*SelectPendingPagesResponse) ProtoMessage() {} func (x *SelectPendingPagesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[4] + mi := &file_kg_v1_kg_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -291,7 +297,7 @@ func (x *SelectPendingPagesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectPendingPagesResponse.ProtoReflect.Descriptor instead. func (*SelectPendingPagesResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{4} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{4} } func (x *SelectPendingPagesResponse) GetPages() []*PendingPage { @@ -313,7 +319,7 @@ type BeginPageRequest struct { func (x *BeginPageRequest) Reset() { *x = BeginPageRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[5] + mi := &file_kg_v1_kg_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -325,7 +331,7 @@ func (x *BeginPageRequest) String() string { func (*BeginPageRequest) ProtoMessage() {} func (x *BeginPageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[5] + mi := &file_kg_v1_kg_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -338,7 +344,7 @@ func (x *BeginPageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginPageRequest.ProtoReflect.Descriptor instead. func (*BeginPageRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{5} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{5} } func (x *BeginPageRequest) GetBvid() string { @@ -377,7 +383,7 @@ type BeginPageResponse struct { func (x *BeginPageResponse) Reset() { *x = BeginPageResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[6] + mi := &file_kg_v1_kg_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -389,7 +395,7 @@ func (x *BeginPageResponse) String() string { func (*BeginPageResponse) ProtoMessage() {} func (x *BeginPageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[6] + mi := &file_kg_v1_kg_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -402,7 +408,7 @@ func (x *BeginPageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginPageResponse.ProtoReflect.Descriptor instead. func (*BeginPageResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{6} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{6} } func (x *BeginPageResponse) GetFound() bool { @@ -465,7 +471,7 @@ type MarkPageDoneRequest struct { func (x *MarkPageDoneRequest) Reset() { *x = MarkPageDoneRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[7] + mi := &file_kg_v1_kg_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -477,7 +483,7 @@ func (x *MarkPageDoneRequest) String() string { func (*MarkPageDoneRequest) ProtoMessage() {} func (x *MarkPageDoneRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[7] + mi := &file_kg_v1_kg_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -490,7 +496,7 @@ func (x *MarkPageDoneRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MarkPageDoneRequest.ProtoReflect.Descriptor instead. func (*MarkPageDoneRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{7} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{7} } func (x *MarkPageDoneRequest) GetBvid() string { @@ -522,7 +528,7 @@ type MarkPageDoneResponse struct { func (x *MarkPageDoneResponse) Reset() { *x = MarkPageDoneResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[8] + mi := &file_kg_v1_kg_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -534,7 +540,7 @@ func (x *MarkPageDoneResponse) String() string { func (*MarkPageDoneResponse) ProtoMessage() {} func (x *MarkPageDoneResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[8] + mi := &file_kg_v1_kg_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -547,7 +553,7 @@ func (x *MarkPageDoneResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MarkPageDoneResponse.ProtoReflect.Descriptor instead. func (*MarkPageDoneResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{8} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{8} } type MarkPageFailedRequest struct { @@ -560,7 +566,7 @@ type MarkPageFailedRequest struct { func (x *MarkPageFailedRequest) Reset() { *x = MarkPageFailedRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[9] + mi := &file_kg_v1_kg_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -572,7 +578,7 @@ func (x *MarkPageFailedRequest) String() string { func (*MarkPageFailedRequest) ProtoMessage() {} func (x *MarkPageFailedRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[9] + mi := &file_kg_v1_kg_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -585,7 +591,7 @@ func (x *MarkPageFailedRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MarkPageFailedRequest.ProtoReflect.Descriptor instead. func (*MarkPageFailedRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{9} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{9} } func (x *MarkPageFailedRequest) GetBvid() string { @@ -610,7 +616,7 @@ type MarkPageFailedResponse struct { func (x *MarkPageFailedResponse) Reset() { *x = MarkPageFailedResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[10] + mi := &file_kg_v1_kg_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -622,7 +628,7 @@ func (x *MarkPageFailedResponse) String() string { func (*MarkPageFailedResponse) ProtoMessage() {} func (x *MarkPageFailedResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[10] + mi := &file_kg_v1_kg_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -635,7 +641,7 @@ func (x *MarkPageFailedResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MarkPageFailedResponse.ProtoReflect.Descriptor instead. func (*MarkPageFailedResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{10} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{10} } type CountPendingPagesRequest struct { @@ -646,7 +652,7 @@ type CountPendingPagesRequest struct { func (x *CountPendingPagesRequest) Reset() { *x = CountPendingPagesRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[11] + mi := &file_kg_v1_kg_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -658,7 +664,7 @@ func (x *CountPendingPagesRequest) String() string { func (*CountPendingPagesRequest) ProtoMessage() {} func (x *CountPendingPagesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[11] + mi := &file_kg_v1_kg_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -671,7 +677,7 @@ func (x *CountPendingPagesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CountPendingPagesRequest.ProtoReflect.Descriptor instead. func (*CountPendingPagesRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{11} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{11} } type CountPendingPagesResponse struct { @@ -683,7 +689,7 @@ type CountPendingPagesResponse struct { func (x *CountPendingPagesResponse) Reset() { *x = CountPendingPagesResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[12] + mi := &file_kg_v1_kg_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -695,7 +701,7 @@ func (x *CountPendingPagesResponse) String() string { func (*CountPendingPagesResponse) ProtoMessage() {} func (x *CountPendingPagesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[12] + mi := &file_kg_v1_kg_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -708,7 +714,7 @@ func (x *CountPendingPagesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CountPendingPagesResponse.ProtoReflect.Descriptor instead. func (*CountPendingPagesResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{12} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{12} } func (x *CountPendingPagesResponse) GetCount() int64 { @@ -733,7 +739,7 @@ type EntityUpsert struct { func (x *EntityUpsert) Reset() { *x = EntityUpsert{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[13] + mi := &file_kg_v1_kg_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -745,7 +751,7 @@ func (x *EntityUpsert) String() string { func (*EntityUpsert) ProtoMessage() {} func (x *EntityUpsert) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[13] + mi := &file_kg_v1_kg_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -758,7 +764,7 @@ func (x *EntityUpsert) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityUpsert.ProtoReflect.Descriptor instead. func (*EntityUpsert) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{13} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{13} } func (x *EntityUpsert) GetEid() string { @@ -812,7 +818,7 @@ type UpsertEntitiesRequest struct { func (x *UpsertEntitiesRequest) Reset() { *x = UpsertEntitiesRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[14] + mi := &file_kg_v1_kg_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -824,7 +830,7 @@ func (x *UpsertEntitiesRequest) String() string { func (*UpsertEntitiesRequest) ProtoMessage() {} func (x *UpsertEntitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[14] + mi := &file_kg_v1_kg_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -837,7 +843,7 @@ func (x *UpsertEntitiesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpsertEntitiesRequest.ProtoReflect.Descriptor instead. func (*UpsertEntitiesRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{14} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{14} } func (x *UpsertEntitiesRequest) GetEntities() []*EntityUpsert { @@ -856,7 +862,7 @@ type UpsertEntitiesResponse struct { func (x *UpsertEntitiesResponse) Reset() { *x = UpsertEntitiesResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[15] + mi := &file_kg_v1_kg_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -868,7 +874,7 @@ func (x *UpsertEntitiesResponse) String() string { func (*UpsertEntitiesResponse) ProtoMessage() {} func (x *UpsertEntitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[15] + mi := &file_kg_v1_kg_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -881,7 +887,7 @@ func (x *UpsertEntitiesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpsertEntitiesResponse.ProtoReflect.Descriptor instead. func (*UpsertEntitiesResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{15} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{15} } func (x *UpsertEntitiesResponse) GetEids() []string { @@ -901,7 +907,7 @@ type Appearance struct { func (x *Appearance) Reset() { *x = Appearance{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[16] + mi := &file_kg_v1_kg_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -913,7 +919,7 @@ func (x *Appearance) String() string { func (*Appearance) ProtoMessage() {} func (x *Appearance) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[16] + mi := &file_kg_v1_kg_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -926,7 +932,7 @@ func (x *Appearance) ProtoReflect() protoreflect.Message { // Deprecated: Use Appearance.ProtoReflect.Descriptor instead. func (*Appearance) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{16} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{16} } func (x *Appearance) GetEid() string { @@ -954,7 +960,7 @@ type LinkAppearancesRequest struct { func (x *LinkAppearancesRequest) Reset() { *x = LinkAppearancesRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[17] + mi := &file_kg_v1_kg_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -966,7 +972,7 @@ func (x *LinkAppearancesRequest) String() string { func (*LinkAppearancesRequest) ProtoMessage() {} func (x *LinkAppearancesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[17] + mi := &file_kg_v1_kg_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -979,7 +985,7 @@ func (x *LinkAppearancesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LinkAppearancesRequest.ProtoReflect.Descriptor instead. func (*LinkAppearancesRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{17} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{17} } func (x *LinkAppearancesRequest) GetBvid() string { @@ -1012,7 +1018,7 @@ type LinkAppearancesResponse struct { func (x *LinkAppearancesResponse) Reset() { *x = LinkAppearancesResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[18] + mi := &file_kg_v1_kg_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1024,7 +1030,7 @@ func (x *LinkAppearancesResponse) String() string { func (*LinkAppearancesResponse) ProtoMessage() {} func (x *LinkAppearancesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[18] + mi := &file_kg_v1_kg_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1037,7 +1043,7 @@ func (x *LinkAppearancesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LinkAppearancesResponse.ProtoReflect.Descriptor instead. func (*LinkAppearancesResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{18} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{18} } func (x *LinkAppearancesResponse) GetLinked() int64 { @@ -1059,7 +1065,7 @@ type RelationUpsert struct { func (x *RelationUpsert) Reset() { *x = RelationUpsert{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[19] + mi := &file_kg_v1_kg_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1071,7 +1077,7 @@ func (x *RelationUpsert) String() string { func (*RelationUpsert) ProtoMessage() {} func (x *RelationUpsert) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[19] + mi := &file_kg_v1_kg_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1084,7 +1090,7 @@ func (x *RelationUpsert) ProtoReflect() protoreflect.Message { // Deprecated: Use RelationUpsert.ProtoReflect.Descriptor instead. func (*RelationUpsert) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{19} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{19} } func (x *RelationUpsert) GetSrcEid() string { @@ -1124,7 +1130,7 @@ type UpsertRelationsRequest struct { func (x *UpsertRelationsRequest) Reset() { *x = UpsertRelationsRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[20] + mi := &file_kg_v1_kg_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1136,7 +1142,7 @@ func (x *UpsertRelationsRequest) String() string { func (*UpsertRelationsRequest) ProtoMessage() {} func (x *UpsertRelationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[20] + mi := &file_kg_v1_kg_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1149,7 +1155,7 @@ func (x *UpsertRelationsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpsertRelationsRequest.ProtoReflect.Descriptor instead. func (*UpsertRelationsRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{20} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{20} } func (x *UpsertRelationsRequest) GetRelations() []*RelationUpsert { @@ -1167,7 +1173,7 @@ type UpsertRelationsResponse struct { func (x *UpsertRelationsResponse) Reset() { *x = UpsertRelationsResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[21] + mi := &file_kg_v1_kg_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1179,7 +1185,7 @@ func (x *UpsertRelationsResponse) String() string { func (*UpsertRelationsResponse) ProtoMessage() {} func (x *UpsertRelationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[21] + mi := &file_kg_v1_kg_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1192,7 +1198,7 @@ func (x *UpsertRelationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpsertRelationsResponse.ProtoReflect.Descriptor instead. func (*UpsertRelationsResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{21} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{21} } type DeletePageEvidenceRequest struct { @@ -1205,7 +1211,7 @@ type DeletePageEvidenceRequest struct { func (x *DeletePageEvidenceRequest) Reset() { *x = DeletePageEvidenceRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[22] + mi := &file_kg_v1_kg_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1217,7 +1223,7 @@ func (x *DeletePageEvidenceRequest) String() string { func (*DeletePageEvidenceRequest) ProtoMessage() {} func (x *DeletePageEvidenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[22] + mi := &file_kg_v1_kg_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1230,7 +1236,7 @@ func (x *DeletePageEvidenceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePageEvidenceRequest.ProtoReflect.Descriptor instead. func (*DeletePageEvidenceRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{22} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{22} } func (x *DeletePageEvidenceRequest) GetBvid() string { @@ -1256,7 +1262,7 @@ type DeletePageEvidenceResponse struct { func (x *DeletePageEvidenceResponse) Reset() { *x = DeletePageEvidenceResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[23] + mi := &file_kg_v1_kg_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1268,7 +1274,7 @@ func (x *DeletePageEvidenceResponse) String() string { func (*DeletePageEvidenceResponse) ProtoMessage() {} func (x *DeletePageEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[23] + mi := &file_kg_v1_kg_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1281,7 +1287,7 @@ func (x *DeletePageEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePageEvidenceResponse.ProtoReflect.Descriptor instead. func (*DeletePageEvidenceResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{23} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{23} } func (x *DeletePageEvidenceResponse) GetDeleted() int64 { @@ -1300,7 +1306,7 @@ type DeleteVideoRequest struct { func (x *DeleteVideoRequest) Reset() { *x = DeleteVideoRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[24] + mi := &file_kg_v1_kg_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1312,7 +1318,7 @@ func (x *DeleteVideoRequest) String() string { func (*DeleteVideoRequest) ProtoMessage() {} func (x *DeleteVideoRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[24] + mi := &file_kg_v1_kg_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1325,7 +1331,7 @@ func (x *DeleteVideoRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteVideoRequest.ProtoReflect.Descriptor instead. func (*DeleteVideoRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{24} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{24} } func (x *DeleteVideoRequest) GetBvid() string { @@ -1345,7 +1351,7 @@ type DeleteVideoResponse struct { func (x *DeleteVideoResponse) Reset() { *x = DeleteVideoResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[25] + mi := &file_kg_v1_kg_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1357,7 +1363,7 @@ func (x *DeleteVideoResponse) String() string { func (*DeleteVideoResponse) ProtoMessage() {} func (x *DeleteVideoResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[25] + mi := &file_kg_v1_kg_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1370,7 +1376,7 @@ func (x *DeleteVideoResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteVideoResponse.ProtoReflect.Descriptor instead. func (*DeleteVideoResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{25} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{25} } func (x *DeleteVideoResponse) GetEntitiesRemoved() int64 { @@ -1395,7 +1401,7 @@ type ClearAllRequest struct { func (x *ClearAllRequest) Reset() { *x = ClearAllRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[26] + mi := &file_kg_v1_kg_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1407,7 +1413,7 @@ func (x *ClearAllRequest) String() string { func (*ClearAllRequest) ProtoMessage() {} func (x *ClearAllRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[26] + mi := &file_kg_v1_kg_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1420,7 +1426,7 @@ func (x *ClearAllRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearAllRequest.ProtoReflect.Descriptor instead. func (*ClearAllRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{26} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{26} } type ClearAllResponse struct { @@ -1431,7 +1437,7 @@ type ClearAllResponse struct { func (x *ClearAllResponse) Reset() { *x = ClearAllResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[27] + mi := &file_kg_v1_kg_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1443,7 +1449,7 @@ func (x *ClearAllResponse) String() string { func (*ClearAllResponse) ProtoMessage() {} func (x *ClearAllResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[27] + mi := &file_kg_v1_kg_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1456,7 +1462,233 @@ func (x *ClearAllResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearAllResponse.ProtoReflect.Descriptor instead. func (*ClearAllResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{27} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{27} +} + +type LinkCloudDocAppearancesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Uid int64 `protobuf:"varint,1,opt,name=uid,proto3" json:"uid,omitempty"` + UploadUuid string `protobuf:"bytes,2,opt,name=upload_uuid,json=uploadUuid,proto3" json:"upload_uuid,omitempty"` + // 非空才覆盖 CloudDoc.title;空 = 保留路由图已有值。 + Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"` + // 非空才覆盖 CloudDoc.content_hash;空 = 保留路由图已有值。 + ContentHash string `protobuf:"bytes,4,opt,name=content_hash,json=contentHash,proto3" json:"content_hash,omitempty"` + Appearances []*Appearance `protobuf:"bytes,5,rep,name=appearances,proto3" json:"appearances,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LinkCloudDocAppearancesRequest) Reset() { + *x = LinkCloudDocAppearancesRequest{} + mi := &file_kg_v1_kg_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LinkCloudDocAppearancesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LinkCloudDocAppearancesRequest) ProtoMessage() {} + +func (x *LinkCloudDocAppearancesRequest) ProtoReflect() protoreflect.Message { + mi := &file_kg_v1_kg_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LinkCloudDocAppearancesRequest.ProtoReflect.Descriptor instead. +func (*LinkCloudDocAppearancesRequest) Descriptor() ([]byte, []int) { + return file_kg_v1_kg_proto_rawDescGZIP(), []int{28} +} + +func (x *LinkCloudDocAppearancesRequest) GetUid() int64 { + if x != nil { + return x.Uid + } + return 0 +} + +func (x *LinkCloudDocAppearancesRequest) GetUploadUuid() string { + if x != nil { + return x.UploadUuid + } + return "" +} + +func (x *LinkCloudDocAppearancesRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *LinkCloudDocAppearancesRequest) GetContentHash() string { + if x != nil { + return x.ContentHash + } + return "" +} + +func (x *LinkCloudDocAppearancesRequest) GetAppearances() []*Appearance { + if x != nil { + return x.Appearances + } + return nil +} + +type LinkCloudDocAppearancesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Linked int64 `protobuf:"varint,1,opt,name=linked,proto3" json:"linked,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LinkCloudDocAppearancesResponse) Reset() { + *x = LinkCloudDocAppearancesResponse{} + mi := &file_kg_v1_kg_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LinkCloudDocAppearancesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LinkCloudDocAppearancesResponse) ProtoMessage() {} + +func (x *LinkCloudDocAppearancesResponse) ProtoReflect() protoreflect.Message { + mi := &file_kg_v1_kg_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LinkCloudDocAppearancesResponse.ProtoReflect.Descriptor instead. +func (*LinkCloudDocAppearancesResponse) Descriptor() ([]byte, []int) { + return file_kg_v1_kg_proto_rawDescGZIP(), []int{29} +} + +func (x *LinkCloudDocAppearancesResponse) GetLinked() int64 { + if x != nil { + return x.Linked + } + return 0 +} + +type DeleteCloudDocAppearancesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Uid int64 `protobuf:"varint,1,opt,name=uid,proto3" json:"uid,omitempty"` + UploadUuid string `protobuf:"bytes,2,opt,name=upload_uuid,json=uploadUuid,proto3" json:"upload_uuid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteCloudDocAppearancesRequest) Reset() { + *x = DeleteCloudDocAppearancesRequest{} + mi := &file_kg_v1_kg_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteCloudDocAppearancesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteCloudDocAppearancesRequest) ProtoMessage() {} + +func (x *DeleteCloudDocAppearancesRequest) ProtoReflect() protoreflect.Message { + mi := &file_kg_v1_kg_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteCloudDocAppearancesRequest.ProtoReflect.Descriptor instead. +func (*DeleteCloudDocAppearancesRequest) Descriptor() ([]byte, []int) { + return file_kg_v1_kg_proto_rawDescGZIP(), []int{30} +} + +func (x *DeleteCloudDocAppearancesRequest) GetUid() int64 { + if x != nil { + return x.Uid + } + return 0 +} + +func (x *DeleteCloudDocAppearancesRequest) GetUploadUuid() string { + if x != nil { + return x.UploadUuid + } + return "" +} + +type DeleteCloudDocAppearancesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Deleted int64 `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + EntitiesRemoved int64 `protobuf:"varint,2,opt,name=entities_removed,json=entitiesRemoved,proto3" json:"entities_removed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteCloudDocAppearancesResponse) Reset() { + *x = DeleteCloudDocAppearancesResponse{} + mi := &file_kg_v1_kg_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteCloudDocAppearancesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteCloudDocAppearancesResponse) ProtoMessage() {} + +func (x *DeleteCloudDocAppearancesResponse) ProtoReflect() protoreflect.Message { + mi := &file_kg_v1_kg_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteCloudDocAppearancesResponse.ProtoReflect.Descriptor instead. +func (*DeleteCloudDocAppearancesResponse) Descriptor() ([]byte, []int) { + return file_kg_v1_kg_proto_rawDescGZIP(), []int{31} +} + +func (x *DeleteCloudDocAppearancesResponse) GetDeleted() int64 { + if x != nil { + return x.Deleted + } + return 0 +} + +func (x *DeleteCloudDocAppearancesResponse) GetEntitiesRemoved() int64 { + if x != nil { + return x.EntitiesRemoved + } + return 0 } type NeighborsRequest struct { @@ -1470,7 +1702,7 @@ type NeighborsRequest struct { func (x *NeighborsRequest) Reset() { *x = NeighborsRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[28] + mi := &file_kg_v1_kg_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1482,7 +1714,7 @@ func (x *NeighborsRequest) String() string { func (*NeighborsRequest) ProtoMessage() {} func (x *NeighborsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[28] + mi := &file_kg_v1_kg_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1495,7 +1727,7 @@ func (x *NeighborsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NeighborsRequest.ProtoReflect.Descriptor instead. func (*NeighborsRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{28} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{32} } func (x *NeighborsRequest) GetEids() []string { @@ -1533,7 +1765,7 @@ type NeighborRow struct { func (x *NeighborRow) Reset() { *x = NeighborRow{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[29] + mi := &file_kg_v1_kg_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1545,7 +1777,7 @@ func (x *NeighborRow) String() string { func (*NeighborRow) ProtoMessage() {} func (x *NeighborRow) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[29] + mi := &file_kg_v1_kg_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1558,7 +1790,7 @@ func (x *NeighborRow) ProtoReflect() protoreflect.Message { // Deprecated: Use NeighborRow.ProtoReflect.Descriptor instead. func (*NeighborRow) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{29} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{33} } func (x *NeighborRow) GetEid() string { @@ -1612,7 +1844,7 @@ type NeighborsResponse struct { func (x *NeighborsResponse) Reset() { *x = NeighborsResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[30] + mi := &file_kg_v1_kg_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1624,7 +1856,7 @@ func (x *NeighborsResponse) String() string { func (*NeighborsResponse) ProtoMessage() {} func (x *NeighborsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[30] + mi := &file_kg_v1_kg_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1637,7 +1869,7 @@ func (x *NeighborsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NeighborsResponse.ProtoReflect.Descriptor instead. func (*NeighborsResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{30} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{34} } func (x *NeighborsResponse) GetRows() []*NeighborRow { @@ -1658,7 +1890,7 @@ type FetchEvidenceRequest struct { func (x *FetchEvidenceRequest) Reset() { *x = FetchEvidenceRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[31] + mi := &file_kg_v1_kg_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1670,7 +1902,7 @@ func (x *FetchEvidenceRequest) String() string { func (*FetchEvidenceRequest) ProtoMessage() {} func (x *FetchEvidenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[31] + mi := &file_kg_v1_kg_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1683,7 +1915,7 @@ func (x *FetchEvidenceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FetchEvidenceRequest.ProtoReflect.Descriptor instead. func (*FetchEvidenceRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{31} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{35} } func (x *FetchEvidenceRequest) GetEids() []string { @@ -1722,7 +1954,7 @@ type EvidenceRow struct { func (x *EvidenceRow) Reset() { *x = EvidenceRow{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[32] + mi := &file_kg_v1_kg_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1734,7 +1966,7 @@ func (x *EvidenceRow) String() string { func (*EvidenceRow) ProtoMessage() {} func (x *EvidenceRow) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[32] + mi := &file_kg_v1_kg_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1747,7 +1979,7 @@ func (x *EvidenceRow) ProtoReflect() protoreflect.Message { // Deprecated: Use EvidenceRow.ProtoReflect.Descriptor instead. func (*EvidenceRow) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{32} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{36} } func (x *EvidenceRow) GetEid() string { @@ -1808,7 +2040,7 @@ type FetchEvidenceResponse struct { func (x *FetchEvidenceResponse) Reset() { *x = FetchEvidenceResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[33] + mi := &file_kg_v1_kg_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1820,7 +2052,7 @@ func (x *FetchEvidenceResponse) String() string { func (*FetchEvidenceResponse) ProtoMessage() {} func (x *FetchEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[33] + mi := &file_kg_v1_kg_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1833,7 +2065,7 @@ func (x *FetchEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FetchEvidenceResponse.ProtoReflect.Descriptor instead. func (*FetchEvidenceResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{33} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{37} } func (x *FetchEvidenceResponse) GetRows() []*EvidenceRow { @@ -1852,7 +2084,7 @@ type RelationsAmongRequest struct { func (x *RelationsAmongRequest) Reset() { *x = RelationsAmongRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[34] + mi := &file_kg_v1_kg_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1864,7 +2096,7 @@ func (x *RelationsAmongRequest) String() string { func (*RelationsAmongRequest) ProtoMessage() {} func (x *RelationsAmongRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[34] + mi := &file_kg_v1_kg_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1877,7 +2109,7 @@ func (x *RelationsAmongRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RelationsAmongRequest.ProtoReflect.Descriptor instead. func (*RelationsAmongRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{34} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{38} } func (x *RelationsAmongRequest) GetEids() []string { @@ -1902,7 +2134,7 @@ type RelationRow struct { func (x *RelationRow) Reset() { *x = RelationRow{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[35] + mi := &file_kg_v1_kg_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1914,7 +2146,7 @@ func (x *RelationRow) String() string { func (*RelationRow) ProtoMessage() {} func (x *RelationRow) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[35] + mi := &file_kg_v1_kg_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1927,7 +2159,7 @@ func (x *RelationRow) ProtoReflect() protoreflect.Message { // Deprecated: Use RelationRow.ProtoReflect.Descriptor instead. func (*RelationRow) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{35} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{39} } func (x *RelationRow) GetSrcEid() string { @@ -1988,7 +2220,7 @@ type RelationsAmongResponse struct { func (x *RelationsAmongResponse) Reset() { *x = RelationsAmongResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[36] + mi := &file_kg_v1_kg_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2000,7 +2232,7 @@ func (x *RelationsAmongResponse) String() string { func (*RelationsAmongResponse) ProtoMessage() {} func (x *RelationsAmongResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[36] + mi := &file_kg_v1_kg_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2013,7 +2245,7 @@ func (x *RelationsAmongResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RelationsAmongResponse.ProtoReflect.Descriptor instead. func (*RelationsAmongResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{36} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{40} } func (x *RelationsAmongResponse) GetRows() []*RelationRow { @@ -2032,7 +2264,7 @@ type EntitiesByEidsRequest struct { func (x *EntitiesByEidsRequest) Reset() { *x = EntitiesByEidsRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[37] + mi := &file_kg_v1_kg_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2044,7 +2276,7 @@ func (x *EntitiesByEidsRequest) String() string { func (*EntitiesByEidsRequest) ProtoMessage() {} func (x *EntitiesByEidsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[37] + mi := &file_kg_v1_kg_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2057,7 +2289,7 @@ func (x *EntitiesByEidsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EntitiesByEidsRequest.ProtoReflect.Descriptor instead. func (*EntitiesByEidsRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{37} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{41} } func (x *EntitiesByEidsRequest) GetEids() []string { @@ -2080,7 +2312,7 @@ type EntityDetail struct { func (x *EntityDetail) Reset() { *x = EntityDetail{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[38] + mi := &file_kg_v1_kg_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2092,7 +2324,7 @@ func (x *EntityDetail) String() string { func (*EntityDetail) ProtoMessage() {} func (x *EntityDetail) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[38] + mi := &file_kg_v1_kg_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2105,7 +2337,7 @@ func (x *EntityDetail) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityDetail.ProtoReflect.Descriptor instead. func (*EntityDetail) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{38} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{42} } func (x *EntityDetail) GetEid() string { @@ -2152,7 +2384,7 @@ type EntitiesByEidsResponse struct { func (x *EntitiesByEidsResponse) Reset() { *x = EntitiesByEidsResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[39] + mi := &file_kg_v1_kg_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2164,7 +2396,7 @@ func (x *EntitiesByEidsResponse) String() string { func (*EntitiesByEidsResponse) ProtoMessage() {} func (x *EntitiesByEidsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[39] + mi := &file_kg_v1_kg_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2177,7 +2409,7 @@ func (x *EntitiesByEidsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EntitiesByEidsResponse.ProtoReflect.Descriptor instead. func (*EntitiesByEidsResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{39} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{43} } func (x *EntitiesByEidsResponse) GetRows() []*EntityDetail { @@ -2197,7 +2429,7 @@ type EntityExposureRequest struct { func (x *EntityExposureRequest) Reset() { *x = EntityExposureRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[40] + mi := &file_kg_v1_kg_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2209,7 +2441,7 @@ func (x *EntityExposureRequest) String() string { func (*EntityExposureRequest) ProtoMessage() {} func (x *EntityExposureRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[40] + mi := &file_kg_v1_kg_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2222,7 +2454,7 @@ func (x *EntityExposureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityExposureRequest.ProtoReflect.Descriptor instead. func (*EntityExposureRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{40} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{44} } func (x *EntityExposureRequest) GetBvids() []string { @@ -2250,7 +2482,7 @@ type ExposureEvidence struct { func (x *ExposureEvidence) Reset() { *x = ExposureEvidence{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[41] + mi := &file_kg_v1_kg_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2262,7 +2494,7 @@ func (x *ExposureEvidence) String() string { func (*ExposureEvidence) ProtoMessage() {} func (x *ExposureEvidence) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[41] + mi := &file_kg_v1_kg_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2275,7 +2507,7 @@ func (x *ExposureEvidence) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposureEvidence.ProtoReflect.Descriptor instead. func (*ExposureEvidence) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{41} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{45} } func (x *ExposureEvidence) GetBvid() string { @@ -2313,7 +2545,7 @@ type ExposureRow struct { func (x *ExposureRow) Reset() { *x = ExposureRow{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[42] + mi := &file_kg_v1_kg_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2325,7 +2557,7 @@ func (x *ExposureRow) String() string { func (*ExposureRow) ProtoMessage() {} func (x *ExposureRow) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[42] + mi := &file_kg_v1_kg_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2338,7 +2570,7 @@ func (x *ExposureRow) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposureRow.ProtoReflect.Descriptor instead. func (*ExposureRow) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{42} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{46} } func (x *ExposureRow) GetEid() string { @@ -2392,7 +2624,7 @@ type EntityExposureResponse struct { func (x *EntityExposureResponse) Reset() { *x = EntityExposureResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[43] + mi := &file_kg_v1_kg_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2404,7 +2636,7 @@ func (x *EntityExposureResponse) String() string { func (*EntityExposureResponse) ProtoMessage() {} func (x *EntityExposureResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[43] + mi := &file_kg_v1_kg_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2417,7 +2649,7 @@ func (x *EntityExposureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityExposureResponse.ProtoReflect.Descriptor instead. func (*EntityExposureResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{43} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{47} } func (x *EntityExposureResponse) GetRows() []*ExposureRow { @@ -2438,7 +2670,7 @@ type EntityAppearancesRequest struct { func (x *EntityAppearancesRequest) Reset() { *x = EntityAppearancesRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[44] + mi := &file_kg_v1_kg_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2450,7 +2682,7 @@ func (x *EntityAppearancesRequest) String() string { func (*EntityAppearancesRequest) ProtoMessage() {} func (x *EntityAppearancesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[44] + mi := &file_kg_v1_kg_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2463,7 +2695,7 @@ func (x *EntityAppearancesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityAppearancesRequest.ProtoReflect.Descriptor instead. func (*EntityAppearancesRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{44} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{48} } func (x *EntityAppearancesRequest) GetEid() string { @@ -2498,7 +2730,7 @@ type AppearanceRow struct { func (x *AppearanceRow) Reset() { *x = AppearanceRow{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[45] + mi := &file_kg_v1_kg_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2510,7 +2742,7 @@ func (x *AppearanceRow) String() string { func (*AppearanceRow) ProtoMessage() {} func (x *AppearanceRow) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[45] + mi := &file_kg_v1_kg_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2523,7 +2755,7 @@ func (x *AppearanceRow) ProtoReflect() protoreflect.Message { // Deprecated: Use AppearanceRow.ProtoReflect.Descriptor instead. func (*AppearanceRow) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{45} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{49} } func (x *AppearanceRow) GetBvid() string { @@ -2556,7 +2788,7 @@ type EntityAppearancesResponse struct { func (x *EntityAppearancesResponse) Reset() { *x = EntityAppearancesResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[46] + mi := &file_kg_v1_kg_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2568,7 +2800,7 @@ func (x *EntityAppearancesResponse) String() string { func (*EntityAppearancesResponse) ProtoMessage() {} func (x *EntityAppearancesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[46] + mi := &file_kg_v1_kg_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2581,7 +2813,7 @@ func (x *EntityAppearancesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityAppearancesResponse.ProtoReflect.Descriptor instead. func (*EntityAppearancesResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{46} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{50} } func (x *EntityAppearancesResponse) GetRows() []*AppearanceRow { @@ -2600,7 +2832,7 @@ type TopEntitiesRequest struct { func (x *TopEntitiesRequest) Reset() { *x = TopEntitiesRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[47] + mi := &file_kg_v1_kg_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2612,7 +2844,7 @@ func (x *TopEntitiesRequest) String() string { func (*TopEntitiesRequest) ProtoMessage() {} func (x *TopEntitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[47] + mi := &file_kg_v1_kg_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2625,7 +2857,7 @@ func (x *TopEntitiesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TopEntitiesRequest.ProtoReflect.Descriptor instead. func (*TopEntitiesRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{47} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{51} } func (x *TopEntitiesRequest) GetLimit() int32 { @@ -2648,7 +2880,7 @@ type TopEntityRow struct { func (x *TopEntityRow) Reset() { *x = TopEntityRow{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[48] + mi := &file_kg_v1_kg_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2660,7 +2892,7 @@ func (x *TopEntityRow) String() string { func (*TopEntityRow) ProtoMessage() {} func (x *TopEntityRow) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[48] + mi := &file_kg_v1_kg_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2673,7 +2905,7 @@ func (x *TopEntityRow) ProtoReflect() protoreflect.Message { // Deprecated: Use TopEntityRow.ProtoReflect.Descriptor instead. func (*TopEntityRow) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{48} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{52} } func (x *TopEntityRow) GetEid() string { @@ -2720,7 +2952,7 @@ type TopEntitiesResponse struct { func (x *TopEntitiesResponse) Reset() { *x = TopEntitiesResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[49] + mi := &file_kg_v1_kg_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2732,7 +2964,7 @@ func (x *TopEntitiesResponse) String() string { func (*TopEntitiesResponse) ProtoMessage() {} func (x *TopEntitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[49] + mi := &file_kg_v1_kg_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2745,7 +2977,7 @@ func (x *TopEntitiesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TopEntitiesResponse.ProtoReflect.Descriptor instead. func (*TopEntitiesResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{49} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{53} } func (x *TopEntitiesResponse) GetRows() []*TopEntityRow { @@ -2764,7 +2996,7 @@ type EdgesAmongRequest struct { func (x *EdgesAmongRequest) Reset() { *x = EdgesAmongRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[50] + mi := &file_kg_v1_kg_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2776,7 +3008,7 @@ func (x *EdgesAmongRequest) String() string { func (*EdgesAmongRequest) ProtoMessage() {} func (x *EdgesAmongRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[50] + mi := &file_kg_v1_kg_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2789,7 +3021,7 @@ func (x *EdgesAmongRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EdgesAmongRequest.ProtoReflect.Descriptor instead. func (*EdgesAmongRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{50} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{54} } func (x *EdgesAmongRequest) GetEids() []string { @@ -2810,7 +3042,7 @@ type EdgeRow struct { func (x *EdgeRow) Reset() { *x = EdgeRow{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[51] + mi := &file_kg_v1_kg_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2822,7 +3054,7 @@ func (x *EdgeRow) String() string { func (*EdgeRow) ProtoMessage() {} func (x *EdgeRow) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[51] + mi := &file_kg_v1_kg_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2835,7 +3067,7 @@ func (x *EdgeRow) ProtoReflect() protoreflect.Message { // Deprecated: Use EdgeRow.ProtoReflect.Descriptor instead. func (*EdgeRow) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{51} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{55} } func (x *EdgeRow) GetSrcEid() string { @@ -2868,7 +3100,7 @@ type EdgesAmongResponse struct { func (x *EdgesAmongResponse) Reset() { *x = EdgesAmongResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[52] + mi := &file_kg_v1_kg_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2880,7 +3112,7 @@ func (x *EdgesAmongResponse) String() string { func (*EdgesAmongResponse) ProtoMessage() {} func (x *EdgesAmongResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[52] + mi := &file_kg_v1_kg_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2893,7 +3125,7 @@ func (x *EdgesAmongResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EdgesAmongResponse.ProtoReflect.Descriptor instead. func (*EdgesAmongResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{52} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{56} } func (x *EdgesAmongResponse) GetRows() []*EdgeRow { @@ -2903,6 +3135,204 @@ func (x *EdgesAmongResponse) GetRows() []*EdgeRow { return nil } +type FetchCloudDocEvidenceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Eids []string `protobuf:"bytes,1,rep,name=eids,proto3" json:"eids,omitempty"` + // 必填:多租户隔离按 CloudDoc.uid 过滤。 + Uid int64 `protobuf:"varint,2,opt,name=uid,proto3" json:"uid,omitempty"` + // 空 = 该用户全部云盘文档;否则限定 upload_uuid 集合。 + UploadUuids []string `protobuf:"bytes,3,rep,name=upload_uuids,json=uploadUuids,proto3" json:"upload_uuids,omitempty"` + Limit int32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FetchCloudDocEvidenceRequest) Reset() { + *x = FetchCloudDocEvidenceRequest{} + mi := &file_kg_v1_kg_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FetchCloudDocEvidenceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FetchCloudDocEvidenceRequest) ProtoMessage() {} + +func (x *FetchCloudDocEvidenceRequest) ProtoReflect() protoreflect.Message { + mi := &file_kg_v1_kg_proto_msgTypes[57] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FetchCloudDocEvidenceRequest.ProtoReflect.Descriptor instead. +func (*FetchCloudDocEvidenceRequest) Descriptor() ([]byte, []int) { + return file_kg_v1_kg_proto_rawDescGZIP(), []int{57} +} + +func (x *FetchCloudDocEvidenceRequest) GetEids() []string { + if x != nil { + return x.Eids + } + return nil +} + +func (x *FetchCloudDocEvidenceRequest) GetUid() int64 { + if x != nil { + return x.Uid + } + return 0 +} + +func (x *FetchCloudDocEvidenceRequest) GetUploadUuids() []string { + if x != nil { + return x.UploadUuids + } + return nil +} + +func (x *FetchCloudDocEvidenceRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +type CloudDocEvidenceRow struct { + state protoimpl.MessageState `protogen:"open.v1"` + Eid string `protobuf:"bytes,1,opt,name=eid,proto3" json:"eid,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` + UploadUuid string `protobuf:"bytes,4,opt,name=upload_uuid,json=uploadUuid,proto3" json:"upload_uuid,omitempty"` + Quote string `protobuf:"bytes,5,opt,name=quote,proto3" json:"quote,omitempty"` + Title string `protobuf:"bytes,6,opt,name=title,proto3" json:"title,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloudDocEvidenceRow) Reset() { + *x = CloudDocEvidenceRow{} + mi := &file_kg_v1_kg_proto_msgTypes[58] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloudDocEvidenceRow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloudDocEvidenceRow) ProtoMessage() {} + +func (x *CloudDocEvidenceRow) ProtoReflect() protoreflect.Message { + mi := &file_kg_v1_kg_proto_msgTypes[58] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloudDocEvidenceRow.ProtoReflect.Descriptor instead. +func (*CloudDocEvidenceRow) Descriptor() ([]byte, []int) { + return file_kg_v1_kg_proto_rawDescGZIP(), []int{58} +} + +func (x *CloudDocEvidenceRow) GetEid() string { + if x != nil { + return x.Eid + } + return "" +} + +func (x *CloudDocEvidenceRow) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CloudDocEvidenceRow) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *CloudDocEvidenceRow) GetUploadUuid() string { + if x != nil { + return x.UploadUuid + } + return "" +} + +func (x *CloudDocEvidenceRow) GetQuote() string { + if x != nil { + return x.Quote + } + return "" +} + +func (x *CloudDocEvidenceRow) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +type FetchCloudDocEvidenceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Rows []*CloudDocEvidenceRow `protobuf:"bytes,1,rep,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FetchCloudDocEvidenceResponse) Reset() { + *x = FetchCloudDocEvidenceResponse{} + mi := &file_kg_v1_kg_proto_msgTypes[59] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FetchCloudDocEvidenceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FetchCloudDocEvidenceResponse) ProtoMessage() {} + +func (x *FetchCloudDocEvidenceResponse) ProtoReflect() protoreflect.Message { + mi := &file_kg_v1_kg_proto_msgTypes[59] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FetchCloudDocEvidenceResponse.ProtoReflect.Descriptor instead. +func (*FetchCloudDocEvidenceResponse) Descriptor() ([]byte, []int) { + return file_kg_v1_kg_proto_rawDescGZIP(), []int{59} +} + +func (x *FetchCloudDocEvidenceResponse) GetRows() []*CloudDocEvidenceRow { + if x != nil { + return x.Rows + } + return nil +} + type StatsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -2911,7 +3341,7 @@ type StatsRequest struct { func (x *StatsRequest) Reset() { *x = StatsRequest{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[53] + mi := &file_kg_v1_kg_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2923,7 +3353,7 @@ func (x *StatsRequest) String() string { func (*StatsRequest) ProtoMessage() {} func (x *StatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[53] + mi := &file_kg_v1_kg_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2936,23 +3366,26 @@ func (x *StatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StatsRequest.ProtoReflect.Descriptor instead. func (*StatsRequest) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{53} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{60} } type StatsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Entities int64 `protobuf:"varint,1,opt,name=entities,proto3" json:"entities,omitempty"` - Relations int64 `protobuf:"varint,2,opt,name=relations,proto3" json:"relations,omitempty"` - Evidence int64 `protobuf:"varint,3,opt,name=evidence,proto3" json:"evidence,omitempty"` - Videos int64 `protobuf:"varint,4,opt,name=videos,proto3" json:"videos,omitempty"` - PendingPages int64 `protobuf:"varint,5,opt,name=pending_pages,json=pendingPages,proto3" json:"pending_pages,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Entities int64 `protobuf:"varint,1,opt,name=entities,proto3" json:"entities,omitempty"` + Relations int64 `protobuf:"varint,2,opt,name=relations,proto3" json:"relations,omitempty"` + Evidence int64 `protobuf:"varint,3,opt,name=evidence,proto3" json:"evidence,omitempty"` + Videos int64 `protobuf:"varint,4,opt,name=videos,proto3" json:"videos,omitempty"` + PendingPages int64 `protobuf:"varint,5,opt,name=pending_pages,json=pendingPages,proto3" json:"pending_pages,omitempty"` + // 云盘知识点计数(全局,与 entities/relations 同口径)。 + CloudDocs int64 `protobuf:"varint,6,opt,name=cloud_docs,json=cloudDocs,proto3" json:"cloud_docs,omitempty"` + CloudEvidence int64 `protobuf:"varint,7,opt,name=cloud_evidence,json=cloudEvidence,proto3" json:"cloud_evidence,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StatsResponse) Reset() { *x = StatsResponse{} - mi := &file_proto_kg_v1_kg_proto_msgTypes[54] + mi := &file_kg_v1_kg_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2964,7 +3397,7 @@ func (x *StatsResponse) String() string { func (*StatsResponse) ProtoMessage() {} func (x *StatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_kg_v1_kg_proto_msgTypes[54] + mi := &file_kg_v1_kg_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2977,7 +3410,7 @@ func (x *StatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StatsResponse.ProtoReflect.Descriptor instead. func (*StatsResponse) Descriptor() ([]byte, []int) { - return file_proto_kg_v1_kg_proto_rawDescGZIP(), []int{54} + return file_kg_v1_kg_proto_rawDescGZIP(), []int{61} } func (x *StatsResponse) GetEntities() int64 { @@ -3015,11 +3448,25 @@ func (x *StatsResponse) GetPendingPages() int64 { return 0 } -var File_proto_kg_v1_kg_proto protoreflect.FileDescriptor +func (x *StatsResponse) GetCloudDocs() int64 { + if x != nil { + return x.CloudDocs + } + return 0 +} -const file_proto_kg_v1_kg_proto_rawDesc = "" + +func (x *StatsResponse) GetCloudEvidence() int64 { + if x != nil { + return x.CloudEvidence + } + return 0 +} + +var File_kg_v1_kg_proto protoreflect.FileDescriptor + +const file_kg_v1_kg_proto_rawDesc = "" + "\n" + - "\x14proto/kg/v1/kg.proto\x12\x05kg.v1\"F\n" + + "\x0ekg/v1/kg.proto\x12\x05kg.v1\"F\n" + "\x13ResolveScopeRequest\x12\x10\n" + "\x03uid\x18\x01 \x01(\x03R\x03uid\x12\x1d\n" + "\n" + @@ -3108,7 +3555,23 @@ const file_proto_kg_v1_kg_proto_rawDesc = "" + "\x10entities_removed\x18\x01 \x01(\x03R\x0fentitiesRemoved\x12%\n" + "\x0evideos_removed\x18\x02 \x01(\x03R\rvideosRemoved\"\x11\n" + "\x0fClearAllRequest\"\x12\n" + - "\x10ClearAllResponse\"_\n" + + "\x10ClearAllResponse\"\xc1\x01\n" + + "\x1eLinkCloudDocAppearancesRequest\x12\x10\n" + + "\x03uid\x18\x01 \x01(\x03R\x03uid\x12\x1f\n" + + "\vupload_uuid\x18\x02 \x01(\tR\n" + + "uploadUuid\x12\x14\n" + + "\x05title\x18\x03 \x01(\tR\x05title\x12!\n" + + "\fcontent_hash\x18\x04 \x01(\tR\vcontentHash\x123\n" + + "\vappearances\x18\x05 \x03(\v2\x11.kg.v1.AppearanceR\vappearances\"9\n" + + "\x1fLinkCloudDocAppearancesResponse\x12\x16\n" + + "\x06linked\x18\x01 \x01(\x03R\x06linked\"U\n" + + " DeleteCloudDocAppearancesRequest\x12\x10\n" + + "\x03uid\x18\x01 \x01(\x03R\x03uid\x12\x1f\n" + + "\vupload_uuid\x18\x02 \x01(\tR\n" + + "uploadUuid\"h\n" + + "!DeleteCloudDocAppearancesResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\x03R\adeleted\x12)\n" + + "\x10entities_removed\x18\x02 \x01(\x03R\x0fentitiesRemoved\"_\n" + "\x10NeighborsRequest\x12\x12\n" + "\x04eids\x18\x01 \x03(\tR\x04eids\x12!\n" + "\fexclude_eids\x18\x02 \x03(\tR\vexcludeEids\x12\x14\n" + @@ -3205,14 +3668,32 @@ const file_proto_kg_v1_kg_proto_rawDesc = "" + "\adst_eid\x18\x02 \x01(\tR\x06dstEid\x12\x19\n" + "\brel_type\x18\x03 \x01(\tR\arelType\"8\n" + "\x12EdgesAmongResponse\x12\"\n" + - "\x04rows\x18\x01 \x03(\v2\x0e.kg.v1.EdgeRowR\x04rows\"\x0e\n" + - "\fStatsRequest\"\xa2\x01\n" + + "\x04rows\x18\x01 \x03(\v2\x0e.kg.v1.EdgeRowR\x04rows\"}\n" + + "\x1cFetchCloudDocEvidenceRequest\x12\x12\n" + + "\x04eids\x18\x01 \x03(\tR\x04eids\x12\x10\n" + + "\x03uid\x18\x02 \x01(\x03R\x03uid\x12!\n" + + "\fupload_uuids\x18\x03 \x03(\tR\vuploadUuids\x12\x14\n" + + "\x05limit\x18\x04 \x01(\x05R\x05limit\"\x9c\x01\n" + + "\x13CloudDocEvidenceRow\x12\x10\n" + + "\x03eid\x18\x01 \x01(\tR\x03eid\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" + + "\x04type\x18\x03 \x01(\tR\x04type\x12\x1f\n" + + "\vupload_uuid\x18\x04 \x01(\tR\n" + + "uploadUuid\x12\x14\n" + + "\x05quote\x18\x05 \x01(\tR\x05quote\x12\x14\n" + + "\x05title\x18\x06 \x01(\tR\x05title\"O\n" + + "\x1dFetchCloudDocEvidenceResponse\x12.\n" + + "\x04rows\x18\x01 \x03(\v2\x1a.kg.v1.CloudDocEvidenceRowR\x04rows\"\x0e\n" + + "\fStatsRequest\"\xe8\x01\n" + "\rStatsResponse\x12\x1a\n" + "\bentities\x18\x01 \x01(\x03R\bentities\x12\x1c\n" + "\trelations\x18\x02 \x01(\x03R\trelations\x12\x1a\n" + "\bevidence\x18\x03 \x01(\x03R\bevidence\x12\x16\n" + "\x06videos\x18\x04 \x01(\x03R\x06videos\x12#\n" + - "\rpending_pages\x18\x05 \x01(\x03R\fpendingPages2\xbc\f\n" + + "\rpending_pages\x18\x05 \x01(\x03R\fpendingPages\x12\x1d\n" + + "\n" + + "cloud_docs\x18\x06 \x01(\x03R\tcloudDocs\x12%\n" + + "\x0ecloud_evidence\x18\a \x01(\x03R\rcloudEvidence2\xfa\x0e\n" + "\aKgGraph\x12G\n" + "\fResolveScope\x12\x1a.kg.v1.ResolveScopeRequest\x1a\x1b.kg.v1.ResolveScopeResponse\x12Y\n" + "\x12SelectPendingPages\x12 .kg.v1.SelectPendingPagesRequest\x1a!.kg.v1.SelectPendingPagesResponse\x12>\n" + @@ -3225,7 +3706,10 @@ const file_proto_kg_v1_kg_proto_rawDesc = "" + "\x0fUpsertRelations\x12\x1d.kg.v1.UpsertRelationsRequest\x1a\x1e.kg.v1.UpsertRelationsResponse\x12Y\n" + "\x12DeletePageEvidence\x12 .kg.v1.DeletePageEvidenceRequest\x1a!.kg.v1.DeletePageEvidenceResponse\x12D\n" + "\vDeleteVideo\x12\x19.kg.v1.DeleteVideoRequest\x1a\x1a.kg.v1.DeleteVideoResponse\x12;\n" + - "\bClearAll\x12\x16.kg.v1.ClearAllRequest\x1a\x17.kg.v1.ClearAllResponse\x12>\n" + + "\bClearAll\x12\x16.kg.v1.ClearAllRequest\x1a\x17.kg.v1.ClearAllResponse\x12h\n" + + "\x17LinkCloudDocAppearances\x12%.kg.v1.LinkCloudDocAppearancesRequest\x1a&.kg.v1.LinkCloudDocAppearancesResponse\x12n\n" + + "\x19DeleteCloudDocAppearances\x12'.kg.v1.DeleteCloudDocAppearancesRequest\x1a(.kg.v1.DeleteCloudDocAppearancesResponse\x12b\n" + + "\x15FetchCloudDocEvidence\x12#.kg.v1.FetchCloudDocEvidenceRequest\x1a$.kg.v1.FetchCloudDocEvidenceResponse\x12>\n" + "\tNeighbors\x12\x17.kg.v1.NeighborsRequest\x1a\x18.kg.v1.NeighborsResponse\x12J\n" + "\rFetchEvidence\x12\x1b.kg.v1.FetchEvidenceRequest\x1a\x1c.kg.v1.FetchEvidenceResponse\x12M\n" + "\x0eRelationsAmong\x12\x1c.kg.v1.RelationsAmongRequest\x1a\x1d.kg.v1.RelationsAmongResponse\x12M\n" + @@ -3238,158 +3722,173 @@ const file_proto_kg_v1_kg_proto_rawDesc = "" + "\x05Stats\x12\x13.kg.v1.StatsRequest\x1a\x14.kg.v1.StatsResponseB\x1bZ\x19app-kg/internal/kgpb;kgpbb\x06proto3" var ( - file_proto_kg_v1_kg_proto_rawDescOnce sync.Once - file_proto_kg_v1_kg_proto_rawDescData []byte + file_kg_v1_kg_proto_rawDescOnce sync.Once + file_kg_v1_kg_proto_rawDescData []byte ) -func file_proto_kg_v1_kg_proto_rawDescGZIP() []byte { - file_proto_kg_v1_kg_proto_rawDescOnce.Do(func() { - file_proto_kg_v1_kg_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_kg_v1_kg_proto_rawDesc), len(file_proto_kg_v1_kg_proto_rawDesc))) +func file_kg_v1_kg_proto_rawDescGZIP() []byte { + file_kg_v1_kg_proto_rawDescOnce.Do(func() { + file_kg_v1_kg_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_kg_v1_kg_proto_rawDesc), len(file_kg_v1_kg_proto_rawDesc))) }) - return file_proto_kg_v1_kg_proto_rawDescData -} - -var file_proto_kg_v1_kg_proto_msgTypes = make([]protoimpl.MessageInfo, 55) -var file_proto_kg_v1_kg_proto_goTypes = []any{ - (*ResolveScopeRequest)(nil), // 0: kg.v1.ResolveScopeRequest - (*ResolveScopeResponse)(nil), // 1: kg.v1.ResolveScopeResponse - (*SelectPendingPagesRequest)(nil), // 2: kg.v1.SelectPendingPagesRequest - (*PendingPage)(nil), // 3: kg.v1.PendingPage - (*SelectPendingPagesResponse)(nil), // 4: kg.v1.SelectPendingPagesResponse - (*BeginPageRequest)(nil), // 5: kg.v1.BeginPageRequest - (*BeginPageResponse)(nil), // 6: kg.v1.BeginPageResponse - (*MarkPageDoneRequest)(nil), // 7: kg.v1.MarkPageDoneRequest - (*MarkPageDoneResponse)(nil), // 8: kg.v1.MarkPageDoneResponse - (*MarkPageFailedRequest)(nil), // 9: kg.v1.MarkPageFailedRequest - (*MarkPageFailedResponse)(nil), // 10: kg.v1.MarkPageFailedResponse - (*CountPendingPagesRequest)(nil), // 11: kg.v1.CountPendingPagesRequest - (*CountPendingPagesResponse)(nil), // 12: kg.v1.CountPendingPagesResponse - (*EntityUpsert)(nil), // 13: kg.v1.EntityUpsert - (*UpsertEntitiesRequest)(nil), // 14: kg.v1.UpsertEntitiesRequest - (*UpsertEntitiesResponse)(nil), // 15: kg.v1.UpsertEntitiesResponse - (*Appearance)(nil), // 16: kg.v1.Appearance - (*LinkAppearancesRequest)(nil), // 17: kg.v1.LinkAppearancesRequest - (*LinkAppearancesResponse)(nil), // 18: kg.v1.LinkAppearancesResponse - (*RelationUpsert)(nil), // 19: kg.v1.RelationUpsert - (*UpsertRelationsRequest)(nil), // 20: kg.v1.UpsertRelationsRequest - (*UpsertRelationsResponse)(nil), // 21: kg.v1.UpsertRelationsResponse - (*DeletePageEvidenceRequest)(nil), // 22: kg.v1.DeletePageEvidenceRequest - (*DeletePageEvidenceResponse)(nil), // 23: kg.v1.DeletePageEvidenceResponse - (*DeleteVideoRequest)(nil), // 24: kg.v1.DeleteVideoRequest - (*DeleteVideoResponse)(nil), // 25: kg.v1.DeleteVideoResponse - (*ClearAllRequest)(nil), // 26: kg.v1.ClearAllRequest - (*ClearAllResponse)(nil), // 27: kg.v1.ClearAllResponse - (*NeighborsRequest)(nil), // 28: kg.v1.NeighborsRequest - (*NeighborRow)(nil), // 29: kg.v1.NeighborRow - (*NeighborsResponse)(nil), // 30: kg.v1.NeighborsResponse - (*FetchEvidenceRequest)(nil), // 31: kg.v1.FetchEvidenceRequest - (*EvidenceRow)(nil), // 32: kg.v1.EvidenceRow - (*FetchEvidenceResponse)(nil), // 33: kg.v1.FetchEvidenceResponse - (*RelationsAmongRequest)(nil), // 34: kg.v1.RelationsAmongRequest - (*RelationRow)(nil), // 35: kg.v1.RelationRow - (*RelationsAmongResponse)(nil), // 36: kg.v1.RelationsAmongResponse - (*EntitiesByEidsRequest)(nil), // 37: kg.v1.EntitiesByEidsRequest - (*EntityDetail)(nil), // 38: kg.v1.EntityDetail - (*EntitiesByEidsResponse)(nil), // 39: kg.v1.EntitiesByEidsResponse - (*EntityExposureRequest)(nil), // 40: kg.v1.EntityExposureRequest - (*ExposureEvidence)(nil), // 41: kg.v1.ExposureEvidence - (*ExposureRow)(nil), // 42: kg.v1.ExposureRow - (*EntityExposureResponse)(nil), // 43: kg.v1.EntityExposureResponse - (*EntityAppearancesRequest)(nil), // 44: kg.v1.EntityAppearancesRequest - (*AppearanceRow)(nil), // 45: kg.v1.AppearanceRow - (*EntityAppearancesResponse)(nil), // 46: kg.v1.EntityAppearancesResponse - (*TopEntitiesRequest)(nil), // 47: kg.v1.TopEntitiesRequest - (*TopEntityRow)(nil), // 48: kg.v1.TopEntityRow - (*TopEntitiesResponse)(nil), // 49: kg.v1.TopEntitiesResponse - (*EdgesAmongRequest)(nil), // 50: kg.v1.EdgesAmongRequest - (*EdgeRow)(nil), // 51: kg.v1.EdgeRow - (*EdgesAmongResponse)(nil), // 52: kg.v1.EdgesAmongResponse - (*StatsRequest)(nil), // 53: kg.v1.StatsRequest - (*StatsResponse)(nil), // 54: kg.v1.StatsResponse -} -var file_proto_kg_v1_kg_proto_depIdxs = []int32{ + return file_kg_v1_kg_proto_rawDescData +} + +var file_kg_v1_kg_proto_msgTypes = make([]protoimpl.MessageInfo, 62) +var file_kg_v1_kg_proto_goTypes = []any{ + (*ResolveScopeRequest)(nil), // 0: kg.v1.ResolveScopeRequest + (*ResolveScopeResponse)(nil), // 1: kg.v1.ResolveScopeResponse + (*SelectPendingPagesRequest)(nil), // 2: kg.v1.SelectPendingPagesRequest + (*PendingPage)(nil), // 3: kg.v1.PendingPage + (*SelectPendingPagesResponse)(nil), // 4: kg.v1.SelectPendingPagesResponse + (*BeginPageRequest)(nil), // 5: kg.v1.BeginPageRequest + (*BeginPageResponse)(nil), // 6: kg.v1.BeginPageResponse + (*MarkPageDoneRequest)(nil), // 7: kg.v1.MarkPageDoneRequest + (*MarkPageDoneResponse)(nil), // 8: kg.v1.MarkPageDoneResponse + (*MarkPageFailedRequest)(nil), // 9: kg.v1.MarkPageFailedRequest + (*MarkPageFailedResponse)(nil), // 10: kg.v1.MarkPageFailedResponse + (*CountPendingPagesRequest)(nil), // 11: kg.v1.CountPendingPagesRequest + (*CountPendingPagesResponse)(nil), // 12: kg.v1.CountPendingPagesResponse + (*EntityUpsert)(nil), // 13: kg.v1.EntityUpsert + (*UpsertEntitiesRequest)(nil), // 14: kg.v1.UpsertEntitiesRequest + (*UpsertEntitiesResponse)(nil), // 15: kg.v1.UpsertEntitiesResponse + (*Appearance)(nil), // 16: kg.v1.Appearance + (*LinkAppearancesRequest)(nil), // 17: kg.v1.LinkAppearancesRequest + (*LinkAppearancesResponse)(nil), // 18: kg.v1.LinkAppearancesResponse + (*RelationUpsert)(nil), // 19: kg.v1.RelationUpsert + (*UpsertRelationsRequest)(nil), // 20: kg.v1.UpsertRelationsRequest + (*UpsertRelationsResponse)(nil), // 21: kg.v1.UpsertRelationsResponse + (*DeletePageEvidenceRequest)(nil), // 22: kg.v1.DeletePageEvidenceRequest + (*DeletePageEvidenceResponse)(nil), // 23: kg.v1.DeletePageEvidenceResponse + (*DeleteVideoRequest)(nil), // 24: kg.v1.DeleteVideoRequest + (*DeleteVideoResponse)(nil), // 25: kg.v1.DeleteVideoResponse + (*ClearAllRequest)(nil), // 26: kg.v1.ClearAllRequest + (*ClearAllResponse)(nil), // 27: kg.v1.ClearAllResponse + (*LinkCloudDocAppearancesRequest)(nil), // 28: kg.v1.LinkCloudDocAppearancesRequest + (*LinkCloudDocAppearancesResponse)(nil), // 29: kg.v1.LinkCloudDocAppearancesResponse + (*DeleteCloudDocAppearancesRequest)(nil), // 30: kg.v1.DeleteCloudDocAppearancesRequest + (*DeleteCloudDocAppearancesResponse)(nil), // 31: kg.v1.DeleteCloudDocAppearancesResponse + (*NeighborsRequest)(nil), // 32: kg.v1.NeighborsRequest + (*NeighborRow)(nil), // 33: kg.v1.NeighborRow + (*NeighborsResponse)(nil), // 34: kg.v1.NeighborsResponse + (*FetchEvidenceRequest)(nil), // 35: kg.v1.FetchEvidenceRequest + (*EvidenceRow)(nil), // 36: kg.v1.EvidenceRow + (*FetchEvidenceResponse)(nil), // 37: kg.v1.FetchEvidenceResponse + (*RelationsAmongRequest)(nil), // 38: kg.v1.RelationsAmongRequest + (*RelationRow)(nil), // 39: kg.v1.RelationRow + (*RelationsAmongResponse)(nil), // 40: kg.v1.RelationsAmongResponse + (*EntitiesByEidsRequest)(nil), // 41: kg.v1.EntitiesByEidsRequest + (*EntityDetail)(nil), // 42: kg.v1.EntityDetail + (*EntitiesByEidsResponse)(nil), // 43: kg.v1.EntitiesByEidsResponse + (*EntityExposureRequest)(nil), // 44: kg.v1.EntityExposureRequest + (*ExposureEvidence)(nil), // 45: kg.v1.ExposureEvidence + (*ExposureRow)(nil), // 46: kg.v1.ExposureRow + (*EntityExposureResponse)(nil), // 47: kg.v1.EntityExposureResponse + (*EntityAppearancesRequest)(nil), // 48: kg.v1.EntityAppearancesRequest + (*AppearanceRow)(nil), // 49: kg.v1.AppearanceRow + (*EntityAppearancesResponse)(nil), // 50: kg.v1.EntityAppearancesResponse + (*TopEntitiesRequest)(nil), // 51: kg.v1.TopEntitiesRequest + (*TopEntityRow)(nil), // 52: kg.v1.TopEntityRow + (*TopEntitiesResponse)(nil), // 53: kg.v1.TopEntitiesResponse + (*EdgesAmongRequest)(nil), // 54: kg.v1.EdgesAmongRequest + (*EdgeRow)(nil), // 55: kg.v1.EdgeRow + (*EdgesAmongResponse)(nil), // 56: kg.v1.EdgesAmongResponse + (*FetchCloudDocEvidenceRequest)(nil), // 57: kg.v1.FetchCloudDocEvidenceRequest + (*CloudDocEvidenceRow)(nil), // 58: kg.v1.CloudDocEvidenceRow + (*FetchCloudDocEvidenceResponse)(nil), // 59: kg.v1.FetchCloudDocEvidenceResponse + (*StatsRequest)(nil), // 60: kg.v1.StatsRequest + (*StatsResponse)(nil), // 61: kg.v1.StatsResponse +} +var file_kg_v1_kg_proto_depIdxs = []int32{ 3, // 0: kg.v1.SelectPendingPagesResponse.pages:type_name -> kg.v1.PendingPage 13, // 1: kg.v1.UpsertEntitiesRequest.entities:type_name -> kg.v1.EntityUpsert 16, // 2: kg.v1.LinkAppearancesRequest.appearances:type_name -> kg.v1.Appearance 19, // 3: kg.v1.UpsertRelationsRequest.relations:type_name -> kg.v1.RelationUpsert - 29, // 4: kg.v1.NeighborsResponse.rows:type_name -> kg.v1.NeighborRow - 32, // 5: kg.v1.FetchEvidenceResponse.rows:type_name -> kg.v1.EvidenceRow - 35, // 6: kg.v1.RelationsAmongResponse.rows:type_name -> kg.v1.RelationRow - 38, // 7: kg.v1.EntitiesByEidsResponse.rows:type_name -> kg.v1.EntityDetail - 41, // 8: kg.v1.ExposureRow.evidence_sample:type_name -> kg.v1.ExposureEvidence - 42, // 9: kg.v1.EntityExposureResponse.rows:type_name -> kg.v1.ExposureRow - 45, // 10: kg.v1.EntityAppearancesResponse.rows:type_name -> kg.v1.AppearanceRow - 48, // 11: kg.v1.TopEntitiesResponse.rows:type_name -> kg.v1.TopEntityRow - 51, // 12: kg.v1.EdgesAmongResponse.rows:type_name -> kg.v1.EdgeRow - 0, // 13: kg.v1.KgGraph.ResolveScope:input_type -> kg.v1.ResolveScopeRequest - 2, // 14: kg.v1.KgGraph.SelectPendingPages:input_type -> kg.v1.SelectPendingPagesRequest - 5, // 15: kg.v1.KgGraph.BeginPage:input_type -> kg.v1.BeginPageRequest - 7, // 16: kg.v1.KgGraph.MarkPageDone:input_type -> kg.v1.MarkPageDoneRequest - 9, // 17: kg.v1.KgGraph.MarkPageFailed:input_type -> kg.v1.MarkPageFailedRequest - 11, // 18: kg.v1.KgGraph.CountPendingPages:input_type -> kg.v1.CountPendingPagesRequest - 14, // 19: kg.v1.KgGraph.UpsertEntities:input_type -> kg.v1.UpsertEntitiesRequest - 17, // 20: kg.v1.KgGraph.LinkAppearances:input_type -> kg.v1.LinkAppearancesRequest - 20, // 21: kg.v1.KgGraph.UpsertRelations:input_type -> kg.v1.UpsertRelationsRequest - 22, // 22: kg.v1.KgGraph.DeletePageEvidence:input_type -> kg.v1.DeletePageEvidenceRequest - 24, // 23: kg.v1.KgGraph.DeleteVideo:input_type -> kg.v1.DeleteVideoRequest - 26, // 24: kg.v1.KgGraph.ClearAll:input_type -> kg.v1.ClearAllRequest - 28, // 25: kg.v1.KgGraph.Neighbors:input_type -> kg.v1.NeighborsRequest - 31, // 26: kg.v1.KgGraph.FetchEvidence:input_type -> kg.v1.FetchEvidenceRequest - 34, // 27: kg.v1.KgGraph.RelationsAmong:input_type -> kg.v1.RelationsAmongRequest - 37, // 28: kg.v1.KgGraph.EntitiesByEids:input_type -> kg.v1.EntitiesByEidsRequest - 40, // 29: kg.v1.KgGraph.EntityExposure:input_type -> kg.v1.EntityExposureRequest - 44, // 30: kg.v1.KgGraph.EntityAppearances:input_type -> kg.v1.EntityAppearancesRequest - 47, // 31: kg.v1.KgGraph.TopEntities:input_type -> kg.v1.TopEntitiesRequest - 50, // 32: kg.v1.KgGraph.EdgesAmong:input_type -> kg.v1.EdgesAmongRequest - 53, // 33: kg.v1.KgGraph.Stats:input_type -> kg.v1.StatsRequest - 1, // 34: kg.v1.KgGraph.ResolveScope:output_type -> kg.v1.ResolveScopeResponse - 4, // 35: kg.v1.KgGraph.SelectPendingPages:output_type -> kg.v1.SelectPendingPagesResponse - 6, // 36: kg.v1.KgGraph.BeginPage:output_type -> kg.v1.BeginPageResponse - 8, // 37: kg.v1.KgGraph.MarkPageDone:output_type -> kg.v1.MarkPageDoneResponse - 10, // 38: kg.v1.KgGraph.MarkPageFailed:output_type -> kg.v1.MarkPageFailedResponse - 12, // 39: kg.v1.KgGraph.CountPendingPages:output_type -> kg.v1.CountPendingPagesResponse - 15, // 40: kg.v1.KgGraph.UpsertEntities:output_type -> kg.v1.UpsertEntitiesResponse - 18, // 41: kg.v1.KgGraph.LinkAppearances:output_type -> kg.v1.LinkAppearancesResponse - 21, // 42: kg.v1.KgGraph.UpsertRelations:output_type -> kg.v1.UpsertRelationsResponse - 23, // 43: kg.v1.KgGraph.DeletePageEvidence:output_type -> kg.v1.DeletePageEvidenceResponse - 25, // 44: kg.v1.KgGraph.DeleteVideo:output_type -> kg.v1.DeleteVideoResponse - 27, // 45: kg.v1.KgGraph.ClearAll:output_type -> kg.v1.ClearAllResponse - 30, // 46: kg.v1.KgGraph.Neighbors:output_type -> kg.v1.NeighborsResponse - 33, // 47: kg.v1.KgGraph.FetchEvidence:output_type -> kg.v1.FetchEvidenceResponse - 36, // 48: kg.v1.KgGraph.RelationsAmong:output_type -> kg.v1.RelationsAmongResponse - 39, // 49: kg.v1.KgGraph.EntitiesByEids:output_type -> kg.v1.EntitiesByEidsResponse - 43, // 50: kg.v1.KgGraph.EntityExposure:output_type -> kg.v1.EntityExposureResponse - 46, // 51: kg.v1.KgGraph.EntityAppearances:output_type -> kg.v1.EntityAppearancesResponse - 49, // 52: kg.v1.KgGraph.TopEntities:output_type -> kg.v1.TopEntitiesResponse - 52, // 53: kg.v1.KgGraph.EdgesAmong:output_type -> kg.v1.EdgesAmongResponse - 54, // 54: kg.v1.KgGraph.Stats:output_type -> kg.v1.StatsResponse - 34, // [34:55] is the sub-list for method output_type - 13, // [13:34] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name -} - -func init() { file_proto_kg_v1_kg_proto_init() } -func file_proto_kg_v1_kg_proto_init() { - if File_proto_kg_v1_kg_proto != nil { + 16, // 4: kg.v1.LinkCloudDocAppearancesRequest.appearances:type_name -> kg.v1.Appearance + 33, // 5: kg.v1.NeighborsResponse.rows:type_name -> kg.v1.NeighborRow + 36, // 6: kg.v1.FetchEvidenceResponse.rows:type_name -> kg.v1.EvidenceRow + 39, // 7: kg.v1.RelationsAmongResponse.rows:type_name -> kg.v1.RelationRow + 42, // 8: kg.v1.EntitiesByEidsResponse.rows:type_name -> kg.v1.EntityDetail + 45, // 9: kg.v1.ExposureRow.evidence_sample:type_name -> kg.v1.ExposureEvidence + 46, // 10: kg.v1.EntityExposureResponse.rows:type_name -> kg.v1.ExposureRow + 49, // 11: kg.v1.EntityAppearancesResponse.rows:type_name -> kg.v1.AppearanceRow + 52, // 12: kg.v1.TopEntitiesResponse.rows:type_name -> kg.v1.TopEntityRow + 55, // 13: kg.v1.EdgesAmongResponse.rows:type_name -> kg.v1.EdgeRow + 58, // 14: kg.v1.FetchCloudDocEvidenceResponse.rows:type_name -> kg.v1.CloudDocEvidenceRow + 0, // 15: kg.v1.KgGraph.ResolveScope:input_type -> kg.v1.ResolveScopeRequest + 2, // 16: kg.v1.KgGraph.SelectPendingPages:input_type -> kg.v1.SelectPendingPagesRequest + 5, // 17: kg.v1.KgGraph.BeginPage:input_type -> kg.v1.BeginPageRequest + 7, // 18: kg.v1.KgGraph.MarkPageDone:input_type -> kg.v1.MarkPageDoneRequest + 9, // 19: kg.v1.KgGraph.MarkPageFailed:input_type -> kg.v1.MarkPageFailedRequest + 11, // 20: kg.v1.KgGraph.CountPendingPages:input_type -> kg.v1.CountPendingPagesRequest + 14, // 21: kg.v1.KgGraph.UpsertEntities:input_type -> kg.v1.UpsertEntitiesRequest + 17, // 22: kg.v1.KgGraph.LinkAppearances:input_type -> kg.v1.LinkAppearancesRequest + 20, // 23: kg.v1.KgGraph.UpsertRelations:input_type -> kg.v1.UpsertRelationsRequest + 22, // 24: kg.v1.KgGraph.DeletePageEvidence:input_type -> kg.v1.DeletePageEvidenceRequest + 24, // 25: kg.v1.KgGraph.DeleteVideo:input_type -> kg.v1.DeleteVideoRequest + 26, // 26: kg.v1.KgGraph.ClearAll:input_type -> kg.v1.ClearAllRequest + 28, // 27: kg.v1.KgGraph.LinkCloudDocAppearances:input_type -> kg.v1.LinkCloudDocAppearancesRequest + 30, // 28: kg.v1.KgGraph.DeleteCloudDocAppearances:input_type -> kg.v1.DeleteCloudDocAppearancesRequest + 57, // 29: kg.v1.KgGraph.FetchCloudDocEvidence:input_type -> kg.v1.FetchCloudDocEvidenceRequest + 32, // 30: kg.v1.KgGraph.Neighbors:input_type -> kg.v1.NeighborsRequest + 35, // 31: kg.v1.KgGraph.FetchEvidence:input_type -> kg.v1.FetchEvidenceRequest + 38, // 32: kg.v1.KgGraph.RelationsAmong:input_type -> kg.v1.RelationsAmongRequest + 41, // 33: kg.v1.KgGraph.EntitiesByEids:input_type -> kg.v1.EntitiesByEidsRequest + 44, // 34: kg.v1.KgGraph.EntityExposure:input_type -> kg.v1.EntityExposureRequest + 48, // 35: kg.v1.KgGraph.EntityAppearances:input_type -> kg.v1.EntityAppearancesRequest + 51, // 36: kg.v1.KgGraph.TopEntities:input_type -> kg.v1.TopEntitiesRequest + 54, // 37: kg.v1.KgGraph.EdgesAmong:input_type -> kg.v1.EdgesAmongRequest + 60, // 38: kg.v1.KgGraph.Stats:input_type -> kg.v1.StatsRequest + 1, // 39: kg.v1.KgGraph.ResolveScope:output_type -> kg.v1.ResolveScopeResponse + 4, // 40: kg.v1.KgGraph.SelectPendingPages:output_type -> kg.v1.SelectPendingPagesResponse + 6, // 41: kg.v1.KgGraph.BeginPage:output_type -> kg.v1.BeginPageResponse + 8, // 42: kg.v1.KgGraph.MarkPageDone:output_type -> kg.v1.MarkPageDoneResponse + 10, // 43: kg.v1.KgGraph.MarkPageFailed:output_type -> kg.v1.MarkPageFailedResponse + 12, // 44: kg.v1.KgGraph.CountPendingPages:output_type -> kg.v1.CountPendingPagesResponse + 15, // 45: kg.v1.KgGraph.UpsertEntities:output_type -> kg.v1.UpsertEntitiesResponse + 18, // 46: kg.v1.KgGraph.LinkAppearances:output_type -> kg.v1.LinkAppearancesResponse + 21, // 47: kg.v1.KgGraph.UpsertRelations:output_type -> kg.v1.UpsertRelationsResponse + 23, // 48: kg.v1.KgGraph.DeletePageEvidence:output_type -> kg.v1.DeletePageEvidenceResponse + 25, // 49: kg.v1.KgGraph.DeleteVideo:output_type -> kg.v1.DeleteVideoResponse + 27, // 50: kg.v1.KgGraph.ClearAll:output_type -> kg.v1.ClearAllResponse + 29, // 51: kg.v1.KgGraph.LinkCloudDocAppearances:output_type -> kg.v1.LinkCloudDocAppearancesResponse + 31, // 52: kg.v1.KgGraph.DeleteCloudDocAppearances:output_type -> kg.v1.DeleteCloudDocAppearancesResponse + 59, // 53: kg.v1.KgGraph.FetchCloudDocEvidence:output_type -> kg.v1.FetchCloudDocEvidenceResponse + 34, // 54: kg.v1.KgGraph.Neighbors:output_type -> kg.v1.NeighborsResponse + 37, // 55: kg.v1.KgGraph.FetchEvidence:output_type -> kg.v1.FetchEvidenceResponse + 40, // 56: kg.v1.KgGraph.RelationsAmong:output_type -> kg.v1.RelationsAmongResponse + 43, // 57: kg.v1.KgGraph.EntitiesByEids:output_type -> kg.v1.EntitiesByEidsResponse + 47, // 58: kg.v1.KgGraph.EntityExposure:output_type -> kg.v1.EntityExposureResponse + 50, // 59: kg.v1.KgGraph.EntityAppearances:output_type -> kg.v1.EntityAppearancesResponse + 53, // 60: kg.v1.KgGraph.TopEntities:output_type -> kg.v1.TopEntitiesResponse + 56, // 61: kg.v1.KgGraph.EdgesAmong:output_type -> kg.v1.EdgesAmongResponse + 61, // 62: kg.v1.KgGraph.Stats:output_type -> kg.v1.StatsResponse + 39, // [39:63] is the sub-list for method output_type + 15, // [15:39] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name +} + +func init() { file_kg_v1_kg_proto_init() } +func file_kg_v1_kg_proto_init() { + if File_kg_v1_kg_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_kg_v1_kg_proto_rawDesc), len(file_proto_kg_v1_kg_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_kg_v1_kg_proto_rawDesc), len(file_kg_v1_kg_proto_rawDesc)), NumEnums: 0, - NumMessages: 55, + NumMessages: 62, NumExtensions: 0, NumServices: 1, }, - GoTypes: file_proto_kg_v1_kg_proto_goTypes, - DependencyIndexes: file_proto_kg_v1_kg_proto_depIdxs, - MessageInfos: file_proto_kg_v1_kg_proto_msgTypes, + GoTypes: file_kg_v1_kg_proto_goTypes, + DependencyIndexes: file_kg_v1_kg_proto_depIdxs, + MessageInfos: file_kg_v1_kg_proto_msgTypes, }.Build() - File_proto_kg_v1_kg_proto = out.File - file_proto_kg_v1_kg_proto_goTypes = nil - file_proto_kg_v1_kg_proto_depIdxs = nil + File_kg_v1_kg_proto = out.File + file_kg_v1_kg_proto_goTypes = nil + file_kg_v1_kg_proto_depIdxs = nil } diff --git a/app-go/app-kg/internal/kgpb/kg_grpc.pb.go b/app-go/app-kg/internal/kgpb/kg_grpc.pb.go index ef314c4..5859fb4 100644 --- a/app-go/app-kg/internal/kgpb/kg_grpc.pb.go +++ b/app-go/app-kg/internal/kgpb/kg_grpc.pb.go @@ -5,6 +5,12 @@ // video.kg_status/kg_version 记账 + 收藏夹 scope 解析。本服务不感知 // uid 之外的任何业务语义,不发起任何 LLM/embedding 调用。 // +// 知识点来源(source)有两类,统一进同一张 Entity/RELATES 知识图: +// - 收藏夹视频分P:(:Video {bvid}),证据边 APPEARS_IN {page_index, quote} +// - 云盘文档:(:CloudDoc {uid, upload_uuid, title, content_hash})(节点由 +// 云盘 L1 路由图创建/复用),证据边 APPEARS_IN {quote} +// 因此云盘文档的知识点与视频共用一个实体/关系空间,可跨来源检索与关联。 +// // 过渡期共享存储:Python KgGraphRepository 与本服务双写同一 Neo4j // (同 schema、确定性 eid=sha1(name_lower)[:16],merge 键 name_lower)。 // name_lower 与 eid 由调用方(app-ts resolver / Python repository) @@ -14,7 +20,7 @@ // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc v6.31.1 -// source: proto/kg/v1/kg.proto +// source: kg/v1/kg.proto package kgpb @@ -31,27 +37,30 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - KgGraph_ResolveScope_FullMethodName = "/kg.v1.KgGraph/ResolveScope" - KgGraph_SelectPendingPages_FullMethodName = "/kg.v1.KgGraph/SelectPendingPages" - KgGraph_BeginPage_FullMethodName = "/kg.v1.KgGraph/BeginPage" - KgGraph_MarkPageDone_FullMethodName = "/kg.v1.KgGraph/MarkPageDone" - KgGraph_MarkPageFailed_FullMethodName = "/kg.v1.KgGraph/MarkPageFailed" - KgGraph_CountPendingPages_FullMethodName = "/kg.v1.KgGraph/CountPendingPages" - KgGraph_UpsertEntities_FullMethodName = "/kg.v1.KgGraph/UpsertEntities" - KgGraph_LinkAppearances_FullMethodName = "/kg.v1.KgGraph/LinkAppearances" - KgGraph_UpsertRelations_FullMethodName = "/kg.v1.KgGraph/UpsertRelations" - KgGraph_DeletePageEvidence_FullMethodName = "/kg.v1.KgGraph/DeletePageEvidence" - KgGraph_DeleteVideo_FullMethodName = "/kg.v1.KgGraph/DeleteVideo" - KgGraph_ClearAll_FullMethodName = "/kg.v1.KgGraph/ClearAll" - KgGraph_Neighbors_FullMethodName = "/kg.v1.KgGraph/Neighbors" - KgGraph_FetchEvidence_FullMethodName = "/kg.v1.KgGraph/FetchEvidence" - KgGraph_RelationsAmong_FullMethodName = "/kg.v1.KgGraph/RelationsAmong" - KgGraph_EntitiesByEids_FullMethodName = "/kg.v1.KgGraph/EntitiesByEids" - KgGraph_EntityExposure_FullMethodName = "/kg.v1.KgGraph/EntityExposure" - KgGraph_EntityAppearances_FullMethodName = "/kg.v1.KgGraph/EntityAppearances" - KgGraph_TopEntities_FullMethodName = "/kg.v1.KgGraph/TopEntities" - KgGraph_EdgesAmong_FullMethodName = "/kg.v1.KgGraph/EdgesAmong" - KgGraph_Stats_FullMethodName = "/kg.v1.KgGraph/Stats" + KgGraph_ResolveScope_FullMethodName = "/kg.v1.KgGraph/ResolveScope" + KgGraph_SelectPendingPages_FullMethodName = "/kg.v1.KgGraph/SelectPendingPages" + KgGraph_BeginPage_FullMethodName = "/kg.v1.KgGraph/BeginPage" + KgGraph_MarkPageDone_FullMethodName = "/kg.v1.KgGraph/MarkPageDone" + KgGraph_MarkPageFailed_FullMethodName = "/kg.v1.KgGraph/MarkPageFailed" + KgGraph_CountPendingPages_FullMethodName = "/kg.v1.KgGraph/CountPendingPages" + KgGraph_UpsertEntities_FullMethodName = "/kg.v1.KgGraph/UpsertEntities" + KgGraph_LinkAppearances_FullMethodName = "/kg.v1.KgGraph/LinkAppearances" + KgGraph_UpsertRelations_FullMethodName = "/kg.v1.KgGraph/UpsertRelations" + KgGraph_DeletePageEvidence_FullMethodName = "/kg.v1.KgGraph/DeletePageEvidence" + KgGraph_DeleteVideo_FullMethodName = "/kg.v1.KgGraph/DeleteVideo" + KgGraph_ClearAll_FullMethodName = "/kg.v1.KgGraph/ClearAll" + KgGraph_LinkCloudDocAppearances_FullMethodName = "/kg.v1.KgGraph/LinkCloudDocAppearances" + KgGraph_DeleteCloudDocAppearances_FullMethodName = "/kg.v1.KgGraph/DeleteCloudDocAppearances" + KgGraph_FetchCloudDocEvidence_FullMethodName = "/kg.v1.KgGraph/FetchCloudDocEvidence" + KgGraph_Neighbors_FullMethodName = "/kg.v1.KgGraph/Neighbors" + KgGraph_FetchEvidence_FullMethodName = "/kg.v1.KgGraph/FetchEvidence" + KgGraph_RelationsAmong_FullMethodName = "/kg.v1.KgGraph/RelationsAmong" + KgGraph_EntitiesByEids_FullMethodName = "/kg.v1.KgGraph/EntitiesByEids" + KgGraph_EntityExposure_FullMethodName = "/kg.v1.KgGraph/EntityExposure" + KgGraph_EntityAppearances_FullMethodName = "/kg.v1.KgGraph/EntityAppearances" + KgGraph_TopEntities_FullMethodName = "/kg.v1.KgGraph/TopEntities" + KgGraph_EdgesAmong_FullMethodName = "/kg.v1.KgGraph/EdgesAmong" + KgGraph_Stats_FullMethodName = "/kg.v1.KgGraph/Stats" ) // KgGraphClient is the client API for KgGraph service. @@ -85,6 +94,18 @@ type KgGraphClient interface { DeleteVideo(ctx context.Context, in *DeleteVideoRequest, opts ...grpc.CallOption) (*DeleteVideoResponse, error) // 清空图谱(危险操作;Entity/Video 标签之外的数据不动)。 ClearAll(ctx context.Context, in *ClearAllRequest, opts ...grpc.CallOption) (*ClearAllResponse, error) + // 写一份云盘文档的知识点证据:MERGE (:CloudDoc {uid, upload_uuid}) + // (复用云盘 L1 路由图节点;title/content_hash 非空才覆盖),删除该文档 + // 已有 APPEARS_IN 证据边(delete-before-write),再写 + // (entity)-[:APPEARS_IN {quote}]->(cloudDoc)。同文档同实体去重保留最短 + // 非空引文(与 LinkAppearances 同语义,无 page_index)。 + LinkCloudDocAppearances(ctx context.Context, in *LinkCloudDocAppearancesRequest, opts ...grpc.CallOption) (*LinkCloudDocAppearancesResponse, error) + // 删除一份云盘文档的全部知识点证据边,并回收因此变成孤儿的 Entity + // (文档删除/重抽取前清理;CloudDoc 节点本体归云盘路由图,不在此删除)。 + DeleteCloudDocAppearances(ctx context.Context, in *DeleteCloudDocAppearancesRequest, opts ...grpc.CallOption) (*DeleteCloudDocAppearancesResponse, error) + // 云盘证据回捞:uid 必填(多租户隔离);upload_uuids 空 = 该用户全部 + // 云盘文档;否则限定在这些文档内。行内含 CloudDoc.title。 + FetchCloudDocEvidence(ctx context.Context, in *FetchCloudDocEvidenceRequest, opts ...grpc.CallOption) (*FetchCloudDocEvidenceResponse, error) // 一跳邻居展开(BFS 单步;多跳循环在调用方)。 Neighbors(ctx context.Context, in *NeighborsRequest, opts ...grpc.CallOption) (*NeighborsResponse, error) // 证据回捞(bvids 空 = 不过滤作用域)。每行带 page_title @@ -234,6 +255,36 @@ func (c *kgGraphClient) ClearAll(ctx context.Context, in *ClearAllRequest, opts return out, nil } +func (c *kgGraphClient) LinkCloudDocAppearances(ctx context.Context, in *LinkCloudDocAppearancesRequest, opts ...grpc.CallOption) (*LinkCloudDocAppearancesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LinkCloudDocAppearancesResponse) + err := c.cc.Invoke(ctx, KgGraph_LinkCloudDocAppearances_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kgGraphClient) DeleteCloudDocAppearances(ctx context.Context, in *DeleteCloudDocAppearancesRequest, opts ...grpc.CallOption) (*DeleteCloudDocAppearancesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteCloudDocAppearancesResponse) + err := c.cc.Invoke(ctx, KgGraph_DeleteCloudDocAppearances_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kgGraphClient) FetchCloudDocEvidence(ctx context.Context, in *FetchCloudDocEvidenceRequest, opts ...grpc.CallOption) (*FetchCloudDocEvidenceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FetchCloudDocEvidenceResponse) + err := c.cc.Invoke(ctx, KgGraph_FetchCloudDocEvidence_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *kgGraphClient) Neighbors(ctx context.Context, in *NeighborsRequest, opts ...grpc.CallOption) (*NeighborsResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(NeighborsResponse) @@ -355,6 +406,18 @@ type KgGraphServer interface { DeleteVideo(context.Context, *DeleteVideoRequest) (*DeleteVideoResponse, error) // 清空图谱(危险操作;Entity/Video 标签之外的数据不动)。 ClearAll(context.Context, *ClearAllRequest) (*ClearAllResponse, error) + // 写一份云盘文档的知识点证据:MERGE (:CloudDoc {uid, upload_uuid}) + // (复用云盘 L1 路由图节点;title/content_hash 非空才覆盖),删除该文档 + // 已有 APPEARS_IN 证据边(delete-before-write),再写 + // (entity)-[:APPEARS_IN {quote}]->(cloudDoc)。同文档同实体去重保留最短 + // 非空引文(与 LinkAppearances 同语义,无 page_index)。 + LinkCloudDocAppearances(context.Context, *LinkCloudDocAppearancesRequest) (*LinkCloudDocAppearancesResponse, error) + // 删除一份云盘文档的全部知识点证据边,并回收因此变成孤儿的 Entity + // (文档删除/重抽取前清理;CloudDoc 节点本体归云盘路由图,不在此删除)。 + DeleteCloudDocAppearances(context.Context, *DeleteCloudDocAppearancesRequest) (*DeleteCloudDocAppearancesResponse, error) + // 云盘证据回捞:uid 必填(多租户隔离);upload_uuids 空 = 该用户全部 + // 云盘文档;否则限定在这些文档内。行内含 CloudDoc.title。 + FetchCloudDocEvidence(context.Context, *FetchCloudDocEvidenceRequest) (*FetchCloudDocEvidenceResponse, error) // 一跳邻居展开(BFS 单步;多跳循环在调用方)。 Neighbors(context.Context, *NeighborsRequest) (*NeighborsResponse, error) // 证据回捞(bvids 空 = 不过滤作用域)。每行带 page_title @@ -420,6 +483,15 @@ func (UnimplementedKgGraphServer) DeleteVideo(context.Context, *DeleteVideoReque func (UnimplementedKgGraphServer) ClearAll(context.Context, *ClearAllRequest) (*ClearAllResponse, error) { return nil, status.Error(codes.Unimplemented, "method ClearAll not implemented") } +func (UnimplementedKgGraphServer) LinkCloudDocAppearances(context.Context, *LinkCloudDocAppearancesRequest) (*LinkCloudDocAppearancesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method LinkCloudDocAppearances not implemented") +} +func (UnimplementedKgGraphServer) DeleteCloudDocAppearances(context.Context, *DeleteCloudDocAppearancesRequest) (*DeleteCloudDocAppearancesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteCloudDocAppearances not implemented") +} +func (UnimplementedKgGraphServer) FetchCloudDocEvidence(context.Context, *FetchCloudDocEvidenceRequest) (*FetchCloudDocEvidenceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FetchCloudDocEvidence not implemented") +} func (UnimplementedKgGraphServer) Neighbors(context.Context, *NeighborsRequest) (*NeighborsResponse, error) { return nil, status.Error(codes.Unimplemented, "method Neighbors not implemented") } @@ -684,6 +756,60 @@ func _KgGraph_ClearAll_Handler(srv interface{}, ctx context.Context, dec func(in return interceptor(ctx, in, info, handler) } +func _KgGraph_LinkCloudDocAppearances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LinkCloudDocAppearancesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KgGraphServer).LinkCloudDocAppearances(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KgGraph_LinkCloudDocAppearances_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KgGraphServer).LinkCloudDocAppearances(ctx, req.(*LinkCloudDocAppearancesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KgGraph_DeleteCloudDocAppearances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteCloudDocAppearancesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KgGraphServer).DeleteCloudDocAppearances(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KgGraph_DeleteCloudDocAppearances_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KgGraphServer).DeleteCloudDocAppearances(ctx, req.(*DeleteCloudDocAppearancesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KgGraph_FetchCloudDocEvidence_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FetchCloudDocEvidenceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KgGraphServer).FetchCloudDocEvidence(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KgGraph_FetchCloudDocEvidence_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KgGraphServer).FetchCloudDocEvidence(ctx, req.(*FetchCloudDocEvidenceRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _KgGraph_Neighbors_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(NeighborsRequest) if err := dec(in); err != nil { @@ -901,6 +1027,18 @@ var KgGraph_ServiceDesc = grpc.ServiceDesc{ MethodName: "ClearAll", Handler: _KgGraph_ClearAll_Handler, }, + { + MethodName: "LinkCloudDocAppearances", + Handler: _KgGraph_LinkCloudDocAppearances_Handler, + }, + { + MethodName: "DeleteCloudDocAppearances", + Handler: _KgGraph_DeleteCloudDocAppearances_Handler, + }, + { + MethodName: "FetchCloudDocEvidence", + Handler: _KgGraph_FetchCloudDocEvidence_Handler, + }, { MethodName: "Neighbors", Handler: _KgGraph_Neighbors_Handler, @@ -939,5 +1077,5 @@ var KgGraph_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "proto/kg/v1/kg.proto", + Metadata: "kg/v1/kg.proto", } diff --git a/app-go/app-kg/internal/repo/mysql/page.go b/app-go/app-kg/internal/repo/mysql/page.go new file mode 100644 index 0000000..572fbc4 --- /dev/null +++ b/app-go/app-kg/internal/repo/mysql/page.go @@ -0,0 +1,171 @@ +// Package mysql is the SQL data-access layer for the shared bilirag MySQL. +// +// Write surface is exactly two columns on `video` (kg_status, kg_version); +// everything else is read-only. All queries are parameterised; there is no +// DDL and no ORM model — app-kg owns no tables. +package mysql + +import ( + "context" + + "gorm.io/gorm" +) + +// PageKey identifies one video page for title lookups. +type PageKey struct { + Bvid string + PageIndex int32 +} + +// PendingPage is one page selected for (re-)extraction. +type PendingPage struct { + Bvid string + Cid int64 + PageIndex int32 + PageTitle string + Version int64 +} + +// PageRow is the video-row projection the bookkeeping state machine needs. +type PageRow struct { + Bvid string + PageIndex int32 + PageTitle string + IsProcess bool + Version int64 + KgStatus *string + KgVersion *int64 +} + +// PageRepo — video / favorite_folders / collection access used by the KG +// bookkeeping and scope-resolution flows. +type PageRepo struct{ db *gorm.DB } + +// New wires the repository onto the infra handle (see infra/mysql). +func New(db *gorm.DB) *PageRepo { return &PageRepo{db: db} } + +// MediaIDs lists the user's non-deleted favorite-folder ids, newest first. +// Empty folderIDs = all folders. Port of chat/scope.get_media_ids_for_uid. +func (r *PageRepo) MediaIDs(ctx context.Context, uid int64, folderIDs []int64) ([]int64, error) { + q := r.db.WithContext(ctx).Raw( + "SELECT media_id FROM favorite_folders WHERE uid = ? AND deleted_at IS NULL "+ + "ORDER BY updated_at DESC", + uid, + ) + if len(folderIDs) > 0 { + q = r.db.WithContext(ctx).Raw( + "SELECT media_id FROM favorite_folders WHERE uid = ? AND deleted_at IS NULL "+ + "AND media_id IN (?) ORDER BY updated_at DESC", + uid, folderIDs, + ) + } + var ids []int64 + err := q.Scan(&ids).Error + return ids, err +} + +// BvidsByMediaIDs maps folder media ids → bvids (collection table, read-only). +func (r *PageRepo) BvidsByMediaIDs(ctx context.Context, mediaIDs []int64) ([]string, error) { + if len(mediaIDs) == 0 { + return nil, nil + } + var bvids []string + if err := r.db.WithContext(ctx).Raw( + "SELECT bvid FROM collection WHERE media_id IN (?)", mediaIDs, + ).Scan(&bvids).Error; err != nil { + return nil, err + } + return bvids, nil +} + +// ListPending picks pages whose ASR is done and whose kg extraction is +// missing / failed / stale. Port of service._select_pending_pages. +func (r *PageRepo) ListPending(ctx context.Context, bvids []string, limit int32) ([]PendingPage, error) { + if len(bvids) == 0 { + return nil, nil + } + var rows []PendingPage + err := r.db.WithContext(ctx).Raw( + "SELECT bvid, cid, page_index, page_title, version FROM video "+ + "WHERE bvid IN (?) AND is_processed = 1 "+ + "AND (kg_version IS NULL OR kg_status = 'failed' OR kg_version <> version) "+ + "LIMIT ?", + bvids, limit, + ).Scan(&rows).Error + return rows, err +} + +// CountPending is the scope-free stats counter (service.stats). +func (r *PageRepo) CountPending(ctx context.Context) (int64, error) { + var n int64 + err := r.db.WithContext(ctx).Raw( + "SELECT COUNT(id) FROM video " + + "WHERE is_processed = 1 " + + "AND (kg_version IS NULL OR kg_status = 'failed' OR kg_version <> version)", + ).Scan(&n).Error + return n, err +} + +// GetPage loads one video page row; (nil, nil) = the row does not exist. +func (r *PageRepo) GetPage(ctx context.Context, bvid string, cid int64) (*PageRow, error) { + var row PageRow + err := r.db.WithContext(ctx).Raw( + "SELECT bvid, page_index, page_title, is_processed AS is_process, version, "+ + "kg_status, kg_version FROM video WHERE bvid = ? AND cid = ?", + bvid, cid, + ).Scan(&row).Error + if err != nil { + return nil, err + } + if row.Bvid == "" { + return nil, nil + } + return &row, nil +} + +// SetStatus moves a page's kg_status to an arbitrary value. The only +// permitted writer of these two columns in the whole stack. +func (r *PageRepo) SetStatus(ctx context.Context, bvid string, cid int64, status string) error { + return r.db.WithContext(ctx).Exec( + "UPDATE video SET kg_status = ? WHERE bvid = ? AND cid = ?", + status, bvid, cid, + ).Error +} + +// MarkDone sets kg_status='done' + kg_version=content_version (Phase 7 of +// the bookkeeping flow). +func (r *PageRepo) MarkDone(ctx context.Context, bvid string, cid int64, contentVersion int64) error { + return r.db.WithContext(ctx).Exec( + "UPDATE video SET kg_status = 'done', kg_version = ? WHERE bvid = ? AND cid = ?", + contentVersion, bvid, cid, + ).Error +} + +// PageTitles returns (bvid, page_index) → page_title for evidence annotation +// (retriever._page_titles). A missing/empty title is returned as "" — the +// "P{n+1}" fallback is a presentation rule owned by the service layer. +func (r *PageRepo) PageTitles(ctx context.Context, bvids []string) (map[PageKey]string, error) { + out := map[PageKey]string{} + if len(bvids) == 0 { + return out, nil + } + var rows []struct { + Bvid string + PageIndex int32 + PageTitle *string + } + if err := r.db.WithContext(ctx).Raw( + "SELECT bvid, page_index, page_title FROM video WHERE bvid IN (?)", + bvids, + ).Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + title := "" + if row.PageTitle != nil { + title = *row.PageTitle + } + out[PageKey{Bvid: row.Bvid, PageIndex: row.PageIndex}] = title + } + return out, nil +} diff --git a/app-go/app-kg/internal/graph/cloud.go b/app-go/app-kg/internal/repo/neo4j/cloud.go similarity index 84% rename from app-go/app-kg/internal/graph/cloud.go rename to app-go/app-kg/internal/repo/neo4j/cloud.go index 7c5bf00..869dc24 100644 --- a/app-go/app-kg/internal/graph/cloud.go +++ b/app-go/app-kg/internal/repo/neo4j/cloud.go @@ -9,7 +9,7 @@ // filters by it — isolation is enforced here, never by the caller. // Normalization red line (same as graph.go): name_lower is computed by the // CALLER; this package never lowercases or hashes names. -package graph +package neo4j import ( "context" @@ -55,9 +55,9 @@ func cloudTopicRows(names []string) []map[string]any { // stale topic links behind), and recycles nodes left with no incoming edge. // Orphan cleanup races a concurrent upsert in theory; the next upsert // recreates any node lost that way, so it self-heals. -func (g *Store) UpsertCloudDocGraph(ctx context.Context, d CloudDocGraph) error { +func (r *GraphRepo) UpsertCloudDocGraph(ctx context.Context, d CloudDocGraph) error { // 1. doc node + drop stale edges - if _, err := g.run(ctx, ` + if _, err := r.run(ctx, ` MERGE (d:CloudDoc {uid: $uid, upload_uuid: $upload_uuid}) ON CREATE SET d.created_at = timestamp() SET d.title = $title, d.content_hash = $content_hash, d.updated_at = timestamp() @@ -72,7 +72,7 @@ DELETE r } // 2. topics + ABOUT edges if rows := cloudTopicRows(d.Topics); len(rows) > 0 { - if _, err := g.run(ctx, ` + if _, err := r.run(ctx, ` MATCH (d:CloudDoc {uid: $uid, upload_uuid: $upload_uuid}) UNWIND $rows AS row MERGE (t:CloudTopic {uid: $uid, name_lower: row.name_lower}) @@ -87,7 +87,7 @@ MERGE (d)-[:ABOUT]->(t) } // 3. entities + MENTIONS edges if rows := cloudTopicRows(d.Entities); len(rows) > 0 { - if _, err := g.run(ctx, ` + if _, err := r.run(ctx, ` MATCH (d:CloudDoc {uid: $uid, upload_uuid: $upload_uuid}) UNWIND $rows AS row MERGE (en:CloudEntity {uid: $uid, name_lower: row.name_lower}) @@ -100,32 +100,37 @@ MERGE (d)-[:MENTIONS]->(en) return fmt.Errorf("cloud entities upsert: %w", err) } } - g.recycleCloudOrphans(ctx) + r.recycleCloudOrphans(ctx) return nil } // DeleteCloudDocGraph detaches and deletes the doc node (purge / reprocess // cleanup), then recycles any topic/entity nodes left edge-less. -func (g *Store) DeleteCloudDocGraph(ctx context.Context, uid int64, uploadUUID string) error { - if _, err := g.run(ctx, ` +func (r *GraphRepo) DeleteCloudDocGraph(ctx context.Context, uid int64, uploadUUID string) error { + if _, err := r.run(ctx, ` MATCH (d:CloudDoc {uid: $uid, upload_uuid: $upload_uuid}) DETACH DELETE d `, map[string]any{"uid": uid, "upload_uuid": uploadUUID}); err != nil { return fmt.Errorf("cloud doc delete: %w", err) } - g.recycleCloudOrphans(ctx) + r.recycleCloudOrphans(ctx) + // Knowledge evidence edges die with the node (DETACH DELETE); recycle the + // entities left with no relationship at all (cloud_kg.go). + if _, err := r.recycleOrphanEntities(ctx); err != nil { + // best-effort: leftover orphan entities are harmless garbage + } return nil } // recycleCloudOrphans removes topic/entity nodes no document links to // anymore. Best-effort: a failure here leaves harmless garbage that the next // upsert cycle cleans. -func (g *Store) recycleCloudOrphans(ctx context.Context) { +func (r *GraphRepo) recycleCloudOrphans(ctx context.Context) { for _, stmt := range []string{ `MATCH (t:CloudTopic) WHERE NOT ( ()-[:ABOUT]->(t) ) DETACH DELETE t`, `MATCH (en:CloudEntity) WHERE NOT ( ()-[:MENTIONS]->(en) ) DETACH DELETE en`, } { - if _, err := g.run(ctx, stmt, nil); err != nil { + if _, err := r.run(ctx, stmt, nil); err != nil { // logged by the driver layer; nothing actionable here return } @@ -135,7 +140,7 @@ func (g *Store) recycleCloudOrphans(ctx context.Context) { // MatchCloudDocs matches documents by topic/entity name against the given // keywords (case-insensitive substring in BOTH directions, so short keywords // still hit longer topic names). Returns docs ordered by distinct-hit score. -func (g *Store) MatchCloudDocs(ctx context.Context, uid int64, keywords []string, limit int) ([]CloudDocMatch, error) { +func (r *GraphRepo) MatchCloudDocs(ctx context.Context, uid int64, keywords []string, limit int) ([]CloudDocMatch, error) { keys := make([]string, 0, len(keywords)) for _, k := range keywords { k = strings.ToLower(strings.TrimSpace(k)) @@ -175,27 +180,27 @@ RETURN d.upload_uuid AS upload_uuid, d.title AS title, merged := map[string]*CloudDocMatch{} var order []string for _, lg := range legs { - res, err := g.run(ctx, lg.cypher, map[string]any{"uid": uid, "keys": keys}) + res, err := r.run(ctx, lg.cypher, map[string]any{"uid": uid, "keys": keys}) if err != nil { return nil, fmt.Errorf("cloud match %s: %w", lg.field, err) } - for _, r := range res { - uu := asString(r["upload_uuid"]) + for _, row := range res { + uu := asString(row["upload_uuid"]) if uu == "" { continue } m, ok := merged[uu] if !ok { - m = &CloudDocMatch{UploadUUID: uu, Title: asString(r["title"])} + m = &CloudDocMatch{UploadUUID: uu, Title: asString(row["title"])} merged[uu] = m order = append(order, uu) } score := 0 - if v, ok := r["score"].(int64); ok { + if v, ok := row["score"].(int64); ok { score = int(v) } m.Score += score - if names, ok := r["names"].([]any); ok { + if names, ok := row["names"].([]any); ok { for _, n := range names { if s, ok := n.(string); ok && s != "" { if lg.field == "topics" { diff --git a/app-go/app-kg/internal/repo/neo4j/cloud_kg.go b/app-go/app-kg/internal/repo/neo4j/cloud_kg.go new file mode 100644 index 0000000..9e748f7 --- /dev/null +++ b/app-go/app-kg/internal/repo/neo4j/cloud_kg.go @@ -0,0 +1,168 @@ +package neo4j + +import ( + "context" + "fmt" + "log/slog" +) + +// ── cloud-doc knowledge (Entity/RELATES shared with the video KG) ──── +// +// Cloud-document knowledge points live in the SAME Entity/RELATES space as the +// video KG; only the evidence target differs: +// +// (e:Entity)-[:APPEARS_IN {quote}]->(d:CloudDoc {uid, upload_uuid, title, content_hash}) +// +// The CloudDoc node is created by the cloud-drive L1 routing graph (see +// cloud.go) and reused here as the knowledge source node. Multi-tenant rule: +// every statement filters by d.uid — isolation is enforced here, never by the +// caller. Normalization red line: eid/name arrive precomputed; this package +// never normalizes names. + +// CloudDocEvidence is one APPEARS_IN row from an Entity to a CloudDoc source. +type CloudDocEvidence struct { + Eid string + Name string + Type string + UploadUUID string + Quote string + Title string +} + +// LinkCloudDocAppearances replaces one document's knowledge evidence +// (delete-before-write): MERGE the CloudDoc source node (title/content_hash +// only overwrite when non-empty, so routing metadata survives an empty +// payload), drop the document's existing evidence edges, then write +// (entity)-[:APPEARS_IN {quote}]->(cloudDoc). Same-document duplicates resolve +// to the shortest non-empty quote (parity with LinkAppearances); there is no +// page index — the document is the unit. Returns the linked edge count. +func (r *GraphRepo) LinkCloudDocAppearances(ctx context.Context, uid int64, uploadUUID, title, contentHash string, appearances []AppearanceInput) (int64, error) { + dedup := map[string]string{} + var order []string + for _, a := range appearances { + if a.Eid == "" { + continue + } + cur, ok := dedup[a.Eid] + if !ok { + order = append(order, a.Eid) + dedup[a.Eid] = a.Quote + continue + } + if a.Quote != "" && (cur == "" || len(a.Quote) < len(cur)) { + dedup[a.Eid] = a.Quote + } + } + rows := make([]map[string]any, 0, len(order)) + for _, eid := range order { + rows = append(rows, map[string]any{"eid": eid, "quote": dedup[eid]}) + } + + cypher := ` +MERGE (d:CloudDoc {uid: $uid, upload_uuid: $upload_uuid}) +ON CREATE SET d.created_at = timestamp() +SET d.updated_at = timestamp() +SET d.title = CASE WHEN $title <> '' THEN $title ELSE coalesce(d.title, '') END, + d.content_hash = CASE WHEN $content_hash <> '' THEN $content_hash ELSE coalesce(d.content_hash, '') END +WITH d +OPTIONAL MATCH (d)<-[old:APPEARS_IN]-(:Entity) +DELETE old +WITH DISTINCT d +UNWIND $rows AS row +MATCH (e:Entity {eid: row.eid}) +MERGE (e)-[ev:APPEARS_IN]->(d) +ON CREATE SET ev.created_at = timestamp() +SET ev.quote = row.quote, ev.updated_at = timestamp() +RETURN count(ev) AS linked` + res, err := r.run(ctx, cypher, map[string]any{ + "uid": uid, "upload_uuid": uploadUUID, "title": title, + "content_hash": contentHash, "rows": rows, + }) + if err != nil { + return 0, err + } + if len(res) == 0 { + return 0, nil + } + return asInt64(res[0]["linked"]), nil +} + +// DeleteCloudDocAppearances removes every knowledge evidence edge of one +// document and recycles Entity nodes left with no relationship at all (the +// CloudDoc node itself belongs to the routing graph and is not deleted here). +func (r *GraphRepo) DeleteCloudDocAppearances(ctx context.Context, uid int64, uploadUUID string) (deleted, entitiesRemoved int64, err error) { + cypher := ` +MATCH (d:CloudDoc {uid: $uid, upload_uuid: $upload_uuid})<-[ev:APPEARS_IN]-(:Entity) +DELETE ev +RETURN count(ev) AS deleted` + res, err := r.run(ctx, cypher, map[string]any{"uid": uid, "upload_uuid": uploadUUID}) + if err != nil { + return 0, 0, err + } + if len(res) > 0 { + deleted = asInt64(res[0]["deleted"]) + } + entitiesRemoved, err = r.recycleOrphanEntities(ctx) + if err != nil { + return deleted, 0, err + } + if deleted != 0 || entitiesRemoved != 0 { + slog.Info("[KG_REPO] delete_cloud_doc_evidence", "uid", uid, + "upload_uuid", uploadUUID, "deleted", deleted, "entities_removed", entitiesRemoved) + } + return deleted, entitiesRemoved, nil +} + +// FetchCloudDocEvidence pulls the evidence rows of the given entities from the +// user's cloud documents. uid is mandatory (multi-tenant isolation); +// uploadUUIDs empty = all of the user's documents, otherwise scoped to that +// set. Rows carry the document title for citation rendering. +func (r *GraphRepo) FetchCloudDocEvidence(ctx context.Context, eids []string, uid int64, uploadUUIDs []string, limit int32) ([]CloudDocEvidence, error) { + if len(eids) == 0 { + return nil, nil + } + if uploadUUIDs == nil { + uploadUUIDs = []string{} + } + cypher := ` +UNWIND $eids AS eid +MATCH (e:Entity {eid: eid})-[ev:APPEARS_IN]->(d:CloudDoc) +WHERE d.uid = $uid AND (size($upload_uuids) = 0 OR d.upload_uuid IN $upload_uuids) +RETURN e.eid AS eid, e.name AS name, e.type AS type, + d.upload_uuid AS upload_uuid, coalesce(d.title, '') AS title, ev.quote AS quote +LIMIT $limit` + rows, err := r.run(ctx, cypher, map[string]any{ + "eids": eids, "uid": uid, "upload_uuids": uploadUUIDs, "limit": limit, + }) + if err != nil { + return nil, err + } + out := make([]CloudDocEvidence, 0, len(rows)) + for _, row := range rows { + out = append(out, CloudDocEvidence{ + Eid: asString(row["eid"]), + Name: asString(row["name"]), + Type: asString(row["type"]), + UploadUUID: asString(row["upload_uuid"]), + Title: asString(row["title"]), + Quote: asString(row["quote"]), + }) + } + return out, nil +} + +// recycleOrphanEntities removes Entity nodes with no relationship at all +// (neither RELATES nor any APPEARS_IN target). Shared cleanup for cloud-doc +// evidence deletion and cloud-doc graph deletion; video entities survive while +// they still have an edge. +func (r *GraphRepo) recycleOrphanEntities(ctx context.Context) (int64, error) { + res, err := r.run(ctx, + "MATCH (e:Entity) WHERE NOT (e)--() DELETE e RETURN count(e) AS removed", nil) + if err != nil { + return 0, fmt.Errorf("recycle orphan entities: %w", err) + } + if len(res) == 0 { + return 0, nil + } + return asInt64(res[0]["removed"]), nil +} diff --git a/app-go/app-kg/internal/repo/neo4j/graph.go b/app-go/app-kg/internal/repo/neo4j/graph.go new file mode 100644 index 0000000..d965c95 --- /dev/null +++ b/app-go/app-kg/internal/repo/neo4j/graph.go @@ -0,0 +1,733 @@ +// Package neo4j is the Cypher data-access layer of app-kg: every statement +// that reads or writes the knowledge graph lives here. It never validates +// business input and never speaks gRPC/HTTP — internal/service owns the +// rules, internal/infra/neo4j owns the driver. +// +// Cypher semantics must stay compatible with the retired Python +// KgGraphRepository (the guard tests pin the merge keys and the quotes +// sliding window): merge keys name_lower / bvid, deterministic +// eid = sha1(name_lower)[:16] computed by the CALLER — this package never +// normalizes names. +// +// Graph schema (unchanged): +// +// (:Entity {eid, name, name_lower, type, description, aliases, +// mention_count, created_at, updated_at}) +// (:Video {bvid}) +// (a:Entity)-[:RELATES {rel_type, weight, quotes}]->(b:Entity) +// (e:Entity)-[:APPEARS_IN {page_index, quote}]->(v:Video) +package neo4j + +import ( + "context" + "fmt" + "log/slog" + + neostore "app-kg/internal/infra/neo4j" +) + +// MaxRelationQuotes caps the evidence quotes kept per RELATES edge (parity +// with the Python MAX_RELATION_QUOTES). +const MaxRelationQuotes = 3 + +// GraphRepo executes Cypher against the configured Neo4j database. +type GraphRepo struct { + st *neostore.Store +} + +// New wires the graph repository onto the infra handle. +func New(st *neostore.Store) *GraphRepo { return &GraphRepo{st: st} } + +// ── inputs (typed Cypher parameters) ──────────────────────────────── + +// EntityInput is one entity upsert row. Normalization (name_lower / eid) is +// the caller's job and arrives precomputed. +type EntityInput struct { + Eid string + Name string + NameLower string + Type string + Description string + Alias string +} + +// AppearanceInput is one APPEARS_IN evidence link. +type AppearanceInput struct { + Eid string + Quote string +} + +// RelationInput is one RELATES edge row. +type RelationInput struct { + SrcEid string + DstEid string + RelType string + Quote string +} + +// ── read models (typed Cypher row projections) ────────────────────── + +// Neighbor is one hop of the BFS expansion. +type Neighbor struct { + Eid string + Name string + Type string + Description string + RelType string + FromEid string +} + +// Evidence is one APPEARS_IN row. PageTitle is filled by the service layer +// (video-table join), never by Cypher. +type Evidence struct { + Eid string + Name string + Type string + Bvid string + PageIndex int32 + Quote string + PageTitle string +} + +// Relation is one RELATES edge between subgraph members. +type Relation struct { + SrcEid string + SrcName string + RelType string + DstEid string + DstName string + Weight int64 + Quotes []string +} + +// Entity is one entity's detail projection. +type Entity struct { + Eid string + Name string + Type string + Description string + Aliases []string +} + +// ExposureEvidence is one sampled appearance of an over-exposed entity. +type ExposureEvidence struct { + Bvid string + PageIndex int32 + Quote string +} + +// Exposure is one row of the blindspot exposure aggregation. +type Exposure struct { + Eid string + Name string + Type string + Description string + Pages int64 + EvidenceSample []ExposureEvidence +} + +// Appearance is one appearance of a single entity. +type Appearance struct { + Bvid string + PageIndex int32 + Quote string +} + +// TopEntity is one head entity ranked by RELATES degree. +type TopEntity struct { + Eid string + Name string + Type string + Description string + Degree int64 +} + +// Edge is one directed relation edge inside an entity set. +type Edge struct { + SrcEid string + DstEid string + RelType string +} + +// GraphStats holds the Neo4j-side counters (the service composes the Milvus / +// pending-page counters on top). Evidence is video-only; cloud-document +// knowledge evidence is reported separately so the video stats stay comparable. +type GraphStats struct { + Entities int64 + Relations int64 + Evidence int64 + Videos int64 + CloudDocs int64 + CloudEvidence int64 +} + +// run executes a query and collects all records as value maps. Map rows stay +// private to this package — methods return typed projections instead. +func (r *GraphRepo) run(ctx context.Context, cypher string, params map[string]any) ([]map[string]any, error) { + session := r.st.NewSession(ctx) + defer session.Close(ctx) + result, err := session.Run(ctx, cypher, params) + if err != nil { + return nil, err + } + records, err := result.Collect(ctx) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(records)) + for _, rec := range records { + row := make(map[string]any, len(rec.Keys)) + for i, key := range rec.Keys { + row[key] = rec.Values[i] + } + out = append(out, row) + } + return out, nil +} + +// ── entity / video / evidence / relation writes ────────────────────── + +// UpsertEntities bulk-MERGEs entities (merge key name_lower). Description is +// only overwritten when the incoming value is non-empty (stub rows must not +// clear existing descriptions); mention_count increments per upsert; alias is +// appended when new. Returns all involved eids. +func (r *GraphRepo) UpsertEntities(ctx context.Context, rows []EntityInput) ([]string, error) { + if len(rows) == 0 { + return nil, nil + } + params := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + params = append(params, map[string]any{ + "eid": row.Eid, "name": row.Name, "name_lower": row.NameLower, + "type": row.Type, "description": row.Description, "alias": row.Alias, + }) + } + cypher := ` +UNWIND $rows AS row +MERGE (e:Entity {name_lower: row.name_lower}) +ON CREATE SET e.eid = row.eid, e.created_at = timestamp(), + e.aliases = [], e.mention_count = 0 +SET e.name = row.name, + e.updated_at = timestamp(), + e.type = CASE WHEN coalesce(row.type,'') <> '' + THEN row.type ELSE coalesce(e.type, 'other') END, + e.description = CASE WHEN coalesce(row.description,'') <> '' + THEN row.description ELSE coalesce(e.description,'') END, + e.mention_count = coalesce(e.mention_count, 0) + 1 +FOREACH (_ IN CASE WHEN coalesce(row.alias,'') <> '' + AND NOT row.alias IN coalesce(e.aliases, []) + THEN [1] ELSE [] END | + SET e.aliases = coalesce(e.aliases, []) + row.alias +) +RETURN DISTINCT e.eid AS eid` + res, err := r.run(ctx, cypher, map[string]any{"rows": params}) + if err != nil { + return nil, err + } + eids := make([]string, 0, len(res)) + for _, row := range res { + eids = append(eids, asString(row["eid"])) + } + return eids, nil +} + +// LinkAppearances writes APPEARS_IN evidence edges for (bvid, page_index). +// Same-page duplicates are resolved to the shortest non-empty quote (more +// focused), mirroring the Python dedup. Returns the linked edge count. +func (r *GraphRepo) LinkAppearances(ctx context.Context, bvid string, pageIndex int32, appearances []AppearanceInput) (int64, error) { + dedup := map[string]string{} + var order []string + for _, a := range appearances { + if a.Eid == "" { + continue + } + cur, ok := dedup[a.Eid] + if !ok { + order = append(order, a.Eid) + dedup[a.Eid] = a.Quote + continue + } + if a.Quote != "" && (cur == "" || len(a.Quote) < len(cur)) { + dedup[a.Eid] = a.Quote + } + } + if len(order) == 0 { + return 0, nil + } + params := make([]map[string]any, 0, len(order)) + for _, eid := range order { + params = append(params, map[string]any{"eid": eid, "quote": dedup[eid]}) + } + cypher := ` +MERGE (v:Video {bvid: $bvid}) +WITH v +UNWIND $rows AS row +MATCH (e:Entity {eid: row.eid}) +MERGE (e)-[ev:APPEARS_IN {page_index: $page_index}]->(v) +ON CREATE SET ev.created_at = timestamp() +SET ev.quote = row.quote, ev.updated_at = timestamp() +RETURN count(ev) AS linked` + res, err := r.run(ctx, cypher, map[string]any{ + "bvid": bvid, "page_index": pageIndex, "rows": params, + }) + if err != nil { + return 0, err + } + if len(res) == 0 { + return 0, nil + } + return asInt64(res[0]["linked"]), nil +} + +// UpsertRelations bulk-MERGEs RELATES edges: weight increments, quotes keep +// the most recent dedup samples within MaxRelationQuotes. +func (r *GraphRepo) UpsertRelations(ctx context.Context, relations []RelationInput) error { + seen := map[string]bool{} + params := make([]map[string]any, 0, len(relations)) + for _, rel := range relations { + key := rel.SrcEid + "|" + rel.DstEid + "|" + rel.RelType + if seen[key] { + continue + } + seen[key] = true + params = append(params, map[string]any{ + "src_eid": rel.SrcEid, "dst_eid": rel.DstEid, + "rel_type": rel.RelType, "quote": rel.Quote, + }) + } + if len(params) == 0 { + return nil + } + cypher := ` +UNWIND $rows AS row +MATCH (a:Entity {eid: row.src_eid}) +MATCH (b:Entity {eid: row.dst_eid}) +MERGE (a)-[r:RELATES {rel_type: row.rel_type}]->(b) +SET r.weight = coalesce(r.weight, 0) + 1, + r.updated_at = timestamp(), + r.quotes = CASE + WHEN coalesce(row.quote,'') = '' THEN coalesce(r.quotes, []) + WHEN row.quote IN coalesce(r.quotes, []) THEN r.quotes + WHEN size(coalesce(r.quotes, [])) < $max_quotes + THEN coalesce(r.quotes, []) + row.quote + ELSE coalesce(r.quotes, [])[1..] + row.quote + END` + _, err := r.run(ctx, cypher, map[string]any{"rows": params, "max_quotes": MaxRelationQuotes}) + return err +} + +// ── deletion / cleanup ─────────────────────────────────────────────── + +// DeletePageEvidence removes all evidence edges of one page +// (delete-before-write). Returns the deleted edge count. +func (r *GraphRepo) DeletePageEvidence(ctx context.Context, bvid string, pageIndex int32) (int64, error) { + cypher := ` +MATCH (:Video {bvid: $bvid})<-[ev:APPEARS_IN]-(:Entity) +WHERE ev.page_index = $page_index +DELETE ev +RETURN count(ev) AS deleted` + res, err := r.run(ctx, cypher, map[string]any{"bvid": bvid, "page_index": pageIndex}) + if err != nil { + return 0, err + } + if len(res) == 0 { + return 0, nil + } + return asInt64(res[0]["deleted"]), nil +} + +// DeleteVideo cascades all graph traces of a video and collects orphan +// nodes. Returns (entitiesRemoved, videosRemoved). +func (r *GraphRepo) DeleteVideo(ctx context.Context, bvid string) (entitiesRemoved, videosRemoved int64, err error) { + session := r.st.NewSession(ctx) + defer session.Close(ctx) + + runCount := func(cypher string, params map[string]any) (int64, error) { + result, err := session.Run(ctx, cypher, params) + if err != nil { + return 0, err + } + records, err := result.Collect(ctx) + if err != nil { + return 0, err + } + if len(records) == 0 { + return 0, nil + } + return asInt64(records[0].Values[0]), nil + } + + videosRemoved, err = runCount( + "MATCH (v:Video {bvid: $bvid}) DETACH DELETE v RETURN count(v) AS deleted", + map[string]any{"bvid": bvid}) + if err != nil { + return 0, 0, err + } + entitiesRemoved, err = runCount( + "MATCH (e:Entity) WHERE NOT (e)--() DELETE e RETURN count(e) AS removed", + nil) + if err != nil { + return 0, 0, err + } + videosRemovedExtra, err := runCount( + "MATCH (v:Video) WHERE NOT ()-[:APPEARS_IN]->(v) DELETE v RETURN count(v) AS removed", + nil) + if err != nil { + return 0, 0, err + } + videosRemoved += videosRemovedExtra + if entitiesRemoved != 0 || videosRemoved != 0 { + slog.Info("[KG_REPO] delete_video", "bvid", bvid, + "entities_removed", entitiesRemoved, "videos_removed", videosRemoved) + } + return entitiesRemoved, videosRemoved, nil +} + +// ClearAll wipes every Entity/Video node (dangerous; admin/test only). +// Nodes with other labels are untouched. +func (r *GraphRepo) ClearAll(ctx context.Context) error { + if _, err := r.run(ctx, "MATCH (n) WHERE n:Entity OR n:Video DETACH DELETE n", nil); err != nil { + return err + } + slog.Warn("[KG_REPO] graph cleared (all Entity/Video nodes)") + return nil +} + +// ── retrieval reads ────────────────────────────────────────────────── + +// Neighbors is the one-hop BFS expansion: neighbour entities of the frontier +// eids with their connecting relation info. +func (r *GraphRepo) Neighbors(ctx context.Context, eids, excludeEids []string, limit int32) ([]Neighbor, error) { + if len(eids) == 0 { + return nil, nil + } + cypher := ` +UNWIND $eids AS eid +MATCH (e:Entity {eid: eid})-[r:RELATES]-(n:Entity) +WHERE n.eid <> eid AND NOT n.eid IN $exclude +RETURN DISTINCT n.eid AS eid, n.name AS name, n.type AS type, + n.description AS description, r.rel_type AS rel_type, + e.eid AS from_eid +LIMIT $limit` + rows, err := r.run(ctx, cypher, map[string]any{ + "eids": eids, "exclude": excludeEids, "limit": limit, + }) + if err != nil { + return nil, err + } + out := make([]Neighbor, 0, len(rows)) + for _, row := range rows { + out = append(out, Neighbor{ + Eid: asString(row["eid"]), + Name: asString(row["name"]), + Type: asString(row["type"]), + Description: asString(row["description"]), + RelType: asString(row["rel_type"]), + FromEid: asString(row["from_eid"]), + }) + } + return out, nil +} + +// FetchEvidence pulls APPEARS_IN evidence rows; empty bvids = no scope +// filter (admin/debug semantics, mirroring the Python repository). +func (r *GraphRepo) FetchEvidence(ctx context.Context, eids, bvids []string, limit int32) ([]Evidence, error) { + if len(eids) == 0 { + return nil, nil + } + if bvids == nil { + bvids = []string{} + } + cypher := ` +UNWIND $eids AS eid +MATCH (e:Entity {eid: eid})-[ev:APPEARS_IN]->(v:Video) +WHERE size($bvids) = 0 OR v.bvid IN $bvids +RETURN e.eid AS eid, e.name AS name, e.type AS type, + v.bvid AS bvid, ev.page_index AS page_index, ev.quote AS quote +LIMIT $limit` + rows, err := r.run(ctx, cypher, map[string]any{ + "eids": eids, "bvids": bvids, "limit": limit, + }) + if err != nil { + return nil, err + } + out := make([]Evidence, 0, len(rows)) + for _, row := range rows { + out = append(out, Evidence{ + Eid: asString(row["eid"]), + Name: asString(row["name"]), + Type: asString(row["type"]), + Bvid: asString(row["bvid"]), + PageIndex: int32(asInt64(row["page_index"])), + Quote: asString(row["quote"]), + }) + } + return out, nil +} + +// RelationsAmong returns the relation edges between subgraph members. +func (r *GraphRepo) RelationsAmong(ctx context.Context, eids []string) ([]Relation, error) { + if len(eids) < 2 { + return nil, nil + } + cypher := ` +UNWIND $eids AS eid +MATCH (a:Entity {eid: eid})-[r:RELATES]->(b:Entity) +WHERE b.eid IN $eids +RETURN DISTINCT a.eid AS src_eid, a.name AS src_name, + r.rel_type AS rel_type, b.eid AS dst_eid, b.name AS dst_name, + coalesce(r.weight, 1) AS weight, coalesce(r.quotes, []) AS quotes` + rows, err := r.run(ctx, cypher, map[string]any{"eids": eids}) + if err != nil { + return nil, err + } + out := make([]Relation, 0, len(rows)) + for _, row := range rows { + out = append(out, Relation{ + SrcEid: asString(row["src_eid"]), + SrcName: asString(row["src_name"]), + RelType: asString(row["rel_type"]), + DstEid: asString(row["dst_eid"]), + DstName: asString(row["dst_name"]), + Weight: asInt64(row["weight"]), + Quotes: asStrings(row["quotes"]), + }) + } + return out, nil +} + +// EntitiesByEids fetches entity details. +func (r *GraphRepo) EntitiesByEids(ctx context.Context, eids []string) ([]Entity, error) { + if len(eids) == 0 { + return nil, nil + } + cypher := ` +UNWIND $eids AS eid +MATCH (e:Entity {eid: eid}) +RETURN e.eid AS eid, e.name AS name, e.type AS type, + e.description AS description, coalesce(e.aliases, []) AS aliases` + rows, err := r.run(ctx, cypher, map[string]any{"eids": eids}) + if err != nil { + return nil, err + } + out := make([]Entity, 0, len(rows)) + for _, row := range rows { + out = append(out, Entity{ + Eid: asString(row["eid"]), + Name: asString(row["name"]), + Type: asString(row["type"]), + Description: asString(row["description"]), + Aliases: asStrings(row["aliases"]), + }) + } + return out, nil +} + +// EntityExposure aggregates APPEARS_IN page counts per entity within scope +// (blindspot map). Empty bvids returns nil — callers must pass a scope +// (different from FetchEvidence where empty means "no filter"). +func (r *GraphRepo) EntityExposure(ctx context.Context, bvids []string, limit int32) ([]Exposure, error) { + if len(bvids) == 0 { + return nil, nil + } + cypher := ` +MATCH (e:Entity)-[ev:APPEARS_IN]->(v:Video) +WHERE v.bvid IN $bvids +WITH e, count(ev) AS pages, + collect({bvid: v.bvid, page_index: ev.page_index, quote: ev.quote})[..5] + AS evidence_sample +RETURN e.eid AS eid, e.name AS name, e.type AS type, + e.description AS description, + pages, evidence_sample +ORDER BY pages DESC +LIMIT $limit` + rows, err := r.run(ctx, cypher, map[string]any{"bvids": bvids, "limit": limit}) + if err != nil { + return nil, err + } + out := make([]Exposure, 0, len(rows)) + for _, row := range rows { + var samples []ExposureEvidence + if raw, ok := row["evidence_sample"].([]any); ok { + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + samples = append(samples, ExposureEvidence{ + Bvid: asString(m["bvid"]), + PageIndex: int32(asInt64(m["page_index"])), + Quote: asString(m["quote"]), + }) + } + } + out = append(out, Exposure{ + Eid: asString(row["eid"]), + Name: asString(row["name"]), + Type: asString(row["type"]), + Description: asString(row["description"]), + Pages: asInt64(row["pages"]), + EvidenceSample: samples, + }) + } + return out, nil +} + +// EntityAppearances lists all appearances of one entity. +func (r *GraphRepo) EntityAppearances(ctx context.Context, eid string, bvids []string, limit int32) ([]Appearance, error) { + if bvids == nil { + bvids = []string{} + } + cypher := ` +MATCH (e:Entity {eid: $eid})-[ev:APPEARS_IN]->(v:Video) +WHERE size($bvids) = 0 OR v.bvid IN $bvids +RETURN v.bvid AS bvid, ev.page_index AS page_index, + ev.quote AS quote +ORDER BY v.bvid, ev.page_index +LIMIT $limit` + rows, err := r.run(ctx, cypher, map[string]any{"eid": eid, "bvids": bvids, "limit": limit}) + if err != nil { + return nil, err + } + out := make([]Appearance, 0, len(rows)) + for _, row := range rows { + out = append(out, Appearance{ + Bvid: asString(row["bvid"]), + PageIndex: int32(asInt64(row["page_index"])), + Quote: asString(row["quote"]), + }) + } + return out, nil +} + +// TopEntities ranks head entities by RELATES degree (overview mode). +func (r *GraphRepo) TopEntities(ctx context.Context, limit int32) ([]TopEntity, error) { + cypher := ` +MATCH (e:Entity)-[r:RELATES]-() +RETURN e.eid AS eid, e.name AS name, e.type AS type, + e.description AS description, count(r) AS degree +ORDER BY degree DESC +LIMIT $limit` + rows, err := r.run(ctx, cypher, map[string]any{"limit": limit}) + if err != nil { + return nil, err + } + out := make([]TopEntity, 0, len(rows)) + for _, row := range rows { + out = append(out, TopEntity{ + Eid: asString(row["eid"]), + Name: asString(row["name"]), + Type: asString(row["type"]), + Description: asString(row["description"]), + Degree: asInt64(row["degree"]), + }) + } + return out, nil +} + +// EdgesAmong returns directed relation edges inside an entity set (undirected +// dedup is the caller's job). +func (r *GraphRepo) EdgesAmong(ctx context.Context, eids []string) ([]Edge, error) { + if len(eids) < 2 { + return nil, nil + } + cypher := ` +UNWIND $eids AS eid +MATCH (a:Entity {eid: eid})-[r:RELATES]->(b:Entity) +WHERE b.eid IN $eids +RETURN DISTINCT a.eid AS src_eid, b.eid AS dst_eid, + r.rel_type AS rel_type` + rows, err := r.run(ctx, cypher, map[string]any{"eids": eids}) + if err != nil { + return nil, err + } + out := make([]Edge, 0, len(rows)) + for _, row := range rows { + out = append(out, Edge{ + SrcEid: asString(row["src_eid"]), + DstEid: asString(row["dst_eid"]), + RelType: asString(row["rel_type"]), + }) + } + return out, nil +} + +// Stats returns the four graph counters. CALL () { ... } scope clauses are +// required on Neo4j 5.23+ (bare CALL {} deprecated) — same as Python. +func (r *GraphRepo) Stats(ctx context.Context) (GraphStats, error) { + cypher := ` +CALL () { + MATCH (e:Entity) RETURN count(e) AS entities +} +CALL () { + MATCH ()-[r:RELATES]->() RETURN count(r) AS relations +} +CALL () { + MATCH (:Entity)-[a:APPEARS_IN]->(:Video) RETURN count(a) as evidence +} +CALL () { + MATCH (v:Video) RETURN count(v) AS videos +} +CALL () { + MATCH (:Entity)-[:APPEARS_IN]->(d:CloudDoc) RETURN count(DISTINCT d) AS cloud_docs +} +CALL () { + MATCH (:Entity)-[a:APPEARS_IN]->(:CloudDoc) RETURN count(a) AS cloud_evidence +} +RETURN entities, relations, evidence, videos, cloud_docs, cloud_evidence` + rows, err := r.run(ctx, cypher, nil) + if err != nil { + return GraphStats{}, err + } + var out GraphStats + if len(rows) > 0 { + out.Entities = asInt64(rows[0]["entities"]) + out.Relations = asInt64(rows[0]["relations"]) + out.Evidence = asInt64(rows[0]["evidence"]) + out.Videos = asInt64(rows[0]["videos"]) + out.CloudDocs = asInt64(rows[0]["cloud_docs"]) + out.CloudEvidence = asInt64(rows[0]["cloud_evidence"]) + } + return out, nil +} + +// ── value coercion helpers ─────────────────────────────────────────── + +func asString(v any) string { + if s, ok := v.(string); ok { + return s + } + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} + +func asInt64(v any) int64 { + switch n := v.(type) { + case int64: + return n + case int: + return int64(n) + case float64: + return int64(n) + case nil: + return 0 + default: + return 0 + } +} + +func asStrings(v any) []string { + raw, ok := v.([]any) + if !ok { + return []string{} + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} diff --git a/app-go/app-kg/internal/server/health.go b/app-go/app-kg/internal/server/health.go deleted file mode 100644 index 4bdbd7c..0000000 --- a/app-go/app-kg/internal/server/health.go +++ /dev/null @@ -1,149 +0,0 @@ -// Health — plain HTTP probe on a secondary port (humans / future gateway -// checks / compose debugging). MySQL down → 503; Neo4j down → degraded -// (still 503: without the graph store this service has no purpose, unlike -// the app-note/app-quiz read paths which degrade gracefully). -// -// The same mux also serves the cloud-doc routing graph (plan 1.0.15, L1) as -// JSON endpoints for app-cloud: gRPC stays the app-ts face (proto contract), -// while app-cloud — a separate Go module whose Docker build context cannot -// import this module's internal kgpb — speaks simple internal JSON here. -// Endpoints are network-internal; when internal_token is configured they -// additionally require a matching X-Internal-Token header (constant time). -package server - -import ( - "context" - "crypto/subtle" - "encoding/json" - "net/http" - "time" - - "app-kg/internal/db" - "app-kg/internal/graph" - "app-kg/internal/neo4jstore" -) - -// StartHealth runs the HTTP health endpoint until ctx is cancelled. -func StartHealth(ctx context.Context, addr string, st *neo4jstore.Store, g *graph.Store, internalToken string) *http.Server { - mux := http.NewServeMux() - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - body := map[string]any{"status": "ok", "service": "app-kg"} - code := http.StatusOK - if err := st.Ping(r.Context()); err != nil { - body["status"] = "degraded" - body["neo4j"] = "down" - code = http.StatusServiceUnavailable - } - if db.DB != nil { - pingCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second) - if err := db.DB.WithContext(pingCtx).Exec("select 1").Error; err != nil { - body["status"] = "degraded" - body["mysql"] = "down" - code = http.StatusServiceUnavailable - } - cancel() - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(body) - }) - registerCloudDocRoutes(mux, g, internalToken) - srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} - go func() { - _ = srv.ListenAndServe() - }() - return srv -} - -// ── cloud-doc routing endpoints (internal; X-Internal-Token when set) ── - -func checkInternalToken(w http.ResponseWriter, r *http.Request, token string) bool { - if token == "" { - return true - } - if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Internal-Token")), []byte(token)) == 1 { - return true - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - _ = json.NewEncoder(w).Encode(map[string]any{"detail": "invalid internal token"}) - return false -} - -func writeJSON(w http.ResponseWriter, code int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(v) -} - -func registerCloudDocRoutes(mux *http.ServeMux, g *graph.Store, token string) { - if g == nil { - return - } - mux.HandleFunc("/internal/kg/cloud-doc/upsert", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || !checkInternalToken(w, r, token) { - return - } - var req struct { - UID int64 `json:"uid"` - UploadUUID string `json:"upload_uuid"` - Title string `json:"title"` - ContentHash string `json:"content_hash"` - Topics []string `json:"topics"` - Entities []string `json:"entities"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UploadUUID == "" || req.UID <= 0 { - writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid body"}) - return - } - if err := g.UpsertCloudDocGraph(r.Context(), graph.CloudDocGraph{ - UID: req.UID, UploadUUID: req.UploadUUID, Title: req.Title, - ContentHash: req.ContentHash, Topics: req.Topics, Entities: req.Entities, - }); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - }) - mux.HandleFunc("/internal/kg/cloud-doc/delete", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || !checkInternalToken(w, r, token) { - return - } - var req struct { - UID int64 `json:"uid"` - UploadUUID string `json:"upload_uuid"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UploadUUID == "" || req.UID <= 0 { - writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid body"}) - return - } - if err := g.DeleteCloudDocGraph(r.Context(), req.UID, req.UploadUUID); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - }) - mux.HandleFunc("/internal/kg/cloud-doc/match", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || !checkInternalToken(w, r, token) { - return - } - var req struct { - UID int64 `json:"uid"` - Keywords []string `json:"keywords"` - Limit int `json:"limit"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UID <= 0 { - writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid body"}) - return - } - if req.Limit <= 0 { - req.Limit = 10 - } - matches, err := g.MatchCloudDocs(r.Context(), req.UID, req.Keywords, req.Limit) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"matches": matches}) - }) -} diff --git a/app-go/app-kg/internal/server/server.go b/app-go/app-kg/internal/server/server.go deleted file mode 100644 index 1ce92af..0000000 --- a/app-go/app-kg/internal/server/server.go +++ /dev/null @@ -1,529 +0,0 @@ -// Package server implements the kg.v1.KgGraph gRPC service: proto ↔ graph/db -// mapping, standard health, reflection, and a logging/recovery interceptor. -// The service is a pure data face — no LLM, no embedding, no business logic. -package server - -import ( - "context" - "errors" - "fmt" - "log/slog" - "strings" - "time" - - "app-kg/internal/db" - "app-kg/internal/graph" - "app-kg/internal/kgpb" - - "github.com/google/uuid" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" -) - -// Deps wires the storage faces into the gRPC service. MySQL access goes -// through the db package functions (global connection initialized by db.Init -// at boot — the established pattern across the app-go services). -type Deps struct { - Graph *graph.Store -} - -// Server implements kgpb.KgGraphServer. MySQL access goes through the db -// package functions (global connection initialized by db.Init at boot — the -// established pattern across the app-go services). -type Server struct { - kgpb.UnimplementedKgGraphServer - g *graph.Store -} - -// New builds the server. -func New(d Deps) *Server { return &Server{g: d.Graph} } - -func invalid(format string, args ...any) error { - return status.Errorf(codes.InvalidArgument, format, args...) -} - -func internal(op string, err error) error { - return status.Errorf(codes.Internal, "%s: %v", op, err) -} - -// ── 记账(MySQL)────────────────────────────────────────────────────── - -func (s *Server) ResolveScope(ctx context.Context, req *kgpb.ResolveScopeRequest) (*kgpb.ResolveScopeResponse, error) { - if req.Uid <= 0 { - return nil, invalid("uid must be positive") - } - bvids, err := db.ResolveScope(ctx, req.Uid, req.FolderIds) - if err != nil { - return nil, internal("resolve scope", err) - } - if bvids == nil { - bvids = []string{} - } - return &kgpb.ResolveScopeResponse{Bvids: bvids}, nil -} - -func (s *Server) SelectPendingPages(ctx context.Context, req *kgpb.SelectPendingPagesRequest) (*kgpb.SelectPendingPagesResponse, error) { - if req.Limit <= 0 { - return nil, invalid("limit must be positive") - } - pages, err := db.SelectPendingPages(ctx, req.Bvids, req.Limit) - if err != nil { - return nil, internal("select pending pages", err) - } - out := make([]*kgpb.PendingPage, 0, len(pages)) - for _, p := range pages { - out = append(out, &kgpb.PendingPage{ - Bvid: p.Bvid, - Cid: p.Cid, - PageIndex: p.PageIndex, - PageTitle: p.PageTitle, - Version: p.Version, - }) - } - return &kgpb.SelectPendingPagesResponse{Pages: out}, nil -} - -func (s *Server) BeginPage(ctx context.Context, req *kgpb.BeginPageRequest) (*kgpb.BeginPageResponse, error) { - if req.Bvid == "" { - return nil, invalid("bvid is required") - } - var expected *int64 - if req.ExpectedVersion >= 0 { - v := req.ExpectedVersion - expected = &v - } - res, err := db.BeginPage(ctx, req.Bvid, req.Cid, expected) - if err != nil { - if errors.Is(err, db.ErrPageNotFound) { - return &kgpb.BeginPageResponse{Found: false, Reason: res.Reason}, nil - } - return nil, internal("begin page", err) - } - return &kgpb.BeginPageResponse{ - Found: res.Found, - AsrDone: res.AsrDone, - Skip: res.Skip, - Reason: res.Reason, - ContentVersion: res.ContentVersion, - PageTitle: res.PageTitle, - PageIndex: res.PageIndex, - }, nil -} - -func (s *Server) MarkPageDone(ctx context.Context, req *kgpb.MarkPageDoneRequest) (*kgpb.MarkPageDoneResponse, error) { - if req.Bvid == "" { - return nil, invalid("bvid is required") - } - if err := db.MarkPageDone(ctx, req.Bvid, req.Cid, req.ContentVersion); err != nil { - return nil, internal("mark page done", err) - } - return &kgpb.MarkPageDoneResponse{}, nil -} - -func (s *Server) MarkPageFailed(ctx context.Context, req *kgpb.MarkPageFailedRequest) (*kgpb.MarkPageFailedResponse, error) { - if req.Bvid == "" { - return nil, invalid("bvid is required") - } - if err := db.MarkPageFailed(ctx, req.Bvid, req.Cid); err != nil { - return nil, internal("mark page failed", err) - } - return &kgpb.MarkPageFailedResponse{}, nil -} - -func (s *Server) CountPendingPages(ctx context.Context, req *kgpb.CountPendingPagesRequest) (*kgpb.CountPendingPagesResponse, error) { - n, err := db.CountPendingPages(ctx) - if err != nil { - return nil, internal("count pending pages", err) - } - return &kgpb.CountPendingPagesResponse{Count: n}, nil -} - -// ── 图写(Neo4j)────────────────────────────────────────────────────── - -func (s *Server) UpsertEntities(ctx context.Context, req *kgpb.UpsertEntitiesRequest) (*kgpb.UpsertEntitiesResponse, error) { - rows := make([]map[string]any, 0, len(req.Entities)) - for _, e := range req.Entities { - if e.Name == "" || e.NameLower == "" || e.Eid == "" { - return nil, invalid("entity requires eid/name/name_lower (caller computes normalization)") - } - rows = append(rows, map[string]any{ - "eid": e.Eid, "name": e.Name, "name_lower": e.NameLower, - "type": e.Type, "description": e.Description, "alias": e.Alias, - }) - } - eids, err := s.g.UpsertEntities(ctx, rows) - if err != nil { - return nil, internal("upsert entities", err) - } - if eids == nil { - eids = []string{} - } - return &kgpb.UpsertEntitiesResponse{Eids: eids}, nil -} - -func (s *Server) LinkAppearances(ctx context.Context, req *kgpb.LinkAppearancesRequest) (*kgpb.LinkAppearancesResponse, error) { - if req.Bvid == "" { - return nil, invalid("bvid is required") - } - appearances := make([]map[string]any, 0, len(req.Appearances)) - for _, a := range req.Appearances { - appearances = append(appearances, map[string]any{"eid": a.Eid, "quote": a.Quote}) - } - linked, err := s.g.LinkAppearances(ctx, req.Bvid, req.PageIndex, appearances) - if err != nil { - return nil, internal("link appearances", err) - } - return &kgpb.LinkAppearancesResponse{Linked: linked}, nil -} - -func (s *Server) UpsertRelations(ctx context.Context, req *kgpb.UpsertRelationsRequest) (*kgpb.UpsertRelationsResponse, error) { - rows := make([]map[string]any, 0, len(req.Relations)) - for _, r := range req.Relations { - rows = append(rows, map[string]any{ - "src_eid": r.SrcEid, "dst_eid": r.DstEid, - "rel_type": r.RelType, "quote": r.Quote, - }) - } - if err := s.g.UpsertRelations(ctx, rows); err != nil { - return nil, internal("upsert relations", err) - } - return &kgpb.UpsertRelationsResponse{}, nil -} - -func (s *Server) DeletePageEvidence(ctx context.Context, req *kgpb.DeletePageEvidenceRequest) (*kgpb.DeletePageEvidenceResponse, error) { - if req.Bvid == "" { - return nil, invalid("bvid is required") - } - deleted, err := s.g.DeletePageEvidence(ctx, req.Bvid, req.PageIndex) - if err != nil { - return nil, internal("delete page evidence", err) - } - return &kgpb.DeletePageEvidenceResponse{Deleted: deleted}, nil -} - -func (s *Server) DeleteVideo(ctx context.Context, req *kgpb.DeleteVideoRequest) (*kgpb.DeleteVideoResponse, error) { - if req.Bvid == "" { - return nil, invalid("bvid is required") - } - entitiesRemoved, videosRemoved, err := s.g.DeleteVideo(ctx, req.Bvid) - if err != nil { - return nil, internal("delete video", err) - } - return &kgpb.DeleteVideoResponse{EntitiesRemoved: entitiesRemoved, VideosRemoved: videosRemoved}, nil -} - -func (s *Server) ClearAll(ctx context.Context, req *kgpb.ClearAllRequest) (*kgpb.ClearAllResponse, error) { - if err := s.g.ClearAll(ctx); err != nil { - return nil, internal("clear all", err) - } - return &kgpb.ClearAllResponse{}, nil -} - -// ── 图读(Neo4j + video 标题 join)─────────────────────────────────── - -func (s *Server) Neighbors(ctx context.Context, req *kgpb.NeighborsRequest) (*kgpb.NeighborsResponse, error) { - if req.Limit <= 0 { - return nil, invalid("limit must be positive") - } - rows, err := s.g.Neighbors(ctx, req.Eids, req.ExcludeEids, req.Limit) - if err != nil { - return nil, internal("neighbors", err) - } - out := make([]*kgpb.NeighborRow, 0, len(rows)) - for _, r := range rows { - out = append(out, &kgpb.NeighborRow{ - Eid: str(r["eid"]), - Name: str(r["name"]), - Type: str(r["type"]), - Description: str(r["description"]), - RelType: str(r["rel_type"]), - FromEid: str(r["from_eid"]), - }) - } - return &kgpb.NeighborsResponse{Rows: out}, nil -} - -func (s *Server) FetchEvidence(ctx context.Context, req *kgpb.FetchEvidenceRequest) (*kgpb.FetchEvidenceResponse, error) { - if req.Limit <= 0 { - return nil, invalid("limit must be positive") - } - rows, err := s.g.FetchEvidence(ctx, req.Eids, req.Bvids, req.Limit) - if err != nil { - return nil, internal("fetch evidence", err) - } - // Title join: (bvid, page_index) → page_title from the video table - // (retriever._page_titles semantics; missing → "P{n+1}"). - bvSet := map[string]bool{} - for _, r := range rows { - if bv := str(r["bvid"]); bv != "" { - bvSet[bv] = true - } - } - bvids := make([]string, 0, len(bvSet)) - for bv := range bvSet { - bvids = append(bvids, bv) - } - titles, err := db.PageTitles(ctx, bvids) - if err != nil { - slog.Warn("[KG_GRPC] page title lookup failed", "err", err) - titles = map[db.PageKey]string{} - } - out := make([]*kgpb.EvidenceRow, 0, len(rows)) - for _, r := range rows { - bvid := str(r["bvid"]) - pageIndex := int32(i64(r["page_index"])) - title := titles[db.PageKey{Bvid: bvid, PageIndex: pageIndex}] - if title == "" { - title = fmt.Sprintf("P%d", pageIndex+1) - } - out = append(out, &kgpb.EvidenceRow{ - Eid: str(r["eid"]), - Name: str(r["name"]), - Type: str(r["type"]), - Bvid: bvid, - PageIndex: pageIndex, - Quote: str(r["quote"]), - PageTitle: title, - }) - } - return &kgpb.FetchEvidenceResponse{Rows: out}, nil -} - -func (s *Server) RelationsAmong(ctx context.Context, req *kgpb.RelationsAmongRequest) (*kgpb.RelationsAmongResponse, error) { - rows, err := s.g.RelationsAmong(ctx, req.Eids) - if err != nil { - return nil, internal("relations among", err) - } - out := make([]*kgpb.RelationRow, 0, len(rows)) - for _, r := range rows { - quotes := strList(r["quotes"]) - out = append(out, &kgpb.RelationRow{ - SrcEid: str(r["src_eid"]), - SrcName: str(r["src_name"]), - RelType: str(r["rel_type"]), - DstEid: str(r["dst_eid"]), - DstName: str(r["dst_name"]), - Weight: i64(r["weight"]), - Quotes: quotes, - }) - } - return &kgpb.RelationsAmongResponse{Rows: out}, nil -} - -func (s *Server) EntitiesByEids(ctx context.Context, req *kgpb.EntitiesByEidsRequest) (*kgpb.EntitiesByEidsResponse, error) { - rows, err := s.g.EntitiesByEids(ctx, req.Eids) - if err != nil { - return nil, internal("entities by eids", err) - } - out := make([]*kgpb.EntityDetail, 0, len(rows)) - for _, r := range rows { - out = append(out, &kgpb.EntityDetail{ - Eid: str(r["eid"]), - Name: str(r["name"]), - Type: str(r["type"]), - Description: str(r["description"]), - Aliases: strList(r["aliases"]), - }) - } - return &kgpb.EntitiesByEidsResponse{Rows: out}, nil -} - -func (s *Server) EntityExposure(ctx context.Context, req *kgpb.EntityExposureRequest) (*kgpb.EntityExposureResponse, error) { - if req.Limit <= 0 { - return nil, invalid("limit must be positive") - } - rows, err := s.g.EntityExposure(ctx, req.Bvids, req.Limit) - if err != nil { - return nil, internal("entity exposure", err) - } - out := make([]*kgpb.ExposureRow, 0, len(rows)) - for _, r := range rows { - var samples []*kgpb.ExposureEvidence - if raw, ok := r["evidence_sample"].([]any); ok { - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - samples = append(samples, &kgpb.ExposureEvidence{ - Bvid: str(m["bvid"]), - PageIndex: int32(i64(m["page_index"])), - Quote: str(m["quote"]), - }) - } - } - out = append(out, &kgpb.ExposureRow{ - Eid: str(r["eid"]), - Name: str(r["name"]), - Type: str(r["type"]), - Description: str(r["description"]), - Pages: i64(r["pages"]), - EvidenceSample: samples, - }) - } - return &kgpb.EntityExposureResponse{Rows: out}, nil -} - -func (s *Server) EntityAppearances(ctx context.Context, req *kgpb.EntityAppearancesRequest) (*kgpb.EntityAppearancesResponse, error) { - if req.Eid == "" { - return nil, invalid("eid is required") - } - if req.Limit <= 0 { - return nil, invalid("limit must be positive") - } - rows, err := s.g.EntityAppearances(ctx, req.Eid, req.Bvids, req.Limit) - if err != nil { - return nil, internal("entity appearances", err) - } - out := make([]*kgpb.AppearanceRow, 0, len(rows)) - for _, r := range rows { - out = append(out, &kgpb.AppearanceRow{ - Bvid: str(r["bvid"]), - PageIndex: int32(i64(r["page_index"])), - Quote: str(r["quote"]), - }) - } - return &kgpb.EntityAppearancesResponse{Rows: out}, nil -} - -func (s *Server) TopEntities(ctx context.Context, req *kgpb.TopEntitiesRequest) (*kgpb.TopEntitiesResponse, error) { - if req.Limit <= 0 { - return nil, invalid("limit must be positive") - } - rows, err := s.g.TopEntities(ctx, req.Limit) - if err != nil { - return nil, internal("top entities", err) - } - out := make([]*kgpb.TopEntityRow, 0, len(rows)) - for _, r := range rows { - out = append(out, &kgpb.TopEntityRow{ - Eid: str(r["eid"]), - Name: str(r["name"]), - Type: str(r["type"]), - Description: str(r["description"]), - Degree: i64(r["degree"]), - }) - } - return &kgpb.TopEntitiesResponse{Rows: out}, nil -} - -func (s *Server) EdgesAmong(ctx context.Context, req *kgpb.EdgesAmongRequest) (*kgpb.EdgesAmongResponse, error) { - rows, err := s.g.EdgesAmong(ctx, req.Eids) - if err != nil { - return nil, internal("edges among", err) - } - out := make([]*kgpb.EdgeRow, 0, len(rows)) - for _, r := range rows { - out = append(out, &kgpb.EdgeRow{ - SrcEid: str(r["src_eid"]), - DstEid: str(r["dst_eid"]), - RelType: str(r["rel_type"]), - }) - } - return &kgpb.EdgesAmongResponse{Rows: out}, nil -} - -func (s *Server) Stats(ctx context.Context, req *kgpb.StatsRequest) (*kgpb.StatsResponse, error) { - gs, err := s.g.Stats(ctx) - if err != nil { - return nil, internal("stats", err) - } - pending, err := db.CountPendingPages(ctx) - if err != nil { - slog.Warn("[KG_GRPC] pending count failed", "err", err) - } - return &kgpb.StatsResponse{ - Entities: gs.Entities, - Relations: gs.Relations, - Evidence: gs.Evidence, - Videos: gs.Videos, - PendingPages: pending, - }, nil -} - -// ── helpers ────────────────────────────────────────────────────────── - -func str(v any) string { - if s, ok := v.(string); ok { - return s - } - if v == nil { - return "" - } - return fmt.Sprintf("%v", v) -} - -func i64(v any) int64 { - switch n := v.(type) { - case int64: - return n - case int: - return int64(n) - case float64: - return int64(n) - case nil: - return 0 - default: - return 0 - } -} - -func strList(v any) []string { - raw, ok := v.([]any) - if !ok { - return []string{} - } - out := make([]string, 0, len(raw)) - for _, item := range raw { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out -} - -// requestIDFromContext reads the gateway/caller-generated request id -// (grpc metadata "x-request-id") for log correlation. -func requestIDFromContext(ctx context.Context) string { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return "" - } - values := md.Get("x-request-id") - if len(values) == 0 { - return "" - } - return strings.TrimSpace(values[0]) -} - -// Interceptor returns the unary server chain: request-id correlation + -// structured access log + panic recovery. Neo4j/MySQL unavailability -// surfaces as codes.Internal with the operation name (the app-ts side -// degrades or fails the build page — never the whole service). -func Interceptor() grpc.UnaryServerInterceptor { - return func( - ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, - ) (resp any, err error) { - requestID := requestIDFromContext(ctx) - if requestID == "" { - requestID = uuid.NewString() - } - start := time.Now() - resp, err = handler(ctx, req) - attrs := []slog.Attr{ - slog.String("module", "grpc"), - slog.String("method", info.FullMethod), - slog.Duration("latency", time.Since(start)), - slog.String("request_id", requestID), - } - if err != nil { - attrs = append(attrs, slog.String("err", err.Error())) - slog.LogAttrs(ctx, slog.LevelWarn, "grpc request failed", attrs...) - } else { - slog.LogAttrs(ctx, slog.LevelInfo, "grpc request", attrs...) - } - return resp, err - } -} diff --git a/app-go/app-kg/internal/service/cloud_doc.go b/app-go/app-kg/internal/service/cloud_doc.go new file mode 100644 index 0000000..61b6a66 --- /dev/null +++ b/app-go/app-kg/internal/service/cloud_doc.go @@ -0,0 +1,54 @@ +package service + +import ( + "context" + "strings" + + neo4jrepo "app-kg/internal/repo/neo4j" +) + +// CloudDocService — business rules for the cloud-doc routing graph (plan +// 1.0.15 L1): payload validation + default paging, with the Cypher isolated in +// repo/neo4j. Consumed by the internal JSON endpoints that app-cloud calls. +type CloudDocService struct { + graph *neo4jrepo.GraphRepo +} + +// NewCloudDoc wires the service onto the graph repository. +func NewCloudDoc(graph *neo4jrepo.GraphRepo) *CloudDocService { + return &CloudDocService{graph: graph} +} + +// Domain models are aliases of the repo projections (transports import service +// only). +type ( + CloudDocGraph = neo4jrepo.CloudDocGraph + CloudDocMatch = neo4jrepo.CloudDocMatch +) + +// Upsert creates/refreshes one document's routing graph. +func (s *CloudDocService) Upsert(ctx context.Context, doc CloudDocGraph) error { + if doc.UID <= 0 || strings.TrimSpace(doc.UploadUUID) == "" { + return invalidf("uid and upload_uuid are required") + } + return s.graph.UpsertCloudDocGraph(ctx, doc) +} + +// Delete drops one document's routing graph and recycles orphan topics. +func (s *CloudDocService) Delete(ctx context.Context, uid int64, uploadUUID string) error { + if uid <= 0 || strings.TrimSpace(uploadUUID) == "" { + return invalidf("uid and upload_uuid are required") + } + return s.graph.DeleteCloudDocGraph(ctx, uid, uploadUUID) +} + +// Match matches documents by topic/entity keywords; limit defaults to 10. +func (s *CloudDocService) Match(ctx context.Context, uid int64, keywords []string, limit int) ([]CloudDocMatch, error) { + if uid <= 0 { + return nil, invalidf("uid must be positive") + } + if limit <= 0 { + limit = 10 + } + return s.graph.MatchCloudDocs(ctx, uid, keywords, limit) +} diff --git a/app-go/app-kg/internal/service/errors.go b/app-go/app-kg/internal/service/errors.go new file mode 100644 index 0000000..179fbd6 --- /dev/null +++ b/app-go/app-kg/internal/service/errors.go @@ -0,0 +1,29 @@ +// Package service is the business layer of app-kg: KG rules (bookkeeping +// state machine, scope resolution, evidence title join, stats composition, +// input validation) plus orchestration of the repo layer. It never speaks +// gRPC/HTTP and never opens a database connection — the layering is pinned by +// internal/arch/arch_test.go. +package service + +import ( + "errors" + "fmt" +) + +// ErrPageNotFound — the (bvid, cid) row does not exist. Transport maps it to +// a "found=false" proto result rather than an RPC error (contract of +// BeginPage). +var ErrPageNotFound = errors.New("video page not found") + +// InvalidArgumentError — the caller's input violates the domain contract. +// Transport maps it to codes.InvalidArgument (gRPC) or HTTP 422. +type InvalidArgumentError struct { + Msg string +} + +func (e *InvalidArgumentError) Error() string { return e.Msg } + +// invalidf builds an InvalidArgumentError. +func invalidf(format string, args ...any) error { + return &InvalidArgumentError{Msg: fmt.Sprintf(format, args...)} +} diff --git a/app-go/app-kg/internal/service/graph.go b/app-go/app-kg/internal/service/graph.go new file mode 100644 index 0000000..de89978 --- /dev/null +++ b/app-go/app-kg/internal/service/graph.go @@ -0,0 +1,207 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + + mysqlrepo "app-kg/internal/repo/mysql" +) + +// This file holds the graph-operation business rules: input validation, the +// cross-store evidence title join, and stats composition. Cypher lives in +// internal/repo/neo4j. + +// UpsertEntities validates the normalization contract (eid / name_lower are +// computed by the caller, never here) and bulk-MERGEs the entities. +func (s *KGService) UpsertEntities(ctx context.Context, entities []EntityInput) ([]string, error) { + for _, e := range entities { + if e.Name == "" || e.NameLower == "" || e.Eid == "" { + return nil, invalidf("entity requires eid/name/name_lower (caller computes normalization)") + } + } + return s.graph.UpsertEntities(ctx, entities) +} + +// LinkAppearances writes APPEARS_IN evidence edges for one (bvid, page_index). +func (s *KGService) LinkAppearances(ctx context.Context, bvid string, pageIndex int32, appearances []AppearanceInput) (int64, error) { + if bvid == "" { + return 0, invalidf("bvid is required") + } + return s.graph.LinkAppearances(ctx, bvid, pageIndex, appearances) +} + +// UpsertRelations bulk-MERGEs RELATES edges. +func (s *KGService) UpsertRelations(ctx context.Context, relations []RelationInput) error { + return s.graph.UpsertRelations(ctx, relations) +} + +// LinkCloudDocAppearances replaces one cloud document's knowledge evidence +// (delete-before-write is a repository guarantee) and upserts the source node +// metadata. Cloud documents are user-scoped, so uid is mandatory. +func (s *KGService) LinkCloudDocAppearances(ctx context.Context, uid int64, uploadUUID, title, contentHash string, appearances []AppearanceInput) (int64, error) { + if uid <= 0 { + return 0, invalidf("uid must be positive") + } + if uploadUUID == "" { + return 0, invalidf("upload_uuid is required") + } + return s.graph.LinkCloudDocAppearances(ctx, uid, uploadUUID, title, contentHash, appearances) +} + +// DeleteCloudDocAppearances removes one document's knowledge evidence and +// recycles entities left without any relationship. +func (s *KGService) DeleteCloudDocAppearances(ctx context.Context, uid int64, uploadUUID string) (deleted, entitiesRemoved int64, err error) { + if uid <= 0 { + return 0, 0, invalidf("uid must be positive") + } + if uploadUUID == "" { + return 0, 0, invalidf("upload_uuid is required") + } + return s.graph.DeleteCloudDocAppearances(ctx, uid, uploadUUID) +} + +// FetchCloudDocEvidence pulls knowledge evidence from the user's cloud +// documents (uploadUUIDs empty = all of them). +func (s *KGService) FetchCloudDocEvidence(ctx context.Context, eids []string, uid int64, uploadUUIDs []string, limit int32) ([]CloudDocEvidence, error) { + if uid <= 0 { + return nil, invalidf("uid must be positive") + } + if limit <= 0 { + return nil, invalidf("limit must be positive") + } + return s.graph.FetchCloudDocEvidence(ctx, eids, uid, uploadUUIDs, limit) +} + +// DeletePageEvidence removes all evidence edges of one page +// (delete-before-write). +func (s *KGService) DeletePageEvidence(ctx context.Context, bvid string, pageIndex int32) (int64, error) { + if bvid == "" { + return 0, invalidf("bvid is required") + } + return s.graph.DeletePageEvidence(ctx, bvid, pageIndex) +} + +// DeleteVideo cascades all graph traces of a video. +func (s *KGService) DeleteVideo(ctx context.Context, bvid string) (entitiesRemoved, videosRemoved int64, err error) { + if bvid == "" { + return 0, 0, invalidf("bvid is required") + } + return s.graph.DeleteVideo(ctx, bvid) +} + +// ClearAll wipes every Entity/Video node (dangerous; admin/test only). +func (s *KGService) ClearAll(ctx context.Context) error { + return s.graph.ClearAll(ctx) +} + +// Neighbors is the one-hop BFS expansion. +func (s *KGService) Neighbors(ctx context.Context, eids, excludeEids []string, limit int32) ([]Neighbor, error) { + if limit <= 0 { + return nil, invalidf("limit must be positive") + } + return s.graph.Neighbors(ctx, eids, excludeEids, limit) +} + +// FetchEvidence pulls APPEARS_IN evidence rows and joins the video page +// titles: (bvid, page_index) → page_title, missing → "P{n+1}". The join is a +// business decision (callers must not need a second MySQL round-trip), so it +// lives here rather than in the Cypher repository. +func (s *KGService) FetchEvidence(ctx context.Context, eids, bvids []string, limit int32) ([]Evidence, error) { + if limit <= 0 { + return nil, invalidf("limit must be positive") + } + rows, err := s.graph.FetchEvidence(ctx, eids, bvids, limit) + if err != nil { + return nil, err + } + bvSet := map[string]bool{} + for _, row := range rows { + if row.Bvid != "" { + bvSet[row.Bvid] = true + } + } + titleBvids := make([]string, 0, len(bvSet)) + for bvid := range bvSet { + titleBvids = append(titleBvids, bvid) + } + titles, err := s.pages.PageTitles(ctx, titleBvids) + if err != nil { + slog.Warn("[KG] page title lookup failed", "err", err) + titles = map[mysqlrepo.PageKey]string{} + } + for i := range rows { + title := titles[mysqlrepo.PageKey{Bvid: rows[i].Bvid, PageIndex: rows[i].PageIndex}] + if title == "" { + title = fmt.Sprintf("P%d", rows[i].PageIndex+1) + } + rows[i].PageTitle = title + } + return rows, nil +} + +// RelationsAmong returns the relation edges between subgraph members. +func (s *KGService) RelationsAmong(ctx context.Context, eids []string) ([]Relation, error) { + return s.graph.RelationsAmong(ctx, eids) +} + +// EntitiesByEids fetches entity details. +func (s *KGService) EntitiesByEids(ctx context.Context, eids []string) ([]Entity, error) { + return s.graph.EntitiesByEids(ctx, eids) +} + +// EntityExposure aggregates page exposure per entity within scope +// (blindspot map). +func (s *KGService) EntityExposure(ctx context.Context, bvids []string, limit int32) ([]Exposure, error) { + if limit <= 0 { + return nil, invalidf("limit must be positive") + } + return s.graph.EntityExposure(ctx, bvids, limit) +} + +// EntityAppearances lists all appearances of one entity. +func (s *KGService) EntityAppearances(ctx context.Context, eid string, bvids []string, limit int32) ([]Appearance, error) { + if eid == "" { + return nil, invalidf("eid is required") + } + if limit <= 0 { + return nil, invalidf("limit must be positive") + } + return s.graph.EntityAppearances(ctx, eid, bvids, limit) +} + +// TopEntities ranks head entities by RELATES degree (overview mode). +func (s *KGService) TopEntities(ctx context.Context, limit int32) ([]TopEntity, error) { + if limit <= 0 { + return nil, invalidf("limit must be positive") + } + return s.graph.TopEntities(ctx, limit) +} + +// EdgesAmong returns directed relation edges inside an entity set. +func (s *KGService) EdgesAmong(ctx context.Context, eids []string) ([]Edge, error) { + return s.graph.EdgesAmong(ctx, eids) +} + +// Stats composes the Neo4j counters with the pending-page counter from MySQL. +// A failed pending count degrades the stats (warn) instead of failing the RPC — +// same behaviour as the historical implementation. +func (s *KGService) Stats(ctx context.Context) (Stats, error) { + graphStats, err := s.graph.Stats(ctx) + if err != nil { + return Stats{}, err + } + pending, err := s.pages.CountPending(ctx) + if err != nil { + slog.Warn("[KG] pending count failed", "err", err) + } + return Stats{ + Entities: graphStats.Entities, + Relations: graphStats.Relations, + Evidence: graphStats.Evidence, + Videos: graphStats.Videos, + PendingPages: pending, + CloudDocs: graphStats.CloudDocs, + CloudEvidence: graphStats.CloudEvidence, + }, nil +} diff --git a/app-go/app-kg/internal/service/kg.go b/app-go/app-kg/internal/service/kg.go new file mode 100644 index 0000000..b34b224 --- /dev/null +++ b/app-go/app-kg/internal/service/kg.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + + mysqlrepo "app-kg/internal/repo/mysql" + neo4jrepo "app-kg/internal/repo/neo4j" +) + +// KGService — business orchestration of the kg.v1 surface over the shared +// MySQL bookkeeping (video.kg_status/kg_version + scope resolution) and the +// Neo4j graph. +type KGService struct { + pages *mysqlrepo.PageRepo + graph *neo4jrepo.GraphRepo +} + +// NewKG wires the service onto the repositories. +func NewKG(pages *mysqlrepo.PageRepo, graph *neo4jrepo.GraphRepo) *KGService { + return &KGService{pages: pages, graph: graph} +} + +// Domain read models are aliases of the repo projections: transports import +// the service package only (never repo), while repo stays the single owner of +// the row shape. +type ( + PendingPage = mysqlrepo.PendingPage + Neighbor = neo4jrepo.Neighbor + Evidence = neo4jrepo.Evidence + Relation = neo4jrepo.Relation + Entity = neo4jrepo.Entity + Exposure = neo4jrepo.Exposure + ExposureEvidence = neo4jrepo.ExposureEvidence + Appearance = neo4jrepo.Appearance + TopEntity = neo4jrepo.TopEntity + Edge = neo4jrepo.Edge + EntityInput = neo4jrepo.EntityInput + AppearanceInput = neo4jrepo.AppearanceInput + RelationInput = neo4jrepo.RelationInput + CloudDocEvidence = neo4jrepo.CloudDocEvidence +) + +// BeginResult mirrors the Phase-1 bookkeeping outcome of the historical +// Python process_page: missing row / ASR not done / idempotent skip / marked +// processing. +type BeginResult struct { + Found bool + AsrDone bool + Skip bool + Reason string + ContentVersion int64 + PageTitle string + PageIndex int32 +} + +// Stats is the composed service counter (graph + pending pages). Evidence is +// the video-only counter; cloud-document knowledge evidence is reported +// separately (CloudDocs/CloudEvidence). +type Stats struct { + Entities int64 + Relations int64 + Evidence int64 + Videos int64 + PendingPages int64 + CloudDocs int64 + CloudEvidence int64 +} + +// ResolveScope resolves favorite folders → bvids (dedup, order-stable). Empty +// folderIDs = all of the user's non-deleted folders. Port of +// chat/scope.get_media_ids_for_uid + get_bvids_by_media_ids. +func (s *KGService) ResolveScope(ctx context.Context, uid int64, folderIDs []int64) ([]string, error) { + if uid <= 0 { + return nil, invalidf("uid must be positive") + } + mediaIDs, err := s.pages.MediaIDs(ctx, uid, folderIDs) + if err != nil { + return nil, err + } + seen := map[int64]bool{} + uniq := make([]int64, 0, len(mediaIDs)) + for _, mediaID := range mediaIDs { + if mediaID != 0 && !seen[mediaID] { + seen[mediaID] = true + uniq = append(uniq, mediaID) + } + } + if len(uniq) == 0 { + return nil, nil + } + bvids, err := s.pages.BvidsByMediaIDs(ctx, uniq) + if err != nil { + return nil, err + } + seenBv := map[string]bool{} + out := make([]string, 0, len(bvids)) + for _, bvid := range bvids { + if bvid != "" && !seenBv[bvid] { + seenBv[bvid] = true + out = append(out, bvid) + } + } + return out, nil +} + +// SelectPendingPages picks pages whose ASR is done and whose kg extraction is +// missing / failed / stale. +func (s *KGService) SelectPendingPages(ctx context.Context, bvids []string, limit int32) ([]PendingPage, error) { + if limit <= 0 { + return nil, invalidf("limit must be positive") + } + return s.pages.ListPending(ctx, bvids, limit) +} + +// BeginPage performs the idempotency check and marks the page processing +// (Phase 1 of the build flow): missing row / ASR not done → typed result; +// done-and-current (only when expectedVersion is unset) → skip=true; +// otherwise kg_status='processing'. +func (s *KGService) BeginPage(ctx context.Context, bvid string, cid int64, expectedVersion *int64) (BeginResult, error) { + if bvid == "" { + return BeginResult{}, invalidf("bvid is required") + } + row, err := s.pages.GetPage(ctx, bvid, cid) + if err != nil { + return BeginResult{}, err + } + if row == nil { + return BeginResult{Found: false, Reason: "video page not found"}, ErrPageNotFound + } + + res := BeginResult{ + Found: true, + AsrDone: row.IsProcess, + PageTitle: row.PageTitle, + PageIndex: row.PageIndex, + } + if !row.IsProcess { + res.Reason = "ASR not done yet" + return res, nil + } + if expectedVersion != nil { + res.ContentVersion = *expectedVersion + } else { + res.ContentVersion = row.Version + } + if row.KgStatus != nil && *row.KgStatus == "done" && + row.KgVersion != nil && *row.KgVersion == res.ContentVersion && + expectedVersion == nil { + res.Skip = true + res.Reason = "up to date" + return res, nil + } + if err := s.pages.SetStatus(ctx, bvid, cid, "processing"); err != nil { + return res, err + } + return res, nil +} + +// MarkPageDone sets kg_status='done' + kg_version=content_version (Phase 7). +func (s *KGService) MarkPageDone(ctx context.Context, bvid string, cid, contentVersion int64) error { + if bvid == "" { + return invalidf("bvid is required") + } + return s.pages.MarkDone(ctx, bvid, cid, contentVersion) +} + +// MarkPageFailed marks the page failed (retryable) — failure isolation path. +func (s *KGService) MarkPageFailed(ctx context.Context, bvid string, cid int64) error { + if bvid == "" { + return invalidf("bvid is required") + } + return s.pages.SetStatus(ctx, bvid, cid, "failed") +} + +// CountPendingPages is the scope-free stats counter. +func (s *KGService) CountPendingPages(ctx context.Context) (int64, error) { + return s.pages.CountPending(ctx) +} diff --git a/app-go/app-kg/internal/transport/grpc/bookkeeping.go b/app-go/app-kg/internal/transport/grpc/bookkeeping.go new file mode 100644 index 0000000..657853c --- /dev/null +++ b/app-go/app-kg/internal/transport/grpc/bookkeeping.go @@ -0,0 +1,86 @@ +package grpc + +import ( + "context" + "errors" + + "app-kg/internal/kgpb" + "app-kg/internal/service" +) + +// 记账(MySQL)────────────────────────────────────────────────────────── + +func (s *Server) ResolveScope(ctx context.Context, req *kgpb.ResolveScopeRequest) (*kgpb.ResolveScopeResponse, error) { + bvids, err := s.kg.ResolveScope(ctx, req.Uid, req.FolderIds) + if err != nil { + return nil, mapError("resolve scope", err) + } + if bvids == nil { + bvids = []string{} + } + return &kgpb.ResolveScopeResponse{Bvids: bvids}, nil +} + +func (s *Server) SelectPendingPages(ctx context.Context, req *kgpb.SelectPendingPagesRequest) (*kgpb.SelectPendingPagesResponse, error) { + pages, err := s.kg.SelectPendingPages(ctx, req.Bvids, req.Limit) + if err != nil { + return nil, mapError("select pending pages", err) + } + out := make([]*kgpb.PendingPage, 0, len(pages)) + for _, p := range pages { + out = append(out, &kgpb.PendingPage{ + Bvid: p.Bvid, + Cid: p.Cid, + PageIndex: p.PageIndex, + PageTitle: p.PageTitle, + Version: p.Version, + }) + } + return &kgpb.SelectPendingPagesResponse{Pages: out}, nil +} + +func (s *Server) BeginPage(ctx context.Context, req *kgpb.BeginPageRequest) (*kgpb.BeginPageResponse, error) { + var expected *int64 + if req.ExpectedVersion >= 0 { + v := req.ExpectedVersion + expected = &v + } + res, err := s.kg.BeginPage(ctx, req.Bvid, req.Cid, expected) + if err != nil { + if errors.Is(err, service.ErrPageNotFound) { + return &kgpb.BeginPageResponse{Found: false, Reason: res.Reason}, nil + } + return nil, mapError("begin page", err) + } + return &kgpb.BeginPageResponse{ + Found: res.Found, + AsrDone: res.AsrDone, + Skip: res.Skip, + Reason: res.Reason, + ContentVersion: res.ContentVersion, + PageTitle: res.PageTitle, + PageIndex: res.PageIndex, + }, nil +} + +func (s *Server) MarkPageDone(ctx context.Context, req *kgpb.MarkPageDoneRequest) (*kgpb.MarkPageDoneResponse, error) { + if err := s.kg.MarkPageDone(ctx, req.Bvid, req.Cid, req.ContentVersion); err != nil { + return nil, mapError("mark page done", err) + } + return &kgpb.MarkPageDoneResponse{}, nil +} + +func (s *Server) MarkPageFailed(ctx context.Context, req *kgpb.MarkPageFailedRequest) (*kgpb.MarkPageFailedResponse, error) { + if err := s.kg.MarkPageFailed(ctx, req.Bvid, req.Cid); err != nil { + return nil, mapError("mark page failed", err) + } + return &kgpb.MarkPageFailedResponse{}, nil +} + +func (s *Server) CountPendingPages(ctx context.Context, req *kgpb.CountPendingPagesRequest) (*kgpb.CountPendingPagesResponse, error) { + n, err := s.kg.CountPendingPages(ctx) + if err != nil { + return nil, mapError("count pending pages", err) + } + return &kgpb.CountPendingPagesResponse{Count: n}, nil +} diff --git a/app-go/app-kg/internal/transport/grpc/cloud_knowledge.go b/app-go/app-kg/internal/transport/grpc/cloud_knowledge.go new file mode 100644 index 0000000..d467dc7 --- /dev/null +++ b/app-go/app-kg/internal/transport/grpc/cloud_knowledge.go @@ -0,0 +1,50 @@ +package grpc + +import ( + "context" + + "app-kg/internal/kgpb" + "app-kg/internal/service" +) + +// 云盘知识点(CloudDoc knowledge)────────────────────────────────────── +// 与视频共用 Entity/RELATES 空间;证据边目标是 CloudDoc 节点(无 page_index)。 + +func (s *Server) LinkCloudDocAppearances(ctx context.Context, req *kgpb.LinkCloudDocAppearancesRequest) (*kgpb.LinkCloudDocAppearancesResponse, error) { + appearances := make([]service.AppearanceInput, 0, len(req.Appearances)) + for _, a := range req.Appearances { + appearances = append(appearances, service.AppearanceInput{Eid: a.Eid, Quote: a.Quote}) + } + linked, err := s.kg.LinkCloudDocAppearances(ctx, req.Uid, req.UploadUuid, req.Title, req.ContentHash, appearances) + if err != nil { + return nil, mapError("link cloud doc appearances", err) + } + return &kgpb.LinkCloudDocAppearancesResponse{Linked: linked}, nil +} + +func (s *Server) DeleteCloudDocAppearances(ctx context.Context, req *kgpb.DeleteCloudDocAppearancesRequest) (*kgpb.DeleteCloudDocAppearancesResponse, error) { + deleted, entitiesRemoved, err := s.kg.DeleteCloudDocAppearances(ctx, req.Uid, req.UploadUuid) + if err != nil { + return nil, mapError("delete cloud doc appearances", err) + } + return &kgpb.DeleteCloudDocAppearancesResponse{Deleted: deleted, EntitiesRemoved: entitiesRemoved}, nil +} + +func (s *Server) FetchCloudDocEvidence(ctx context.Context, req *kgpb.FetchCloudDocEvidenceRequest) (*kgpb.FetchCloudDocEvidenceResponse, error) { + rows, err := s.kg.FetchCloudDocEvidence(ctx, req.Eids, req.Uid, req.UploadUuids, req.Limit) + if err != nil { + return nil, mapError("fetch cloud doc evidence", err) + } + out := make([]*kgpb.CloudDocEvidenceRow, 0, len(rows)) + for _, r := range rows { + out = append(out, &kgpb.CloudDocEvidenceRow{ + Eid: r.Eid, + Name: r.Name, + Type: r.Type, + UploadUuid: r.UploadUUID, + Quote: r.Quote, + Title: r.Title, + }) + } + return &kgpb.FetchCloudDocEvidenceResponse{Rows: out}, nil +} diff --git a/app-go/app-kg/internal/transport/grpc/graph_read.go b/app-go/app-kg/internal/transport/grpc/graph_read.go new file mode 100644 index 0000000..6e0df8c --- /dev/null +++ b/app-go/app-kg/internal/transport/grpc/graph_read.go @@ -0,0 +1,179 @@ +package grpc + +import ( + "context" + + "app-kg/internal/kgpb" +) + +// 图读(Neo4j + video 标题 join)───────────────────────────────────── + +func (s *Server) Neighbors(ctx context.Context, req *kgpb.NeighborsRequest) (*kgpb.NeighborsResponse, error) { + rows, err := s.kg.Neighbors(ctx, req.Eids, req.ExcludeEids, req.Limit) + if err != nil { + return nil, mapError("neighbors", err) + } + out := make([]*kgpb.NeighborRow, 0, len(rows)) + for _, r := range rows { + out = append(out, &kgpb.NeighborRow{ + Eid: r.Eid, + Name: r.Name, + Type: r.Type, + Description: r.Description, + RelType: r.RelType, + FromEid: r.FromEid, + }) + } + return &kgpb.NeighborsResponse{Rows: out}, nil +} + +func (s *Server) FetchEvidence(ctx context.Context, req *kgpb.FetchEvidenceRequest) (*kgpb.FetchEvidenceResponse, error) { + rows, err := s.kg.FetchEvidence(ctx, req.Eids, req.Bvids, req.Limit) + if err != nil { + return nil, mapError("fetch evidence", err) + } + out := make([]*kgpb.EvidenceRow, 0, len(rows)) + for _, r := range rows { + out = append(out, &kgpb.EvidenceRow{ + Eid: r.Eid, + Name: r.Name, + Type: r.Type, + Bvid: r.Bvid, + PageIndex: r.PageIndex, + Quote: r.Quote, + PageTitle: r.PageTitle, + }) + } + return &kgpb.FetchEvidenceResponse{Rows: out}, nil +} + +func (s *Server) RelationsAmong(ctx context.Context, req *kgpb.RelationsAmongRequest) (*kgpb.RelationsAmongResponse, error) { + rows, err := s.kg.RelationsAmong(ctx, req.Eids) + if err != nil { + return nil, mapError("relations among", err) + } + out := make([]*kgpb.RelationRow, 0, len(rows)) + for _, r := range rows { + out = append(out, &kgpb.RelationRow{ + SrcEid: r.SrcEid, + SrcName: r.SrcName, + RelType: r.RelType, + DstEid: r.DstEid, + DstName: r.DstName, + Weight: r.Weight, + Quotes: r.Quotes, + }) + } + return &kgpb.RelationsAmongResponse{Rows: out}, nil +} + +func (s *Server) EntitiesByEids(ctx context.Context, req *kgpb.EntitiesByEidsRequest) (*kgpb.EntitiesByEidsResponse, error) { + rows, err := s.kg.EntitiesByEids(ctx, req.Eids) + if err != nil { + return nil, mapError("entities by eids", err) + } + out := make([]*kgpb.EntityDetail, 0, len(rows)) + for _, r := range rows { + out = append(out, &kgpb.EntityDetail{ + Eid: r.Eid, + Name: r.Name, + Type: r.Type, + Description: r.Description, + Aliases: r.Aliases, + }) + } + return &kgpb.EntitiesByEidsResponse{Rows: out}, nil +} + +func (s *Server) EntityExposure(ctx context.Context, req *kgpb.EntityExposureRequest) (*kgpb.EntityExposureResponse, error) { + rows, err := s.kg.EntityExposure(ctx, req.Bvids, req.Limit) + if err != nil { + return nil, mapError("entity exposure", err) + } + out := make([]*kgpb.ExposureRow, 0, len(rows)) + for _, r := range rows { + samples := make([]*kgpb.ExposureEvidence, 0, len(r.EvidenceSample)) + for _, e := range r.EvidenceSample { + samples = append(samples, &kgpb.ExposureEvidence{ + Bvid: e.Bvid, + PageIndex: e.PageIndex, + Quote: e.Quote, + }) + } + out = append(out, &kgpb.ExposureRow{ + Eid: r.Eid, + Name: r.Name, + Type: r.Type, + Description: r.Description, + Pages: r.Pages, + EvidenceSample: samples, + }) + } + return &kgpb.EntityExposureResponse{Rows: out}, nil +} + +func (s *Server) EntityAppearances(ctx context.Context, req *kgpb.EntityAppearancesRequest) (*kgpb.EntityAppearancesResponse, error) { + rows, err := s.kg.EntityAppearances(ctx, req.Eid, req.Bvids, req.Limit) + if err != nil { + return nil, mapError("entity appearances", err) + } + out := make([]*kgpb.AppearanceRow, 0, len(rows)) + for _, r := range rows { + out = append(out, &kgpb.AppearanceRow{ + Bvid: r.Bvid, + PageIndex: r.PageIndex, + Quote: r.Quote, + }) + } + return &kgpb.EntityAppearancesResponse{Rows: out}, nil +} + +func (s *Server) TopEntities(ctx context.Context, req *kgpb.TopEntitiesRequest) (*kgpb.TopEntitiesResponse, error) { + rows, err := s.kg.TopEntities(ctx, req.Limit) + if err != nil { + return nil, mapError("top entities", err) + } + out := make([]*kgpb.TopEntityRow, 0, len(rows)) + for _, r := range rows { + out = append(out, &kgpb.TopEntityRow{ + Eid: r.Eid, + Name: r.Name, + Type: r.Type, + Description: r.Description, + Degree: r.Degree, + }) + } + return &kgpb.TopEntitiesResponse{Rows: out}, nil +} + +func (s *Server) EdgesAmong(ctx context.Context, req *kgpb.EdgesAmongRequest) (*kgpb.EdgesAmongResponse, error) { + rows, err := s.kg.EdgesAmong(ctx, req.Eids) + if err != nil { + return nil, mapError("edges among", err) + } + out := make([]*kgpb.EdgeRow, 0, len(rows)) + for _, r := range rows { + out = append(out, &kgpb.EdgeRow{ + SrcEid: r.SrcEid, + DstEid: r.DstEid, + RelType: r.RelType, + }) + } + return &kgpb.EdgesAmongResponse{Rows: out}, nil +} + +func (s *Server) Stats(ctx context.Context, req *kgpb.StatsRequest) (*kgpb.StatsResponse, error) { + st, err := s.kg.Stats(ctx) + if err != nil { + return nil, mapError("stats", err) + } + return &kgpb.StatsResponse{ + Entities: st.Entities, + Relations: st.Relations, + Evidence: st.Evidence, + Videos: st.Videos, + PendingPages: st.PendingPages, + CloudDocs: st.CloudDocs, + CloudEvidence: st.CloudEvidence, + }, nil +} diff --git a/app-go/app-kg/internal/transport/grpc/graph_write.go b/app-go/app-kg/internal/transport/grpc/graph_write.go new file mode 100644 index 0000000..984ee32 --- /dev/null +++ b/app-go/app-kg/internal/transport/grpc/graph_write.go @@ -0,0 +1,77 @@ +package grpc + +import ( + "context" + + "app-kg/internal/kgpb" + "app-kg/internal/service" +) + +// 图写(Neo4j)────────────────────────────────────────────────────────── + +func (s *Server) UpsertEntities(ctx context.Context, req *kgpb.UpsertEntitiesRequest) (*kgpb.UpsertEntitiesResponse, error) { + entities := make([]service.EntityInput, 0, len(req.Entities)) + for _, e := range req.Entities { + entities = append(entities, service.EntityInput{ + Eid: e.Eid, Name: e.Name, NameLower: e.NameLower, + Type: e.Type, Description: e.Description, Alias: e.Alias, + }) + } + eids, err := s.kg.UpsertEntities(ctx, entities) + if err != nil { + return nil, mapError("upsert entities", err) + } + if eids == nil { + eids = []string{} + } + return &kgpb.UpsertEntitiesResponse{Eids: eids}, nil +} + +func (s *Server) LinkAppearances(ctx context.Context, req *kgpb.LinkAppearancesRequest) (*kgpb.LinkAppearancesResponse, error) { + appearances := make([]service.AppearanceInput, 0, len(req.Appearances)) + for _, a := range req.Appearances { + appearances = append(appearances, service.AppearanceInput{Eid: a.Eid, Quote: a.Quote}) + } + linked, err := s.kg.LinkAppearances(ctx, req.Bvid, req.PageIndex, appearances) + if err != nil { + return nil, mapError("link appearances", err) + } + return &kgpb.LinkAppearancesResponse{Linked: linked}, nil +} + +func (s *Server) UpsertRelations(ctx context.Context, req *kgpb.UpsertRelationsRequest) (*kgpb.UpsertRelationsResponse, error) { + relations := make([]service.RelationInput, 0, len(req.Relations)) + for _, r := range req.Relations { + relations = append(relations, service.RelationInput{ + SrcEid: r.SrcEid, DstEid: r.DstEid, + RelType: r.RelType, Quote: r.Quote, + }) + } + if err := s.kg.UpsertRelations(ctx, relations); err != nil { + return nil, mapError("upsert relations", err) + } + return &kgpb.UpsertRelationsResponse{}, nil +} + +func (s *Server) DeletePageEvidence(ctx context.Context, req *kgpb.DeletePageEvidenceRequest) (*kgpb.DeletePageEvidenceResponse, error) { + deleted, err := s.kg.DeletePageEvidence(ctx, req.Bvid, req.PageIndex) + if err != nil { + return nil, mapError("delete page evidence", err) + } + return &kgpb.DeletePageEvidenceResponse{Deleted: deleted}, nil +} + +func (s *Server) DeleteVideo(ctx context.Context, req *kgpb.DeleteVideoRequest) (*kgpb.DeleteVideoResponse, error) { + entitiesRemoved, videosRemoved, err := s.kg.DeleteVideo(ctx, req.Bvid) + if err != nil { + return nil, mapError("delete video", err) + } + return &kgpb.DeleteVideoResponse{EntitiesRemoved: entitiesRemoved, VideosRemoved: videosRemoved}, nil +} + +func (s *Server) ClearAll(ctx context.Context, req *kgpb.ClearAllRequest) (*kgpb.ClearAllResponse, error) { + if err := s.kg.ClearAll(ctx); err != nil { + return nil, mapError("clear all", err) + } + return &kgpb.ClearAllResponse{}, nil +} diff --git a/app-go/app-kg/internal/transport/grpc/interceptor.go b/app-go/app-kg/internal/transport/grpc/interceptor.go new file mode 100644 index 0000000..0e80652 --- /dev/null +++ b/app-go/app-kg/internal/transport/grpc/interceptor.go @@ -0,0 +1,54 @@ +package grpc + +import ( + "context" + "log/slog" + "strings" + "time" + + "github.com/google/uuid" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// requestIDFromContext reads the gateway/caller-generated request id (grpc +// metadata "x-request-id") for log correlation. +func requestIDFromContext(ctx context.Context) string { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "" + } + values := md.Get("x-request-id") + if len(values) == 0 { + return "" + } + return strings.TrimSpace(values[0]) +} + +// Interceptor returns the unary server chain: request-id correlation + +// structured access log + panic recovery. +func Interceptor() grpc.UnaryServerInterceptor { + return func( + ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, + ) (resp any, err error) { + requestID := requestIDFromContext(ctx) + if requestID == "" { + requestID = uuid.NewString() + } + start := time.Now() + resp, err = handler(ctx, req) + attrs := []slog.Attr{ + slog.String("module", "grpc"), + slog.String("method", info.FullMethod), + slog.Duration("latency", time.Since(start)), + slog.String("request_id", requestID), + } + if err != nil { + attrs = append(attrs, slog.String("err", err.Error())) + slog.LogAttrs(ctx, slog.LevelWarn, "grpc request failed", attrs...) + } else { + slog.LogAttrs(ctx, slog.LevelInfo, "grpc request", attrs...) + } + return resp, err + } +} diff --git a/app-go/app-kg/internal/transport/grpc/server.go b/app-go/app-kg/internal/transport/grpc/server.go new file mode 100644 index 0000000..da73497 --- /dev/null +++ b/app-go/app-kg/internal/transport/grpc/server.go @@ -0,0 +1,41 @@ +// Package grpc is app-kg's gRPC transport boundary (kg.v1.KgGraph): proto ↔ +// service mapping plus request-id correlation, structured access logging and +// panic recovery. It holds no graph/SQL logic — business rules live in +// internal/service, data access in internal/repo. +package grpc + +import ( + "errors" + + "app-kg/internal/kgpb" + "app-kg/internal/service" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Deps wires the service into the gRPC transport. +type Deps struct { + KG *service.KGService +} + +// Server implements kgpb.KgGraphServer as a pure protocol adapter. +type Server struct { + kgpb.UnimplementedKgGraphServer + kg *service.KGService +} + +// New builds the transport. +func New(d Deps) *Server { return &Server{kg: d.KG} } + +// mapError translates service-layer errors to gRPC statuses: domain +// validation → InvalidArgument, everything else → Internal with the operation +// name (Neo4j/MySQL unavailability surfaces as Internal; the app-ts side +// degrades or fails the build page, never the whole service). +func mapError(op string, err error) error { + var inv *service.InvalidArgumentError + if errors.As(err, &inv) { + return status.Error(codes.InvalidArgument, inv.Error()) + } + return status.Errorf(codes.Internal, "%s: %v", op, err) +} diff --git a/app-go/app-kg/internal/transport/http/http.go b/app-go/app-kg/internal/transport/http/http.go new file mode 100644 index 0000000..6ab33d7 --- /dev/null +++ b/app-go/app-kg/internal/transport/http/http.go @@ -0,0 +1,165 @@ +// Package http is app-kg's operational HTTP face: the /health probe plus the +// internal cloud-doc routing JSON endpoints consumed by app-cloud. Pure +// transport — decode the payload, call the service, write the status code; no +// SQL/Cypher here. +package http + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "net/http" + "time" + + "app-kg/internal/service" +) + +// Pinger is the connectivity face of a store (satisfied by infra/mysql.DB and +// infra/neo4j.Store) — the health probe needs no driver details. +type Pinger interface { + Ping(ctx context.Context) error +} + +// Deps wires the health probes and the cloud-doc service. +type Deps struct { + MySQL Pinger + Neo4j Pinger + CloudDoc *service.CloudDocService + // InternalToken guards the cloud-doc routing endpoints. Empty = allow + // (network-internal posture, same as the probe itself); set to require a + // matching X-Internal-Token header. + InternalToken string +} + +// StartHealth runs the HTTP endpoint on addr until the process exits. +func StartHealth(ctx context.Context, addr string, d Deps) *http.Server { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + body := map[string]any{"status": "ok", "service": "app-kg"} + code := http.StatusOK + if d.Neo4j != nil { + if err := d.Neo4j.Ping(r.Context()); err != nil { + body["status"] = "degraded" + body["neo4j"] = "down" + code = http.StatusServiceUnavailable + } + } + if d.MySQL != nil { + if err := d.MySQL.Ping(r.Context()); err != nil { + body["status"] = "degraded" + body["mysql"] = "down" + code = http.StatusServiceUnavailable + } + } + writeJSON(w, code, body) + }) + registerCloudDocRoutes(mux, d) + srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + go func() { + _ = srv.ListenAndServe() + }() + return srv +} + +// ── cloud-doc routing endpoints (internal; X-Internal-Token when set) ── + +func registerCloudDocRoutes(mux *http.ServeMux, d Deps) { + if d.CloudDoc == nil { + return + } + mux.HandleFunc("/internal/kg/cloud-doc/upsert", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !checkInternalToken(w, r, d.InternalToken) { + return + } + var req struct { + UID int64 `json:"uid"` + UploadUUID string `json:"upload_uuid"` + Title string `json:"title"` + ContentHash string `json:"content_hash"` + Topics []string `json:"topics"` + Entities []string `json:"entities"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid body"}) + return + } + if err := d.CloudDoc.Upsert(r.Context(), service.CloudDocGraph{ + UID: req.UID, UploadUUID: req.UploadUUID, Title: req.Title, + ContentHash: req.ContentHash, Topics: req.Topics, Entities: req.Entities, + }); err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + }) + mux.HandleFunc("/internal/kg/cloud-doc/delete", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !checkInternalToken(w, r, d.InternalToken) { + return + } + var req struct { + UID int64 `json:"uid"` + UploadUUID string `json:"upload_uuid"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid body"}) + return + } + if err := d.CloudDoc.Delete(r.Context(), req.UID, req.UploadUUID); err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + }) + mux.HandleFunc("/internal/kg/cloud-doc/match", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !checkInternalToken(w, r, d.InternalToken) { + return + } + var req struct { + UID int64 `json:"uid"` + Keywords []string `json:"keywords"` + Limit int `json:"limit"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid body"}) + return + } + matches, err := d.CloudDoc.Match(r.Context(), req.UID, req.Keywords, req.Limit) + if err != nil { + writeServiceError(w, err) + return + } + if matches == nil { + matches = []service.CloudDocMatch{} + } + writeJSON(w, http.StatusOK, map[string]any{"matches": matches}) + }) +} + +func checkInternalToken(w http.ResponseWriter, r *http.Request, token string) bool { + if token == "" { + return true + } + if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Internal-Token")), []byte(token)) == 1 { + return true + } + writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "invalid internal token"}) + return false +} + +// writeServiceError maps domain validation to 422 ("invalid body", the +// established app-cloud contract) and everything else to 500. +func writeServiceError(w http.ResponseWriter, err error) { + var inv *service.InvalidArgumentError + if errors.As(err, &inv) { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid body"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()}) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/app-go/app-kg/main.go b/app-go/app-kg/main.go index c0c5585..d079577 100644 --- a/app-go/app-kg/main.go +++ b/app-go/app-kg/main.go @@ -1,11 +1,19 @@ -// app-kg entrypoint: load config, init MySQL + Neo4j, ensure constraints, -// start the gRPC server (kg.v1.KgGraph) + HTTP health probe, graceful -// shutdown. +// app-kg entrypoint: load config, initialize the stores through the infra +// layer, wire repos → services → transports (gRPC + HTTP health), then run +// until SIGINT/SIGTERM. // -// KG storage service: the complete Neo4j data access for the knowledge -// graph plus video.kg_status/kg_version bookkeeping and favorite-folder -// scope resolution. The AI side (extraction / retrieval / build -// orchestration) lives in app-ts. Run from project root: +// Layering (pinned by internal/arch/arch_test.go): +// +// main → transport/{grpc,http} → service → repo → infra +// +// infra owns database initialization/configuration (MySQL + Neo4j), repo owns +// every SQL/Cypher statement, service owns the business rules, transport is a +// thin protocol boundary. +// +// KG storage service: the complete Neo4j data access for the knowledge graph +// plus video.kg_status/kg_version bookkeeping and favorite-folder scope +// resolution. The AI side (extraction / retrieval / build orchestration) +// lives in app-ts. Run from project root: // go run ./app-go/app-kg package main @@ -23,12 +31,15 @@ import ( _ "time/tzdata" // embed IANA tz database so Asia/Shanghai works in distroless "app-kg/internal/config" - "app-kg/internal/db" - "app-kg/internal/graph" + mysqlinfra "app-kg/internal/infra/mysql" + neo4jinfra "app-kg/internal/infra/neo4j" "app-kg/internal/kgpb" "app-kg/internal/logger" - "app-kg/internal/neo4jstore" - "app-kg/internal/server" + mysqlrepo "app-kg/internal/repo/mysql" + neo4jrepo "app-kg/internal/repo/neo4j" + "app-kg/internal/service" + transportgrpc "app-kg/internal/transport/grpc" + transporthttp "app-kg/internal/transport/http" "google.golang.org/grpc" "google.golang.org/grpc/health" @@ -73,25 +84,25 @@ func main() { os.Exit(1) } + // ── infra: database initialization ────────────────────────────── // MySQL — shared bilirag database (read-mostly; writes ONLY the // video.kg_status/kg_version columns). - if err := db.Init(cfg.RDBMS.URL, cfg.RDBMS.MaxOpenConns, cfg.RDBMS.MaxIdleConns, - cfg.RDBMS.ConnMaxLifetime, cfg.App.Debug); err != nil { + mysqlDB, err := mysqlinfra.Open(cfg.RDBMS, cfg.App.Debug) + if err != nil { slog.Error("init mysql failed", "err", err) os.Exit(1) } - defer db.Close() + defer mysqlDB.Close() // Neo4j — the graph store this service owns. ctx := context.Background() - st, err := neo4jstore.Connect(ctx, cfg.Neo4j.URI, cfg.Neo4j.Username, - cfg.Neo4j.Password, cfg.Neo4j.Database) + neo4jStore, err := neo4jinfra.Connect(ctx, cfg.Neo4j) if err != nil { slog.Error("connect neo4j failed", "err", err) os.Exit(1) } - defer st.Close(ctx) - if err := st.EnsureConstraints(ctx); err != nil { + defer neo4jStore.Close(ctx) + if err := neo4jStore.EnsureConstraints(ctx); err != nil { slog.Warn("[NEO4J] ensure constraints failed (continuing — may already exist)", "err", err) } @@ -102,18 +113,23 @@ func main() { slog.Warn("invalid timezone, falling back to UTC", "tz", cfg.Timezone, "err", lerr) } - // gRPC server: interceptors + health + reflection (grpcurl debugging). + // ── repos → services ──────────────────────────────────────────── + pages := mysqlrepo.New(mysqlDB.Gorm()) + graphRepo := neo4jrepo.New(neo4jStore) + kgService := service.NewKG(pages, graphRepo) + cloudDocService := service.NewCloudDoc(graphRepo) + + // ── gRPC transport (app-ts face) ──────────────────────────────── lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.GrpcPort)) if err != nil { slog.Error("listen failed", "err", err) os.Exit(1) } grpcServer := grpc.NewServer( - grpc.ChainUnaryInterceptor(server.Interceptor()), + grpc.ChainUnaryInterceptor(transportgrpc.Interceptor()), ) - graphStore := graph.New(st) - kgpb.RegisterKgGraphServer(grpcServer, server.New(server.Deps{ - Graph: graphStore, + kgpb.RegisterKgGraphServer(grpcServer, transportgrpc.New(transportgrpc.Deps{ + KG: kgService, })) healthSrv := health.NewServer() healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) @@ -128,10 +144,17 @@ func main() { } }() + // ── HTTP transport (health probe + cloud-doc routing for app-cloud) ── healthAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.HealthPort) - healthHTTP := server.StartHealth(ctx, healthAddr, st, graphStore, cfg.Server.InternalToken) + healthHTTP := transporthttp.StartHealth(ctx, healthAddr, transporthttp.Deps{ + MySQL: mysqlDB, + Neo4j: neo4jStore, + CloudDoc: cloudDocService, + InternalToken: cfg.Server.InternalToken, + }) slog.Info("app-kg health endpoint", "addr", healthAddr) + // ── graceful shutdown ─────────────────────────────────────────── quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit diff --git a/app-go/app-kg/tests/arch_test.go b/app-go/app-kg/tests/arch_test.go new file mode 100644 index 0000000..c550d83 --- /dev/null +++ b/app-go/app-kg/tests/arch_test.go @@ -0,0 +1,100 @@ +package tests + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// Guard: structure-only test — parse the import graph of the service and fail +// on layer violations. The layering contract is +// +// main → transport/{grpc,http} → service → repo → infra +// +// config only loads settings (and embeds the infra config shapes); it is the +// single place that reads YAML/env. +const modulePrefix = "app-kg/internal/" + +type layer struct { + dir string + forbidden []string +} + +func TestLayerBoundaries(t *testing.T) { + dirs := []string{ + "../internal/config", + "../internal/infra/mysql", + "../internal/infra/neo4j", + "../internal/repo/mysql", + "../internal/repo/neo4j", + "../internal/service", + "../internal/transport/grpc", + "../internal/transport/http", + } + for _, dir := range dirs { + if st, err := os.Stat(dir); err != nil || !st.IsDir() { + t.Fatalf("expected layer package directory %s to exist (got %v)", dir, err) + } + } + + layers := []layer{ + // infra: database initialization only — never data access or business. + {dir: "../internal/infra/mysql", forbidden: []string{"repo/", "service/", "transport/", "config"}}, + {dir: "../internal/infra/neo4j", forbidden: []string{"repo/", "service/", "transport/", "config"}}, + // repo: SQL/Cypher only — may use infra handles, never business/protocol. + {dir: "../internal/repo/mysql", forbidden: []string{"service/", "transport/", "config"}}, + {dir: "../internal/repo/neo4j", forbidden: []string{"service/", "transport/", "config"}}, + // service: business rules only — never a driver, connection, proto or HTTP. + {dir: "../internal/service", forbidden: []string{"infra/", "transport/", "config", "kgpb"}}, + // transport: protocol mapping only — never SQL/Cypher or connections. + {dir: "../internal/transport/grpc", forbidden: []string{"infra/", "repo/", "config"}}, + {dir: "../internal/transport/http", forbidden: []string{"infra/", "repo/", "config"}}, + // config: loads settings, embeds infra config shapes; no business. + {dir: "../internal/config", forbidden: []string{"repo/", "service/", "transport/", "kgpb"}}, + } + + for _, l := range layers { + for _, imp := range internalImports(t, l.dir) { + rest := strings.TrimPrefix(imp, modulePrefix) + for _, forbidden := range l.forbidden { + if rest == forbidden || strings.HasPrefix(rest, forbidden) { + t.Errorf("%s must not import %s (layer violation)", l.dir, imp) + } + } + } + } +} + +// internalImports lists the app-kg/internal/... imports of the non-test Go +// files in dir. +func internalImports(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read dir %s: %v", dir, err) + } + seen := map[string]bool{} + var out []string + fset := token.NewFileSet() + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || + strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(dir, entry.Name()), nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s/%s: %v", dir, entry.Name(), err) + } + for _, spec := range file.Imports { + path := strings.Trim(spec.Path.Value, `"`) + if strings.HasPrefix(path, modulePrefix) && !seen[path] { + seen[path] = true + out = append(out, path) + } + } + } + return out +} diff --git a/app-go/app-kg/tests/config_test.go b/app-go/app-kg/tests/config_test.go new file mode 100644 index 0000000..c31e06c --- /dev/null +++ b/app-go/app-kg/tests/config_test.go @@ -0,0 +1,66 @@ +package tests + +import ( + "os" + "strings" + "testing" + + "app-kg/internal/config" +) + +// TestDefaultsParse pins the YAML key ↔ struct mapping, including the +// infra-owned rdbms/neo4j blocks (the shape moved into internal/infra, so a +// wrong yaml tag here would silently drop production settings). +func TestDefaultsParse(t *testing.T) { + raw, err := os.ReadFile("../default.yaml") + if err != nil { + t.Fatalf("read default.yaml: %v", err) + } + t.Setenv("APPKG__NEO4J__URI", "bolt://localhost:7687") + t.Setenv("APPKG__RDBMS__URL", "mysql+aiomysql://u:p@localhost:3306/bilirag") + + cfg, err := config.Load(raw) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.App.Name != "app-kg" { + t.Errorf("app.name = %q", cfg.App.Name) + } + if cfg.Server.GrpcPort != 8013 || cfg.Server.HealthPort != 8014 { + t.Errorf("ports = %d/%d", cfg.Server.GrpcPort, cfg.Server.HealthPort) + } + if cfg.Timezone != "Asia/Shanghai" { + t.Errorf("timezone = %q", cfg.Timezone) + } + if cfg.RDBMS.MaxOpenConns != 10 || cfg.RDBMS.MaxIdleConns != 5 || cfg.RDBMS.ConnMaxLifetime != 1800 { + t.Errorf("rdbms pool = %+v", cfg.RDBMS) + } + if cfg.RDBMS.URL != "mysql+aiomysql://u:p@localhost:3306/bilirag" { + t.Errorf("rdbms.url = %q (env override not applied)", cfg.RDBMS.URL) + } + if cfg.Neo4j.URI != "bolt://localhost:7687" { + t.Errorf("neo4j.uri = %q (env override not applied)", cfg.Neo4j.URI) + } + if cfg.Log.File.Path != "/app/logs/app-kg.log" { + t.Errorf("log.file.path = %q", cfg.Log.File.Path) + } + if err := config.Validate(cfg); err != nil { + t.Fatalf("validate: %v", err) + } + if cfg.Neo4j.Database != "neo4j" || cfg.Neo4j.Username != "neo4j" { + t.Errorf("neo4j defaults = %+v (want neo4j/neo4j)", cfg.Neo4j) + } +} + +// TestValidateFailsLoud: both stores are required; the error names the env var +// an operator must set. +func TestValidateFailsLoud(t *testing.T) { + var cfg config.Config + if err := config.Validate(&cfg); err == nil || !strings.Contains(err.Error(), "neo4j.uri is required") { + t.Fatalf("missing neo4j.uri: err = %v", err) + } + cfg.Neo4j.URI = "bolt://localhost:7687" + if err := config.Validate(&cfg); err == nil || !strings.Contains(err.Error(), "rdbms.url is required") { + t.Fatalf("missing rdbms.url: err = %v", err) + } +} diff --git a/app-go/app-kg/tests/guard_graph_test.go b/app-go/app-kg/tests/guard_graph_test.go new file mode 100644 index 0000000..f3eae6a --- /dev/null +++ b/app-go/app-kg/tests/guard_graph_test.go @@ -0,0 +1,200 @@ +// Package tests holds every Go test of app-kg in ONE place: structural guards +// (Cypher/SQL contracts, layering) plus service/transport/config behaviour. +// Tests live in a separate package (not mixed into the tested packages) so the +// public surface is the only thing under test — matching app-cloud/tests. +package tests + +import ( + "os" + "path/filepath" + "strings" + "testing" + + neo4jrepo "app-kg/internal/repo/neo4j" +) + +const repoNeo4jDir = "../internal/repo/neo4j" + +// readRepoSource loads one repository source file for structural assertions +// (no external deps needed). +func readRepoSource(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(repoNeo4jDir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return string(b) +} + +// Guard: every Cypher statement issued by the graph repository must MERGE on +// the canonical keys (name_lower for Entity, bvid for Video) and never +// normalize names itself — normalization (name_lower/eid) is the CALLER's job +// and the single implementation lives in app-ts's resolver. Pins the +// dual-write contract with the retired Python KgGraphRepository. +func TestMergeKeysAndNoNormalization(t *testing.T) { + src := readRepoSource(t, "graph.go") + checks := []struct { + name string + must []string + mustNot []string + }{ + { + name: "UpsertEntities", + must: []string{"MERGE (e:Entity {name_lower: row.name_lower})", "ON CREATE SET e.eid = row.eid"}, + mustNot: []string{ + // sha1/normalization must never happen in Cypher or Go code here + "sha1", "toLower(row.name_lower)", + }, + }, + { + name: "LinkAppearances", + must: []string{"MERGE (v:Video {bvid: $bvid})", "MERGE (e)-[ev:APPEARS_IN {page_index: $page_index}]->(v)"}, + }, + { + name: "UpsertRelations", + must: []string{"MERGE (a)-[r:RELATES {rel_type: row.rel_type}]->(b)"}, + }, + } + for _, c := range checks { + segment := segmentFor(t, src, c.name) + for _, m := range c.must { + if !strings.Contains(segment, m) { + t.Errorf("%s: Cypher missing canonical fragment %q", c.name, m) + } + } + for _, banned := range c.mustNot { + if strings.Contains(strings.ToLower(segment), strings.ToLower(banned)) { + t.Errorf("%s: Cypher contains banned fragment %q (normalization belongs to the caller)", c.name, banned) + } + } + } +} + +// Guard: the relation quote sliding window must stay at 3 (parity with the +// Python MAX_RELATION_QUOTES). +func TestMaxRelationQuotes(t *testing.T) { + if neo4jrepo.MaxRelationQuotes != 3 { + t.Fatalf("MaxRelationQuotes = %d, want 3 (parity with Python)", neo4jrepo.MaxRelationQuotes) + } +} + +func segmentFor(t *testing.T, src string, fn string) string { + t.Helper() + idx := strings.Index(src, "func (r *GraphRepo) "+fn+"(") + if idx < 0 { + t.Fatalf("method %s not found", fn) + } + rest := src[idx:] + end := strings.Index(rest, "\nfunc ") + if end < 0 { + end = len(rest) + } + return rest[:end] +} + +// Guard: every cloud-doc routing Cypher statement must scope on uid — +// multi-tenant isolation is enforced in Cypher, never by the caller. Also: the +// graph never normalizes names (name_lower arrives precomputed from the caller). +func TestCloudDocGraphUIDScoping(t *testing.T) { + for _, frag := range []string{ + "{uid: $uid, upload_uuid: $upload_uuid}", + "(t:CloudTopic {uid: $uid, name_lower: row.name_lower})", + "(en:CloudEntity {uid: $uid, name_lower: row.name_lower})", + "(t:CloudTopic {uid: $uid})", + "(en:CloudEntity {uid: $uid})", + } { + if !strings.Contains(frag, "$uid") { + t.Fatalf("fragment missing uid scope: %s", frag) + } + } + src := readRepoSource(t, "cloud.go") + for _, banned := range []string{"sha1(", "toLower(row.name_lower)"} { + if strings.Contains(src, banned) { + t.Errorf("cloud graph Cypher contains banned normalization fragment %q", banned) + } + } +} + +// Guard: label separation — cloud routing operations must not touch the video +// KG labels (Entity/Video) and vice versa; ClearAll semantics stay intact. +func TestCloudDocGraphLabelSeparation(t *testing.T) { + src := readRepoSource(t, "cloud.go") + for _, banned := range []string{":Entity ", ":Video ", "APPEARS_IN", "RELATES"} { + if strings.Contains(src, banned) { + t.Errorf("cloud graph must not touch video-KG labels/edge types: %q found", banned) + } + } +} + +// Guard: cloud-doc knowledge evidence must stay multi-tenant (uid on the +// CloudDoc node and in every query) and must never normalize names itself. +func TestCloudDocKnowledgeUIDScoping(t *testing.T) { + src := readRepoSource(t, "cloud_kg.go") + for _, frag := range []string{ + "MERGE (d:CloudDoc {uid: $uid, upload_uuid: $upload_uuid})", + "MATCH (d:CloudDoc {uid: $uid, upload_uuid: $upload_uuid})<-[ev:APPEARS_IN]-(:Entity)", + "WHERE d.uid = $uid", + } { + if !strings.Contains(src, frag) { + t.Errorf("cloud knowledge Cypher missing uid-scoped fragment %q", frag) + } + } + for _, banned := range []string{"sha1", "toLower(row.name_lower)"} { + if strings.Contains(src, banned) { + t.Errorf("cloud knowledge Cypher contains banned normalization fragment %q", banned) + } + } +} + +// Guard: link is delete-before-write — a document's old evidence must be +// dropped before the new edges are written (a recomputed extraction must not +// leave stale relationships behind). +func TestCloudDocKnowledgeDeleteBeforeWrite(t *testing.T) { + src := readRepoSource(t, "cloud_kg.go") + del := strings.Index(src, "DELETE old") + write := strings.Index(src, "MERGE (e)-[ev:APPEARS_IN]->(d)") + if del < 0 || write < 0 { + t.Fatalf("delete-before-write fragments not found (delete=%d write=%d)", del, write) + } + if del > write { + t.Fatalf("old evidence must be deleted before the new edges are written") + } + if !strings.Contains(src, "WITH DISTINCT d") { + t.Error("rows must be collapsed after deleting old evidence (one row per doc, not one per deleted edge)") + } +} + +// Guard: cloud-doc knowledge operations never touch the video label, and the +// duplicate-quote rule matches the video KG (shortest non-empty quote wins, +// dedupe by eid). +func TestCloudDocKnowledgeLabelAndDedup(t *testing.T) { + src := readRepoSource(t, "cloud_kg.go") + for _, banned := range []string{":Video ", "page_index"} { + if strings.Contains(src, banned) { + t.Errorf("cloud knowledge must not touch video-KG specifics: %q found", banned) + } + } + if !strings.Contains(src, "if a.Quote != \"\" && (cur == \"\" || len(a.Quote) < len(cur))") { + t.Error("shortest-quote dedup rule missing (parity with LinkAppearances)") + } + if !strings.Contains(src, "MATCH (e:Entity) WHERE NOT (e)--() DELETE e") { + t.Error("orphan Entity recycling missing from cloud-doc evidence deletion") + } +} + +// Guard: knowledge evidence must not be counted as video evidence — Stats +// treats the two sources separately. +func TestStatsSplitsVideoAndCloudEvidence(t *testing.T) { + src := readRepoSource(t, "graph.go") + if !strings.Contains(src, "MATCH (:Entity)-[a:APPEARS_IN]->(:Video) RETURN count(a) as evidence") { + t.Error("video evidence counter must be scoped to :Video") + } + for _, frag := range []string{ + "count(DISTINCT d) AS cloud_docs", + "count(a) AS cloud_evidence", + } { + if !strings.Contains(src, frag) { + t.Errorf("graph stats missing cloud counter fragment %q", frag) + } + } +} diff --git a/app-go/app-kg/tests/guard_mysql_test.go b/app-go/app-kg/tests/guard_mysql_test.go new file mode 100644 index 0000000..a18eef6 --- /dev/null +++ b/app-go/app-kg/tests/guard_mysql_test.go @@ -0,0 +1,70 @@ +package tests + +import ( + "os" + "strings" + "testing" +) + +// Guard: the MySQL repository may never run DDL and must never write anything +// except the two KG bookkeeping columns on `video`. This mirrors the service +// red lines in AGENTS.md §2.18 (app-kg = the only writer of +// kg_status/kg_version; no tables of its own, no AutoMigrate). +func TestWriteSurfaceIsPinnedToKGColumns(t *testing.T) { + src := readPageSource(t) + + for _, banned := range []string{ + "ALTER ", "CREATE ", "DROP ", "TRUNCATE ", "INSERT ", "DELETE FROM ", + "AutoMigrate", "CREATE TABLE", + } { + if strings.Contains(src, banned) { + t.Errorf("repo/mysql must stay DDL-free and read-only except the two KG columns; found %q", banned) + } + } + + allowed := map[string]bool{"kg_status": true, "kg_version": true} + updates := 0 + for offset := 0; ; { + idx := strings.Index(src[offset:], "UPDATE ") + if idx < 0 { + break + } + start := offset + idx + end := strings.Index(src[start:], `"`) + if end < 0 { + t.Fatalf("unterminated UPDATE statement near offset %d", start) + } + stmt := src[start : start+end] + updates++ + if !strings.HasPrefix(stmt, "UPDATE video SET ") { + t.Errorf("only the shared video table may be updated; got %q", stmt) + } + setClause := strings.TrimPrefix(stmt, "UPDATE video SET ") + whereIdx := strings.Index(setClause, " WHERE") + if whereIdx < 0 { + t.Fatalf("UPDATE without WHERE guard (must be keyed): %q", stmt) + } + setClause = setClause[:whereIdx] + for _, assignment := range strings.Split(setClause, ",") { + column := strings.TrimSpace(strings.SplitN(assignment, "=", 2)[0]) + if !allowed[column] { + t.Errorf("UPDATE writes non-KG column %q (only kg_status/kg_version are permitted)", column) + } + } + offset = start + end + } + if updates != 2 { + t.Errorf("expected exactly the two pinned UPDATE statements (SetStatus, MarkDone), found %d", updates) + } +} + +// readPageSource loads the MySQL repository source for structural assertions +// (no DB needed). +func readPageSource(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("../internal/repo/mysql/page.go") + if err != nil { + t.Fatalf("read page.go: %v", err) + } + return string(b) +} diff --git a/app-go/app-kg/tests/service_validate_test.go b/app-go/app-kg/tests/service_validate_test.go new file mode 100644 index 0000000..546216c --- /dev/null +++ b/app-go/app-kg/tests/service_validate_test.go @@ -0,0 +1,82 @@ +package tests + +import ( + "context" + "errors" + "testing" + + "app-kg/internal/service" +) + +func isInvalid(err error, want string) bool { + var inv *service.InvalidArgumentError + return errors.As(err, &inv) && inv.Error() == want +} + +// TestKGValidation: all domain validation must happen in the service layer, +// before any repo call — nil repositories prove the order. +func TestKGValidation(t *testing.T) { + ctx := context.Background() + kg := service.NewKG(nil, nil) + + checks := []struct { + name string + err error + want string + }{ + {"scope uid", second(kg.ResolveScope(ctx, 0, nil)), "uid must be positive"}, + {"pending limit", second(kg.SelectPendingPages(ctx, []string{"BV1"}, 0)), "limit must be positive"}, + {"begin bvid", second(kg.BeginPage(ctx, "", 1, nil)), "bvid is required"}, + {"done bvid", kg.MarkPageDone(ctx, "", 1, 1), "bvid is required"}, + {"failed bvid", kg.MarkPageFailed(ctx, "", 1), "bvid is required"}, + {"neighbors limit", second(kg.Neighbors(ctx, []string{"e"}, nil, 0)), "limit must be positive"}, + {"evidence limit", second(kg.FetchEvidence(ctx, []string{"e"}, nil, 0)), "limit must be positive"}, + {"exposure limit", second(kg.EntityExposure(ctx, []string{"BV1"}, 0)), "limit must be positive"}, + {"appearances eid", second(kg.EntityAppearances(ctx, "", nil, 10)), "eid is required"}, + {"appearances limit", second(kg.EntityAppearances(ctx, "e", nil, 0)), "limit must be positive"}, + {"top limit", second(kg.TopEntities(ctx, 0)), "limit must be positive"}, + { + "entity normalization", + second(kg.UpsertEntities(ctx, []service.EntityInput{{Eid: "e1", Name: "Name"}})), + "entity requires eid/name/name_lower (caller computes normalization)", + }, + {"link bvid", second(kg.LinkAppearances(ctx, "", 0, nil)), "bvid is required"}, + {"delete evidence bvid", second(kg.DeletePageEvidence(ctx, "", 0)), "bvid is required"}, + {"delete video bvid", second3(kg.DeleteVideo(ctx, "")), "bvid is required"}, + {"cloud link uid", second(kg.LinkCloudDocAppearances(ctx, 0, "u", "", "", nil)), "uid must be positive"}, + {"cloud link upload_uuid", second(kg.LinkCloudDocAppearances(ctx, 1, "", "", "", nil)), "upload_uuid is required"}, + {"cloud delete uid", second3(kg.DeleteCloudDocAppearances(ctx, 0, "u")), "uid must be positive"}, + {"cloud delete upload_uuid", second3(kg.DeleteCloudDocAppearances(ctx, 1, "")), "upload_uuid is required"}, + {"cloud evidence uid", second(kg.FetchCloudDocEvidence(ctx, []string{"e"}, 0, nil, 10)), "uid must be positive"}, + {"cloud evidence limit", second(kg.FetchCloudDocEvidence(ctx, []string{"e"}, 1, nil, 0)), "limit must be positive"}, + } + for _, c := range checks { + if !isInvalid(c.err, c.want) { + t.Errorf("%s: err = %v, want InvalidArgumentError(%q)", c.name, c.err, c.want) + } + } +} + +// TestCloudDocValidation covers the internal JSON endpoint contract: invalid +// payloads are rejected by the service (transport maps them to HTTP 422). +func TestCloudDocValidation(t *testing.T) { + ctx := context.Background() + svc := service.NewCloudDoc(nil) + + if err := svc.Upsert(ctx, service.CloudDocGraph{UID: 0, UploadUUID: "u"}); !isInvalid(err, "uid and upload_uuid are required") { + t.Errorf("upsert uid: err = %v", err) + } + if err := svc.Upsert(ctx, service.CloudDocGraph{UID: 1, UploadUUID: " "}); !isInvalid(err, "uid and upload_uuid are required") { + t.Errorf("upsert upload_uuid: err = %v", err) + } + if err := svc.Delete(ctx, 0, "u"); !isInvalid(err, "uid and upload_uuid are required") { + t.Errorf("delete uid: err = %v", err) + } + if _, err := svc.Match(ctx, 0, []string{"k"}, 10); !isInvalid(err, "uid must be positive") { + t.Errorf("match uid: err = %v", err) + } +} + +func second[T any](_ T, err error) error { return err } + +func second3[T1, T2 any](_ T1, _ T2, err error) error { return err } diff --git a/app-go/app-kg/tests/transport_test.go b/app-go/app-kg/tests/transport_test.go new file mode 100644 index 0000000..3ce70d3 --- /dev/null +++ b/app-go/app-kg/tests/transport_test.go @@ -0,0 +1,59 @@ +package tests + +import ( + "context" + "testing" + + "app-kg/internal/kgpb" + "app-kg/internal/service" + transportgrpc "app-kg/internal/transport/grpc" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Guard: domain validation errors surface as codes.InvalidArgument with the +// original message (clients treat them as caller bugs). The service validates +// before touching any repository, so nil repos exercise the real handler path. +func TestTransportMapsValidationErrors(t *testing.T) { + ctx := context.Background() + srv := transportgrpc.New(transportgrpc.Deps{KG: service.NewKG(nil, nil)}) + + checks := []struct { + name string + err error + want string + }{ + { + "resolve scope uid", + secondResp(srv.ResolveScope(ctx, &kgpb.ResolveScopeRequest{Uid: 0})), + "uid must be positive", + }, + { + "begin page bvid", + secondResp(srv.BeginPage(ctx, &kgpb.BeginPageRequest{Bvid: ""})), + "bvid is required", + }, + { + "cloud link uid", + secondResp(srv.LinkCloudDocAppearances(ctx, &kgpb.LinkCloudDocAppearancesRequest{Uid: 0, UploadUuid: "u"})), + "uid must be positive", + }, + { + "cloud evidence limit", + secondResp(srv.FetchCloudDocEvidence(ctx, &kgpb.FetchCloudDocEvidenceRequest{Eids: []string{"e"}, Uid: 1, Limit: 0})), + "limit must be positive", + }, + } + for _, c := range checks { + if status.Code(c.err) != codes.InvalidArgument { + t.Errorf("%s: code = %v, want InvalidArgument", c.name, status.Code(c.err)) + continue + } + if got := status.Convert(c.err).Message(); got != c.want { + t.Errorf("%s: message = %q, want %q", c.name, got, c.want) + } + } +} + +func secondResp(_ any, err error) error { return err } diff --git a/app-ts/config/default.yaml b/app-ts/config/default.yaml index de13906..f3f9bf4 100644 --- a/app-ts/config/default.yaml +++ b/app-ts/config/default.yaml @@ -147,6 +147,10 @@ kg: # ASR 正文源 = Python 后端的 Mongo 库(跨库只读——源数据归 bili 域所有) sourceDatabase: mind_base sourceCollection: asr_documents + # 云盘知识点:单次构建最多处理的文档数(分页拉取到该上限即停止) + cloudMaxDocs: 200 + # 云盘文档列表分页大小(app-cloud /internal/cloud/kg/documents 单页上限 100) + cloudListPageSize: 50 toolsStub: # false = 不注册 list_videos/get_video_summaries(VideoMetaRepository 还是内存假数据; @@ -282,6 +286,9 @@ agent: turnRetries: 1 # 同一工具+同参数在一轮内第 N 次调用被拒绝(防绕圈烧 token) toolRepeatLimit: 3 + # 整轮硬超时(毫秒):LLM 流卡死时兜底中止,避免会话锁与请求永久挂起。 + # 30 分钟:普通问答远用不到,但给长任务(沙箱/长委托)留足余量。 + turnTimeoutMs: 1800000 scheduling: # 问答 turn 级加权排队(与 tools.maxConcurrent 的工具级闸门分层:本段限制 diff --git a/app-ts/prompts/agents/main.md b/app-ts/prompts/agents/main.md index c208590..5eeadb3 100644 --- a/app-ts/prompts/agents/main.md +++ b/app-ts/prompts/agents/main.md @@ -10,12 +10,14 @@ - **ask_user**:问题含糊需要澄清,或写操作(如删除板子)前需要确认时调用;一次只发起一个交互,调用后本轮结束等待用户回复。不要替用户作答,也不要在回复文本里重复问题内容。 - **fetch_webpage**:用户给出具体 URL,或问题需要网页的当前内容(新闻/在线文档/知识库不可能有的信息)时调用;抓取公开网页正文,内网地址会被拒绝。可跟进返回的 links 抓取后续页面。 - **web_search**:需要公开/最新资料而知识库可能没有时先联网搜索(考试大纲、行业规范、新闻、版本动态),拿到线索后用 fetch_webpage 抓正文;出题/规范类需求可把线索完整转交给 quiz 子 agent。 -- **cloud_doc_summary**:用户明确给出云盘文件 UUID(如 /cloud-drive/ 链接)并询问该文档的摘要/要点/待办时调用,读取已生成的 AI 摘要;不要凭空编造 UUID,摘要尚未生成时如实告知用户可重新入库。 +- **cloud_doc_summary**:用户明确给出云盘文件 UUID(如 /cloud-drive/ 链接)并询问该文档的摘要/要点/待办时调用,读取已生成的 AI 摘要;不要凭空编造 UUID,摘要尚未生成时如实告知用户可稍后刷新或重新上传该文件。 - **cloud_semantic_search**:用户要在云盘里按语义精确找文件("哪份文档讲过 X""找一下关于 X 的资料")时调用,可限定文件夹;通用知识库问答(视频/云盘混合)仍用 vector_search。 - **cloud_similar_files**:已知某云盘文件 UUID、用户想找内容相近的其他文件时调用;服务端已排除文件自身。 {{KG_SEARCH_SECTION}} {{SKILLS_SECTION}} +- **render_plot**:用户想看函数图像、想拖参数看图形变化("画个 sin 函数""参数 a 变了图会怎样")时调用。曲线必须写成以 x 为自变量的表达式,参数会自动变成滑块;一张图最多 4 条曲线、8 个滑块。只用于数学函数,不用于统计图表。 +- **render_diagram**:用户要流程图/架构图/链路图/步骤图("画个流程""把检索链路画出来""要能拖着看")时调用。节点坐标自己排版(从左到右或从上到下、间距 ≥120),连线必须引用已存在的节点 id;用户可在前端缩放、拖拽、全屏查看。 - **timeoutMs**(所有工具通用的可选参数):本次调用的超时毫秒,1s–1h。非必要避免调高——默认值足以覆盖绝大多数调用;仅长任务(委托整理长笔记、沙箱运行复杂代码等)才放大,如 60000 / 1800000。超出 1 小时会被强制截断。 ## plan @@ -24,6 +26,16 @@ 执行过程中每完成一步立即 `action=update` 更新该步状态(done / in_progress / failed / skipped,可带一句 note);某步失败时先 update 标 failed,再用 `action=revise` 修订后续步骤(reason 必填,最多 3 次)。 计划会实时展示给用户,是给用户看的进度条——简单任务(1-2 步就能完成)不要创建计划,也不要在正文里重复整份计划。 +## 交互式产物 + +需要"图"而不是"文字描述图"时,用 render_plot / render_diagram 产出可交互卡片(用户可缩放、拖拽、拖参数),不要用纯文本画 ASCII 图,也不要用 mermaid 代码块代替。 + +- 先给结论再给图:正文用一两句话说明图的重点(曲线含义 / 阅读顺序),不要把节点或公式逐条复述一遍。 +- 数据必须来自对话上下文或工具结果,不要编造坐标、数值与关系。 +- render_plot:`curves[].expr` 只能包含 x、已声明参数名、常数(pi/e)和白名单函数(sin/cos/tan/sqrt/abs/log/ln/exp/min/max/pow…),支持 `2x` 这类隐式乘法;参数需要在 `params` 里声明 min/max/step/value。 +- render_diagram:节点坐标自己排版,横向从左到右或纵向从上到下,节点间距 ≥120;边只连已存在的 id;强调节点用 tone=accent,状态/结果用 success/warning。 +- 一次回答最多产出 2 张图;图是回答的一部分,不要只发图不说话。 + # 当前状态 - 今天日期:{{TODAY}} diff --git a/app-ts/prompts/kg/extract.md b/app-ts/prompts/kg/extract.md index 1d120c9..af815f4 100644 --- a/app-ts/prompts/kg/extract.md +++ b/app-ts/prompts/kg/extract.md @@ -1,19 +1,19 @@ -你是知识图谱构建专家,从视频文字稿中抽取「实体」和「实体间关系」。 +你是知识图谱构建专家,从文本内容(视频文字稿或云盘文档正文)中抽取「实体」和「实体间关系」。 ## 实体抽取规则 1. 只抽取**具体、有信息量**的实体:人物(person)、组织机构(org)、技术概念(concept)、技术/框架/算法(tech)、工具/软件(tool)、书籍/论文(book)、事件(event)、方法论(method)、地点(place) 2. 忽略:泛指词("大家""这个方法")、无实义的普通名词、纯口语填充 -3. name 使用视频中的原文叫法;type 从给定枚举中选择 -4. description 用一句话说明该实体在**本视频语境下**的含义,不超过 50 字 -5. quote 是文字稿中提到该实体的原文摘抄(20 字以内);找不到原文就留空,禁止编造 -6. 单个视频片段最多 30 个实体,宁缺毋滥 +3. name 使用原文中的叫法;type 从给定枚举中选择 +4. description 用一句话说明该实体在**本文语境下**的含义,不超过 50 字 +5. quote 是原文中提到该实体的摘抄(20 字以内);找不到原文就留空,禁止编造 +6. 单篇文本最多 30 个实体,宁缺毋滥 ## 关系抽取规则 -1. 只抽取文本稿中**明确表达**的关系;禁止根据常识推测文中未提及的关系 +1. 只抽取文本中**明确表达**的关系;禁止根据常识推测文中未提及的关系 2. src/dst 必须严格等于已抽取实体的 name 3. relation_type 用简短中文动词短语(讲解了/使用了/提出了/对比了/依赖了/创立了) -4. **quote 必须是文字稿的原文摘抄**(20 字以内),作为该关系的证据;找不到原文支撑就放弃这条关系 -5. 单个视频片段最多 40 条关系 +4. **quote 必须是原文摘抄**(20 字以内),作为该关系的证据;找不到原文支撑就放弃这条关系 +5. 单篇文本最多 40 条关系 ## 输出 严格按照 schema 返回 JSON,没有实体时返回空列表。 diff --git a/app-ts/src/agent/main/agent.ts b/app-ts/src/agent/main/agent.ts index a40c9ed..aeedeb0 100644 --- a/app-ts/src/agent/main/agent.ts +++ b/app-ts/src/agent/main/agent.ts @@ -62,6 +62,8 @@ export function createMainAgent( "delegate_to_agent", "ask_user", "plan", + "render_plot", + "render_diagram", "fetch_webpage", "web_search", "cloud_doc_summary", diff --git a/app-ts/src/agent/react.ts b/app-ts/src/agent/react.ts index 1fb7f97..e2ef0fc 100644 --- a/app-ts/src/agent/react.ts +++ b/app-ts/src/agent/react.ts @@ -59,9 +59,9 @@ export interface ReactAgentOptions { * re-injected on every run and survive across runs (run state done ≠ context gone). * main agent omits this — its persistent context is the session message store. */ - context?: AgentContextBinding; + context ?: AgentContextBinding; /** model override (tests inject a mock; production defaults to the gateway model) */ - model?: LanguageModel; + model ?: LanguageModel; } /** Wiring for a sub-agent's private context + read access to the shared session summary. */ @@ -74,6 +74,8 @@ export interface AgentContextBinding { interface AttemptOutcome { hadError: boolean; + /** the turn-level signal fired (client disconnect / cancellation) — not an error */ + aborted: boolean; errorMessage: string; retryable: boolean; fullText: string; @@ -118,6 +120,7 @@ export function createReactAgent(opts: ReactAgentOptions): Agent { chatSessionId: input.chatSessionId, scope: input.scope, collector, + ...(input.signal ? { signal: input.signal } : {}), }; const tools = opts.runtime.buildSdkTools(ctx, opts.toolNames); @@ -193,10 +196,15 @@ export function createReactAgent(opts: ReactAgentOptions): Agent { temperature: config.llm.temperature, // L1 省流:输出预算封顶(思考 token 计入输出,随之一并截断) ...(maxOutputTokensFor(input.uid) > 0 ? { maxOutputTokens: maxOutputTokensFor(input.uid) } : {}), + // Turn-level cancellation: client disconnect aborts the upstream request + // instead of letting it run (and bill) to completion. + ...(input.signal ? { abortSignal: input.signal } : {}), ...aiTelemetry(`agent.${opts.name}`), // structured interaction ends the turn normally (round-based ask_user) stopWhen: [stepCountIs(maxStepsFor(opts.name)), stopOnToolCall("ask_user")], onError: ({ error }) => { + // a cancellation is expected: don't log it as a stream failure + if (input.signal?.aborted) return; createLogger("AGENT").error(`[AGENT/${opts.name}] stream error: ${error instanceof Error ? error.message : String(error)}`); }, }); @@ -211,6 +219,7 @@ export function createReactAgent(opts: ReactAgentOptions): Agent { /** toolCallId → start timestamp, for the done frame's durationMs */ const toolStarts = new Map(); let hadError = false; + let aborted = false; let errorMessage = ""; let fullText = ""; let usage: AttemptOutcome["usage"]; @@ -293,18 +302,36 @@ export function createReactAgent(opts: ReactAgentOptions): Agent { } break; } + case "abort": { + // AI SDK emits this when the abortSignal fires (client disconnect / + // turn cancellation). It is not an error: no retry, no breaker hit. + aborted = true; + break; + } case "error": { hadError = true; const err = p.error; errorMessage = err instanceof Error ? err.message : typeof err === "string" ? err : JSON.stringify(err); + if (input.signal?.aborted) { + // providers may surface a cancellation as an error part + aborted = true; + hadError = false; + } break; } default: break; } } - return { hadError, errorMessage, retryable: hadError && isRetryableError(errorMessage), fullText, usage }; + return { + hadError, + aborted, + errorMessage, + retryable: hadError && !aborted && isRetryableError(errorMessage), + fullText, + usage, + }; } // Turn-level retry (mirrors Python error_node): retryable failures abort the attempt, @@ -315,8 +342,18 @@ export function createReactAgent(opts: ReactAgentOptions): Agent { let contextTrimmed = false; while (true) { if (attempt > 0) yield { type: "reset" }; - const outcome = yield* runAttempt(history); + let outcome: AttemptOutcome; + try { + outcome = yield* runAttempt(history); + } catch (err) { + if (input.signal?.aborted || (err as { name?: string }).name === "AbortError") { + last = { hadError: false, aborted: true, errorMessage: "", retryable: false, fullText: "" }; + break; + } + throw err; + } last = outcome; + if (outcome.aborted) break; // cancelled: never retry, never emit an error frame if (!outcome.hadError) break; // context-length exhaustion → drop the older half of the history and retry @@ -337,12 +374,12 @@ export function createReactAgent(opts: ReactAgentOptions): Agent { } break; } - const { fullText, usage } = last!; + const { fullText, usage, aborted } = last!; // Persist the distilled exchange into this agent's private context (write-side // pollution control: only task → final answer, never tool-call noise), then // schedule its own compression. - if (binding && input.chatSessionId && fullText.trim()) { + if (binding && input.chatSessionId && !aborted && fullText.trim()) { const now = Date.now(); try { await binding.port.append(input.chatSessionId, opts.name, { @@ -376,9 +413,9 @@ export function createReactAgent(opts: ReactAgentOptions): Agent { for (const interaction of collector.interactions) { yield { type: "interact", interact: interaction }; } - if (last!.hadError) { + if (last!.hadError && !aborted) { yield { type: "error", message: last!.errorMessage || "agent 执行失败" }; - } else if (!fullText.trim() && collector.interactions.length === 0 && collector.artifacts.length === 0) { + } else if (!aborted && !fullText.trim() && collector.interactions.length === 0 && collector.artifacts.length === 0) { // loop ended without an answer (max steps reached / model produced nothing) — // an empty assistant bubble is worse than an explicit failure yield { type: "error", message: "模型未返回有效内容(可能已达最大工具步数),请重试或换个问法" }; diff --git a/app-ts/src/agent/types.ts b/app-ts/src/agent/types.ts index b6bd676..b855c6a 100644 --- a/app-ts/src/agent/types.ts +++ b/app-ts/src/agent/types.ts @@ -15,6 +15,9 @@ export interface AgentInvokeInput { images?: string[]; /** User-selected installed skills forced into the system prompt this turn */ skillIds?: string[]; + /** Turn-level cancellation (client disconnect / aborted turn). Forwarded to + * streamText (abortSignal) and to every tool via ToolRunContext.signal. */ + signal ?: AbortSignal; } export interface Agent { diff --git a/app-ts/src/artifacts/interactive.ts b/app-ts/src/artifacts/interactive.ts new file mode 100644 index 0000000..4741a20 --- /dev/null +++ b/app-ts/src/artifacts/interactive.ts @@ -0,0 +1,160 @@ +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import type { Artifact } from "../context/types.js"; + +/** + * Interactive artifact specs (plot / diagram). + * + * The model emits a small, fully declarative JSON spec — never HTML or JS — + * and the web client renders it with trusted components (canvas / SVG). + * This module is the server-side contract: zod schemas shown to the model, + * plus cross-field checks that zod alone cannot express (unique node ids, + * edge endpoints, expression identifiers, payload size). + * + * Keep the limits in sync with frontendv2/lib/artifacts/interactive-spec.ts. + */ + +export const SPEC_VERSION = 1; +export const MAX_SPEC_BYTES = 32 * 1024; +export const MAX_CURVES = 4; +export const MAX_PARAMS = 8; +export const MAX_EXPR_CHARS = 300; +export const MAX_NODES = 60; +export const MAX_EDGES = 120; +export const MAX_ARTIFACTS_PER_TURN = 4; +export const MAX_ARTIFACT_BYTES_PER_TURN = 64 * 1024; + +const FUNCS = new Set([ + "sin", "cos", "tan", "asin", "acos", "atan", "sinh", "cosh", "tanh", + "sqrt", "abs", "exp", "ln", "log", "floor", "ceil", "round", "sign", + "min", "max", "pow", +]); +const CONSTS = new Set(["pi", "e"]); + +export const paramSchema = z + .object({ + name: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]{0,15}$/).describe("参数名(单个字母或短标识符,如 a / b / k)"), + label: z.string().min(1).max(40).optional().describe("滑块显示名,如「振幅」"), + min: z.number().finite().describe("最小值"), + max: z.number().finite().describe("最大值"), + step: z.number().positive().describe("步长"), + value: z.number().finite().describe("初始值,必须在 [min, max] 内"), + }) + .refine((p) => p.max > p.min, { message: "max 必须大于 min" }) + .refine((p) => p.value >= p.min && p.value <= p.max, { message: "value 必须在 [min, max] 内" }); + +export const curveSchema = z.object({ + expr: z.string().min(1).max(MAX_EXPR_CHARS).describe("以 x 为自变量的表达式,可含参数名,如 a*sin(b*x + c) + d"), + label: z.string().max(60).optional().describe("图例名,如 f(x)"), +}); + +export const plotArgsSchema = z.object({ + title: z.string().min(1).max(120).describe("图标题,如 y = a·sin(bx+c) + d"), + subtitle: z.string().max(160).optional().describe("一句话说明怎么交互"), + xMin: z.number().finite().describe("x 轴下界"), + xMax: z.number().finite().describe("x 轴上界"), + curves: z.array(curveSchema).min(1).max(MAX_CURVES).describe("曲线列表;主曲线放第一条"), + params: z.array(paramSchema).max(MAX_PARAMS).optional().describe("可拖动参数(没有参数就省略)"), +}); + +const shapeSchema = z.enum(["rect", "round", "diamond", "ellipse"]); +const toneSchema = z.enum(["default", "accent", "success", "warning"]); + +export const nodeSchema = z.object({ + id: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/).describe("唯一 id(英文/数字/-_),供连线引用"), + label: z.string().min(1).max(80).describe("节点文字;换行用 \\n"), + x: z.number().finite().min(-5000).max(5000).describe("左上角 x(画布坐标)"), + y: z.number().finite().min(-5000).max(5000).describe("左上角 y(画布坐标)"), + w: z.number().min(40).max(400).optional().describe("宽度,默认 156"), + h: z.number().min(24).max(200).optional().describe("高度,默认 56"), + shape: shapeSchema.optional().describe("形状:rect / round / diamond / ellipse"), + tone: toneSchema.optional().describe("配色:default / accent(强调)/ success / warning"), +}); + +export const edgeSchema = z.object({ + from: z.string().min(1).max(32).describe("起点节点 id"), + to: z.string().min(1).max(32).describe("终点节点 id"), + label: z.string().max(40).optional().describe("连线上的文字"), + dashed: z.boolean().optional().describe("虚线(异步/可选路径)"), +}); + +export const diagramArgsSchema = z.object({ + title: z.string().min(1).max(120).describe("图标题,如「检索链路」"), + nodes: z.array(nodeSchema).min(1).max(MAX_NODES).describe("节点列表,坐标自己排版:左→右或上→下,间距 ≥ 120"), + edges: z.array(edgeSchema).max(MAX_EDGES).describe("连线列表,务必引用已存在的节点 id"), +}); + +export type PlotArgs = z.infer; +export type DiagramArgs = z.infer; + +export type PlotArtifactData = PlotArgs & { version: number; id: string }; +export type DiagramArtifactData = DiagramArgs & { version: number; id: string }; + +/** Reject identifiers that are neither x, a declared param, a whitelisted function nor a constant. */ +export function validateExpression(expr: string, params: readonly string[]): string | null { + if (!/^[0-9A-Za-z_+\-*/^().,\s]+$/.test(expr)) { + return "表达式只能包含数字、变量、括号和 + - * / ^ 与函数调用"; + } + const allowed = new Set(["x", ...params.map((p) => p.toLowerCase()), ...FUNCS, ...CONSTS]); + const identifiers = expr.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []; + for (const raw of identifiers) { + if (!allowed.has(raw.toLowerCase())) { + return `表达式里有未知变量/函数 "${raw}"(可用:x、${params.join(", ") || "无参数"}、sin/cos/tan/sqrt/abs/log/ln/exp 等)`; + } + } + return null; +} + +function oversized(data: unknown): string | null { + const size = Buffer.byteLength(JSON.stringify(data), "utf8"); + return size > MAX_SPEC_BYTES ? `spec 体积 ${size}B 超过上限 ${MAX_SPEC_BYTES}B,请精简节点/标签` : null; +} + +export function buildPlotArtifact(args: PlotArgs): { ok: true; artifact: Artifact } | { ok: false; error: string } { + if (args.xMax <= args.xMin) return { ok: false, error: "xMax 必须大于 xMin" }; + const paramNames = (args.params ?? []).map((p) => p.name); + if (new Set(paramNames).size !== paramNames.length) return { ok: false, error: "参数名不能重复" }; + for (const curve of args.curves) { + const error = validateExpression(curve.expr, paramNames); + if (error) return { ok: false, error }; + } + const data: PlotArtifactData = { ...args, version: SPEC_VERSION, id: `plot_${randomUUID().slice(0, 8)}` }; + const sizeError = oversized(data); + if (sizeError) return { ok: false, error: sizeError }; + return { ok: true, artifact: { kind: "plot", data } }; +} + +export function buildDiagramArtifact(args: DiagramArgs): { ok: true; artifact: Artifact } | { ok: false; error: string } { + const ids = args.nodes.map((n) => n.id); + if (new Set(ids).size !== ids.length) return { ok: false, error: "节点 id 不能重复" }; + const known = new Set(ids); + for (const edge of args.edges) { + if (!known.has(edge.from) || !known.has(edge.to)) { + return { ok: false, error: `连线 ${edge.from} -> ${edge.to} 引用了不存在的节点 id` }; + } + if (edge.from === edge.to) return { ok: false, error: "连线不能指向自身" }; + } + const data: DiagramArtifactData = { ...args, version: SPEC_VERSION, id: `diagram_${randomUUID().slice(0, 8)}` }; + const sizeError = oversized(data); + if (sizeError) return { ok: false, error: sizeError }; + return { ok: true, artifact: { kind: "diagram", data } }; +} + +/** Keep a turn's persisted artifacts bounded (per-turn byte/count caps). */ +export function capArtifacts(artifacts: Artifact[]): Artifact[] { + const kept: Artifact[] = []; + let bytes = 0; + for (const artifact of artifacts) { + if (kept.length >= MAX_ARTIFACTS_PER_TURN) break; + let size = 0; + try { + size = Buffer.byteLength(JSON.stringify(artifact), "utf8"); + } catch { + continue; + } + if (bytes + size > MAX_ARTIFACT_BYTES_PER_TURN) break; + bytes += size; + kept.push(artifact); + } + return kept; +} diff --git a/app-ts/src/auth/app-cloud-client.ts b/app-ts/src/auth/app-cloud-client.ts index 156bc03..9b97cfb 100644 --- a/app-ts/src/auth/app-cloud-client.ts +++ b/app-ts/src/auth/app-cloud-client.ts @@ -62,6 +62,27 @@ export interface CloudSimilarResponse { items: CloudSemanticHit[]; } +/** One cloud-drive document with parsed text — the KG build source (app-cloud M2M face). */ +export interface CloudKgDocumentMeta { + upload_uuid: string; + title: string; + /** app-cloud content hash; "" when the service did not report one */ + content_hash: string; + /** parsed text length in characters (0 when unknown) */ + size_chars: number; +} + +export interface CloudKgDocument extends CloudKgDocumentMeta { + /** parsed plain text; app-cloud truncates very large documents server-side */ + text: string; +} + +export interface CloudKgDocumentList { + items: CloudKgDocumentMeta[]; + /** upload_uuid of the last row when the page is full; null = no more pages */ + next_cursor: string | null; +} + export class AppCloudError extends Error { constructor( message: string, @@ -115,6 +136,19 @@ function normalizeHits(raw: unknown): CloudSemanticHit[] { return hits; } +function normalizeKgMeta(raw: unknown): CloudKgDocumentMeta | null { + if (raw === null || typeof raw !== "object") return null; + const r = raw as Record; + const uploadUuid = optionalText(r.upload_uuid); + if (uploadUuid === undefined) return null; + return { + upload_uuid: uploadUuid, + title: optionalText(r.title) ?? "", + content_hash: optionalText(r.content_hash) ?? "", + size_chars: finiteNumber(r.size_chars, 0), + }; +} + function normalizeSummary(raw: unknown): CloudDocumentSummary | null { if (raw === null || typeof raw !== "object") return null; const r = raw as Record; @@ -203,6 +237,52 @@ export class AppCloudClient extends InternalServiceClient { } } + /** + * List the user's cloud documents that have parsed text (KG build source), + * one cursor page at a time (app-cloud: upload_uuid ASC pagination). + */ + async listKgDocuments( + uid: string, + opts: { limit?: number; cursor?: string } = {}, + ): Promise { + const params = new URLSearchParams({ uid }); + if (opts.limit !== undefined) params.set("limit", String(opts.limit)); + if (opts.cursor) params.set("cursor", opts.cursor); + try { + const res = await this.request("GET", `/internal/cloud/kg/documents?${params.toString()}`, undefined, uid); + const raw = await this.json>(res, "云盘文档列表读取失败"); + const items: CloudKgDocumentMeta[] = []; + if (Array.isArray(raw.items)) { + for (const item of raw.items) { + const meta = normalizeKgMeta(item); + if (meta) items.push(meta); + } + } + return { items, next_cursor: optionalText(raw.next_cursor) ?? null }; + } catch (err) { + throw this.wrapCallFailure("云盘文档列表调用失败", err); + } + } + + /** Read one document's parsed text for `uid`; undefined on 404 (missing / not parsed). */ + async getKgDocument(uid: string, uploadUuid: string): Promise { + const params = new URLSearchParams({ uid, upload_uuid: uploadUuid }); + try { + const res = await this.request("GET", `/internal/cloud/kg/document?${params.toString()}`, undefined, uid); + const raw = await this.json>(res, "云盘文档正文读取失败"); + return { + upload_uuid: optionalText(raw.upload_uuid) ?? uploadUuid, + title: optionalText(raw.title) ?? "", + content_hash: optionalText(raw.content_hash) ?? "", + size_chars: finiteNumber(raw.size_chars, 0), + text: typeof raw.text === "string" ? raw.text : "", + }; + } catch (err) { + if (err instanceof AppCloudError && err.status === 404) return undefined; + throw this.wrapCallFailure("云盘文档正文调用失败", err); + } + } + private wrapCallFailure(label: string, err: unknown): AppCloudError { if (err instanceof AppCloudError) return err; return new AppCloudError(`${label}:${err instanceof Error ? err.message : String(err)}`); diff --git a/app-ts/src/bootstrap/container.ts b/app-ts/src/bootstrap/container.ts index e9d0497..7f9e81f 100644 --- a/app-ts/src/bootstrap/container.ts +++ b/app-ts/src/bootstrap/container.ts @@ -17,6 +17,7 @@ import { buildServer } from "../transport/http/server.js"; import { createDelegateTool } from "../tools/harness/delegate.js"; import { createAskUserTool } from "../tools/interaction/ask-user.js"; import { createPlanTool } from "../tools/harness/plan.js"; +import { createRenderDiagramTool, createRenderPlotTool } from "../tools/render/index.js"; import { createFetchWebpageTool } from "../tools/web/fetch-page.js"; import { createWebSearchTool } from "../tools/web/search.js"; import { @@ -91,6 +92,9 @@ import { MemoryKgSourceStore } from "../repository/memory/kg-sources.js"; import { MongoKgSourceStore } from "../repository/mongo/kg-sources.js"; import { MemoryKgBuildStore } from "../repository/memory/kg-builds.js"; import { MongoKgBuildStore } from "../repository/mongo/kg-builds.js"; +import { HttpKgCloudSourceStore } from "../repository/http/kg-cloud-sources.js"; +import { MemoryKgDocStore } from "../repository/memory/kg-docs.js"; +import { MongoKgDocStore } from "../repository/mongo/kg-docs.js"; import { KgEntityIndex } from "../repository/kg-entity-index.js"; import { KgService } from "../kg/service.js"; import { KgExtractor } from "../kg/extractor.js"; @@ -302,6 +306,10 @@ export async function bootstrap() { // Task plan state machine (in-process, keyed by chatSessionId; main agent only via // its toolNames list). Snapshots leave as `plan` SSE frames for the live plan panel. registry.register(createPlanTool()); + // Interactive artifacts: declarative plot/diagram specs rendered by the web + // client with trusted components (no HTML/JS from the model, no iframe). + registry.register(createRenderPlotTool()); + registry.register(createRenderDiagramTool()); const webFetch = createFetchPortWithDeepseekPrimary(new HttpWebFetch()); registry.register(createFetchWebpageTool(webFetch)); registry.register(createWebSearchTool()); @@ -370,10 +378,24 @@ export async function bootstrap() { extractModel: config.kg.extractModel, maxTextChars: config.kg.maxTextChars, }); + // Cloud-doc KG source: app-cloud M2M face when configured, else null + // (cloud builds answer 503 instead of silently building nothing). + const cloudSources = appCloud.configured ? new HttpKgCloudSourceStore(appCloud) : null; + if (!cloudSources) { + log.warn("[BOOT] app-cloud not configured — cloud-doc KG builds disabled (503)"); + } + // Cloud-doc extraction ledger: idempotency key (uid, upload_uuid) → content hash + const docStore = + (config.mongo.uri ? await MongoKgDocStore.connect() : null) ?? new MemoryKgDocStore(); + if (docStore instanceof MemoryKgDocStore) { + log.warn("[BOOT] kg doc ledger: memory fallback (cloud-doc idempotency is lost on restart)"); + } kgService = new KgService({ graph, extractor, sources: sources ?? new MemoryKgSourceStore(), + cloudSources, + docs: docStore, builds: builds ?? new MemoryKgBuildStore(), index, embeddings: rag.embeddings, diff --git a/app-ts/src/config/index.ts b/app-ts/src/config/index.ts index dd4e5fc..2974461 100644 --- a/app-ts/src/config/index.ts +++ b/app-ts/src/config/index.ts @@ -149,6 +149,10 @@ export interface AppConfig { /** ASR transcript source — the Python backend's Mongo database (cross-DB read-only by design) */ sourceDatabase: string; sourceCollection: string; + /** cloud-doc build: max documents per build (paging stops at this cap) */ + cloudMaxDocs: number; + /** cloud-doc listing page size for GET /internal/cloud/kg/documents (service caps at 100) */ + cloudListPageSize: number; }; kgGrpc: { /** false = KG capability fully disabled (app-kg not deployed) */ @@ -247,6 +251,8 @@ export interface AppConfig { turnRetries: number; /** same tool + same args rejected on the Nth identical call within one turn */ toolRepeatLimit: number; + /** whole-turn deadline (ms): a stalled LLM stream can never keep a turn alive forever */ + turnTimeoutMs: number; }; scheduling: { /** turn-level weighted admission queue for chat (services/admission.ts) */ diff --git a/app-ts/src/context/types.ts b/app-ts/src/context/types.ts index 94bf7e1..70dff11 100644 --- a/app-ts/src/context/types.ts +++ b/app-ts/src/context/types.ts @@ -140,4 +140,8 @@ export interface ToolRunContext { chatSessionId?: string; scope: SearchScope; collector: ResultCollector; + /** Turn-level cancellation (client disconnect / aborted turn). Tools with + * long-running I/O must forward it; the runtime also uses it to abort the + * tool's timeout budget and to fail fast on a cancelled turn. */ + signal?: AbortSignal; } diff --git a/app-ts/src/harness/app.ts b/app-ts/src/harness/app.ts index 1e31b79..aa9b841 100644 --- a/app-ts/src/harness/app.ts +++ b/app-ts/src/harness/app.ts @@ -33,6 +33,8 @@ export class AgentHarness { skillIds?: string[]; /** turn-level system directive appended to the agent's own prompt (e.g. board chat context) */ extraSystem?: string; + /** abort on client disconnect: cancels streamText + in-flight tools */ + signal?: AbortSignal; }): AsyncGenerator { const agentName = input.agentName ?? (await this.orchestrator.route(input.query)); yield { type: "route", agent: agentName }; @@ -44,6 +46,7 @@ export class AgentHarness { history: input.history, skillIds: input.skillIds, ...(input.extraSystem ? { extraSystem: input.extraSystem } : {}), + ...(input.signal ? { signal: input.signal } : {}), }); } diff --git a/app-ts/src/kg/extractor.ts b/app-ts/src/kg/extractor.ts index 7d8c528..8dde202 100644 --- a/app-ts/src/kg/extractor.ts +++ b/app-ts/src/kg/extractor.ts @@ -6,25 +6,26 @@ import { createLogger } from "../logger.js"; import type { ExtractionResult } from "./types.js"; /** - * KG extractor — LLM structured extraction from one page's ASR transcript, - * port of app/services/kg/extractor.py. Single-shot generateObject (not the - * ReAct tool-calling pattern: there is no loop here, one call per page). + * KG extractor — LLM structured extraction from one source text (video ASR + * transcript or cloud-drive document body), port of + * app/services/kg/extractor.py. Single-shot generateObject (not the ReAct + * tool-calling pattern: there is no loop here, one call per page/document). * - * Anti-hallucination contract: every relation must quote the transcript — + * Anti-hallucination contract: every relation must quote the source text — * enforcement lives in the resolver; this layer only shapes the schema and - * the prompt. LLM failures throw so the caller can mark the page failed - * (retryable) — never silently swallow. + * the prompt. LLM failures throw so the caller can mark the page/document + * failed (retryable) — never silently swallow. */ const log = createLogger("KG_EXTRACTOR"); export const extractedEntitySchema = z.object({ - name: z.string().describe("实体名称,尽量使用视频中出现的原文叫法"), + name: z.string().describe("实体名称,尽量使用原文中出现的叫法"), type: z .string() .default("other") .describe("实体类型: person/org/concept/tech/tool/book/event/method/place/other"), - description: z.string().default("").describe("一句话描述该实体在本视频中的含义或角色"), + description: z.string().default("").describe("一句话描述该实体在本文中的含义或角色"), quote: z.string().default("").describe("原文中明确提到该实体的片段摘抄(20字以内),找不到就留空"), }); @@ -40,6 +41,11 @@ export const extractionResultSchema = z.object({ relations: z.array(extractedRelationSchema).default([]), }); +/** Knowledge origin: video ASR transcript vs cloud-drive document body. The + * extractor only differs in the user-prompt labels — the schema and the + * anti-hallucination contract are source-independent. */ +export type KgExtractKind = "video" | "document"; + export interface ExtractorOptions { /** injectable for tests; default = gateway model with purpose="kg" */ model?: LanguageModel; @@ -55,21 +61,24 @@ export class KgExtractor { this.model = opts.model; } - /** Extract entities + relations from one page. Text is truncated to - * maxTextChars (cost guard); throws on LLM failure (caller marks failed). */ - async extract(text: string, title: string): Promise { + /** Extract entities + relations from one source text (video transcript or + * cloud-document body). Text is truncated to maxTextChars (cost guard); + * throws on LLM failure (caller marks the page/document failed). */ + async extract(text: string, title: string, kind: KgExtractKind = "video"): Promise { const maxChars = this.opts.maxTextChars; const truncated = text.slice(0, maxChars); if (text.length > maxChars) { log.info(`[KG_EXTRACTOR] text truncated ${text.length} -> ${maxChars} chars`); } - const userPrompt = `视频标题:${title || "(未知)"}\n\n文字稿:\n${truncated}`; + const label = kind === "document" + ? `文档标题:${title || "(未知)"}\n\n文档正文:\n${truncated}` + : `视频标题:${title || "(未知)"}\n\n文字稿:\n${truncated}`; const { object } = await generateObject({ model: this.model ?? createGatewayModel("kg", { model: this.opts.extractModel, temperature: 0 }), schema: extractionResultSchema, system: this.opts.systemPrompt, - prompt: userPrompt, + prompt: label, temperature: 0, maxRetries: 2, }); diff --git a/app-ts/src/kg/retriever.ts b/app-ts/src/kg/retriever.ts index 43759cf..428971a 100644 --- a/app-ts/src/kg/retriever.ts +++ b/app-ts/src/kg/retriever.ts @@ -4,7 +4,7 @@ import type { Source } from "../context/types.js"; import type { KgEntityIndex, EntityHit } from "../repository/kg-entity-index.js"; import type { EmbeddingsClient } from "../llm/embeddings.js"; import type { KgGraphRepository } from "../repository/kg-graph.js"; -import type { EvidenceRow, RelationRow } from "./types.js"; +import type { CloudDocEvidenceRow, EvidenceRow, RelationRow } from "./types.js"; const log = createLogger("KG_RETRIEVER"); @@ -12,10 +12,13 @@ const log = createLogger("KG_RETRIEVER"); * KgRetriever — query-time KG retrieval, port of app/services/kg/retriever.py: * query → entity linking (Milvus, seedTopN, score ≥ linkScoreThreshold) → * BFS subgraph expansion over RELATES edges (≤ maxHops) → APPEARS_IN evidence - * pull (user bvid scope) → entity-card content + vector_search-isomorphic - * sources for the SSE protocol. + * pull (video bvid scope + cloud-doc scope) → entity-card content + + * vector_search-isomorphic sources for the SSE protocol. * - * All dependency failures degrade to an empty result — the tool never throws. + * Cloud-doc evidence is fetched only when the caller supplies a uid (tenant + * isolation); a graph store that predates the cloud RPC degrades to + * video-only results. All dependency failures degrade to an empty result — + * the tool never throws. */ export interface KgRetrievalResult { @@ -25,6 +28,16 @@ export interface KgRetrievalResult { evidenceCount: number; } +/** Retrieval scope: videos (bvids) and/or cloud-drive documents (uid required). */ +export interface KgRetrievalScope { + /** bvid scope resolved from favorites; empty = unrestricted */ + bvids?: string[]; + /** tenant identity for cloud-doc evidence; absent = video evidence only */ + uid?: string; + /** cloud-doc scope; empty = all of the user's cloud documents */ + uploadUuids?: string[]; +} + export interface KgRetrieverDeps { graph: KgGraphRepository; /** null when Milvus is unavailable — semantic linking degrades to "no seeds" */ @@ -42,7 +55,7 @@ export class KgRetriever { return this.deps.index !== null && this.deps.index.available; } - async retrieve(query: string, bvids: string[] | undefined, k = 8): Promise { + async retrieve(query: string, scope: KgRetrievalScope | undefined, k = 8): Promise { const { graph, index, embeddings } = this.deps; if (!index || !index.available) return EMPTY; @@ -63,6 +76,9 @@ export class KgRetriever { const seedScores = new Map(seeds.map((s) => [s.eid, s.score])); const seedEids = seeds.map((s) => s.eid); + const bvids = scope?.bvids ?? []; + const uid = scope?.uid; + const uploadUuids = scope?.uploadUuids ?? []; try { // ── 2. BFS subgraph expansion ── @@ -78,21 +94,33 @@ export class KgRetriever { const allEids = [...visited]; // ── 3. evidence + relations + entity details ── - const evidence = await graph.fetchEvidence(allEids, bvids ?? [], config.kg.evidenceLimit); - const relations = await graph.relationsAmong(allEids); - const entities = await graph.entitiesByEids(allEids); - if (evidence.length === 0 && relations.length === 0) return EMPTY; + // Cloud evidence is best-effort: an old app-kg (or a transient gRPC + // failure) must not take the video results down with it. + const cloudEvidencePromise: Promise = uid + ? graph.fetchCloudDocEvidence(allEids, uid, uploadUuids, config.kg.evidenceLimit).catch((err) => { + log.warn(`[KG_RETRIEVER] cloud evidence unavailable: ${err instanceof Error ? err.message : err}`); + return [] as CloudDocEvidenceRow[]; + }) + : Promise.resolve([] as CloudDocEvidenceRow[]); + const [evidence, cloudEvidence, relations, entities] = await Promise.all([ + graph.fetchEvidence(allEids, bvids, config.kg.evidenceLimit), + cloudEvidencePromise, + graph.relationsAmong(allEids), + graph.entitiesByEids(allEids), + ]); + if (evidence.length === 0 && cloudEvidence.length === 0 && relations.length === 0) return EMPTY; const content = formatContent({ entities, relations, evidence, + cloudEvidence, seedNames: seeds.map((s) => s.name), limit: k, }); - const sources = buildSources(evidence, seedScores); + const sources = [...buildSources(evidence, seedScores), ...buildCloudSources(cloudEvidence)]; log.info( - `[KG_RETRIEVER] done query='${query.slice(0, 50)}' entities=${entities.length} evidence=${evidence.length} sources=${sources.length}`, + `[KG_RETRIEVER] done query='${query.slice(0, 50)}' entities=${entities.length} evidence=${evidence.length} cloudEvidence=${cloudEvidence.length} sources=${sources.length}`, ); return { content, sources, entityCount: entities.length, evidenceCount: evidence.length }; } catch (err) { @@ -103,11 +131,13 @@ export class KgRetriever { } /** Entity-card text the LLM reads (Python _format_content parity: entities - * with evidence first, ≤5 relations and ≤5 quotes each). */ + * with evidence first, ≤5 relations and ≤5 quotes each; cloud-doc evidence + * lines are tagged with the 云盘 · title origin). */ function formatContent(args: { entities: Array<{ eid: string; name: string; type: string; description: string }>; relations: RelationRow[]; evidence: EvidenceRow[]; + cloudEvidence: CloudDocEvidenceRow[]; seedNames: string[]; limit: number; }): string { @@ -119,9 +149,14 @@ function formatContent(args: { for (const ev of args.evidence) { evByEid.set(ev.eid, [...(evByEid.get(ev.eid) ?? []), ev]); } + const cloudByEid = new Map(); + for (const ev of args.cloudEvidence) { + cloudByEid.set(ev.eid, [...(cloudByEid.get(ev.eid) ?? []), ev]); + } - const withEvidence = args.entities.filter((e) => evByEid.has(e.eid)); - const withoutEvidence = args.entities.filter((e) => !evByEid.has(e.eid)); + const hasEvidence = (eid: string): boolean => evByEid.has(eid) || cloudByEid.has(eid); + const withEvidence = args.entities.filter((e) => hasEvidence(e.eid)); + const withoutEvidence = args.entities.filter((e) => !hasEvidence(e.eid)); const ordered = [...withEvidence, ...withoutEvidence]; const parts: string[] = [`知识图谱检索结果(核心实体:${args.seedNames.slice(0, 3).join("、")})`]; @@ -142,12 +177,17 @@ function formatContent(args: { const quote = (ev.quote ?? "").trim(); lines.push(quote ? `出处:【${title}】"${quote}"` : `出处:【${title}】`); } + for (const ev of (cloudByEid.get(ent.eid) ?? []).slice(0, 5)) { + const title = ev.title || ev.uploadUuid; + const quote = (ev.quote ?? "").trim(); + lines.push(quote ? `出处:【云盘 · ${title}】"${quote}"` : `出处:【云盘 · ${title}】`); + } parts.push(lines.join("\n")); shown += 1; } - if (args.evidence.length === 0) { - parts.push("\n(注意:以下实体在你的当前范围内暂无视频出处证据)"); + if (args.evidence.length === 0 && args.cloudEvidence.length === 0) { + parts.push("\n(注意:以下实体在你的当前范围内暂无出处证据)"); } return parts.join("\n\n"); } @@ -169,3 +209,15 @@ function buildSources(evidence: EvidenceRow[], seedScores: Map): } return sources; } + +/** Cloud-doc sources deduped by uploadUuid (frontend source panel contract). */ +function buildCloudSources(rows: CloudDocEvidenceRow[]): Source[] { + const seen = new Set(); + const sources: Source[] = []; + for (const row of rows) { + if (!row.uploadUuid || seen.has(row.uploadUuid)) continue; + seen.add(row.uploadUuid); + sources.push({ uploadUuid: row.uploadUuid, title: row.title || row.uploadUuid }); + } + return sources; +} diff --git a/app-ts/src/kg/service.ts b/app-ts/src/kg/service.ts index 8dbd37e..2f3d50c 100644 --- a/app-ts/src/kg/service.ts +++ b/app-ts/src/kg/service.ts @@ -4,6 +4,8 @@ import { createLogger } from "../logger.js"; import type { KgBuildRecord, KgBuildResult } from "./types.js"; import type { KgGraphRepository } from "../repository/kg-graph.js"; import type { KgSourceStore } from "../repository/kg-sources.js"; +import type { CloudDocContent, KgCloudSourceStore } from "../repository/kg-cloud-sources.js"; +import type { KgDocRepository } from "../repository/kg-docs.js"; import type { KgBuildRepository } from "../repository/kg-builds.js"; import type { KgExtractor } from "./extractor.js"; import { resolveExtraction } from "./resolver.js"; @@ -54,6 +56,10 @@ export interface KgServiceDeps { graph: KgGraphRepository; extractor: KgExtractor; sources: KgSourceStore; + /** null = app-cloud not configured → cloud builds answer 503 */ + cloudSources: KgCloudSourceStore | null; + /** per-cloud-document extraction ledger (idempotency by content hash) */ + docs: KgDocRepository; builds: KgBuildRepository; /** entity index may be absent (Milvus down) — vectors become best-effort */ index: KgEntityIndex | null; @@ -70,6 +76,27 @@ export class KgServiceError extends Error { } } +/** Knowledge origin for a build. */ +export type KgBuildSource = "bilibili" | "cloud"; + +export interface BuildStartOptions { + /** knowledge origin; default bilibili (keeps the pre-cloud API contract) */ + source?: KgBuildSource; + /** bilibili scope: favorite folders (empty = all non-deleted folders) */ + folderIds?: number[]; + /** cloud scope: specific documents (empty/absent = the whole drive) */ + uploadUuids?: string[]; +} + +/** One cloud document selected for a build: listing metadata, or the already + * fetched content on the explicit upload_uuids path (avoids a second fetch). */ +export interface CloudDocBuildTarget { + uploadUuid: string; + title?: string; + contentHash?: string; + preloaded?: CloudDocContent; +} + export interface BuildStartResult { taskId: string; reused: boolean; @@ -91,9 +118,12 @@ export class KgService { } } - /** Trigger a build for the user's folders. Reuses the active task when one - * is still running. Throws KgServiceError(503/404) on unavailable/empty scope. */ - async startBuild(uid: string, folderIds: number[] | undefined): Promise { + /** Trigger a build for the user's favorite folders (source=bilibili, the + * default) or cloud-drive documents (source=cloud). Reuses the active task + * when one is still running. Throws KgServiceError(503/404) on + * unavailable/empty scope. */ + async startBuild(uid: string, opts: BuildStartOptions = {}): Promise { + const source = opts.source ?? "bilibili"; if (!(await this.isAvailable())) { throw new KgServiceError(503, "知识图谱存储不可用(Neo4j 未连接)"); } @@ -101,6 +131,14 @@ export class KgService { if (active) { return { taskId: active.taskId, reused: true }; } + if (source === "cloud") { + return this.startCloudBuild(uid, opts.uploadUuids ?? []); + } + return this.startVideoBuild(uid, opts.folderIds); + } + + /** Video path: resolve favorite scope → record processing → background run. */ + private async startVideoBuild(uid: string, folderIds: number[] | undefined): Promise { const bvids = await this.deps.graph.resolveScope(uid, folderIds ?? []); if (bvids.length === 0) { throw new KgServiceError(404, "未找到已同步的收藏夹视频,请先执行收藏夹同步"); @@ -110,6 +148,7 @@ export class KgService { const record: KgBuildRecord = { taskId: randomUUID(), uid, + source: "bilibili", folderIds: folderIds ?? [], bvidCount: bvids.length, status: "processing", @@ -120,11 +159,80 @@ export class KgService { updatedAt: now, }; await this.deps.builds.save(record); - const completion = this.run(record, bvids); + const completion = this.runVideo(record, bvids); log.info(`[KG] build started task_id=${record.taskId} uid=${uid} bvids=${bvids.length}`); return { taskId: record.taskId, reused: false, completion }; } + /** Cloud path: enumerate parsed documents → record processing → background run. */ + private async startCloudBuild(uid: string, uploadUuids: string[]): Promise { + const targets = await this.collectCloudDocs(uid, uploadUuids); + if (targets.length === 0) { + throw new KgServiceError(404, "云盘中未找到可解析正文的文档,请先上传并等待解析完成"); + } + + const now = Date.now(); + const record: KgBuildRecord = { + taskId: randomUUID(), + uid, + source: "cloud", + folderIds: [], + bvidCount: 0, + docCount: targets.length, + status: "processing", + progress: 0, + currentStep: "init", + steps: [], + createdAt: now, + updatedAt: now, + }; + await this.deps.builds.save(record); + const completion = this.runCloud(record, targets); + log.info(`[KG] cloud build started task_id=${record.taskId} uid=${uid} docs=${targets.length}`); + return { taskId: record.taskId, reused: false, completion }; + } + + /** Resolve the cloud-doc target list: explicit upload_uuids are read one by + * one (404 → failed item), otherwise page the whole drive up to cloudMaxDocs. */ + private async collectCloudDocs(uid: string, uploadUuids: string[]): Promise { + const cloud = this.deps.cloudSources; + if (!cloud) { + throw new KgServiceError(503, "云盘文档服务未配置,暂时无法构建云盘知识点"); + } + if (uploadUuids.length > 0) { + const targets: CloudDocBuildTarget[] = []; + const seen = new Set(); + for (const uploadUuid of uploadUuids) { + if (!uploadUuid || seen.has(uploadUuid)) continue; + seen.add(uploadUuid); + try { + const doc = await cloud.getDocument(uid, uploadUuid); + targets.push(doc ? { uploadUuid, preloaded: doc } : { uploadUuid }); + } catch (err) { + log.warn(`[KG] cloud doc lookup failed upload_uuid=${uploadUuid}: ${err instanceof Error ? err.message : err}`); + targets.push({ uploadUuid }); + } + } + return targets; + } + + const pageSize = Math.max(Math.floor(config.kg.cloudListPageSize), 1); + const maxDocs = Math.max(Math.floor(config.kg.cloudMaxDocs), 1); + const targets: CloudDocBuildTarget[] = []; + let cursor: string | undefined; + while (targets.length < maxDocs) { + const limit = Math.min(pageSize, maxDocs - targets.length); + const page = await cloud.listDocuments(uid, { limit, cursor }); + for (const item of page.items) { + if (!item.uploadUuid) continue; + targets.push({ uploadUuid: item.uploadUuid, title: item.title, contentHash: item.contentHash }); + } + if (!page.nextCursor || page.items.length === 0) break; + cursor = page.nextCursor; + } + return targets.slice(0, maxDocs); + } + get(taskId: string, uid: string): Promise { return this.deps.builds.get(taskId, uid); } @@ -133,9 +241,9 @@ export class KgService { return this.deps.builds.findActive(uid); } - /** Background run: per-page concurrency + failure isolation + progress. + /** Background video run: per-page concurrency + failure isolation + progress. * Never throws — the completion promise exists so tests can await it. */ - private async run(record: KgBuildRecord, bvids: string[]): Promise { + private async runVideo(record: KgBuildRecord, bvids: string[]): Promise { const save = async (patch: Partial): Promise => { await this.deps.builds .save({ ...record, ...patch, updatedAt: Date.now() }) @@ -192,6 +300,191 @@ export class KgService { } } + /** Background cloud run: per-document concurrency + failure isolation. + * Mirrors runVideo, but the per-item step is processCloudDoc (app-kg owns + * the delete-before-write inside LinkCloudDocAppearances). Never throws. */ + private async runCloud(record: KgBuildRecord, targets: CloudDocBuildTarget[]): Promise { + const save = async (patch: Partial): Promise => { + await this.deps.builds + .save({ ...record, ...patch, updatedAt: Date.now() }) + .catch((err) => log.error(`[KG] failed to persist build state: ${err instanceof Error ? err.message : err}`)); + }; + try { + const total = targets.length; + let ok = 0; + let failed = 0; + let skipped = 0; + const sem = new Semaphore(Math.max(config.kg.concurrency, 1)); + + await Promise.all( + targets.map((target) => + sem.acquire(async () => { + const stepName = `extract:cloud:${target.uploadUuid}`; + try { + const outcome = await this.processCloudDoc(record.uid, target); + if (outcome.skipped) skipped += 1; + ok += 1; + } catch (err) { + failed += 1; + log.error( + `[KG] cloud doc failed upload_uuid=${target.uploadUuid}: ${err instanceof Error ? err.message : err}`, + ); + } finally { + const done = ok + failed; + const progress = Math.floor((done * 100) / total); + record.progress = progress; + record.currentStep = stepName; + record.steps = [...record.steps, { name: stepName, status: "done", progress, at: Date.now() }]; + await save({ progress, currentStep: stepName, steps: record.steps }); + } + }), + ), + ); + + record.status = "done"; + const result: KgBuildResult = { + total, + ok, + failed, + ...(skipped > 0 ? { message: `跳过未变更文档 ${skipped} 篇` } : {}), + }; + record.result = result; + await save({ status: "done", result }); + log.info(`[KG] cloud build done task_id=${record.taskId} ok=${ok} failed=${failed} skipped=${skipped}`); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + log.error(`[KG] cloud build failed task_id=${record.taskId}: ${error}`); + record.status = "failed"; + record.error = error; + await save({ status: "failed", error }); + } + } + + /** Per-document idempotent pipeline: skip unchanged (content hash) → read + * text → extract → entities → link evidence (app-kg delete-before-write) → + * relations → entity vectors (best-effort) → done. Throws on failure after + * recording `failed`; the batch keeps going. */ + async processCloudDoc( + uid: string, + target: CloudDocBuildTarget, + ): Promise<{ entities: number; relations: number; droppedRelations: number; skipped: boolean }> { + const skippedResult = { entities: 0, relations: 0, droppedRelations: 0, skipped: true }; + // Listing path: the content hash is already known → skip before fetching text. + if (target.contentHash && (await this.isCloudDocFresh(uid, target.uploadUuid, target.contentHash))) { + return skippedResult; + } + + const cloud = this.deps.cloudSources; + if (!cloud) throw new Error("云盘文档服务未配置"); + const doc = target.preloaded ?? (await cloud.getDocument(uid, target.uploadUuid)); + const text = doc?.text ?? ""; + const title = doc?.title || target.title || target.uploadUuid; + const contentHash = doc?.contentHash || target.contentHash || ""; + if (contentHash && (await this.isCloudDocFresh(uid, target.uploadUuid, contentHash))) { + return skippedResult; + } + if (!text) { + // No parsed body → failed (retryable: only done+same-hash records skip). + const error = `云盘文档无可用正文: upload_uuid=${target.uploadUuid}`; + await this.deps.docs + .save({ uid, uploadUuid: target.uploadUuid, title, contentHash, status: "failed", error, updatedAt: Date.now() }) + .catch((saveErr) => + log.error(`[KG] failed to persist cloud doc status: ${saveErr instanceof Error ? saveErr.message : saveErr}`), + ); + throw new Error(error); + } + + const now = Date.now(); + await this.deps.docs.save({ + uid, + uploadUuid: target.uploadUuid, + title, + contentHash, + status: "processing", + updatedAt: now, + }); + + try { + const raw = await this.deps.extractor.extract(text, title, "document"); + const resolved = resolveExtraction(raw); + if (resolved.entities.length === 0) { + log.info(`[KG] no entities extracted upload_uuid=${target.uploadUuid}`); + } + + if (resolved.entities.length > 0) { + await this.deps.graph.upsertEntities(resolved.entities); + } + // Always link (even with an empty list): app-kg deletes this document's + // stale evidence first, so a re-extraction that found nothing clears it. + await this.deps.graph.linkCloudDocAppearances({ + uid, + uploadUuid: target.uploadUuid, + title, + contentHash, + appearances: resolved.entities.map((ent) => ({ eid: ent.eid, quote: ent.quote })), + }); + if (resolved.relations.length > 0) { + await this.deps.graph.upsertRelations(resolved.relations); + } + + if (resolved.entities.length > 0 && this.deps.index && this.deps.index.available) { + try { + await this.deps.index.upsert(resolved.entities, this.deps.embeddings); + } catch (err) { + log.warn(`[KG] entity index upsert failed: ${err instanceof Error ? err.message : err}`); + } + } + + await this.deps.docs.save({ + uid, + uploadUuid: target.uploadUuid, + title, + contentHash, + status: "done", + entityCount: resolved.entities.length, + relationCount: resolved.relations.length, + updatedAt: Date.now(), + }); + + log.info( + `[KG] cloud doc done upload_uuid=${target.uploadUuid} entities=${resolved.entities.length} relations=${resolved.relations.length}`, + ); + return { + entities: resolved.entities.length, + relations: resolved.relations.length, + droppedRelations: resolved.droppedRelations, + skipped: false, + }; + } catch (err) { + await this.deps.docs + .save({ + uid, + uploadUuid: target.uploadUuid, + title, + contentHash, + status: "failed", + error: err instanceof Error ? err.message : String(err), + updatedAt: Date.now(), + }) + .catch((saveErr) => + log.error(`[KG] failed to persist cloud doc status: ${saveErr instanceof Error ? saveErr.message : saveErr}`), + ); + throw err; + } + } + + /** done + same content hash → nothing to redo (no LLM call, no graph write). */ + private async isCloudDocFresh(uid: string, uploadUuid: string, contentHash: string): Promise { + if (!contentHash) return false; + try { + const existing = await this.deps.docs.get(uid, uploadUuid); + return existing?.status === "done" && existing.contentHash === contentHash; + } catch (err) { + log.warn(`[KG] cloud doc ledger lookup failed upload_uuid=${uploadUuid}: ${err instanceof Error ? err.message : err}`); + return false; + } + } + /** Per-page idempotent pipeline (process_page Phase 1–7). Throws on * failure after marking the page failed — the batch keeps going. */ async processPage( @@ -271,16 +564,32 @@ export class KgService { await this.deps.graph.deleteVideo(bvid); } - /** Graph stats: Neo4j counts + entity-vector count + pending pages. */ + /** Graph stats: Neo4j counts (video + cloud-doc knowledge points) + + * entity-vector count + pending pages. */ async stats(): Promise<{ available: boolean; - graph: { entities: number; relations: number; evidence: number; videos: number }; + graph: { + entities: number; + relations: number; + evidence: number; + videos: number; + cloudDocs: number; + cloudEvidence: number; + }; entityVectors: number; pendingPages: number; }> { + const emptyGraph = { + entities: 0, + relations: 0, + evidence: 0, + videos: 0, + cloudDocs: 0, + cloudEvidence: 0, + }; const empty = { available: false, - graph: { entities: 0, relations: 0, evidence: 0, videos: 0 }, + graph: emptyGraph, entityVectors: 0, pendingPages: 0, }; @@ -293,7 +602,14 @@ export class KgService { } return { available: true, - graph: { entities: graph.entities, relations: graph.relations, evidence: graph.evidence, videos: graph.videos }, + graph: { + entities: graph.entities, + relations: graph.relations, + evidence: graph.evidence, + videos: graph.videos, + cloudDocs: graph.cloudDocs, + cloudEvidence: graph.cloudEvidence, + }, entityVectors, pendingPages: graph.pendingPages, }; diff --git a/app-ts/src/kg/types.ts b/app-ts/src/kg/types.ts index cff2e14..8e4ab0e 100644 --- a/app-ts/src/kg/types.ts +++ b/app-ts/src/kg/types.ts @@ -145,12 +145,26 @@ export interface EdgeRow { relType: string; } +/** One cloud-drive document evidence row (app-kg FetchCloudDocEvidence). */ +export interface CloudDocEvidenceRow { + eid: string; + name: string; + type: string; + uploadUuid: string; + quote: string; + /** CloudDoc.title from the graph node; may be empty for legacy nodes */ + title: string; +} + export interface GraphStats { entities: number; relations: number; evidence: number; videos: number; pendingPages: number; + /** cloud-drive knowledge points: CloudDoc nodes / APPEARS_IN evidence edges */ + cloudDocs: number; + cloudEvidence: number; } // ── build task record (Mongo mind_base_ts.kg_builds) ───────────────── @@ -172,8 +186,12 @@ export interface KgBuildResult { export interface KgBuildRecord { taskId: string; uid: string; + /** knowledge origin: bilibili favorite videos (default) or cloud-drive documents */ + source: "bilibili" | "cloud"; folderIds: number[]; bvidCount: number; + /** cloud source only: documents selected for this build (undefined for video builds) */ + docCount?: number; status: "processing" | "done" | "failed"; progress: number; currentStep: string; @@ -183,3 +201,25 @@ export interface KgBuildRecord { createdAt: number; updatedAt: number; } + +// ── cloud-drive document bookkeeping (Mongo mind_base_ts.kg_docs) ──── + +export type KgDocStatus = "processing" | "done" | "failed"; + +/** + * Per-cloud-document extraction ledger. Idempotency key = (uid, uploadUuid); + * a `done` record whose contentHash matches the current document hash is + * skipped by the build pipeline (no LLM call, no graph rewrite). + */ +export interface KgDocRecord { + uid: string; + uploadUuid: string; + title: string; + /** app-cloud content hash; empty = the source did not report one (never skipped) */ + contentHash: string; + status: KgDocStatus; + entityCount?: number; + relationCount?: number; + error?: string; + updatedAt: number; +} diff --git a/app-ts/src/repository/grpc/kg-graph.ts b/app-ts/src/repository/grpc/kg-graph.ts index 2ba94e6..75c5306 100644 --- a/app-ts/src/repository/grpc/kg-graph.ts +++ b/app-ts/src/repository/grpc/kg-graph.ts @@ -3,6 +3,7 @@ import { GrpcClient } from "../../transport/grpc.js"; import type { AppearanceRow, BeginPageResult, + CloudDocEvidenceRow, EdgeRow, EntityDetail, EvidenceRow, @@ -15,13 +16,24 @@ import type { ResolvedRelation, TopEntityRow, } from "../../kg/types.js"; -import type { AppearanceInput, DeleteVideoStats, KgGraphRepository } from "../kg-graph.js"; +import type { + AppearanceInput, + CloudDocAppearanceInput, + DeleteCloudDocStats, + DeleteVideoStats, + KgGraphRepository, +} from "../kg-graph.js"; /** * gRPC implementation of KgGraphRepository against the app-kg Go service. * Transport (proto loading / channel / deadline / error normalization) lives - * in src/grpc/client.ts; this file is ONLY message↔domain mapping — the + * in src/transport/grpc.ts; this file is ONLY message↔domain mapping — the * repository layer owns "what the data is", never "how the bytes travel". + * + * IMPORTANT: transport/grpc.ts loads the proto with keepCase:false, so the + * runtime message keys are camelCase (uploadUuid / cloudDocs / pageIndex). + * Snake_case keys are silently dropped by the protobuf serializer — always + * map to the camelCase names below. */ /** Service FQN inside proto/kg/v1/kg.proto. */ @@ -48,6 +60,8 @@ function strArr(value: unknown): string[] { return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; } +/** int64 fields are strings inside app-ts (precision-safe); protobuf accepts + * a decimal string for int64, so uids travel as-is. */ export class GrpcKgGraphRepository implements KgGraphRepository { private constructor(private readonly rpc: GrpcClient) {} @@ -70,7 +84,7 @@ export class GrpcKgGraphRepository implements KgGraphRepository { // ── MySQL bookkeeping ──────────────────────────────────────────────── async resolveScope(uid: string, folderIds: number[]): Promise { - const res = await this.rpc.call("ResolveScope", { uid, folder_ids: folderIds }); + const res = await this.rpc.call("ResolveScope", { uid, folderIds }); return strArr(res.bvids); } @@ -80,8 +94,8 @@ export class GrpcKgGraphRepository implements KgGraphRepository { return arr(res.pages).map((p) => ({ bvid: str(p.bvid), cid: num(p.cid), - pageIndex: num(p.page_index), - pageTitle: str(p.page_title), + pageIndex: num(p.pageIndex), + pageTitle: str(p.pageTitle), version: num(p.version), })); } @@ -90,21 +104,21 @@ export class GrpcKgGraphRepository implements KgGraphRepository { const res = await this.rpc.call>("BeginPage", { bvid, cid, - expected_version: expectedVersion ?? -1, + expectedVersion: expectedVersion ?? -1, }); return { found: Boolean(res.found), - asrDone: Boolean(res.asr_done), + asrDone: Boolean(res.asrDone), skip: Boolean(res.skip), reason: str(res.reason), - contentVersion: num(res.content_version), - pageTitle: str(res.page_title), - pageIndex: num(res.page_index), + contentVersion: num(res.contentVersion), + pageTitle: str(res.pageTitle), + pageIndex: num(res.pageIndex), }; } async markPageDone(bvid: string, cid: number, contentVersion: number): Promise { - await this.rpc.call("MarkPageDone", { bvid, cid, content_version: contentVersion }); + await this.rpc.call("MarkPageDone", { bvid, cid, contentVersion }); } async markPageFailed(bvid: string, cid: number): Promise { @@ -124,7 +138,7 @@ export class GrpcKgGraphRepository implements KgGraphRepository { entities: entities.map((e) => ({ eid: e.eid, name: e.name, - name_lower: e.nameLower, + nameLower: e.nameLower, type: e.type, description: e.description, alias: "", @@ -137,7 +151,7 @@ export class GrpcKgGraphRepository implements KgGraphRepository { if (appearances.length === 0) return 0; const res = await this.rpc.call("LinkAppearances", { bvid, - page_index: pageIndex, + pageIndex, appearances: appearances.map((a) => ({ eid: a.eid, quote: a.quote })), }); return num(res.linked); @@ -147,40 +161,83 @@ export class GrpcKgGraphRepository implements KgGraphRepository { if (relations.length === 0) return; await this.rpc.call("UpsertRelations", { relations: relations.map((r) => ({ - src_eid: r.srcEid, - dst_eid: r.dstEid, - rel_type: r.relType, + srcEid: r.srcEid, + dstEid: r.dstEid, + relType: r.relType, quote: r.quote, })), }); } async deletePageEvidence(bvid: string, pageIndex: number): Promise { - const res = await this.rpc.call("DeletePageEvidence", { bvid, page_index: pageIndex }); + const res = await this.rpc.call("DeletePageEvidence", { bvid, pageIndex }); return num(res.deleted); } async deleteVideo(bvid: string): Promise { const res = await this.rpc.call("DeleteVideo", { bvid }); - return { entitiesRemoved: num(res.entities_removed), videosRemoved: num(res.videos_removed) }; + return { entitiesRemoved: num(res.entitiesRemoved), videosRemoved: num(res.videosRemoved) }; } async clearAll(): Promise { await this.rpc.call("ClearAll", {}); } + // ── cloud-drive knowledge points (same Entity/RELATES space as videos) ── + + async linkCloudDocAppearances(input: CloudDocAppearanceInput): Promise { + // Always call: app-kg merges the CloudDoc node AND performs delete-before-write, + // so a re-extraction that yields zero entities still clears stale evidence. + const res = await this.rpc.call("LinkCloudDocAppearances", { + uid: input.uid, + uploadUuid: input.uploadUuid, + title: input.title, + contentHash: input.contentHash, + appearances: input.appearances.map((a) => ({ eid: a.eid, quote: a.quote })), + }); + return num(res.linked); + } + + async deleteCloudDocAppearances(uid: string, uploadUuid: string): Promise { + const res = await this.rpc.call("DeleteCloudDocAppearances", { uid, uploadUuid }); + return { deleted: num(res.deleted), entitiesRemoved: num(res.entitiesRemoved) }; + } + + async fetchCloudDocEvidence( + eids: string[], + uid: string, + uploadUuids: string[], + limit: number, + ): Promise { + if (eids.length === 0 || !uid) return []; + const res = await this.rpc.call("FetchCloudDocEvidence", { + eids, + uid, + uploadUuids, + limit, + }); + return arr(res.rows).map((r) => ({ + eid: str(r.eid), + name: str(r.name), + type: str(r.type), + uploadUuid: str(r.uploadUuid), + quote: str(r.quote), + title: str(r.title), + })); + } + // ── graph reads ────────────────────────────────────────────────────── async neighbors(eids: string[], excludeEids: string[], limit: number): Promise { if (eids.length === 0) return []; - const res = await this.rpc.call("Neighbors", { eids, exclude_eids: excludeEids, limit }); + const res = await this.rpc.call("Neighbors", { eids, excludeEids, limit }); return arr(res.rows).map((r) => ({ eid: str(r.eid), name: str(r.name), type: str(r.type), description: str(r.description), - relType: str(r.rel_type), - fromEid: str(r.from_eid), + relType: str(r.relType), + fromEid: str(r.fromEid), })); } @@ -192,9 +249,9 @@ export class GrpcKgGraphRepository implements KgGraphRepository { name: str(r.name), type: str(r.type), bvid: str(r.bvid), - pageIndex: num(r.page_index), + pageIndex: num(r.pageIndex), quote: str(r.quote), - pageTitle: str(r.page_title), + pageTitle: str(r.pageTitle), })); } @@ -202,11 +259,11 @@ export class GrpcKgGraphRepository implements KgGraphRepository { if (eids.length < 2) return []; const res = await this.rpc.call("RelationsAmong", { eids }); return arr(res.rows).map((r) => ({ - srcEid: str(r.src_eid), - srcName: str(r.src_name), - relType: str(r.rel_type), - dstEid: str(r.dst_eid), - dstName: str(r.dst_name), + srcEid: str(r.srcEid), + srcName: str(r.srcName), + relType: str(r.relType), + dstEid: str(r.dstEid), + dstName: str(r.dstName), weight: num(r.weight), quotes: strArr(r.quotes), })); @@ -233,9 +290,9 @@ export class GrpcKgGraphRepository implements KgGraphRepository { type: str(r.type), description: str(r.description), pages: num(r.pages), - evidenceSample: arr(r.evidence_sample).map((s) => ({ + evidenceSample: arr(r.evidenceSample).map((s) => ({ bvid: str(s.bvid), - pageIndex: num(s.page_index), + pageIndex: num(s.pageIndex), quote: str(s.quote), })), })); @@ -245,7 +302,7 @@ export class GrpcKgGraphRepository implements KgGraphRepository { const res = await this.rpc.call("EntityAppearances", { eid, bvids, limit }); return arr(res.rows).map((r) => ({ bvid: str(r.bvid), - pageIndex: num(r.page_index), + pageIndex: num(r.pageIndex), quote: str(r.quote), })); } @@ -265,9 +322,9 @@ export class GrpcKgGraphRepository implements KgGraphRepository { if (eids.length < 2) return []; const res = await this.rpc.call("EdgesAmong", { eids }); return arr(res.rows).map((r) => ({ - srcEid: str(r.src_eid), - dstEid: str(r.dst_eid), - relType: str(r.rel_type), + srcEid: str(r.srcEid), + dstEid: str(r.dstEid), + relType: str(r.relType), })); } @@ -278,7 +335,9 @@ export class GrpcKgGraphRepository implements KgGraphRepository { relations: num(res.relations), evidence: num(res.evidence), videos: num(res.videos), - pendingPages: num(res.pending_pages), + pendingPages: num(res.pendingPages), + cloudDocs: num(res.cloudDocs), + cloudEvidence: num(res.cloudEvidence), }; } } diff --git a/app-ts/src/repository/http/kg-cloud-sources.ts b/app-ts/src/repository/http/kg-cloud-sources.ts new file mode 100644 index 0000000..ab8a37f --- /dev/null +++ b/app-ts/src/repository/http/kg-cloud-sources.ts @@ -0,0 +1,43 @@ +import type { AppCloudClient } from "../../auth/app-cloud-client.js"; +import type { + CloudDocContent, + CloudDocListPage, + CloudDocMeta, + KgCloudSourceStore, +} from "../kg-cloud-sources.js"; + +/** + * app-cloud implementation of KgCloudSourceStore. + * + * Only message↔domain mapping lives here: apikey / X-Uid transport, baseUrl + * trimming, timeouts and the {"detail"} error convention stay in + * auth/app-cloud-client.ts (same split as repository/grpc vs transport/grpc). + */ +export class HttpKgCloudSourceStore implements KgCloudSourceStore { + constructor(private readonly client: AppCloudClient) {} + + async listDocuments(uid: string, opts: { limit: number; cursor?: string }): Promise { + const page = await this.client.listKgDocuments(uid, { limit: opts.limit, cursor: opts.cursor }); + const items: CloudDocMeta[] = page.items.map((doc) => ({ + uploadUuid: doc.upload_uuid, + title: doc.title, + contentHash: doc.content_hash, + sizeChars: doc.size_chars, + })); + return { items, nextCursor: page.next_cursor }; + } + + async getDocument(uid: string, uploadUuid: string): Promise { + const doc = await this.client.getKgDocument(uid, uploadUuid); + if (!doc) return undefined; + return { + uploadUuid: doc.upload_uuid, + title: doc.title, + contentHash: doc.content_hash, + sizeChars: doc.size_chars, + text: doc.text, + }; + } + + async close(): Promise {} +} diff --git a/app-ts/src/repository/kg-cloud-sources.ts b/app-ts/src/repository/kg-cloud-sources.ts new file mode 100644 index 0000000..259086d --- /dev/null +++ b/app-ts/src/repository/kg-cloud-sources.ts @@ -0,0 +1,40 @@ +/** + * Cloud-drive document source for the KG build pipeline — contract for the + * app-cloud M2M face (GET /internal/cloud/kg/documents + /internal/cloud/kg/document). + * + * Document text is owned by the cloud (app-cloud parses uploads and stores the + * parsed text); app-ts only reads it, exactly like the ASR transcript source + * (kg-sources.ts) reads the bili domain's Mongo collection. uid is a required + * argument on every call — identity never comes from model-controlled input. + * + * Implementations: http/kg-cloud-sources.ts (production, AppCloudClient) · + * memory/kg-cloud-sources.ts (in-memory, tests / degraded fallback). + */ + +export interface CloudDocMeta { + uploadUuid: string; + title: string; + /** app-cloud content hash; "" when the service did not report one */ + contentHash: string; + /** parsed text length in characters (0 when unknown) */ + sizeChars: number; +} + +export interface CloudDocContent extends CloudDocMeta { + /** parsed plain text; empty = not parsed yet (the build marks the doc failed) */ + text: string; +} + +export interface CloudDocListPage { + items: CloudDocMeta[]; + /** upload_uuid of the last row when the page is full (next-page cursor); null = no more */ + nextCursor: string | null; +} + +export interface KgCloudSourceStore { + /** documents with usable parsed text for this uid, upload_uuid ASC */ + listDocuments(uid: string, opts: { limit: number; cursor?: string }): Promise; + /** one document's parsed text; undefined = missing / no parsed text (app-cloud 404) */ + getDocument(uid: string, uploadUuid: string): Promise; + close(): Promise; +} diff --git a/app-ts/src/repository/kg-docs.ts b/app-ts/src/repository/kg-docs.ts new file mode 100644 index 0000000..76fc614 --- /dev/null +++ b/app-ts/src/repository/kg-docs.ts @@ -0,0 +1,20 @@ +import type { KgDocRecord } from "../kg/types.js"; + +/** + * Cloud-drive document extraction ledger — repository contract. + * + * app-ts's OWN bookkeeping store (Mongo database mind_base_ts, collection + * kg_docs), deliberately separate from app-kg's CloudDoc graph nodes and from + * app-cloud's cloud_files table. The idempotency key is (uid, uploadUuid): + * the build pipeline skips a document whose last successful record carries the + * same app-cloud content hash (no LLM call, no graph rewrite). + * + * Implementations: mongo/kg-docs.ts (Mongo, database mind_base_ts) · + * memory/kg-docs.ts (in-memory, tests / degraded fallback). + */ + +export interface KgDocRepository { + get(uid: string, uploadUuid: string): Promise; + save(record: KgDocRecord): Promise; + close(): Promise; +} diff --git a/app-ts/src/repository/kg-graph.ts b/app-ts/src/repository/kg-graph.ts index c697880..e517836 100644 --- a/app-ts/src/repository/kg-graph.ts +++ b/app-ts/src/repository/kg-graph.ts @@ -1,6 +1,7 @@ import type { AppearanceRow, BeginPageResult, + CloudDocEvidenceRow, EdgeRow, EntityDetail, EvidenceRow, @@ -31,6 +32,24 @@ export interface DeleteVideoStats { videosRemoved: number; } +export interface DeleteCloudDocStats { + /** evidence edges removed for this document */ + deleted: number; + /** entities left orphaned by the removal and cleaned up */ + entitiesRemoved: number; +} + +/** One cloud document's evidence batch (app-kg LinkCloudDocAppearances input). */ +export interface CloudDocAppearanceInput { + uid: string; + uploadUuid: string; + /** non-empty overwrites CloudDoc.title; empty keeps the route-graph value */ + title: string; + /** non-empty overwrites CloudDoc.content_hash */ + contentHash: string; + appearances: AppearanceInput[]; +} + export interface KgGraphRepository { /** favorite folders → bvids (dedup, order-stable); empty folderIds = all user folders */ resolveScope(uid: string, folderIds: number[]): Promise; @@ -48,6 +67,26 @@ export interface KgGraphRepository { deleteVideo(bvid: string): Promise; clearAll(): Promise; + // ── cloud-drive knowledge points (same Entity/RELATES space as videos) ── + + /** Write one cloud doc's knowledge evidence: MERGE (:CloudDoc), delete its + * previous APPEARS_IN edges (delete-before-write), then link + * (entity)-[:APPEARS_IN {quote}]->(cloudDoc). Always call it — even with an + * empty appearance list — so stale evidence is removed after a re-extraction. + * Returns the number of linked evidence edges. */ + linkCloudDocAppearances(input: CloudDocAppearanceInput): Promise; + /** Delete one cloud doc's knowledge evidence + orphan Entity cleanup. The + * CloudDoc node itself belongs to the cloud route graph and is kept. */ + deleteCloudDocAppearances(uid: string, uploadUuid: string): Promise; + /** Cloud evidence pull-back. uid is REQUIRED (tenant isolation); empty + * uploadUuids = all of the user's cloud documents, else that set. */ + fetchCloudDocEvidence( + eids: string[], + uid: string, + uploadUuids: string[], + limit: number, + ): Promise; + neighbors(eids: string[], excludeEids: string[], limit: number): Promise; fetchEvidence(eids: string[], bvids: string[], limit: number): Promise; relationsAmong(eids: string[]): Promise; diff --git a/app-ts/src/repository/memory/chat-store.ts b/app-ts/src/repository/memory/chat-store.ts index 0ebc0a6..39b7e9d 100644 --- a/app-ts/src/repository/memory/chat-store.ts +++ b/app-ts/src/repository/memory/chat-store.ts @@ -113,6 +113,7 @@ export class MemoryMessageRepository implements MessageRepository { patch: { sources?: Source[]; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + artifacts?: import("../persistence.js").ChatMessage["artifacts"]; status: "done" | "failed"; error?: string; }, @@ -120,6 +121,7 @@ export class MemoryMessageRepository implements MessageRepository { const m = this.messages.get(assistantMsgId); if (!m) return; if (patch.sources) m.sources = patch.sources; + if (patch.artifacts) m.artifacts = patch.artifacts; if (patch.usage) m.usage = patch.usage; if (patch.error) m.error = patch.error; m.status = patch.status; diff --git a/app-ts/src/repository/memory/kg-cloud-sources.ts b/app-ts/src/repository/memory/kg-cloud-sources.ts new file mode 100644 index 0000000..3a22c57 --- /dev/null +++ b/app-ts/src/repository/memory/kg-cloud-sources.ts @@ -0,0 +1,46 @@ +import type { + CloudDocContent, + CloudDocListPage, + CloudDocMeta, + KgCloudSourceStore, +} from "../kg-cloud-sources.js"; + +/** + * In-memory KgCloudSourceStore (tests / degraded fallback). Mirrors the + * app-cloud contract: upload_uuid ASC ordering, cursor = last upload_uuid of a + * full page, uid isolation. + */ +export class MemoryKgCloudSourceStore implements KgCloudSourceStore { + private readonly docs = new Map(); + + /** Test seam: register one document for a uid. */ + seed(uid: string, doc: CloudDocContent): void { + this.docs.set(`${uid}|${doc.uploadUuid}`, { ...doc }); + } + + async listDocuments(uid: string, opts: { limit: number; cursor?: string }): Promise { + const limit = Math.max(Math.floor(opts.limit), 1); + const prefix = `${uid}|`; + const all = [...this.docs.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([, doc]) => doc) + .sort((a, b) => (a.uploadUuid < b.uploadUuid ? -1 : a.uploadUuid > b.uploadUuid ? 1 : 0)); + const after = opts.cursor ?? ""; + const remaining = after === "" ? all : all.filter((doc) => doc.uploadUuid > after); + const rows = remaining.slice(0, limit); + const items: CloudDocMeta[] = rows.map((doc) => ({ + uploadUuid: doc.uploadUuid, + title: doc.title, + contentHash: doc.contentHash, + sizeChars: doc.sizeChars, + })); + return { items, nextCursor: items.length === limit ? items[items.length - 1].uploadUuid : null }; + } + + async getDocument(uid: string, uploadUuid: string): Promise { + const doc = this.docs.get(`${uid}|${uploadUuid}`); + return doc ? { ...doc } : undefined; + } + + async close(): Promise {} +} diff --git a/app-ts/src/repository/memory/kg-docs.ts b/app-ts/src/repository/memory/kg-docs.ts new file mode 100644 index 0000000..457e1cf --- /dev/null +++ b/app-ts/src/repository/memory/kg-docs.ts @@ -0,0 +1,22 @@ +import type { KgDocRecord } from "../../kg/types.js"; +import type { KgDocRepository } from "../kg-docs.js"; + +/** In-memory implementation (tests / degraded fallback). Key = uid + uploadUuid. */ +export class MemoryKgDocStore implements KgDocRepository { + private readonly records = new Map(); + + private key(uid: string, uploadUuid: string): string { + return `${uid}:${uploadUuid}`; + } + + async get(uid: string, uploadUuid: string): Promise { + const record = this.records.get(this.key(uid, uploadUuid)); + return record ? { ...record } : undefined; + } + + async save(record: KgDocRecord): Promise { + this.records.set(this.key(record.uid, record.uploadUuid), { ...record }); + } + + async close(): Promise {} +} diff --git a/app-ts/src/repository/memory/kg-graph.ts b/app-ts/src/repository/memory/kg-graph.ts index d5a88a3..9f72e4a 100644 --- a/app-ts/src/repository/memory/kg-graph.ts +++ b/app-ts/src/repository/memory/kg-graph.ts @@ -1,6 +1,7 @@ import type { AppearanceRow, BeginPageResult, + CloudDocEvidenceRow, EdgeRow, EntityDetail, EvidenceRow, @@ -13,7 +14,13 @@ import type { ResolvedRelation, TopEntityRow, } from "../../kg/types.js"; -import type { AppearanceInput, DeleteVideoStats, KgGraphRepository } from "../kg-graph.js"; +import type { + AppearanceInput, + CloudDocAppearanceInput, + DeleteCloudDocStats, + DeleteVideoStats, + KgGraphRepository, +} from "../kg-graph.js"; /** * In-memory KgGraphRepository: doubles as the test stub AND the degraded @@ -30,6 +37,14 @@ interface RelEdge { quotes: string[]; } +/** CloudDoc node (created/owned by the cloud route graph; merged here). */ +interface CloudDocNode { + uid: string; + uploadUuid: string; + title: string; + contentHash: string; +} + export class MemoryKgGraphRepository implements KgGraphRepository { /** eid → entity */ readonly entities = new Map(); @@ -39,6 +54,10 @@ export class MemoryKgGraphRepository implements KgGraphRepository { readonly appearances = new Map(); /** (bvid,cid) → kg bookkeeping */ readonly pages = new Map(); + /** `${uid}|${uploadUuid}` → CloudDoc node */ + readonly cloudDocs = new Map(); + /** `${uid}|${uploadUuid}|${eid}` → evidence quote (APPEARS_IN → CloudDoc) */ + readonly cloudAppearances = new Map(); available = true; @@ -193,16 +212,8 @@ export class MemoryKgGraphRepository implements KgGraphRepository { videosRemoved += 1; } } - // orphan entity cleanup: no relations and no appearances left - let entitiesRemoved = 0; - for (const [eid, e] of [...this.entities.entries()]) { - const hasRel = [...this.relations.values()].some((r) => r.srcEid === eid || r.dstEid === eid); - const hasApp = [...this.appearances.values()].some((a) => a.eid === eid); - if (!hasRel && !hasApp) { - this.entities.delete(eid); - entitiesRemoved += 1; - } - } + // orphan entity cleanup: no RELATES edge and no evidence left (video or cloud) + const entitiesRemoved = this.cleanupOrphanEntities(); return { entitiesRemoved, videosRemoved }; } @@ -210,6 +221,106 @@ export class MemoryKgGraphRepository implements KgGraphRepository { this.entities.clear(); this.relations.clear(); this.appearances.clear(); + // CloudDoc nodes belong to the cloud route graph (other labels untouched); + // deleting the entities cascades their evidence edges away. + this.cloudAppearances.clear(); + } + + /** Drop entities that no longer have a RELATES edge or any evidence (video/cloud). */ + private cleanupOrphanEntities(): number { + let removed = 0; + for (const [key, entity] of [...this.entities.entries()]) { + const hasRel = [...this.relations.values()].some( + (r) => r.srcEid === entity.eid || r.dstEid === entity.eid, + ); + const hasVideoEvidence = [...this.appearances.values()].some((a) => a.eid === entity.eid); + const hasCloudEvidence = [...this.cloudAppearances.values()].some((a) => a.eid === entity.eid); + if (!hasRel && !hasVideoEvidence && !hasCloudEvidence) { + this.entities.delete(key); + removed += 1; + } + } + return removed; + } + + // ── cloud-drive knowledge points (same entity/relation space as videos) ── + + async linkCloudDocAppearances(input: CloudDocAppearanceInput): Promise { + if (!this.available) throw new Error("kg unavailable"); + const nodeKey = `${input.uid}|${input.uploadUuid}`; + const node = this.cloudDocs.get(nodeKey) ?? { + uid: input.uid, + uploadUuid: input.uploadUuid, + title: "", + contentHash: "", + }; + if (input.title) node.title = input.title; + if (input.contentHash) node.contentHash = input.contentHash; + this.cloudDocs.set(nodeKey, node); + // delete-before-write (app-kg performs the same deletion server-side) + for (const [key, a] of [...this.cloudAppearances.entries()]) { + if (a.uid === input.uid && a.uploadUuid === input.uploadUuid) this.cloudAppearances.delete(key); + } + // same-doc same-entity dedup keeps the shortest non-empty quote + const quotes = new Map(); + for (const a of input.appearances) { + if (!a.eid) continue; + const quote = (a.quote ?? "").trim(); + const prev = quotes.get(a.eid); + if (prev === undefined || (quote !== "" && (prev === "" || quote.length < prev.length))) { + quotes.set(a.eid, quote); + } + } + for (const [eid, quote] of quotes) { + this.cloudAppearances.set(`${input.uid}|${input.uploadUuid}|${eid}`, { + uid: input.uid, + uploadUuid: input.uploadUuid, + eid, + quote, + }); + } + return quotes.size; + } + + async deleteCloudDocAppearances(uid: string, uploadUuid: string): Promise { + if (!this.available) throw new Error("kg unavailable"); + let deleted = 0; + for (const [key, a] of [...this.cloudAppearances.entries()]) { + if (a.uid === uid && a.uploadUuid === uploadUuid) { + this.cloudAppearances.delete(key); + deleted += 1; + } + } + const entitiesRemoved = this.cleanupOrphanEntities(); + return { deleted, entitiesRemoved }; + } + + async fetchCloudDocEvidence( + eids: string[], + uid: string, + uploadUuids: string[], + limit: number, + ): Promise { + if (!this.available) throw new Error("kg unavailable"); + if (eids.length === 0 || !uid) return []; + const out: CloudDocEvidenceRow[] = []; + for (const a of this.cloudAppearances.values()) { + if (a.uid !== uid) continue; + if (!eids.includes(a.eid)) continue; + if (uploadUuids.length > 0 && !uploadUuids.includes(a.uploadUuid)) continue; + const entity = [...this.entities.values()].find((e) => e.eid === a.eid); + const node = this.cloudDocs.get(`${a.uid}|${a.uploadUuid}`); + out.push({ + eid: a.eid, + name: entity?.name ?? "", + type: entity?.type ?? "other", + uploadUuid: a.uploadUuid, + quote: a.quote, + title: node?.title ?? "", + }); + if (out.length >= limit) break; + } + return out; } async neighbors(eids: string[], excludeEids: string[], limit: number): Promise { @@ -359,6 +470,10 @@ export class MemoryKgGraphRepository implements KgGraphRepository { evidence: this.appearances.size, videos: new Set([...this.pages.values()].map((p) => p.bvid)).size, pendingPages: await this.countPendingPages(), + // parity with app-kg: cloud_docs counts documents that HAVE knowledge + // evidence (a linked doc with zero entities does not count). + cloudDocs: new Set([...this.cloudAppearances.values()].map((a) => `${a.uid}|${a.uploadUuid}`)).size, + cloudEvidence: this.cloudAppearances.size, }; } diff --git a/app-ts/src/repository/mongo/chat-store.ts b/app-ts/src/repository/mongo/chat-store.ts index 2d605e2..4cddc18 100644 --- a/app-ts/src/repository/mongo/chat-store.ts +++ b/app-ts/src/repository/mongo/chat-store.ts @@ -37,6 +37,8 @@ interface MessageDoc { /** Chunks during streaming (joined into content and cleared at finalize); recoverable after a crash */ chunks?: string[]; sources?: Source[]; + /** structured UI artifacts (plot / diagram / quiz / ...) — re-sent on history reload */ + artifacts?: import("../../context/types.js").Artifact[]; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; error?: string; status: "pending" | "done" | "failed"; @@ -61,6 +63,7 @@ function toMessage(doc: MessageDoc): ChatMessage { role: doc.role, content: doc.content, sources: doc.sources, + artifacts: doc.artifacts, usage: doc.usage, error: doc.error, images: doc.images, @@ -235,6 +238,7 @@ export class MongoChatStore implements ChatSessionRepository, MessageRepository patch: { sources?: Source[]; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + artifacts?: import("../persistence.js").ChatMessage["artifacts"]; status: "done" | "failed"; error?: string; }, @@ -249,6 +253,7 @@ export class MongoChatStore implements ChatSessionRepository, MessageRepository content: fullContent, status: patch.status, ...(patch.sources ? { sources: patch.sources } : {}), + ...(patch.artifacts && patch.artifacts.length > 0 ? { artifacts: patch.artifacts } : {}), ...(patch.usage ? { usage: patch.usage } : {}), ...(patch.error ? { error: patch.error } : {}), }, diff --git a/app-ts/src/repository/mongo/kg-builds.ts b/app-ts/src/repository/mongo/kg-builds.ts index ab3ae4f..44a81f2 100644 --- a/app-ts/src/repository/mongo/kg-builds.ts +++ b/app-ts/src/repository/mongo/kg-builds.ts @@ -18,8 +18,12 @@ const log = createLogger("KG_BUILDS"); interface KgBuildDoc { _id: string; uid: string; + /** knowledge origin; absent on records written before cloud-doc support */ + source?: "bilibili" | "cloud"; folder_ids: number[]; bvid_count: number; + /** cloud source only: documents selected for this build */ + doc_count?: number; status: "processing" | "done" | "failed"; progress: number; current_step: string; @@ -34,8 +38,11 @@ function toRecord(doc: KgBuildDoc): KgBuildRecord { return { taskId: doc._id, uid: doc.uid, + // legacy records predate the source field → behave as video builds + source: doc.source === "cloud" ? "cloud" : "bilibili", folderIds: doc.folder_ids, bvidCount: doc.bvid_count, + ...(typeof doc.doc_count === "number" ? { docCount: doc.doc_count } : {}), status: doc.status, progress: doc.progress, currentStep: doc.current_step, @@ -83,8 +90,10 @@ export class MongoKgBuildStore implements KgBuildRepository { const doc: KgBuildDoc = { _id: record.taskId, uid: record.uid, + source: record.source, folder_ids: record.folderIds, bvid_count: record.bvidCount, + ...(record.docCount !== undefined ? { doc_count: record.docCount } : {}), status: record.status, progress: record.progress, current_step: record.currentStep, diff --git a/app-ts/src/repository/mongo/kg-docs.ts b/app-ts/src/repository/mongo/kg-docs.ts new file mode 100644 index 0000000..bea43ed --- /dev/null +++ b/app-ts/src/repository/mongo/kg-docs.ts @@ -0,0 +1,98 @@ +import { MongoClient, type Collection, type Db } from "mongodb"; +import { config } from "../../config/index.js"; +import { createLogger } from "../../logger.js"; +import type { KgDocRecord, KgDocStatus } from "../../kg/types.js"; +import type { KgDocRepository } from "../kg-docs.js"; + +const log = createLogger("KG_DOCS"); + +/** + * Mongo adapter (database mind_base_ts, collection kg_docs) — see ../kg-docs.ts + * for the contract. _id = `${uid}:${uploadUuid}` makes (uid, uploadUuid) the + * natural idempotency key; the uid index backs per-user lookups. + */ + +interface KgDocDoc { + _id: string; + uid: string; + upload_uuid: string; + title: string; + content_hash: string; + status: KgDocStatus; + entity_count?: number; + relation_count?: number; + error?: string; + updated_at: number; +} + +function toRecord(doc: KgDocDoc): KgDocRecord { + return { + uid: doc.uid, + uploadUuid: doc.upload_uuid, + title: doc.title, + contentHash: doc.content_hash, + status: doc.status, + entityCount: doc.entity_count, + relationCount: doc.relation_count, + error: doc.error, + updatedAt: doc.updated_at, + }; +} + +export class MongoKgDocStore implements KgDocRepository { + private constructor( + private readonly client: MongoClient, + private readonly col: Collection, + ) {} + + static async connect(): Promise { + const { uri, database, timeoutMs } = config.mongo; + if (!uri) return null; + const client = new MongoClient(uri, { + serverSelectionTimeoutMS: timeoutMs, + connectTimeoutMS: timeoutMs, + }); + try { + await client.connect(); + await client.db("admin").command({ ping: 1 }); + const db = client.db(database) as Db; + const store = new MongoKgDocStore(client, db.collection("kg_docs")); + await store.ensureIndexes(); + return store; + } catch (err) { + log.warn(`[MONGO] connect failed, kg doc ledger falls back to memory: ${err instanceof Error ? err.message : err}`); + await client.close().catch(() => undefined); + return null; + } + } + + private async ensureIndexes(): Promise { + await this.col.createIndex({ uid: 1 }); + } + + async get(uid: string, uploadUuid: string): Promise { + const doc = await this.col.findOne({ _id: `${uid}:${uploadUuid}` }); + return doc ? toRecord(doc) : undefined; + } + + async save(record: KgDocRecord): Promise { + const id = `${record.uid}:${record.uploadUuid}`; + const doc: KgDocDoc = { + _id: id, + uid: record.uid, + upload_uuid: record.uploadUuid, + title: record.title, + content_hash: record.contentHash, + status: record.status, + ...(record.entityCount !== undefined ? { entity_count: record.entityCount } : {}), + ...(record.relationCount !== undefined ? { relation_count: record.relationCount } : {}), + ...(record.error ? { error: record.error } : {}), + updated_at: record.updatedAt, + }; + await this.col.replaceOne({ _id: id }, doc, { upsert: true }); + } + + async close(): Promise { + await this.client.close().catch(() => undefined); + } +} diff --git a/app-ts/src/repository/persistence.ts b/app-ts/src/repository/persistence.ts index e89eda4..4e51f01 100644 --- a/app-ts/src/repository/persistence.ts +++ b/app-ts/src/repository/persistence.ts @@ -30,6 +30,10 @@ export interface ChatMessage { role: MessageRole; content: string; sources?: import("../context/types.js").Source[]; + /** structured UI artifacts produced this turn (plot / diagram / quiz / academic_*). + * Stream-only payloads are persisted here so a history reload keeps the cards; + * the client renders known kinds and falls back to a JSON card for unknown ones. */ + artifacts?: import("../context/types.js").Artifact[]; /** token usage captured from the LLM finish frame */ usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; /** pending placeholder (while the assistant message is streaming) */ @@ -82,6 +86,8 @@ export interface MessageRepository { patch: { sources?: import("../context/types.js").Source[]; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + /** structured artifacts emitted by the turn; capped by ChatService before persisting */ + artifacts?: import("../context/types.js").Artifact[]; status: "done" | "failed"; /** generation error text when status = failed */ error?: string; diff --git a/app-ts/src/services/chat-sessions.ts b/app-ts/src/services/chat-sessions.ts index 16f68fd..619f2fd 100644 --- a/app-ts/src/services/chat-sessions.ts +++ b/app-ts/src/services/chat-sessions.ts @@ -47,6 +47,8 @@ export interface WireMessage { content: string; status: "completed" | "failed"; sources?: Source[]; + /** structured UI artifacts (plot / diagram / quiz / ...) persisted with the turn */ + artifacts?: import("../context/types.js").Artifact[]; /** attached images: presigned MinIO URLs (durable) or legacy inline data URLs */ images?: string[]; tokens_used?: number; @@ -212,6 +214,7 @@ function toWireMessage(m: ChatMessage, sessionId: string): WireMessage { created_at: iso(m.createdAt), }; if (m.sources && m.sources.length > 0) wire.sources = m.sources.slice(0, 5); + if (m.artifacts && m.artifacts.length > 0) wire.artifacts = m.artifacts; if (m.usage?.totalTokens !== undefined) wire.tokens_used = m.usage.totalTokens; if (m.error) wire.error = m.error; return wire; diff --git a/app-ts/src/services/chat.ts b/app-ts/src/services/chat.ts index ae128dc..1416b74 100644 --- a/app-ts/src/services/chat.ts +++ b/app-ts/src/services/chat.ts @@ -3,7 +3,8 @@ import type { AgentHarness } from "../harness/app.js"; import type { ChatSessionRepository, ChatMessage, MessageRepository, StoredChatImage } from "../repository/persistence.js"; import type { ChatImageStorage } from "../repository/chat-images.js"; import type { SseFrame } from "../transport/sse.js"; -import type { SearchScope, Source } from "../context/types.js"; +import type { Artifact, SearchScope, Source } from "../context/types.js"; +import { capArtifacts } from "../artifacts/interactive.js"; import type { ContextCompressionService } from "./context-compression.js"; import { AdmissionTicket, @@ -54,6 +55,9 @@ export interface AskStreamRequest { * rendered into the system prompt as a controlled section. */ academicProjectId?: string; + /** Aborted when the SSE client disconnects: cancels generation + in-flight tools + * and makes sure the assistant message is finalized instead of left pending. */ + signal?: AbortSignal; } const log = createLogger("CHAT"); @@ -187,15 +191,55 @@ export class ChatService { let fullContent = ""; let finalSources: Source[] = []; + // Artifacts are streamed to the client today; collecting them here makes the + // cards survive a history reload (bounded per turn by capArtifacts). + let finalArtifacts: Artifact[] = []; let usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | undefined; let hadError = false; + // Finalization must happen on every path, including client disconnect: when + // the SSE consumer goes away the generator's `return` skips the code after + // the loop, so the `finally` below still closes the message out. + let finalized = false; + const finalizeAssistantTurn = async (patch: { + sources?: Source[]; + artifacts?: Artifact[]; + usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + status: "done" | "failed"; + error?: string; + }): Promise => { + if (finalized) return; + finalized = true; + try { + await this.messages.finalizeTurn(assistantMsg.id, patch); + } catch (err) { + log.warn(`[CHAT] finalize failed session=${sessionId}: ${err instanceof Error ? err.message : err}`); + } + }; + // Turn-level context directives (board node / academic project) ride on // extraSystem; the agent's own prompt and red lines stay authoritative. const extraSystem = await this.buildExtraSystem(req.board, req.uid, req.academicProjectId); const admission = this.admissionQueue; let ticket: AdmissionTicket | null = null; + + // Whole-turn cancellation: the same controller carries client disconnect and + // the turn deadline. A stalled LLM stream therefore cannot keep the turn (and + // its session lock) alive forever; the two causes are tracked separately so a + // deadline surfaces an error frame while a disconnect stays silent. + const turnAbort = new AbortController(); + const onClientAbort = () => turnAbort.abort(); + if (req.signal) { + if (req.signal.aborted) turnAbort.abort(); + else req.signal.addEventListener("abort", onClientAbort, { once: true }); + } + let deadlineHit = false; + const turnTimer = setTimeout(() => { + deadlineHit = true; + turnAbort.abort(); + }, config.agent.turnTimeoutMs); + try { if (admission) { // weighted admission: under the cap this is a no-op; at capacity the @@ -216,12 +260,16 @@ export class ChatService { agentName: req.agentName, skillIds: req.skillIds, ...(extraSystem ? { extraSystem } : {}), + signal: turnAbort.signal, })) { + if (turnAbort.signal.aborted) break; if (frame.type === "chunk") { fullContent += frame.content; await this.messages.appendChunk(assistantMsg.id, frame.content); } else if (frame.type === "sources") { finalSources = frame.sources; + } else if (frame.type === "artifact") { + finalArtifacts = capArtifacts([...finalArtifacts, ...frame.artifacts]); } else if (frame.type === "error") { hadError = true; } else if (frame.type === "reset") { @@ -235,11 +283,24 @@ export class ChatService { } yield frame; } - await this.messages.finalizeTurn(assistantMsg.id, { + const aborted = turnAbort.signal.aborted; + await finalizeAssistantTurn({ sources: finalSources, + ...(finalArtifacts.length > 0 ? { artifacts: finalArtifacts } : {}), usage, - status: hadError ? "failed" : "done", + status: aborted || hadError ? "failed" : "done", + ...(aborted + ? { error: deadlineHit ? "本轮处理超时,已中止" : "连接已断开,生成已取消" } + : {}), }); + if (aborted) { + if (deadlineHit) { + yield { type: "error", message: "本轮处理超时,已中止。请重试,或把任务拆小后分步提问。" }; + } + // disconnect: the consumer is gone; deadline: the error frame above is the answer + yield { type: "done" }; + return; + } if (usage && (usage.totalTokens || usage.outputTokens)) { log.info(`[CHAT] turn usage session=${sessionId} in=${usage.inputTokens ?? "?"} out=${usage.outputTokens ?? "?"} total=${usage.totalTokens ?? "?"}`); } @@ -270,7 +331,7 @@ export class ChatService { if (!session.title) this.titleGen?.(session.id, req.query); } } catch (err) { - await this.messages.finalizeTurn(assistantMsg.id, { status: "failed" }); + await finalizeAssistantTurn({ status: "failed" }); if (err instanceof QueueFullError) { // fail fast on an overloaded system — never entered the queue log.warn(`[CHAT] admission queue full session=${sessionId} uid=${req.uid ?? "-"}`); @@ -289,6 +350,17 @@ export class ChatService { yield { type: "error", message }; yield { type: "done" }; } finally { + clearTimeout(turnTimer); + if (req.signal) req.signal.removeEventListener("abort", onClientAbort); + // Disconnect path: the consumer closed the generator, so the code after + // the loop never ran. Close the message (partial content is kept) instead + // of leaving it pending forever. + if (!finalized) { + await finalizeAssistantTurn({ + status: "failed", + error: deadlineHit ? "本轮处理超时,已中止" : "连接已断开,生成已取消", + }); + } // `ticket` is non-null only once granted (admitTurn cleans up its own // queued ticket on every unwinding path, including disconnect). if (admission && ticket) { diff --git a/app-ts/src/tools/chat/kg-search.ts b/app-ts/src/tools/chat/kg-search.ts index ef16d4b..00b42db 100644 --- a/app-ts/src/tools/chat/kg-search.ts +++ b/app-ts/src/tools/chat/kg-search.ts @@ -22,7 +22,11 @@ export function createKgSearchTool(retriever: KgRetriever) { if (!retriever.isAvailable()) { return { content: "知识图谱检索暂时不可用。", sources: [] }; } - const result = await retriever.retrieve(args.query, ctx.scope.bvids, args.k ?? 8); + const result = await retriever.retrieve( + args.query, + { bvids: ctx.scope.bvids, uid: ctx.uid, uploadUuids: ctx.scope.uploadUuids }, + args.k ?? 8, + ); if (!result.sources.length && !result.content) { return { content: "知识图谱中没有找到相关实体,可以尝试 vector_search 或换个表述。", diff --git a/app-ts/src/tools/cloud/index.ts b/app-ts/src/tools/cloud/index.ts index a0a555c..a105f5e 100644 --- a/app-ts/src/tools/cloud/index.ts +++ b/app-ts/src/tools/cloud/index.ts @@ -34,7 +34,7 @@ export function formatCloudSummary(summary: CloudDocumentSummary, opts: { title? const header = opts.title ? `【云盘文档摘要】${opts.title}` : "【云盘文档摘要】"; const parts: string[] = [header]; if (opts.stale) { - parts.push("注意:文件内容已更新,当前摘要基于较早版本(可重新入库以刷新)。"); + parts.push("注意:文件内容已更新,当前摘要基于较早版本(如需刷新,请重新上传该文件或联系管理员)。"); } parts.push( `概览:\n${summary.overview || "(无)"}`, @@ -56,7 +56,7 @@ export function createCloudDocSummaryTool(client: CloudDocSummaryClient) { description: "读取某个云盘文档已生成的 AI 摘要(概览/主题/实体/关键点/待办/可回答问题)。" + "当用户询问云盘文档的内容、要点或待办,且已知文件 uploadUuid 时使用;" + - "摘要尚未生成时会返回提示,可告知用户重新入库该文件。", + "摘要尚未生成时会返回提示,可告知用户稍后刷新或重新上传该文件。", inputSchema: z.object({ uploadUuid: z.string().min(16).max(128).describe("云盘文件 UUID(16-128 字符),来自云盘文件列表/引用"), }), @@ -83,7 +83,7 @@ export function createCloudDocSummaryTool(client: CloudDocSummaryClient) { }; if (!resp.available || resp.summary === null) { return { - content: "该云盘文档的摘要尚未生成(可重新入库该文件后再生成摘要)。", + content: "该云盘文档的摘要尚未生成(可稍后刷新;如需重新生成,请重新上传该文件或联系管理员)。", sources: [source], }; } diff --git a/app-ts/src/tools/harness/delegate.ts b/app-ts/src/tools/harness/delegate.ts index 99d0ea4..f12fe75 100644 --- a/app-ts/src/tools/harness/delegate.ts +++ b/app-ts/src/tools/harness/delegate.ts @@ -21,7 +21,7 @@ export function createDelegateTool(lifecycle: AgentLifecycleManager, delegates: agent: z.string().min(1).describe("目标子 agent 名"), message: z.string().min(1).describe("传给子 agent 的完整任务描述/问题"), }), - async execute(args, ctx) { + async execute(args, ctx, signal) { if (!lifecycle.has(args.agent)) { return `委托失败:子 agent "${args.agent}" 不存在。可用:${delegates.map((d) => d.name).join(", ")}`; } @@ -35,12 +35,16 @@ export function createDelegateTool(lifecycle: AgentLifecycleManager, delegates: if ((failures.get(args.agent) ?? 0) >= 2) { return `子 agent "${args.agent}" 本轮已连续失败 2 次,已停止委托。请基于已有信息直接回答,或告知用户该功能暂不可用。`; } + // cancellation must reach the sub-agent's streamText, otherwise a + // disconnected turn keeps burning tokens inside the delegation + const cancelSignal = signal ?? ctx.signal; const result = await lifecycle.invokeReentrant(args.agent, ctx.chatSessionId ?? "anonymous", { query: args.message, uid: ctx.uid, chatSessionId: ctx.chatSessionId, scope: delegateScopeFor(ctx.scope), history: [], + ...(cancelSignal ? { signal: cancelSignal } : {}), }); if (result.failed) { failures.set(args.agent, (failures.get(args.agent) ?? 0) + 1); diff --git a/app-ts/src/tools/render/index.ts b/app-ts/src/tools/render/index.ts new file mode 100644 index 0000000..d409811 --- /dev/null +++ b/app-ts/src/tools/render/index.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { defineTool } from "../base.js"; +import { + buildDiagramArtifact, + buildPlotArtifact, + diagramArgsSchema, + plotArgsSchema, +} from "../../artifacts/interactive.js"; + +/** + * Interactive artifacts: the model emits a declarative spec (plot / diagram) + * instead of asking a sub-agent to write HTML. The web client renders the spec + * with trusted components, so nothing executable ever reaches the chat page. + * + * Cross-field validation (expression identifiers, edge endpoints, payload size) + * happens here; invalid input returns a readable error so the ReAct loop can + * correct itself and retry. + */ + +export function createRenderPlotTool() { + return defineTool({ + name: "render_plot", + description: + "生成一个可交互的函数图像产物:曲线用表达式描述,参数(振幅/频率等)自动变成滑块,用户可拖动实时看变化,并支持滚轮缩放、拖拽平移。当用户想「看图/看函数图像/参数怎么影响图形/拖着看变化」时调用。只用于数学函数,不用于统计图表。", + inputSchema: plotArgsSchema, + async execute(args) { + const result = buildPlotArtifact(args); + if (!result.ok) return `无法渲染:${result.error}。请修正参数后重试。`; + const paramCount = args.params?.length ?? 0; + const curveCount = args.curves.length; + return { + content: `已生成可交互函数图像《${args.title}》:${curveCount} 条曲线${paramCount > 0 ? `、${paramCount} 个参数滑块` : ""}。请先用一两句话说明每条曲线代表什么、拖动参数会怎样变化,不要重复贴公式代码。`, + artifacts: [result.artifact], + }; + }, + }); +} + +export function createRenderDiagramTool() { + return defineTool({ + name: "render_diagram", + description: + "生成一个可缩放/可拖拽的流程图或架构图产物(节点 + 连线)。当用户想「画个流程/画架构图/把步骤画出来/要能拖着看细节」时调用。节点坐标需要自己排版(从左到右或从上到下),不要与 render_plot 混用。", + inputSchema: diagramArgsSchema, + async execute(args) { + const result = buildDiagramArtifact(args); + if (!result.ok) return `无法渲染:${result.error}。请修正节点 id / 连线后重试。`; + return { + content: `已生成流程图《${args.title}》:${args.nodes.length} 个节点、${args.edges.length} 条连线,用户可以缩放/拖拽查看。请用一两句话概括这张图的阅读顺序,不要复述每个节点。`, + artifacts: [result.artifact], + }; + }, + }); +} diff --git a/app-ts/src/tools/runtime.ts b/app-ts/src/tools/runtime.ts index 2a63595..1057e84 100644 --- a/app-ts/src/tools/runtime.ts +++ b/app-ts/src/tools/runtime.ts @@ -166,6 +166,12 @@ export class AgentRuntime { // strip the model-controlled budget before anything else (tools never see it) const { timeoutMs, ...args } = (rawArgs ?? {}) as Record & { timeoutMs?: number }; + // 0. cancelled turn (client disconnect / aborted generation): do not run and + // never blame the tool's breaker for a cancellation. + if (ctx.signal?.aborted) { + return { content: `工具 ${def.name} 已取消(本轮已中止)。` }; + } + // 1. tool-level breaker: consecutive failures/timeouts → short-circuit until cooldown const breaker = this.breakerFor(def.name); if (breaker.isTripped) { @@ -209,11 +215,20 @@ export class AgentRuntime { const started = Date.now(); m.calls += 1; try { - // 4. unified budget enforcement: the manager clamps, aborts and deletes on expiry - const outcome = await timeoutManager.run(def.name, timeoutMs, (signal) => - Promise.resolve(def.execute(args, ctx, signal)), + // 4. unified budget enforcement: the manager clamps, hard-aborts and deletes + // on expiry; the turn-level signal is merged in so a disconnect cancels the tool too. + const outcome = await timeoutManager.run( + def.name, + timeoutMs, + (signal) => Promise.resolve(def.execute(args, ctx, signal)), + ctx.signal, ); if (!outcome.ok) { + if (outcome.aborted) { + // cancellation is not a tool failure: no breaker penalty, no timeout metric + log.info(`[TOOL] ${def.name} cancelled (turn aborted)`); + return { content: `工具 ${def.name} 已取消(本轮已中止)。` }; + } m.timeouts += 1; breaker.recordFailure(); const secs = Math.round(outcome.budgetMs / 1000); diff --git a/app-ts/src/tools/timeout-manager.ts b/app-ts/src/tools/timeout-manager.ts index e7009a0..a83464d 100644 --- a/app-ts/src/tools/timeout-manager.ts +++ b/app-ts/src/tools/timeout-manager.ts @@ -13,6 +13,12 @@ const log = createLogger("TIMEOUT"); * - Each run gets an AbortController: on timeout the signal aborts (real cancellation * for signal-aware I/O), the entry is removed from the active table ("超时则删除"), * and the outcome is reported so the runtime can guide the model. + * - HARD BUDGET: the run races the tool promise against the abort signal, so a tool + * that ignores its signal can never hang a ReAct step forever (it keeps running in + * the background, but the loop gets control back immediately). + * - External cancellation: an optional caller signal (client disconnect / turn abort) + * aborts the same controller and is reported as `aborted` (not as a timeout) so the + * runtime does not blame the tool's breaker. * - The active table is observable (/agent/runtime) and self-cleans on settle. */ export interface TimeoutTicket { @@ -25,7 +31,7 @@ export interface TimeoutTicket { export type TimeoutOutcome = | { ok: true; value: T; budgetMs: number } - | { ok: false; timedOut: true; budgetMs: number }; + | { ok: false; timedOut: boolean; aborted: boolean; budgetMs: number }; export class TimeoutManager { private readonly active = new Map< @@ -40,38 +46,62 @@ export class TimeoutManager { return Math.min(Math.max(Math.floor(requestedMs), minTimeoutMs), maxTimeoutMs); } - /** Run `fn` under a budget. Aborts + deletes the entry on expiry; cleans up on settle. */ + /** + * Run `fn` under a budget. On expiry (or external abort) the controller aborts, + * the entry is deleted, and the outcome is reported immediately — even if `fn` + * never settles. + */ async run( label: string, requestedMs: number | undefined, fn: (signal: AbortSignal) => Promise, + externalSignal?: AbortSignal, ): Promise> { const budgetMs = this.resolveBudget(requestedMs); const controller = new AbortController(); const id = randomUUID(); - const entry = { - id, - label, - startedAt: Date.now(), - budgetMs, - timer: setTimeout(() => { - controller.abort(); - this.active.delete(id); - log.warn(`[TIMEOUT] ${label} aborted after ${budgetMs}ms (id=${id.slice(0, 8)})`); - }, budgetMs), - }; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + this.active.delete(id); + log.warn(`[TIMEOUT] ${label} aborted after ${budgetMs}ms (id=${id.slice(0, 8)})`); + }, budgetMs); + + const onExternalAbort = () => controller.abort(); + if (externalSignal) { + if (externalSignal.aborted) controller.abort(); + else externalSignal.addEventListener("abort", onExternalAbort, { once: true }); + } + + const entry = { id, label, startedAt: Date.now(), budgetMs, timer }; this.active.set(id, entry); + + // Reject as soon as the controller aborts, whichever cause fired first. + const abortPromise = new Promise((_resolve, reject) => { + const fail = () => reject(new Error(`${label} aborted`)); + if (controller.signal.aborted) fail(); + else controller.signal.addEventListener("abort", fail, { once: true }); + }); + // The race consumer handles one branch; swallow the loser so a late rejection + // (or a late success after a timeout) is never an unhandled rejection. + abortPromise.catch(() => undefined); + + const work: Promise = Promise.resolve().then(() => fn(controller.signal)); + work.catch(() => undefined); + try { - const value = await fn(controller.signal); + const value = await Promise.race([work, abortPromise]); return { ok: true, value, budgetMs }; } catch (err) { - // an aborted signal usually surfaces as an AbortError; keep it recognizable if (controller.signal.aborted) { - return { ok: false, timedOut: true, budgetMs }; + return { ok: false, timedOut, aborted: !timedOut, budgetMs }; } throw err; } finally { - clearTimeout(entry.timer); + clearTimeout(timer); + if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort); this.active.delete(id); } } diff --git a/app-ts/src/transport/http/routes/chat.ts b/app-ts/src/transport/http/routes/chat.ts index 9230853..389f040 100644 --- a/app-ts/src/transport/http/routes/chat.ts +++ b/app-ts/src/transport/http/routes/chat.ts @@ -221,8 +221,12 @@ export function registerChatRoutes( // Note: must listen for close on the response (reply.raw), not on the request — // in Node 16+, IncomingMessage's close fires as soon as the request body is fully read, which would cut the stream off prematurely let closed = false; + // Client gone → abort the whole turn: streamText stops, in-flight tools + // receive the signal, and ChatService finalizes the assistant message. + const cancel = new AbortController(); reply.raw.on("close", () => { closed = true; + cancel.abort(); }); // frontend contract: folder_ids resolve to a bvid scope via app-kg (MySQL @@ -252,6 +256,7 @@ export function registerChatRoutes( skillIds: Array.isArray(body.skill_ids) ? body.skill_ids.filter((s) => typeof s === 'string') : undefined, ...(board ? { board } : {}), ...(academic ? { academicProjectId: academic.projectId } : {}), + signal: cancel.signal, })) { if (closed) break; reply.raw.write(`data: ${JSON.stringify(frame)}\n\n`); diff --git a/app-ts/src/transport/http/routes/kg.ts b/app-ts/src/transport/http/routes/kg.ts index 117fc87..2ff8f8c 100644 --- a/app-ts/src/transport/http/routes/kg.ts +++ b/app-ts/src/transport/http/routes/kg.ts @@ -29,10 +29,29 @@ export function registerKgRoutes(app: FastifyInstance, deps: { kg: KgService; qu if (uid === undefined) { return reply.status(401).send({ detail: "缺少用户身份(X-Uid)" }); } - const body = (req.body ?? {}) as { folder_ids?: unknown }; + const body = (req.body ?? {}) as { folder_ids?: unknown; source?: unknown; upload_uuids?: unknown }; + const rawSource = typeof body.source === "string" ? body.source.trim().toLowerCase() : ""; + if (rawSource !== "" && rawSource !== "bilibili" && rawSource !== "cloud") { + return reply.status(400).send({ detail: "source 仅支持 bilibili / cloud" }); + } + const source = rawSource === "cloud" ? "cloud" : "bilibili"; const folderIds = Array.isArray(body.folder_ids) ? body.folder_ids.map((v) => Number(v)).filter((v) => Number.isInteger(v) && v > 0) : undefined; + // upload_uuids 仅对 source=cloud 生效:去重、去空、上限 200 + const uploadUuids = source === "cloud" && Array.isArray(body.upload_uuids) + ? [ + ...new Set( + body.upload_uuids + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter((v) => v !== ""), + ), + ] + : undefined; + if (uploadUuids && uploadUuids.length > 200) { + return reply.status(400).send({ detail: "upload_uuids 数量超过上限(200)" }); + } // KG 构建配额:peek 在启动前,consume 只在真正新建时(reused 幂等不计数) if (deps.quota) { const gate = await deps.quota.peekKind(uid, "kg_build"); @@ -51,7 +70,11 @@ export function registerKgRoutes(app: FastifyInstance, deps: { kg: KgService; qu } } try { - const { taskId, reused } = await deps.kg.startBuild(uid, folderIds); + const { taskId, reused } = await deps.kg.startBuild(uid, { + source, + folderIds: source === "cloud" ? undefined : folderIds, + uploadUuids, + }); if (deps.quota && !reused) await deps.quota.consumeKind(uid, "kg_build"); return { task_id: taskId, reused }; } catch (err) { diff --git a/app-ts/src/transport/http/routes/openai.ts b/app-ts/src/transport/http/routes/openai.ts index ec4ac36..63b666d 100644 --- a/app-ts/src/transport/http/routes/openai.ts +++ b/app-ts/src/transport/http/routes/openai.ts @@ -188,8 +188,10 @@ export function registerOpenAiCompat( }); const heartbeat = setInterval(() => reply.raw.write(HEARTBEAT), HEARTBEAT_INTERVAL_MS); let closed = false; + const cancel = new AbortController(); reply.raw.on("close", () => { closed = true; + cancel.abort(); }); const send = (delta: Record, finishReason: string | null = null, usage?: Record) => { @@ -201,7 +203,7 @@ export function registerOpenAiCompat( try { send({ role: "assistant", content: "" }); let usage: Record | undefined; - for await (const frame of opts.harness.dispatchStream(input)) { + for await (const frame of opts.harness.dispatchStream({ ...input, signal: cancel.signal })) { if (closed) break; switch (frame.type) { case "chunk": @@ -245,7 +247,11 @@ export function registerOpenAiCompat( let content = ""; let usage: Record | undefined; let failed = false; - for await (const frame of opts.harness.dispatchStream(input)) { + // A disconnect mid-request should cancel generation too (same semantics + // as the streaming branch); the response close fires on socket death. + const cancel = new AbortController(); + reply.raw.on("close", () => cancel.abort()); + for await (const frame of opts.harness.dispatchStream({ ...input, signal: cancel.signal })) { switch (frame.type) { case "chunk": content += frame.content; diff --git a/app-ts/test/chat-admission.test.ts b/app-ts/test/chat-admission.test.ts index 53eb1be..b44afa3 100644 --- a/app-ts/test/chat-admission.test.ts +++ b/app-ts/test/chat-admission.test.ts @@ -223,4 +223,74 @@ describe("ChatService weighted admission", () => { expect(queue.running).toBe(0); expect(queue.waiting).toBe(0); }); + + it("disconnect mid-stream aborts generation and finalizes the turn (no pending zombie)", async () => { + const messages = new MemoryMessageRepository(); + const finalizeCalls = spyFinalize(messages); + const controller = new AbortController(); + const harness = { + dispatchStream: async function* (input: { signal?: AbortSignal }): AsyncGenerator { + yield { type: "route", agent: "main" }; + yield { type: "chunk", content: "半截回答" }; + // park until the turn-level signal fires (what streamText does on abort) + await new Promise((resolve) => { + if (input.signal?.aborted) resolve(); + else input.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + if (input.signal?.aborted) return; // consumer gone: no further frames + yield { type: "done" }; + }, + } as unknown as AgentHarness; + const chat = new ChatService(harness, new MemoryChatSessionRepository(), messages, undefined, undefined, null); + + const gen = chat.askStream({ query: "hi", sessionId: "s11", signal: controller.signal }); + await withTimeout(gen.next(), 2000, "route"); + await withTimeout(gen.next(), 2000, "chunk"); + + // HTTP layer: response closed → abort, then stop consuming + controller.abort(); + await gen.return(undefined); + + // finalized as failed (partial content kept), not left pending forever + expect(finalizeCalls.at(-1)?.status).toBe("failed"); + const assistant = (await messages.listBySession("s11")).find((m) => m.role === "assistant"); + expect(assistant?.status).toBe("failed"); + expect(assistant?.content).toBe("半截回答"); + }); +}); + +describe("ChatService turn deadline", () => { + const saved = config.agent.turnTimeoutMs; + afterEach(() => { + config.agent.turnTimeoutMs = saved; + }); + + it("stalled turn hits the deadline: error frame, failed message, generator completes", async () => { + config.agent.turnTimeoutMs = 120; + const messages = new MemoryMessageRepository(); + const finalizeCalls = spyFinalize(messages); + const harness = { + dispatchStream: async function* (input: { signal?: AbortSignal }): AsyncGenerator { + yield { type: "route", agent: "main" }; + yield { type: "chunk", content: "处理中" }; + // stalled upstream: no tokens, no error — only the turn deadline can end this + await new Promise((resolve) => { + if (input.signal?.aborted) resolve(); + else input.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + }, + } as unknown as AgentHarness; + const chat = new ChatService(harness, new MemoryChatSessionRepository(), messages, undefined, undefined, null); + + const frames = await drain(chat.askStream({ query: "hi", sessionId: "s-deadline" }), 4000); + const err = frames.find((f) => f.type === "error"); + expect(err).toBeDefined(); + if (err?.type === "error") expect(err.message).toContain("超时"); + expect(frames[frames.length - 1].type).toBe("done"); + expect(finalizeCalls.at(-1)?.status).toBe("failed"); + + const assistant = (await messages.listBySession("s-deadline")).find((m) => m.role === "assistant"); + expect(assistant?.status).toBe("failed"); + expect(assistant?.content).toBe("处理中"); + }); }); diff --git a/app-ts/test/cloud-doc-summary.test.ts b/app-ts/test/cloud-doc-summary.test.ts index 22e7820..f118d8b 100644 --- a/app-ts/test/cloud-doc-summary.test.ts +++ b/app-ts/test/cloud-doc-summary.test.ts @@ -177,7 +177,7 @@ describe("cloud_doc_summary tool", () => { const out = await tool.execute({ uploadUuid: UUID }, makeCtx("42")); const content = typeof out === "string" ? out : out.content; expect(content).toContain("较早版本"); - expect(content).toContain("重新入库"); + expect(content).toContain("重新上传"); }); it("returns a plain notice when no summary has been generated yet", async () => { diff --git a/app-ts/test/kg-build.test.ts b/app-ts/test/kg-build.test.ts index 045220d..59ab1c4 100644 --- a/app-ts/test/kg-build.test.ts +++ b/app-ts/test/kg-build.test.ts @@ -1,8 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { KgService, KgServiceError } from "../src/kg/service.js"; import { MemoryKgGraphRepository } from "../src/repository/memory/kg-graph.js"; import { MemoryKgSourceStore } from "../src/repository/memory/kg-sources.js"; import { MemoryKgBuildStore } from "../src/repository/memory/kg-builds.js"; +import { MemoryKgDocStore } from "../src/repository/memory/kg-docs.js"; import type { KgExtractor } from "../src/kg/extractor.js"; import type { ExtractionResult } from "../src/kg/types.js"; @@ -22,11 +23,14 @@ describe("kg build service", () => { builds = new MemoryKgBuildStore(); }); + const makeService = (extractor: KgExtractor) => new KgService({ graph, extractor, sources, + cloudSources: null, // cloud-doc source absent: video builds must not care + docs: new MemoryKgDocStore(), builds, index: null, // entity-vector writes are best-effort; absent index is exercised embeddings: { @@ -56,12 +60,13 @@ describe("kg build service", () => { ], })), ); - const { taskId, reused, completion } = await svc.startBuild(1, [100]); + const { taskId, reused, completion } = await svc.startBuild("1", { folderIds: [100] }); expect(reused).toBe(false); await completion!; - const record = await builds.get(taskId, 1); + const record = await builds.get(taskId, "1"); expect(record?.status).toBe("done"); + expect(record?.source).toBe("bilibili"); expect(record?.result).toMatchObject({ total: 2, ok: 2, failed: 0 }); // graph got the entities/relations/evidence (same two entities extracted // for both pages → 2 pages × 2 entities = 4 evidence edges, delete-before-write per page) @@ -85,7 +90,7 @@ describe("kg build service", () => { return { entities: [{ name: "LSTM", type: "tech", description: "", quote: "LSTM 的门控机制" }], relations: [] }; }), ); - const { completion } = await svc.startBuild(1, []); + const { completion } = await svc.startBuild("1", {}); await completion!; expect(graph.pages.get("BV1|11")?.kgStatus).toBe("failed"); @@ -96,15 +101,15 @@ describe("kg build service", () => { it("completes with a message when no pages are pending", async () => { seedTwoPages(); const svc = makeService(fakeExtractor(() => ({ entities: [], relations: [] }))); - const first = await svc.startBuild(1, []); + const first = await svc.startBuild("1", {}); await first.completion!; - expect(builds.get(first.taskId, 1).then((r) => r?.result)).resolves.toMatchObject({ ok: 2 }); + await expect(builds.get(first.taskId, "1").then((r) => r?.result)).resolves.toMatchObject({ ok: 2 }); // everything now up-to-date → second build: total 0 - const second = await svc.startBuild(1, []); + const second = await svc.startBuild("1", {}); expect(second.reused).toBe(false); await second.completion!; - await expect(builds.get(second.taskId, 1).then((r) => r?.result)).resolves.toMatchObject({ + await expect(builds.get(second.taskId, "1").then((r) => r?.result)).resolves.toMatchObject({ total: 0, message: "所有分P已是最新", }); @@ -113,7 +118,7 @@ describe("kg build service", () => { it("skips pages already done at the current content version (beginPage skip)", async () => { seedTwoPages(); const svc = makeService(fakeExtractor(() => ({ entities: [], relations: [] }))); - const first = await svc.startBuild(1, []); + const first = await svc.startBuild("1", {}); await first.completion!; // re-running with the same expected versions → selectPendingPages already filters them; // a direct processPage with expected_version set still skips via BeginPage @@ -133,13 +138,15 @@ describe("kg build service", () => { extract: () => gate.then(() => ({ entities: [], relations: [] })), } as unknown as KgExtractor, sources, + cloudSources: null, + docs: new MemoryKgDocStore(), builds, index: null, embeddings: { embedDocuments: async () => [], embedQuery: async () => [], probeDimension: async () => 8 }, }); - const first = await svc.startBuild(1, []); + const first = await svc.startBuild("1", {}); expect(first.reused).toBe(false); - const second = await svc.startBuild(1, []); + const second = await svc.startBuild("1", {}); expect(second.reused).toBe(true); expect(second.taskId).toBe(first.taskId); expect(second.completion).toBeUndefined(); @@ -149,15 +156,15 @@ describe("kg build service", () => { it("404s when the scope resolves to no videos, 503s when the graph is down", async () => { const svc = makeService(fakeExtractor(() => ({ entities: [], relations: [] }))); - await expect(svc.startBuild(1, [])).rejects.toMatchObject({ status: 404 }); + await expect(svc.startBuild("1", {})).rejects.toMatchObject({ status: 404 }); graph.available = false; - await expect(svc.startBuild(1, [])).rejects.toBeInstanceOf(KgServiceError); + await expect(svc.startBuild("1", {})).rejects.toBeInstanceOf(KgServiceError); }); it("fails the page when the ASR transcript is missing", async () => { graph.seedPage({ bvid: "BV2", cid: 21, pageIndex: 0, pageTitle: "无正文", version: 1 }); const svc = makeService(fakeExtractor(() => ({ entities: [], relations: [] }))); - const { completion } = await svc.startBuild(2, []); + const { completion } = await svc.startBuild("2", {}); await completion!; expect(graph.pages.get("BV2|21")?.kgStatus).toBe("failed"); }); diff --git a/app-ts/test/kg-cloud-build.test.ts b/app-ts/test/kg-cloud-build.test.ts new file mode 100644 index 0000000..65433ba --- /dev/null +++ b/app-ts/test/kg-cloud-build.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { KgService } from "../src/kg/service.js"; +import { MemoryKgGraphRepository } from "../src/repository/memory/kg-graph.js"; +import { MemoryKgSourceStore } from "../src/repository/memory/kg-sources.js"; +import { MemoryKgBuildStore } from "../src/repository/memory/kg-builds.js"; +import { MemoryKgDocStore } from "../src/repository/memory/kg-docs.js"; +import { MemoryKgCloudSourceStore } from "../src/repository/memory/kg-cloud-sources.js"; +import type { KgExtractor } from "../src/kg/extractor.js"; +import type { ExtractionResult } from "../src/kg/types.js"; +import type { CloudDocContent } from "../src/repository/kg-cloud-sources.js"; +import type { EmbeddingsClient } from "../src/llm/embeddings.js"; + +const UID = "42"; + +const embeddings: EmbeddingsClient = { + embedDocuments: async () => [], + embedQuery: async () => [], + probeDimension: async () => 8, +}; + +/** Fake extractor: scripts per (title, kind) and records what it saw. */ +function fakeExtractor(script: (title: string, kind: string | undefined) => ExtractionResult): KgExtractor { + return { + extract: async (_text: string, title: string, kind?: string) => script(title, kind), + } as unknown as KgExtractor; +} + +/** Two entities + one relation, reused across documents. */ +function twoEntities(): ExtractionResult { + return { + entities: [ + { name: "RNN", type: "tech", description: "循环神经网络", quote: "讲解 RNN 的梯度" }, + { name: "LSTM", type: "tech", description: "长短期记忆网络", quote: "LSTM 的门控机制" }, + ], + relations: [{ src: "RNN", dst: "LSTM", relationType: "改进为", quote: "从 RNN 改进到 LSTM 的原文片段" }], + }; +} + +function doc(uploadUuid: string, title: string, hash: string, text: string): CloudDocContent { + return { uploadUuid, title, contentHash: hash, sizeChars: text.length, text }; +} + +describe("kg cloud build", () => { + let graph: MemoryKgGraphRepository; + let sources: MemoryKgSourceStore; + let builds: MemoryKgBuildStore; + let docs: MemoryKgDocStore; + let cloud: MemoryKgCloudSourceStore; + + beforeEach(() => { + graph = new MemoryKgGraphRepository(); + sources = new MemoryKgSourceStore(); + builds = new MemoryKgBuildStore(); + docs = new MemoryKgDocStore(); + cloud = new MemoryKgCloudSourceStore(); + }); + + const makeService = (extractor: KgExtractor) => + new KgService({ + graph, + extractor, + sources, + cloudSources: cloud, + docs, + builds, + index: null, + embeddings, + }); + + const seedTwoDocs = () => { + cloud.seed(UID, doc("u-1", "第一篇", "h1", "第一篇正文,讲解 RNN 的梯度问题")); + cloud.seed(UID, doc("u-2", "第二篇", "h2", "第二篇正文,讲解 LSTM 的门控机制")); + }; + + it("builds cloud-doc knowledge points: evidence edges + ledger + build record", async () => { + seedTwoDocs(); + const kinds: Array = []; + const svc = makeService( + fakeExtractor((_title, kind) => { + kinds.push(kind); + return twoEntities(); + }), + ); + const { taskId, reused, completion } = await svc.startBuild(UID, { source: "cloud" }); + expect(reused).toBe(false); + await completion!; + + const record = await builds.get(taskId, UID); + expect(record?.status).toBe("done"); + expect(record?.source).toBe("cloud"); + expect(record?.docCount).toBe(2); + expect(record?.result).toMatchObject({ total: 2, ok: 2, failed: 0 }); + expect(record?.steps.length).toBe(2); + + // the extractor is told it is reading a document (not a video transcript) + expect(kinds).toEqual(["document", "document"]); + + // CloudDoc nodes + evidence edges (2 docs × 2 entities, delete-before-write per doc) + expect(graph.cloudDocs.size).toBe(2); + expect(graph.cloudAppearances.size).toBe(4); + expect(graph.cloudDocs.get(`${UID}|u-1`)?.title).toBe("第一篇"); + expect(graph.cloudDocs.get(`${UID}|u-1`)?.contentHash).toBe("h1"); + // cloud knowledge points share the entity/relation space with videos + expect(graph.entities.size).toBe(2); + expect(graph.relations.size).toBe(1); + + // ledger: both docs done, content hash recorded + const first = await docs.get(UID, "u-1"); + expect(first?.status).toBe("done"); + expect(first?.contentHash).toBe("h1"); + expect(first?.entityCount).toBe(2); + expect(first?.relationCount).toBe(1); + expect((await docs.get(UID, "u-2"))?.status).toBe("done"); + + // stats expose the additive cloud counters + const stats = await svc.stats(); + expect(stats.graph.cloudDocs).toBe(2); + expect(stats.graph.cloudEvidence).toBe(4); + expect(stats.graph.videos).toBe(0); + }); + + it("skips unchanged documents by content hash (no LLM call, no graph rewrite)", async () => { + seedTwoDocs(); + let calls = 0; + const svc = makeService( + fakeExtractor(() => { + calls += 1; + return { entities: [{ name: "RNN", type: "tech", description: "", quote: "讲解 RNN 的梯度" }], relations: [] }; + }), + ); + const first = await svc.startBuild(UID, { source: "cloud" }); + await first.completion!; + expect(calls).toBe(2); + const evidenceAfterFirst = graph.cloudAppearances.size; + + const second = await svc.startBuild(UID, { source: "cloud" }); + await second.completion!; + expect(calls).toBe(2); // skipped before any extraction + expect(graph.cloudAppearances.size).toBe(evidenceAfterFirst); + const secondRecord = await builds.get(second.taskId, UID); + expect(secondRecord?.result).toMatchObject({ total: 2, ok: 2, failed: 0 }); + expect(secondRecord?.result?.message).toContain("跳过未变更文档 2 篇"); + }); + + it("re-extracts a document whose content hash changed and keeps evidence deduped", async () => { + cloud.seed(UID, doc("u-1", "第一篇", "h1", "第一篇正文,讲解 RNN 的梯度问题")); + let calls = 0; + const svc = makeService( + fakeExtractor(() => { + calls += 1; + return { entities: [{ name: "RNN", type: "tech", description: "", quote: "讲解 RNN 的梯度" }], relations: [] }; + }), + ); + const first = await svc.startBuild(UID, { source: "cloud" }); + await first.completion!; + expect(calls).toBe(1); + + cloud.seed(UID, doc("u-1", "第一篇(改版)", "h2", "改版后的正文,仍然讲解 RNN")); + const second = await svc.startBuild(UID, { source: "cloud" }); + await second.completion!; + expect(calls).toBe(2); + expect((await docs.get(UID, "u-1"))?.contentHash).toBe("h2"); + expect(graph.cloudDocs.get(`${UID}|u-1`)?.title).toBe("第一篇(改版)"); + expect(graph.cloudAppearances.size).toBe(1); // replaced, not duplicated + }); + + it("isolates per-document failures and records them as failed", async () => { + seedTwoDocs(); + const svc = makeService( + fakeExtractor((title) => { + if (title === "第一篇") throw new Error("LLM timeout"); + return { entities: [{ name: "LSTM", type: "tech", description: "", quote: "LSTM 的门控机制" }], relations: [] }; + }), + ); + const { taskId, completion } = await svc.startBuild(UID, { source: "cloud" }); + await completion!; + + const record = await builds.get(taskId, UID); + expect(record?.status).toBe("done"); + expect(record?.result).toMatchObject({ total: 2, ok: 1, failed: 1 }); + expect((await docs.get(UID, "u-1"))?.status).toBe("failed"); + expect((await docs.get(UID, "u-1"))?.error).toContain("LLM timeout"); + expect((await docs.get(UID, "u-2"))?.status).toBe("done"); + // only the successful document wrote evidence + expect(graph.cloudDocs.size).toBe(1); + expect(graph.cloudAppearances.size).toBe(1); + }); + + it("processes only the explicit upload_uuids scope (deduped)", async () => { + seedTwoDocs(); + cloud.seed(UID, doc("u-3", "第三篇", "h3", "第三篇正文,讲解 Transformer")); + let calls = 0; + const svc = makeService( + fakeExtractor(() => { + calls += 1; + return { entities: [], relations: [] }; + }), + ); + const { taskId, completion } = await svc.startBuild(UID, { + source: "cloud", + uploadUuids: ["u-1", "u-3", "u-1"], + }); + await completion!; + expect(calls).toBe(2); + expect((await builds.get(taskId, UID))?.docCount).toBe(2); + expect(await docs.get(UID, "u-1")).toBeDefined(); + expect(await docs.get(UID, "u-3")).toBeDefined(); + expect(await docs.get(UID, "u-2")).toBeUndefined(); + }); + + it("fails a document whose body is empty (not parsed yet)", async () => { + cloud.seed(UID, doc("u-1", "空文档", "h1", "")); + const svc = makeService(fakeExtractor(() => ({ entities: [], relations: [] }))); + const { taskId, completion } = await svc.startBuild(UID, { source: "cloud" }); + await completion!; + expect((await builds.get(taskId, UID))?.result).toMatchObject({ total: 1, ok: 0, failed: 1 }); + expect((await docs.get(UID, "u-1"))?.status).toBe("failed"); + }); + + it("clears stale evidence when a changed document now yields no entities", async () => { + cloud.seed(UID, doc("u-1", "第一篇", "h1", "第一篇正文,讲解 RNN 的梯度问题")); + const svc = makeService( + fakeExtractor(() => ({ + entities: [{ name: "RNN", type: "tech", description: "", quote: "讲解 RNN 的梯度" }], + relations: [], + })), + ); + const first = await svc.startBuild(UID, { source: "cloud" }); + await first.completion!; + expect(graph.cloudAppearances.size).toBe(1); + + cloud.seed(UID, doc("u-1", "第一篇", "h2", "改版正文,没有可抽取的实体")); + const svc2 = makeService(fakeExtractor(() => ({ entities: [], relations: [] }))); + const second = await svc2.startBuild(UID, { source: "cloud" }); + await second.completion!; + expect(graph.cloudAppearances.size).toBe(0); + expect((await docs.get(UID, "u-1"))?.entityCount).toBe(0); + }); + + it("503s when app-cloud is not configured, 404s when the drive has no parsed docs", async () => { + const noCloud = new KgService({ + graph, + extractor: fakeExtractor(() => ({ entities: [], relations: [] })), + sources, + cloudSources: null, + docs, + builds, + index: null, + embeddings, + }); + await expect(noCloud.startBuild(UID, { source: "cloud" })).rejects.toMatchObject({ status: 503 }); + + const svc = makeService(fakeExtractor(() => ({ entities: [], relations: [] }))); + await expect(svc.startBuild(UID, { source: "cloud" })).rejects.toMatchObject({ status: 404 }); + }); + + it("reuses the active task instead of starting a second cloud build", async () => { + seedTwoDocs(); + let release: (() => void) | undefined; + const gate = new Promise((resolve) => (release = resolve)); + const svc = makeService({ + extract: () => gate.then(() => ({ entities: [], relations: [] })), + } as unknown as KgExtractor); + const first = await svc.startBuild(UID, { source: "cloud" }); + expect(first.reused).toBe(false); + const second = await svc.startBuild(UID, { source: "cloud" }); + expect(second.reused).toBe(true); + expect(second.taskId).toBe(first.taskId); + expect(second.completion).toBeUndefined(); + release?.(); + await first.completion!; + }); +}); diff --git a/app-ts/test/kg-search.test.ts b/app-ts/test/kg-search.test.ts index 8e22202..7c53253 100644 --- a/app-ts/test/kg-search.test.ts +++ b/app-ts/test/kg-search.test.ts @@ -33,6 +33,14 @@ function seedGraph() { return { graph, rnn, lstm }; } +const VIDEO_SOURCE = { + bvid: "BV1", + title: "序列模型", + url: "https://www.bilibili.com/video/BV1", + score: 0.8, + pageIndex: 0, +}; + describe("kg retriever", () => { it("links seeds, expands the graph and builds vector_search-isomorphic sources", async () => { const { graph, rnn, lstm } = seedGraph(); @@ -43,27 +51,81 @@ describe("kg retriever", () => { }); expect(retriever.isAvailable()).toBe(true); - const result = await retriever.retrieve("RNN 和 LSTM 的关系", ["BV1"], 8); + const result = await retriever.retrieve("RNN 和 LSTM 的关系", { bvids: ["BV1"] }, 8); expect(result.entityCount).toBe(2); expect(result.evidenceCount).toBe(1); expect(result.content).toContain("## RNN(tech)"); expect(result.content).toContain("RNN --改进为--> LSTM"); expect(result.content).toContain('出处:【序列模型】"讲解 RNN 的梯度问题"'); + expect(result.sources).toEqual([VIDEO_SOURCE]); + }); + + it("adds cloud-doc evidence lines and sources when a uid is supplied", async () => { + const { graph, rnn, lstm } = seedGraph(); + await graph.linkCloudDocAppearances({ + uid: "42", + uploadUuid: "u-1", + title: "深度学习笔记", + contentHash: "h1", + appearances: [{ eid: rnn.eid, quote: "RNN 用于序列建模" }], + }); + const retriever = new KgRetriever({ graph, index: fakeIndex([rnn, lstm], 0.9), embeddings }); + + const result = await retriever.retrieve("RNN", { uid: "42" }, 8); + expect(result.content).toContain('出处:【云盘 · 深度学习笔记】"RNN 用于序列建模"'); + // video evidence in scope is still returned, cloud evidence appended after it + expect(result.content).toContain('出处:【序列模型】"讲解 RNN 的梯度问题"'); expect(result.sources).toEqual([ - { - bvid: "BV1", - title: "序列模型", - url: "https://www.bilibili.com/video/BV1", - score: 0.8, - pageIndex: 0, - }, + expect.objectContaining({ bvid: "BV1" }), + expect.objectContaining({ uploadUuid: "u-1", title: "深度学习笔记" }), ]); }); + it("restricts cloud evidence to the uploadUuids scope", async () => { + const { graph, rnn } = seedGraph(); + await graph.linkCloudDocAppearances({ uid: "42", uploadUuid: "u-1", title: "文档一", contentHash: "h1", appearances: [{ eid: rnn.eid, quote: "文档一的引文" }] }); + await graph.linkCloudDocAppearances({ uid: "42", uploadUuid: "u-2", title: "文档二", contentHash: "h2", appearances: [{ eid: rnn.eid, quote: "文档二的引文" }] }); + const retriever = new KgRetriever({ graph, index: fakeIndex([rnn], 0.9), embeddings }); + + const result = await retriever.retrieve("RNN", { uid: "42", uploadUuids: ["u-2"] }, 8); + expect(result.content).toContain("文档二的引文"); + expect(result.content).not.toContain("文档一的引文"); + expect(result.sources).toEqual([ + expect.objectContaining({ bvid: "BV1" }), + expect.objectContaining({ uploadUuid: "u-2", title: "文档二" }), + ]); + }); + + it("never queries cloud evidence without a uid (video-only behaviour preserved)", async () => { + const { graph, rnn } = seedGraph(); + let cloudCalls = 0; + graph.fetchCloudDocEvidence = async () => { + cloudCalls += 1; + throw new Error("should not be called"); + }; + const retriever = new KgRetriever({ graph, index: fakeIndex([rnn], 0.9), embeddings }); + + const result = await retriever.retrieve("RNN", { bvids: ["BV1"] }, 8); + expect(cloudCalls).toBe(0); + expect(result.sources).toEqual([expect.objectContaining({ bvid: "BV1" })]); + }); + + it("degrades to video-only results when cloud evidence fails (old app-kg)", async () => { + const { graph, rnn } = seedGraph(); + graph.fetchCloudDocEvidence = async () => { + throw new Error("unknown grpc method: FetchCloudDocEvidence"); + }; + const retriever = new KgRetriever({ graph, index: fakeIndex([rnn], 0.8), embeddings }); + + const result = await retriever.retrieve("RNN", { bvids: ["BV1"], uid: "42" }, 8); + expect(result.content).toContain('出处:【序列模型】"讲解 RNN 的梯度问题"'); + expect(result.sources).toEqual([VIDEO_SOURCE]); + }); + it("returns empty when no seed clears the score threshold", async () => { const { graph, rnn } = seedGraph(); const retriever = new KgRetriever({ graph, index: fakeIndex([rnn], 0.3), embeddings }); - const result = await retriever.retrieve("不相关的查询", ["BV1"], 8); + const result = await retriever.retrieve("不相关的查询", { bvids: ["BV1"] }, 8); expect(result).toEqual({ content: "", sources: [], entityCount: 0, evidenceCount: 0 }); }); @@ -71,7 +133,7 @@ describe("kg retriever", () => { const { graph, rnn } = seedGraph(); graph.available = false; const retriever = new KgRetriever({ graph, index: fakeIndex([rnn], 0.9), embeddings }); - const result = await retriever.retrieve("RNN", ["BV1"], 8); + const result = await retriever.retrieve("RNN", { bvids: ["BV1"] }, 8); expect(result.content).toBe(""); }); }); diff --git a/app-ts/test/render-interactive.test.ts b/app-ts/test/render-interactive.test.ts new file mode 100644 index 0000000..0d29d23 --- /dev/null +++ b/app-ts/test/render-interactive.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + buildDiagramArtifact, + buildPlotArtifact, + capArtifacts, + diagramArgsSchema, + MAX_ARTIFACTS_PER_TURN, + plotArgsSchema, + validateExpression, +} from "../src/artifacts/interactive.js"; +import { createRenderDiagramTool, createRenderPlotTool } from "../src/tools/render/index.js"; + +const plotArgs = { + title: "y = a·sin(bx + c) + d", + xMin: -6.5, + xMax: 6.5, + curves: [ + { expr: "a*sin(b*x + c) + d", label: "f(x)" }, + { expr: "a*b*cos(b*x + c)", label: "f'(x)" }, + ], + params: [ + { name: "a", label: "振幅", min: -3, max: 3, step: 0.05, value: 1.4 }, + { name: "b", min: 0.2, max: 4, step: 0.02, value: 1 }, + { name: "c", min: -3.15, max: 3.15, step: 0.01, value: 0 }, + { name: "d", min: -2, max: 2, step: 0.05, value: 0 }, + ], +}; + +const diagramArgs = { + title: "检索链路", + nodes: [ + { id: "user", label: "用户提问", x: 0, y: 96 }, + { id: "router", label: "AgentHarness", x: 150, y: 90, tone: "accent" as const }, + { id: "llm", label: "LLM 生成", x: 496, y: 90, shape: "round" as const }, + ], + edges: [ + { from: "user", to: "router" }, + { from: "router", to: "llm", label: "检索完成" }, + ], +}; + +describe("interactive artifact specs", () => { + it("builds a plot artifact with stable shape", () => { + const result = buildPlotArtifact(plotArgs); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.artifact.kind).toBe("plot"); + const data = result.artifact.data as Record; + expect(data.version).toBe(1); + expect(String(data.id)).toMatch(/^plot_/); + expect(Array.isArray(data.curves)).toBe(true); + }); + + it("rejects unknown identifiers and implicit functions outside the whitelist", () => { + expect(validateExpression("a*sin(b*x)", ["a", "b"])).toBeNull(); + expect(validateExpression("2x + 3", [])).toBeNull(); + expect(validateExpression("evil(x)", [])).toContain("未知变量/函数"); + expect(validateExpression("a*sin(b*x)", ["b"])).toContain("未知变量/函数"); + }); + + it("rejects inverted ranges and duplicate params", () => { + expect(buildPlotArtifact({ ...plotArgs, xMax: -10 }).ok).toBe(false); + expect( + buildPlotArtifact({ + ...plotArgs, + params: [ + { name: "a", min: 0, max: 1, step: 0.1, value: 0.5 }, + { name: "a", min: 0, max: 1, step: 0.1, value: 0.5 }, + ], + }).ok, + ).toBe(false); + }); + + it("zod schema enforces param value within range", () => { + const parsed = plotArgsSchema.safeParse({ + ...plotArgs, + params: [{ name: "a", min: 0, max: 1, step: 0.1, value: 5 }], + }); + expect(parsed.success).toBe(false); + }); + + it("builds a diagram artifact and validates node references", () => { + const result = buildDiagramArtifact(diagramArgs); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.artifact.kind).toBe("diagram"); + expect(String((result.artifact.data as Record).id)).toMatch(/^diagram_/); + + expect( + buildDiagramArtifact({ + ...diagramArgs, + edges: [{ from: "user", to: "missing" }], + }).ok, + ).toBe(false); + expect( + buildDiagramArtifact({ + ...diagramArgs, + nodes: [...diagramArgs.nodes, diagramArgs.nodes[0]!], + }).ok, + ).toBe(false); + }); + + it("diagram schema accepts the documented field set", () => { + expect(diagramArgsSchema.safeParse(diagramArgs).success).toBe(true); + }); + + it("caps persisted artifacts per turn", () => { + const one = { kind: "plot", data: { blob: "x".repeat(1000) } }; + const many = Array.from({ length: MAX_ARTIFACTS_PER_TURN + 3 }, () => one); + expect(capArtifacts(many)).toHaveLength(MAX_ARTIFACTS_PER_TURN); + expect(capArtifacts([])).toHaveLength(0); + }); +}); + +describe("render tools", () => { + it("render_plot returns text + artifact and reports invalid specs", async () => { + const tool = createRenderPlotTool(); + const ok = await tool.execute(plotArgs as never, {} as never); + expect(typeof ok).not.toBe("string"); + if (typeof ok !== "string") { + expect(ok.content).toContain("已生成可交互函数图像"); + expect(ok.artifacts?.[0]?.kind).toBe("plot"); + } + + const bad = await tool.execute({ ...plotArgs, curves: [{ expr: "wat(x)" }] } as never, {} as never); + expect(typeof bad).toBe("string"); + expect(String(bad)).toContain("无法渲染"); + }); + + it("render_diagram returns a diagram artifact", async () => { + const tool = createRenderDiagramTool(); + const out = await tool.execute(diagramArgs as never, {} as never); + expect(typeof out).not.toBe("string"); + if (typeof out !== "string") { + expect(out.artifacts?.[0]?.kind).toBe("diagram"); + expect(out.content).toContain("3 个节点"); + } + }); +}); diff --git a/app-ts/test/timeout-manager.test.ts b/app-ts/test/timeout-manager.test.ts index cc270e6..c5f6298 100644 --- a/app-ts/test/timeout-manager.test.ts +++ b/app-ts/test/timeout-manager.test.ts @@ -156,3 +156,75 @@ describe("schema injection", () => { expect(rejected.success).toBe(false); }); }); + +describe("P0 hard timeout & cancellation", () => { + /** A tool that ignores its AbortSignal and never settles — the worst case. */ + const neverResolves = () => new Promise(() => undefined); + + it("hard timeout returns even when the tool ignores its signal", async () => { + const m = new TimeoutManager(); + const started = Date.now(); + const out = await m.run("stuck", 1_000, () => neverResolves()); + const elapsed = Date.now() - started; + expect(out).toMatchObject({ ok: false, timedOut: true, aborted: false }); + expect(elapsed).toBeLessThan(3_000); + expect(m.size).toBe(0); + }); + + it("external abort (client disconnect) reports aborted, not timedOut", async () => { + const m = new TimeoutManager(); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 30); + const started = Date.now(); + const out = await m.run("stuck", 5_000, () => neverResolves(), controller.signal); + expect(out).toMatchObject({ ok: false, aborted: true, timedOut: false }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it("runtime cancels a hanging tool when the turn signal fires", async () => { + const stuck = defineTool({ + name: "stuck", + description: "never settles", + inputSchema: z.object({}), + execute: () => neverResolves(), + }); + const registry = new ToolRegistry(); + registry.register(stuck); + const runtime = new AgentRuntime(() => registry.list()); + + const controller = new AbortController(); + setTimeout(() => controller.abort(), 30); + const started = Date.now(); + const out = await runtime.execute( + { name: "stuck", execute: stuck.execute }, + {}, + { ...ctx(), signal: controller.signal }, + ); + expect(out.content).toContain("已取消"); + expect(Date.now() - started).toBeLessThan(2_000); + // cancellation is never blamed on the tool + expect(runtime.monitor().stuck.timeouts).toBe(0); + expect(runtime.monitor().stuck.errors).toBe(0); + expect(timeoutManager.size).toBe(0); + }); + + it("runtime refuses to start a tool after the turn was aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const quick = defineTool({ + name: "quick", + description: "quick", + inputSchema: z.object({}), + execute: async () => "ok", + }); + const registry = new ToolRegistry(); + registry.register(quick); + const runtime = new AgentRuntime(() => registry.list()); + const out = await runtime.execute( + { name: "quick", execute: quick.execute }, + {}, + { ...ctx(), signal: controller.signal }, + ); + expect(out.content).toContain("已取消"); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 7a1cd9b..98eb7d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -869,8 +869,11 @@ services: - APPNOTE__RDBMS__URL=${RDBMS__URL:-mysql+aiomysql://mind_base:mind-base@mysql:3306/mind_base} - MONGO__URI=${MONGO__URI:-mongodb://admin:mind-base@mongo:27017/?authSource=admin} - APPNOTE__LOG__OUTPUT=both + # 宿主调试口默认 8018(容器内仍是 8008,APISIX 走 app-note:8008): + # app-cloud 的 8007-8009 端口池会抢占 8008,app-sandbox 占 8017, + # 沿用 8008 会导致 app-note 重建时 "port already allocated" 起不来 ports: - - "127.0.0.1:${APP_NOTE_PORT:-8008}:8008" + - "127.0.0.1:${APP_NOTE_PORT:-8018}:8008" volumes: - app_note_logs:/app/logs restart: unless-stopped diff --git a/docs/api.md b/docs/api.md index a269044..e344e99 100644 --- a/docs/api.md +++ b/docs/api.md @@ -80,7 +80,7 @@ APISIX :9080(forward-auth / key-auth / 路由 / 限流) | 帧 | 语义 | |----|------| -| `route` | 本轮路由到的 agent | +| `route` | 路由决策(协议保留;前端不再展示路由到的 agent,仅用于清除排队态) | | `reasoning` / `reasoning_patch` | 思考过程增量(不计入正文) | | `chunk` | 正文增量 | | `step` | 工具 / 检索步骤生命周期 | diff --git a/docs/app-cloud.md b/docs/app-cloud.md index 4c7a1b9..cb1bdf6 100644 --- a/docs/app-cloud.md +++ b/docs/app-cloud.md @@ -95,6 +95,12 @@ PUT 分块(浏览器直传 MinIO) → complete(POST /cloud/upload/{uuid}/c - **M2M(app-ts)**:`GET /internal/cloud/summary`、`/internal/cloud/similar`,用 `internal.api_key`(compose 复用 APISIX_CONSUMER_KEY)鉴权;app-ts 的 `cloud_doc_summary` 工具经此读取摘要,uid 由服务端会话注入,LLM 不能指定 uid。 +- **KG 云盘文档读取(M2M,2026-11-05)**:`GET /internal/cloud/kg/documents?uid=&limit=&cursor=` + 分页列出该用户已解析正文的文档(只回 upload_uuid/title/content_hash/size_chars,按 + upload_uuid ASC + cursor,limit 1..100 缺省 50)与 + `GET /internal/cloud/kg/document?uid=&upload_uuid=` 读取正文(按字符截断 200k,size_chars + 为全文长度)。供 app-ts 构建云盘知识点;同样走 internal.api_key + uid 校验,无能力门 + (Mongo 未启用时列表为空页、详情 404)。历史文档 size_chars 可能为 0(重新解析后补齐)。 - **文件级 AI**:云盘详情页展示摘要/AI 标签/关键点/待办/可回答问题与相似文件;「问这份文件」进入 `/chat?upload_uuid=`,聊天只以该文件为检索范围;聊天来源支持 uploadUuid 深链回云盘详情。 - **配置**:`pipeline.ocr.*`(enabled/model/max_image_bytes/max_pdf_pages/max_images/timeout_seconds; diff --git a/docs/app-kg.md b/docs/app-kg.md index cf64ff5..c04ad05 100644 --- a/docs/app-kg.md +++ b/docs/app-kg.md @@ -24,47 +24,73 @@ app-ts (Fastify, AI) app-kg (Go, gRPC :8013) 对齐 backend → app-board-mcp 内网先例;宿主回环 127.0.0.1:8013 供 grpcurl 调试。 - **归一化单一实现**:`name_lower`(小写+空白折叠)与 `eid`(sha1 hex 前 16 位) 由调用方(app-ts resolver)计算后传入,app-kg 不做归一化 - ——graph 包守护测试钉死该契约。 + ——tests 包守护测试钉死该契约。 - ~~**过渡期双写**~~(已结束):Python `KgGraphRepository` 曾与 app-kg 写同一 Neo4j(同 schema、确定性 eid、merge 键 name_lower/bvid → 幂等兼容); 2026-10-06 Python 侧删除后 app-kg 成为唯一写者。 ## 二、app-go/app-kg(8013 gRPC + 8014 HTTP /health) -- **RPC 面**(proto/kg/v1/kg.proto,21 个):记账 ResolveScope / +- **RPC 面**(proto/kg/v1/kg.proto,24 个):记账 ResolveScope / SelectPendingPages / BeginPage / MarkPageDone / MarkPageFailed / CountPendingPages;图写 UpsertEntities / LinkAppearances / UpsertRelations / - DeletePageEvidence / DeleteVideo / ClearAll;图读 Neighbors / FetchEvidence + DeletePageEvidence / DeleteVideo / ClearAll;云盘知识点 LinkCloudDocAppearances / + DeleteCloudDocAppearances / FetchCloudDocEvidence;图读 Neighbors / FetchEvidence (带 page_title join)/ RelationsAmong / EntitiesByEids / EntityExposure / - EntityAppearances / TopEntities / EdgesAmong / Stats。 + EntityAppearances / TopEntities / EdgesAmong / Stats(增 cloud_docs/cloud_evidence)。 - **红线**: - ❌ 对 `video` 表只写 `kg_status`/`kg_version` 两列(镜像 app-bili「禁写 kg* 列」) - ❌ 对 `collection`/`favorite_folders` 只读;不建任何表、无任何 DDL - ❌ 不做任何 LLM/embedding 调用(纯数据面) - ❌ 归一化/哈希不落在本服务(调用方传入 name_lower/eid) + - ❌ 云盘知识点证据边目标固定 `(:CloudDoc {uid, upload_uuid})`(复用云盘 L1 路由图节点), + uid 多租户过滤必须在 Cypher 内(`d.uid = $uid`) + - ❌ app-kg 不直连云盘 Mongo/MySQL 业务表;云盘正文由 app-ts 经 app-cloud M2M 接口读取 +- **分层**(`tests/` 独立测试包守护依赖方向):`infra/{mysql,neo4j}`(数据库初始化/连接配置)→ + `repo/{mysql,neo4j}`(SQL/Cypher)→ `service`(业务规则)→ `transport/{grpc,http}`(协议映射); + `config` 是 YAML/env 唯一入口,仅嵌入 infra 的连接配置结构。 - **生成产物提交进仓库**(`internal/kgpb`):CI 无 protoc 依赖;proto 变更时本地 `protoc --go_out=app-go/app-kg --go_opt=module=app-kg … proto/kg/v1/kg.proto` 重新生成。 - compose `app-kg`(profiles ["", "kg", "full"],depends_on mysql+neo4j healthy); env:`APPKG__RDBMS__URL` + 共享 `KG__URI`/`KG__PASSWORD`(NEO4J_PASSWORD)。 -## 三、app-ts 增量 +## 三、云盘文档 → 知识点(2026-11-05 新增) + +- **来源**:app-ts 经 app-cloud M2M 读取云盘解析正文(`GET /internal/cloud/kg/documents`、 + `/internal/cloud/kg/document`),复用同一套 extractor/resolver 抽取知识点,不需要新模型。 +- **存储**:知识点与视频共用 `Entity`/`RELATES` 空间;证据边 + `(Entity)-[:APPEARS_IN {quote}]->(:CloudDoc {uid, upload_uuid, title, content_hash})`。 + CloudDoc 节点复用云盘 L1 路由图(不建第二套文档节点),uid 隔离在 Cypher 内完成。 +- **写入语义**:`LinkCloudDocAppearances` = delete-before-write(重抽取不留旧证据); + `DeleteCloudDocAppearances` 删证据并回收孤儿 Entity;云盘文档被 purge 时走 + `DeleteCloudDocGraph` 的 DETACH DELETE → 知识点证据级联消失 + 孤儿 Entity 回收。 +- **构建记账**:云盘构建状态在 app-ts 自有 Mongo `mind_base_ts.kg_docs`(upload_uuid + + content_hash 幂等),不写 MySQL;`/knowledge/kg/build` body 增 + `source:"bilibili"|"cloud"` 与 `upload_uuids`(cloud 限定范围)。 +- **检索**:`kg_search` 同时回捞视频与云盘证据(`FetchCloudDocEvidence`),来源面板以 + `upload_uuid` 引用云盘文件;`Stats` 增 `cloud_docs`/`cloud_evidence`(视频 evidence 计数 + 仍只统计 APPEARS_IN→Video,两类口径分开)。 + +## 四、app-ts 增量 | 组件 | 位置 | 说明 | |---|---|---| | resolver | `src/kg/resolver.ts` | 移植 resolver.py:normalizeName/eidForName(sha1 utf-8 与 Python 字节兼容)、类型别名表、quote≥8 防幻觉、自环剔除、上限 30/40、stub 补全 | -| extractor | `src/kg/extractor.ts` | generateObject + zod,purpose="kg"、temperature=0、maxRetries=2、maxTextChars 截断;prompt 外置 `prompts/kg-extract.md` | -| gRPC 传输 | `src/grpc/client.ts` | 通用 gRPC 客户端基础设施(对齐 `src/mcp/client.ts` 先例):运行时 proto 加载、channel、deadline、ServiceError 归一化——协议客户端独立成层,repository 不碰传输 | -| 图仓储 | `src/repository/kg-graph.ts` + `grpc/` + `memory/` | 接口 + **纯消息↔领域映射**(传输全部委托 src/grpc)+ 测试/降级 stub | +| extractor | `src/kg/extractor.ts` | generateObject + zod,purpose="kg"、temperature=0、maxRetries=2、maxTextChars 截断;prompt 外置 `prompts/kg/extract.md`(来源无关,视频稿/云盘正文共用,`kind` 仅切提示标签) | +| gRPC 传输 | `src/transport/grpc.ts` | 通用 gRPC 客户端基础设施(对齐 `src/mcp/client.ts` 先例):运行时 proto 加载(**keepCase:false → 消息键 camelCase**)、channel、deadline、ServiceError 归一化——协议客户端独立成层,repository 不碰传输 | +| 图仓储 | `src/repository/kg-graph.ts` + `grpc/` + `memory/` | 接口 + **纯消息↔领域映射**(传输全部委托 src/transport/grpc;2026-11-05 修正 camelCase 消息键,此前 snake_case 请求键被静默丢弃)+ 测试/降级 stub;云盘三方法(Link/Delete/FetchCloudDocEvidence)同契约 | +| 云盘正文源 | `src/repository/kg-cloud-sources.ts` + `http/` + `memory/` | app-cloud M2M(`/internal/cloud/kg/documents\|document`,apikey + X-Uid)分页列文档 + 读正文;memory 实现供离线单测 | +| 云盘账本 | `src/repository/kg-docs.ts` + `mongo/kg-docs.ts` + `memory/` | 自有 Mongo `mind_base_ts.kg_docs`,_id=`${uid}:${uploadUuid}`;done + contentHash 相同才跳过(幂等/省 LLM) | | 实体向量 | `src/repository/kg-entity-index.ts` | 独立 MilvusClient(同 Python 模式):mind_base_ts.kg_entities,IVF_FLAT COSINE nlist=128/nprobe=16,dim 探测 + 失配自愈,delete-then-insert upsert | | ASR 源 | `src/repository/mongo/kg-sources.ts` | 跨库只读 mind_base.asr_documents(源数据归 bili 域) | | 任务态 | `src/repository/mongo/kg-builds.ts` | mind_base_ts.kg_builds(**不写** Python async_tasks 表);per-uid 单活跃守卫(Python 是进程内全局单任务,语义收严) | -| 编排 | `src/kg/service.ts` | startBuild/run/processPage 七阶段(幂等检查→正文→delete-before-write→抽取→校验→写图→实体向量 best-effort→done);单页失败隔离;completion promise 供测试 | -| 检索 | `src/kg/retriever.ts` + `src/tools/chat/kg-search.ts` | 实体链接(≥0.55, top5)→BFS(maxHops=2)→evidence→实体卡片文案 + sources(与 vector_search 同构,Source 增 pageIndex 字段);降级文案与 Python 一致 | -| HTTP | `src/transport/http/routes/kg.ts` | /knowledge/kg/{build,active,status/:id,stats,subgraph} 镜像 Python 契约(前端 kgApi 零改动) | +| 编排 | `src/kg/service.ts` | 视频路径:startBuild/runVideo/processPage 七阶段;云盘路径:startCloudBuild/runCloud/processCloudDoc(枚举→正文→delete-before-write→抽取→校验→写图→实体向量 best-effort→done,kg_docs 幂等 skip);单页/单文档失败隔离;completion promise 供测试 | +| 检索 | `src/kg/retriever.ts` + `src/tools/chat/kg-search.ts` | 实体链接(≥0.55, top5)→BFS(maxHops=2)→视频 evidence + 云盘 evidence(uid 必填,uploadUuids 限定)→实体卡片文案 + sources(视频 {bvid,pageIndex} / 云盘 {uploadUuid,title});无 uid 时行为与旧版一致 | +| HTTP | `src/transport/http/routes/kg.ts` | /knowledge/kg/{build,active,status/:id,stats,subgraph};build body 增 `source:"bilibili"\|"cloud"`(默认 bilibili)与 `upload_uuids`(cloud 限定,≤200);quota 仍按次计;其余字段零改动 | | 装配 | `src/index.ts` | `kg.enabled && kgGrpc.enabled` 双门控;app-kg 不可达 → Memory 降级但打警告(数据不落盘);main agent toolNames 按需加 kg_search + prompts/main.md 注入 `KG_SEARCH_SECTION` | -## 四、已知取舍(有意决策,非遗漏) +## 五、已知取舍(有意决策,非遗漏) 1. **mind_base_ts.kg_entities 与 Python mind_base.kg_entities 互不相通**(用户确认): Python blindspot 的语义归因随旧集合陈旧而降级(其有向量回落路径);app-ts 切流 @@ -75,7 +101,7 @@ app-ts (Fastify, AI) app-kg (Go, gRPC :8013) app-ts 切流时该端点需改调 app-kg `DeleteVideo`(或路由级联到 app-ts)。 4. **per-uid 单活跃任务**(Python 为进程内全局单任务):多实例部署语义正确性前提。 -## 五、联调清单 +## 六、联调清单 1. 起 mysql + neo4j + app-kg(`docker compose --profile kg up -d app-kg`),app-ts `kgGrpc.target` 指向 127.0.0.1:8013(本机)或 app-kg:8013(compose)。 @@ -85,4 +111,4 @@ app-ts (Fastify, AI) app-kg (Go, gRPC :8013) 4. kg_search 工具冒烟(chat 内问 "X 和 Y 有什么关系");/knowledge/kg/subgraph 与前端 graph 视图字段 diff。 5. 与 Python 侧同库互读:Python blindspot `/blindspot/map` 能看到 app-ts 灌的实体 - (前提:mind_base.kg_entities 仍需 Python build 维护——见取舍 1)。 + (前提:mind_base.kg_entities 仍需 Python build 维护——见取舍 1)。 \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 772a892..28b2afe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,7 +50,7 @@ APISIX :9080(容器内;鉴权 + 路由 + 限流) | app-ts | Node 22 + Fastify(`app-ts/`) | 8000 | 127.0.0.1:8000 | 默认(ts) | AI 域唯一实现:chat/agent/RAG/KG AI/向量化/学术 agent 与科研 REST(沿用退役 Python backend 的端口) | | app-auth | Go + Gin(`app-go/app-auth/`) | 8006 | 127.0.0.1:8006 | 默认(auth) | 认证权威;APISIX forward-auth 后端 | | app-cloud | Go + Gin(`app-go/app-cloud/`) | 8007 | 127.0.0.1:8007 | 默认(cloud) | 云盘权威 + workspaces | -| app-note | Go + Gin(`app-go/app-note/`) | 8008 | 127.0.0.1:8008 | 默认(note) | 笔记权威 | +| app-note | Go + Gin(`app-go/app-note/`) | 8008 | 127.0.0.1:8018 | 默认(note) | 笔记权威(宿主调试口 8018:8008 被 app-cloud 端口池占用) | | app-bili | Go + Gin(`app-go/app-bili/`) | 8010 | 127.0.0.1:8010 | 默认(bili) | B站获取权威 + ASR | | app-quiz | Go + Gin(`app-go/app-quiz/`) | 8011 | 127.0.0.1:8011 | 默认(quiz) | quiz 存储权威 | | app-kg | Go + gRPC(`app-go/app-kg/`) | 8013 gRPC / 8014 health | 127.0.0.1:8013/8014 | 默认(kg) | KG 图存储权威(Neo4j 全部 Cypher) | diff --git a/docs/configuration.md b/docs/configuration.md index 96b0798..011b6f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,7 +19,7 @@ | app-note | `app-go/app-note/internal/config/default.yaml` | `APPNOTE__` | `MONGO__URI`/`RDBMS__URL` | | app-bili | `app-go/app-bili/internal/config/default.yaml` | `APPBILI__` | `AI_GATEWAY__*`/`MONGO__URI`/`REDIS__URL`;主密钥走 `_FILE` | | app-quiz | `app-go/app-quiz/internal/config/default.yaml` | `APPQUIZ__` | `MONGO__URI`/`REDIS__URL`/`RDBMS__URL` | -| app-kg | `app-go/app-kg/internal/config/default.yaml` | `APPKG__` | `KG__URI`/`KG__PASSWORD`(=`NEO4J_PASSWORD`)/`RDBMS__URL` | +| app-kg | `app-go/app-kg/default.yaml`(embed 进二进制) | `APPKG__` | `KG__URI`/`KG__PASSWORD`(=`NEO4J_PASSWORD`)/`RDBMS__URL` | | app-settings | `app-go/app-settings/internal/config/default.yaml` | `APPSETTINGS__` | `RDBMS__URL`/`MINIO__*` | | app-board / 三个 MCP | 各自 `default.yaml`(embed) | `APPBOARD__`/`BOARDMCP__`/`NOTEMCP__` 等 | `RDBMS__URL`/`MONGO__URI`/`REDIS__URL` | | app-task | 独立仓库 default.yaml(embed;本仓只放密钥) | `APPTASK__` | `APPTASK__RDBMS__URL`/`APPTASK__EMAIL__API_KEY` | @@ -52,4 +52,4 @@ ## 部署期密钥清单(.env 必填项) -起栈最小集:密钥文件 `secrets/api_key_encryption_key`(含主密钥,一行 base64)、`AI_GATEWAY__API_KEY`、`APISIX_CONSUMER_KEY`(服务间 key-auth)、`RDBMS__URL`/`MONGO__URI`/`REDIS__URL`/`MINIO__ACCESS_KEY`/`MINIO__SECRET_KEY`;按启用的功能补:邮件(`APPTASK_EMAIL_API_KEY`)、短信/微信(app-auth)、支付宝(app-pay)、LangSmith(可选 tracing)。逐项说明见 [.env.example](../.env.example)。 +起栈最小集:密钥文件 `secrets/api_key_encryption_key`(含主密钥,一行 base64)、`AI_GATEWAY__API_KEY`、`APISIX_CONSUMER_KEY`(服务间 key-auth)、`RDBMS__URL`/`MONGO__URI`/`REDIS__URL`/`MINIO__ACCESS_KEY`/`MINIO__SECRET_KEY`;按启用的功能补:邮件(`APPTASK_EMAIL_API_KEY`)、短信/微信(app-auth)、支付宝(app-pay)、LangSmith(可选 tracing)。逐项说明见 [.env.example](../.env.example)。 \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md index b1a55b6..6fb6ccf 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -115,7 +115,7 @@ chmod 600 secrets/api_key_encryption_key | 服务 | 健康/指标端点(容器内) | 备注 | |---|---|---| | app-ts | `:8000/health`、`:8000/agent/runtime` | health 含 Milvus 可用性;runtime 含队列/超时/breaker | -| app-go 服务 | 8006/8007/8008/8010/8011/8015 的 `/health` | app-cloud 另有 `/metrics` | +| app-go 服务 | 8006/8007/8018/8010/8011/8015 的 `/health` | app-cloud 另有 `/metrics`;app-note 容器端口仍 8008 | | app-kg | `:8014/health`(gRPC `:8013` 自带 health) | | | app-board / 三个 MCP | `:8004/8005/8009/8012/health` | app-board 为 HTTPS | | app-task | `:8001/health` | | diff --git a/docs/getting-started.md b/docs/getting-started.md index e31c6b3..ca6c689 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -164,7 +164,7 @@ NEXT_PUBLIC_APISIX_HOST=127.0.0.1:9080 npm run dev **每个 `app-go/*` 都是独立 Go module**(仓库根没有 `go.mod`/`go.work`),必须进入服务目录启动;服务会从工作目录向上读项目根 `.env`: ```bash -docker compose stop app-note # 先让出 8008 +docker compose stop app-note # 停容器后再本地起(容器调试口 8018,本地监听 8008) cd app-go/app-note APPNOTE__RDBMS__URL='mysql+aiomysql://mind_base:mind-base@127.0.0.1:3306/mind_base' \ MONGO__URI='mongodb://admin:mind-base@127.0.0.1:27017/?authSource=admin' \ @@ -236,7 +236,7 @@ docker compose --profile sandbox up -d # 再追加沙箱控制层 | MinIO 控制台 | http://127.0.0.1:9001 | 默认 `minioadmin` / `minioadmin` | | Neo4j Browser | http://127.0.0.1:7474 | 密码取 `NEO4J_PASSWORD`(默认 `mind-base`) | -其余服务只在 `127.0.0.1` 暴露调试端口(app-ts 8000、app-auth 8006、app-cloud 8007、app-note 8008、app-bili 8010、app-quiz 8011、app-kg 8013/8014、app-settings 8015、app-sandbox 8017、app-pay 8002 HTTPS、MCP 8005/8009/8012);**APISIX 不映射宿主端口**,业务流量一律经 nginx 进入。 +其余服务只在 `127.0.0.1` 暴露调试端口(app-ts 8000、app-auth 8006、app-cloud 8007、app-note 8018、app-bili 8010、app-quiz 8011、app-kg 8013/8014、app-settings 8015、app-sandbox 8017、app-pay 8002 HTTPS、MCP 8005/8009/8012);**APISIX 不映射宿主端口**,业务流量一律经 nginx 进入。 --- @@ -246,7 +246,7 @@ docker compose --profile sandbox up -d # 再追加沙箱控制层 ```bash curl -s http://127.0.0.1:8000/health # app-ts:{"status":"ok","milvus":{...}} -for p in 8006 8007 8008 8010 8011 8015 8016; do +for p in 8006 8007 8018 8010 8011 8015 8016; do printf '%s ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:$p/health" done curl -s http://127.0.0.1:8014/health # app-kg:MySQL/Neo4j 不可用 → 503 diff --git a/frontendv2/app/lab/interactive/page.tsx b/frontendv2/app/lab/interactive/page.tsx new file mode 100644 index 0000000..5935375 --- /dev/null +++ b/frontendv2/app/lab/interactive/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { InteractiveArtifactDemo } from "@/components/lab/interactive-demo"; + +export const metadata: Metadata = { + title: "交互式产物原型 · MindBase", + description: "AI 对话中可缩放的流程图与可调参数的函数图像原型", +}; + +export default function InteractiveArtifactLabPage() { + return ( +
+ +
+ ); +} diff --git a/frontendv2/components/account/account-view.tsx b/frontendv2/components/account/account-view.tsx index a1e5ff3..6545138 100644 --- a/frontendv2/components/account/account-view.tsx +++ b/frontendv2/components/account/account-view.tsx @@ -66,6 +66,17 @@ async function fetchAccount(): Promise<[ProfileData, SecurityOverview]> { return Promise.all([userApi.getProfile(), userApi.getSecurity()]); } +/** Placeholder used while the slow security overview is still loading. */ +const EMPTY_SECURITY: SecurityOverview = { + email: null, + email_verified: false, + phone: null, + phone_verified: false, + has_password: false, + oauth_bindings: [], + bilibili: { bound: false, valid: false, mid: null, nickname: null, avatar: null, message: "" }, +}; + export function AccountView() { const router = useRouter(); const [profile, setProfile] = useState(null); @@ -123,23 +134,34 @@ export function AccountView() { } }, [loadMembership]); - // Initial load. + // Initial load: profile first (fast) so the page paints immediately. + // GET /auth/security performs a **live** B站 API check (observed 2–8s on some + // networks) — blocking the whole page on it was why the account page sat on + // a spinner. Load it in the background; rows show placeholders until ready. useEffect(() => { let cancelled = false; loadMembership(); (async () => { setLoading(true); try { - const [p, s] = await fetchAccount(); + const p = await userApi.getProfile(); + if (cancelled) return; + setProfile(p); + setError(null); + setLoading(false); + userApi + .getSecurity() + .then((s) => { + if (!cancelled) setSecurity(s); + }) + .catch(() => { + /* non-fatal: security rows keep their placeholders */ + }); + } catch (e) { if (!cancelled) { - setProfile(p); - setSecurity(s); - setError(null); + setError(e instanceof Error ? e.message : "加载失败"); + setLoading(false); } - } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : "加载失败"); - } finally { - if (!cancelled) setLoading(false); } })(); return () => { @@ -177,7 +199,9 @@ export function AccountView() { ); } - if (!profile || !security) return null; + if (!profile) return null; + // security may still be loading (background B站 check): render placeholders + const sec = security ?? EMPTY_SECURITY; return (
@@ -286,27 +310,27 @@ export function AccountView() { icon={} tint="bg-orange-500/10 text-orange-600" label="密码" - value={security.has_password ? "••••••••" : "未设置"} + value={sec.has_password ? "••••••••" : "未设置"} onClick={() => setDetail("password")} /> } tint="bg-pink-500/10 text-pink-600" label="B 站账号" - value={security.bilibili.nickname || "未绑定"} + value={sec.bilibili.nickname || "未绑定"} tag={ - {security.bilibili.valid + {sec.bilibili.valid ? "可用" - : security.bilibili.bound + : sec.bilibili.bound ? "已失效" : "未绑定"} @@ -376,13 +400,13 @@ export function AccountView() { {detail === "password" && ( )} {detail === "bilibili" && ( - setBindOpen(true)} /> + setBindOpen(true)} /> )} )} @@ -484,20 +508,20 @@ function DetailHeader({ title, onBack }: { title: string; onBack: () => void }) /** B 站账号详情:授权状态行 + 说明 + 绑定按钮(扫码弹窗由父级持有)。 */ function BilibiliDetail({ - security, + security: sec, onBind, }: { security: SecurityOverview; onBind: () => void; }) { - const tone = security.bilibili.valid + const tone = sec.bilibili.valid ? "ok" - : security.bilibili.bound + : sec.bilibili.bound ? "warn" : "neutral"; - const status = security.bilibili.valid + const status = sec.bilibili.valid ? "可用" - : security.bilibili.bound + : sec.bilibili.bound ? "已失效" : "未绑定"; return ( @@ -509,7 +533,7 @@ function BilibiliDetail({
B 站账号
- {security.bilibili.nickname || "未绑定"} + {sec.bilibili.nickname || "未绑定"}
{status} @@ -517,17 +541,17 @@ function BilibiliDetail({

B 站授权用于同步收藏夹内容并构建知识库。 - {security.bilibili.bound && !security.bilibili.valid && + {sec.bilibili.bound && !sec.bilibili.valid && " 当前授权已失效,同步不可用,请重新绑定。"} - {!security.bilibili.bound && " 尚未绑定 B 站账号,绑定后即可同步收藏夹。"} + {!sec.bilibili.bound && " 尚未绑定 B 站账号,绑定后即可同步收藏夹。"}

- {!security.bilibili.valid && ( + {!sec.bilibili.valid && ( )}
diff --git a/frontendv2/components/chat/artifacts/diagram-view.tsx b/frontendv2/components/chat/artifacts/diagram-view.tsx new file mode 100644 index 0000000..15fcdd9 --- /dev/null +++ b/frontendv2/components/chat/artifacts/diagram-view.tsx @@ -0,0 +1,350 @@ +"use client"; + +// Pan/zoom renderer for AI-generated diagrams. +// +// The model emits a node/edge spec (the subset of a drawio/mxGraph diagram that +// generated flowcharts actually need); the client renders it as SVG with the +// same interaction model as a diagram viewer: wheel zoom around the cursor, +// drag to pan, "fit" to frame the whole drawing. No external viewer bundle is +// needed for this subset, so it works offline and inherits the app theme. + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { DiagramNodeSpec, DiagramSpec } from "@/lib/artifacts/interactive-spec"; + +interface Transform { + scale: number; + tx: number; + ty: number; +} + +const NODE_W = 156; +const NODE_H = 56; +const FIT_PAD = 28; + +const TONES: Record = { + default: { fill: "#ffffff", stroke: "#d2d2d7", text: "#1d1d1f" }, + accent: { fill: "#eef4fd", stroke: "#0071e3", text: "#0b4f9e" }, + success: { fill: "#edfaf1", stroke: "#34c759", text: "#1c7a3a" }, + warning: { fill: "#fff7e8", stroke: "#ff9f0a", text: "#8a5a00" }, +}; + +function nodeBox(node: DiagramNodeSpec) { + const w = node.w ?? NODE_W; + const h = node.h ?? NODE_H; + return { x: node.x, y: node.y, w, h, cx: node.x + w / 2, cy: node.y + h / 2 }; +} + +function glyphWidth(ch: string, fontSize: number): number { + // CJK glyphs are full-width, latin/digits roughly 0.58em in the UI font. + return /[\u3000-\u9fff\uff00-\uffef]/.test(ch) ? fontSize : fontSize * 0.58; +} + +/** Word-aware wrapping with a width budget (CJK breaks per character). */ +function wrapLabel(label: string, maxWidth: number, fontSize = 12.5): string[] { + const out: string[] = []; + const tokens = (text: string): string[] => text.match(/[A-Za-z0-9_.+\-/]+|\s+|./gu) ?? [text]; + for (const paragraph of label.split("\n")) { + let line = ""; + let lineWidth = 0; + const flush = () => { + if (line.trim()) out.push(line.trim()); + line = ""; + lineWidth = 0; + }; + for (const token of tokens(paragraph)) { + const width = Array.from(token).reduce((sum, ch) => sum + glyphWidth(ch, fontSize), 0); + if (lineWidth + width <= maxWidth) { + line += token; + lineWidth += width; + continue; + } + flush(); + if (width <= maxWidth) { + line = token; + lineWidth = width; + continue; + } + // A single token wider than the node: hard-break it character by character. + for (const ch of Array.from(token)) { + const cw = glyphWidth(ch, fontSize); + if (lineWidth + cw > maxWidth) flush(); + line += ch; + lineWidth += cw; + } + } + flush(); + } + return out.slice(0, 4); +} + +/** Point on the node boundary towards (tx, ty) — keeps arrowheads off the text. */ +function boundaryPoint(node: DiagramNodeSpec, towardX: number, towardY: number) { + const box = nodeBox(node); + const dx = towardX - box.cx; + const dy = towardY - box.cy; + const hw = box.w / 2 + 2; + const hh = box.h / 2 + 2; + const len = Math.hypot(dx, dy) || 1; + const sx = Math.abs(dx) > 1e-6 ? hw / Math.abs(dx) : Number.POSITIVE_INFINITY; + const sy = Math.abs(dy) > 1e-6 ? hh / Math.abs(dy) : Number.POSITIVE_INFINITY; + const t = Math.min(sx, sy); + return { x: box.cx + (dx / len) * t * len, y: box.cy + (dy / len) * t * len }; +} + +export function DiagramView({ spec, height = 320 }: { spec: DiagramSpec; height?: number }) { + const containerRef = useRef(null); + const [size, setSize] = useState({ w: 0, h: 0 }); + const [tf, setTf] = useState({ scale: 1, tx: 0, ty: 0 }); + const dragRef = useRef<{ px: number; py: number; start: Transform } | null>(null); + const fittedRef = useRef(false); + + const bounds = useMemo(() => { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const node of spec.nodes) { + const box = nodeBox(node); + minX = Math.min(minX, box.x); + minY = Math.min(minY, box.y); + maxX = Math.max(maxX, box.x + box.w); + maxY = Math.max(maxY, box.y + box.h); + } + return { + x: minX, + y: minY, + w: Math.max(1, maxX - minX), + h: Math.max(1, maxY - minY), + }; + }, [spec]); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + const rect = el.getBoundingClientRect(); + setSize({ w: rect.width, h: rect.height }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const fit = useCallback(() => { + if (size.w === 0 || size.h === 0) return; + const scale = Math.min( + (size.w - FIT_PAD * 2) / bounds.w, + (size.h - FIT_PAD * 2) / bounds.h, + 1.4, + ); + setTf({ + scale, + tx: (size.w - bounds.w * scale) / 2 - bounds.x * scale, + ty: (size.h - bounds.h * scale) / 2 - bounds.y * scale, + }); + }, [bounds, size]); + + useEffect(() => { + if (fittedRef.current || size.w === 0) return; + fittedRef.current = true; + fit(); + }, [fit, size.w]); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + const rect = el.getBoundingClientRect(); + const mx = event.clientX - rect.left; + const my = event.clientY - rect.top; + const factor = Math.exp(-event.deltaY * 0.0012); + setTf((prev) => { + const scale = Math.min(3, Math.max(0.15, prev.scale * factor)); + const k = scale / prev.scale; + return { scale, tx: mx - (mx - prev.tx) * k, ty: my - (my - prev.ty) * k }; + }); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); + + const onPointerDown = (event: React.PointerEvent) => { + dragRef.current = { px: event.clientX, py: event.clientY, start: tf }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + const onPointerMove = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + setTf({ + scale: drag.start.scale, + tx: drag.start.tx + (event.clientX - drag.px), + ty: drag.start.ty + (event.clientY - drag.py), + }); + }; + const endDrag = () => { + dragRef.current = null; + }; + + const zoomBy = (factor: number) => { + setTf((prev) => { + const scale = Math.min(3, Math.max(0.15, prev.scale * factor)); + const k = scale / prev.scale; + const cx = size.w / 2; + const cy = size.h / 2; + return { scale, tx: cx - (cx - prev.tx) * k, ty: cy - (cy - prev.ty) * k }; + }); + }; + + const nodeById = useMemo(() => { + const map = new Map(); + for (const node of spec.nodes) map.set(node.id, node); + return map; + }, [spec]); + + return ( +
+
+
+ + + + + + + + + + + + {spec.edges.map((edge, i) => { + const from = nodeById.get(edge.from); + const to = nodeById.get(edge.to); + if (!from || !to) return null; + const a = nodeBox(from); + const b = nodeBox(to); + const start = boundaryPoint(from, b.cx, b.cy); + const end = boundaryPoint(to, a.cx, a.cy); + const dx = end.x - start.x; + const dy = end.y - start.y; + const horizontal = Math.abs(dx) >= Math.abs(dy); + const c1 = horizontal + ? { x: start.x + dx * 0.5, y: start.y } + : { x: start.x, y: start.y + dy * 0.5 }; + const c2 = horizontal + ? { x: end.x - dx * 0.5, y: end.y } + : { x: end.x, y: end.y - dy * 0.5 }; + const mid = { + x: 0.125 * start.x + 0.375 * c1.x + 0.375 * c2.x + 0.125 * end.x, + y: 0.125 * start.y + 0.375 * c1.y + 0.375 * c2.y + 0.125 * end.y, + }; + return ( + + + {edge.label && ( + + {edge.label} + + )} + + ); + })} + {spec.nodes.map((node) => { + const box = nodeBox(node); + const tone = TONES[node.tone ?? "default"] ?? TONES.default; + const lines = wrapLabel(node.label, box.w - 18); + const lineH = 15; + const startY = box.cy - ((lines.length - 1) * lineH) / 2; + return ( + + {node.shape === "ellipse" ? ( + + ) : node.shape === "diamond" ? ( + + ) : ( + + )} + {lines.map((line, i) => ( + + {line} + + ))} + + ); + })} + + +
+
+ + + +
+
+

滚轮缩放 · 拖拽平移 · 「适应」重新框选全图

+
+ ); +} diff --git a/frontendv2/components/chat/artifacts/interactive-artifact.tsx b/frontendv2/components/chat/artifacts/interactive-artifact.tsx new file mode 100644 index 0000000..bf13121 --- /dev/null +++ b/frontendv2/components/chat/artifacts/interactive-artifact.tsx @@ -0,0 +1,72 @@ +"use client"; + +// Interactive artifact cards for the chat transcript. +// +// Dispatched by for kind "plot" / "diagram". Both renderers are +// trusted React components fed a validated declarative spec — no HTML/JS from +// the model, no iframe. Diagrams additionally download as a .drawio file so +// users can open/edit them in draw.io without shipping the 4.9MB viewer. + +import { Download } from "lucide-react"; +import { DiagramView } from "./diagram-view"; +import { InteractiveArtifactFrame } from "./interactive-frame"; +import { PlotView } from "./plot-view"; +import { downloadDrawioFile } from "@/lib/artifacts/drawio-export"; +import { isDiagramSpec, isPlotSpec } from "@/lib/artifacts/interactive-spec"; + +const PLOT_HEIGHT = 300; +const PLOT_EXPANDED_HEIGHT = 520; +const DIAGRAM_HEIGHT = 320; +const DIAGRAM_EXPANDED_HEIGHT = 520; + +export function InteractiveArtifact({ kind, data }: { kind: "plot" | "diagram"; data: unknown }) { + if (kind === "plot") { + if (!isPlotSpec(data)) return null; + const paramCount = data.params?.length ?? 0; + return ( +
+ 0 + ? `${data.curves.length} 条曲线 · ${paramCount} 个参数滑块` + : `${data.curves.length} 条曲线`) + } + spec={data} + expandedChildren={} + > + + +
+ ); + } + + if (!isDiagramSpec(data)) return null; + return ( +
+ } + actions={ + + } + > + + +
+ ); +} diff --git a/frontendv2/components/chat/artifacts/interactive-frame.tsx b/frontendv2/components/chat/artifacts/interactive-frame.tsx new file mode 100644 index 0000000..b64b90b --- /dev/null +++ b/frontendv2/components/chat/artifacts/interactive-frame.tsx @@ -0,0 +1,119 @@ +"use client"; + +// Artifact frame shared by the lab prototypes: header (kind badge + title + +// actions), interactive body, and a collapsible view of the raw spec the model +// emitted. In production this frame is what dispatches to for +// the new interactive kinds. + +import { useEffect, useState, type ReactNode } from "react"; +import { Braces, Maximize2, Minimize2 } from "lucide-react"; + +const KIND_META: Record = { + plot: { badge: "ƒ", label: "交互函数图像" }, + diagram: { badge: "◇", label: "交互流程图" }, +}; + +export function InteractiveArtifactFrame({ + kind, + title, + subtitle, + spec, + children, + expandedChildren, + actions, +}: { + kind: "plot" | "diagram"; + title: string; + subtitle?: string; + spec: unknown; + children: ReactNode; + expandedChildren?: ReactNode; + /** extra header buttons (e.g. "下载 .drawio") rendered before the spec toggle */ + actions?: ReactNode; +}) { + const [showSpec, setShowSpec] = useState(false); + const [expanded, setExpanded] = useState(false); + const meta = KIND_META[kind]; + + useEffect(() => { + if (!expanded) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setExpanded(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [expanded]); + + const body = ( + <> + {expanded && expandedChildren ? expandedChildren : children} + {showSpec && ( +
+          {JSON.stringify(spec, null, 2)}
+        
+ )} + + ); + + return ( + <> +
+
+ + {meta.badge} + + + {title} + + {meta.label} + {subtitle ? ` · ${subtitle}` : ""} + + + + {actions} + + + +
+
{expanded ? null : body}
+
+ + {expanded && ( +
+
+
+ + {meta.badge} + + {title} + +
+
{body}
+
+
+ )} + + ); +} diff --git a/frontendv2/components/chat/artifacts/plot-view.tsx b/frontendv2/components/chat/artifacts/plot-view.tsx new file mode 100644 index 0000000..37a1342 --- /dev/null +++ b/frontendv2/components/chat/artifacts/plot-view.tsx @@ -0,0 +1,334 @@ +"use client"; + +// Interactive function-plot renderer (canvas, no chart dependency). +// +// Reads a declarative PlotSpec: curves are expressions over x plus named +// parameters; the sliders below the canvas re-evaluate them live. Wheel zooms +// around the pointer, drag pans, double-click re-fits the y range. + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { compileExpr, type EvalFn } from "@/lib/artifacts/expr"; +import type { PlotSpec } from "@/lib/artifacts/interactive-spec"; + +interface Box { + x0: number; + x1: number; + y0: number; + y1: number; +} + +const FALLBACK_COLORS = ["#0071e3", "#ff9f0a", "#34c759", "#af52de", "#ff3b30"]; +const PAD = { left: 44, right: 14, top: 12, bottom: 26 }; + +function niceStep(span: number, target = 6): number { + const raw = span / target; + const mag = Math.pow(10, Math.floor(Math.log10(raw || 1))); + const norm = raw / mag; + const mult = norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10; + return mult * mag; +} + +function fmtTick(v: number, step: number): string { + const digits = Math.max(0, Math.min(4, -Math.floor(Math.log10(step)))); + const s = v.toFixed(digits); + return s === "-0" || s === "-0.0" ? "0" : s; +} + +export function PlotView({ spec, height = 300 }: { spec: PlotSpec; height?: number }) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const [size, setSize] = useState({ w: 0, h: 0 }); + const [params, setParams] = useState>(() => initialParams(spec)); + const [view, setView] = useState(() => ({ + x0: spec.xMin, + x1: spec.xMax, + y0: -3, + y1: 3, + })); + const fittedRef = useRef(false); + const dragRef = useRef<{ px: number; py: number; box: Box } | null>(null); + + const compiled = useMemo(() => { + const names = (spec.params ?? []).map((p) => p.name); + return spec.curves.map((curve, i) => { + const color = curve.color ?? FALLBACK_COLORS[i % FALLBACK_COLORS.length]; + try { + return { ...curve, color, fn: compileExpr(curve.expr, names), error: null as string | null }; + } catch (err) { + return { + ...curve, + color, + fn: null as EvalFn | null, + error: err instanceof Error ? err.message : String(err), + }; + } + }); + }, [spec]); + + // Size the backing store to the CSS box (device-pixel-ratio aware). + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + const rect = el.getBoundingClientRect(); + setSize({ w: rect.width, h: rect.height }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const fitY = useCallback( + (box: Box): Box => { + let lo = Number.POSITIVE_INFINITY; + let hi = Number.NEGATIVE_INFINITY; + for (const curve of compiled) { + if (!curve.fn) continue; + for (let i = 0; i <= 400; i += 1) { + const x = box.x0 + ((box.x1 - box.x0) * i) / 400; + const y = curve.fn(x, params); + if (!Number.isFinite(y) || Math.abs(y) > 1e6) continue; + lo = Math.min(lo, y); + hi = Math.max(hi, y); + } + } + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { ...box, y0: -3, y1: 3 }; + if (Math.abs(hi - lo) < 1e-6) { + lo -= 1; + hi += 1; + } + const pad = (hi - lo) * 0.12; + return { ...box, y0: lo - pad, y1: hi + pad }; + }, + [compiled, params], + ); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || size.w === 0 || size.h === 0) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(size.w * dpr); + canvas.height = Math.round(size.h * dpr); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, size.w, size.h); + + const plotW = Math.max(10, size.w - PAD.left - PAD.right); + const plotH = Math.max(10, size.h - PAD.top - PAD.bottom); + const { x0, x1, y0, y1 } = view; + const toX = (x: number) => PAD.left + ((x - x0) / (x1 - x0)) * plotW; + const toY = (y: number) => PAD.top + ((y1 - y) / (y1 - y0)) * plotH; + + // Grid + const stepX = niceStep(x1 - x0); + const stepY = niceStep(y1 - y0); + ctx.lineWidth = 1; + ctx.font = "11px ui-sans-serif, system-ui, -apple-system, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (let x = Math.ceil(x0 / stepX) * stepX; x <= x1; x += stepX) { + const px = toX(x); + ctx.strokeStyle = Math.abs(x) < stepX / 2 ? "#c7c7cc" : "#eeeef2"; + ctx.beginPath(); + ctx.moveTo(px, PAD.top); + ctx.lineTo(px, PAD.top + plotH); + ctx.stroke(); + ctx.fillStyle = "#86868b"; + ctx.fillText(fmtTick(x, stepX), px, PAD.top + plotH + 6); + } + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (let y = Math.ceil(y0 / stepY) * stepY; y <= y1; y += stepY) { + const py = toY(y); + ctx.strokeStyle = Math.abs(y) < stepY / 2 ? "#c7c7cc" : "#eeeef2"; + ctx.beginPath(); + ctx.moveTo(PAD.left, py); + ctx.lineTo(PAD.left + plotW, py); + ctx.stroke(); + ctx.fillStyle = "#86868b"; + ctx.fillText(fmtTick(y, stepY), PAD.left - 7, py); + } + + // Curves + ctx.save(); + ctx.beginPath(); + ctx.rect(PAD.left, PAD.top, plotW, plotH); + ctx.clip(); + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + for (const curve of compiled) { + if (!curve.fn) continue; + ctx.strokeStyle = curve.color; + ctx.beginPath(); + let started = false; + let prevY = 0; + const samples = Math.max(240, Math.floor(plotW * 1.5)); + for (let i = 0; i <= samples; i += 1) { + const x = x0 + ((x1 - x0) * i) / samples; + const y = curve.fn(x, params); + const px = toX(x); + const py = toY(y); + const bad = !Number.isFinite(y) || Math.abs(y - y0) > (y1 - y0) * 50; + if (bad || (started && Math.abs(py - toY(prevY)) > plotH * 4)) { + started = false; + continue; + } + if (!started) { + ctx.moveTo(px, py); + started = true; + } else { + ctx.lineTo(px, py); + } + prevY = y; + } + ctx.stroke(); + } + ctx.restore(); + }, [compiled, params, size, view]); + + useEffect(() => { + draw(); + }, [draw]); + + // First meaningful y range comes from the data, not from a fixed default. + useEffect(() => { + if (fittedRef.current || size.w === 0) return; + fittedRef.current = true; + setView((box) => fitY(box)); + }, [fitY, size.w]); + + // Wheel zoom (non-passive so the page does not scroll while zooming). + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + const rect = el.getBoundingClientRect(); + const mx = event.clientX - rect.left; + const my = event.clientY - rect.top; + const plotW = Math.max(10, rect.width - PAD.left - PAD.right); + const plotH = Math.max(10, rect.height - PAD.top - PAD.bottom); + const fx = Math.min(1, Math.max(0, (mx - PAD.left) / plotW)); + const fy = Math.min(1, Math.max(0, (my - PAD.top) / plotH)); + const factor = Math.exp(event.deltaY * 0.0012); + setView((box) => { + const ax = box.x0 + (box.x1 - box.x0) * fx; + const ay = box.y1 + (box.y0 - box.y1) * fy; + const next = { + x0: ax + (box.x0 - ax) * factor, + x1: ax + (box.x1 - ax) * factor, + y0: ay + (box.y0 - ay) * factor, + y1: ay + (box.y1 - ay) * factor, + }; + const spanX = next.x1 - next.x0; + if (spanX < 1e-4 || spanX > 1e6) return box; + return next; + }); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); + + const onPointerDown = (event: React.PointerEvent) => { + dragRef.current = { px: event.clientX, py: event.clientY, box: view }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + const onPointerMove = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + const plotW = Math.max(10, size.w - PAD.left - PAD.right); + const plotH = Math.max(10, size.h - PAD.top - PAD.bottom); + const dx = ((event.clientX - drag.px) / plotW) * (drag.box.x1 - drag.box.x0); + const dy = ((event.clientY - drag.py) / plotH) * (drag.box.y1 - drag.box.y0); + setView({ + x0: drag.box.x0 - dx, + x1: drag.box.x1 - dx, + y0: drag.box.y0 + dy, + y1: drag.box.y1 + dy, + }); + }; + const endDrag = () => { + dragRef.current = null; + }; + + const reset = () => { + const base: Box = { x0: spec.xMin, x1: spec.xMax, y0: view.y0, y1: view.y1 }; + setView(fitY({ ...base, y0: -3, y1: 3 })); + }; + + const updateParam = (name: string, value: number) => { + setParams((prev) => ({ ...prev, [name]: value })); + }; + + return ( +
+
+ {compiled.map((curve) => ( + + + {curve.label ?? curve.expr} + {curve.error && ({curve.error})} + + ))} +
+ +
+ + +
+ + {(spec.params ?? []).length > 0 && ( +
+ {(spec.params ?? []).map((param) => { + const value = params[param.name] ?? param.value; + return ( + + ); + })} +
+ )} + +

滚轮缩放 · 拖拽平移 · 双击图像复位

+
+ ); +} + +function initialParams(spec: PlotSpec): Record { + const out: Record = {}; + for (const param of spec.params ?? []) out[param.name] = param.value; + return out; +} diff --git a/frontendv2/components/chat/chat-message.tsx b/frontendv2/components/chat/chat-message.tsx index ca83912..eb83adf 100644 --- a/frontendv2/components/chat/chat-message.tsx +++ b/frontendv2/components/chat/chat-message.tsx @@ -20,6 +20,8 @@ import { isTypedArtifact, type ChatArtifact, type ChatInteraction, type ChatPlan import type { ReasoningStep } from "./types"; import { InteractionCard } from "./interaction-card"; import { PlanPanel } from "./plan-panel"; +import { InteractiveArtifact } from "./artifacts/interactive-artifact"; +import { isDiagramSpec, isPlotSpec } from "@/lib/artifacts/interactive-spec"; interface ChatMessageProps { role: "user" | "assistant"; @@ -28,7 +30,6 @@ interface ChatMessageProps { artifacts?: ChatArtifact[] | null; reasoningSteps?: ReasoningStep[] | null; reasoning?: string; - agent?: string; // Turn waiting in the backend admission queue (`queue` SSE frame); shows a // queue-position line in the pending row until the slot is granted. queuePosition?: number; @@ -288,6 +289,15 @@ function ArtifactCard({ artifact }: { artifact: ChatArtifact }) { : jsonPreview(data, 80); return ; } + // Interactive artifacts (plot / diagram): declarative specs rendered by + // trusted canvas/SVG components. Invalid payloads fall through to the JSON + // fallback below instead of breaking the message. + if (kind === "plot" && isPlotSpec(data)) { + return ; + } + if (kind === "diagram" && isDiagramSpec(data)) { + return ; + } // A structured artifact that also carries a file location (e.g. an exported // quiz PDF/image) → render it as an image preview / download link. if (artifact.url) { @@ -317,7 +327,6 @@ function ChatMessage({ artifacts, reasoningSteps, reasoning, - agent, queuePosition, subAgent, interaction, @@ -356,8 +365,11 @@ function ChatMessage({ autoInputs.hasThinking !== hasThinking ) { setAutoInputs({ content, status, hasThinking }); - if (userOpen === null && status === "pending") { - setAutoOpen(content ? false : hasThinking); + if (userOpen === null) { + // 运行中的整轮保持展开:工具步骤/思考随产生即可见(此前一旦正文开始 + // 输出就自动收起,导致"运行步骤看不到")。轮次结束后收起到胶囊, + // 用户仍可一键展开;用户手动点过开关后不再自动干预。 + setAutoOpen(status === "pending" ? hasThinking : false); } } const thinkingOpen = userOpen ?? autoOpen; @@ -469,14 +481,6 @@ function ChatMessage({
- {/* Route badge - which agent handled this turn */} - {agent && ( -
-
- )} - {/* Unified thinking panel - one toggle for the reasoning stream and the tool-execution timeline. */} {hasThinking && ( diff --git a/frontendv2/components/chat/chat-view.tsx b/frontendv2/components/chat/chat-view.tsx index 7bb5653..5bb241f 100644 --- a/frontendv2/components/chat/chat-view.tsx +++ b/frontendv2/components/chat/chat-view.tsx @@ -439,11 +439,13 @@ export function ChatView({ academicProjectId, uploadUuid }: ChatViewProps = {}) }), })); }, - onRoute: (agent: string) => { + // route frame = admission slot granted; the routed agent name is + // intentionally NOT rendered in the UI (all turns go to main). + onRoute: () => { updateActiveSession((s) => ({ ...s, messages: s.messages.map((m) => - m.id === assistantMsgId ? { ...m, agent, queuePosition: undefined } : m + m.id === assistantMsgId ? { ...m, queuePosition: undefined } : m ), })); }, @@ -1021,7 +1023,6 @@ export function ChatView({ academicProjectId, uploadUuid }: ChatViewProps = {}) artifacts={message.artifacts} reasoningSteps={message.reasoningSteps} reasoning={message.reasoning} - agent={message.agent} queuePosition={message.queuePosition} subAgent={message.subAgent} interaction={message.interaction} diff --git a/frontendv2/components/chat/types.ts b/frontendv2/components/chat/types.ts index 4ff5a28..5debe96 100644 --- a/frontendv2/components/chat/types.ts +++ b/frontendv2/components/chat/types.ts @@ -35,8 +35,6 @@ export interface ChatMessageData { // collapsed block, content loads on expand. Not persisted by the backend, // so it disappears on history reload. reasoning?: string; - // Agent name routed to by AgentOrchestrator (from the `route` SSE frame). - agent?: string; // Turn is waiting in the backend admission queue (from the `queue` SSE // frame, 1-based position). Cleared when the slot is granted (route/chunk // frames follow); if the wait times out the turn fails with a queue error. diff --git a/frontendv2/components/cloud-drive/cloud-drive-view.tsx b/frontendv2/components/cloud-drive/cloud-drive-view.tsx index 7bb41b8..52ee3ea 100644 --- a/frontendv2/components/cloud-drive/cloud-drive-view.tsx +++ b/frontendv2/components/cloud-drive/cloud-drive-view.tsx @@ -9,7 +9,7 @@ * to localStorage), chunked upload with progress, processing-status polling, * and folder/file delete. All data flows through lib/api/cloud (cloudApi). */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react"; import { useRouter } from "next/navigation"; import { AnimatePresence, motion } from "framer-motion"; import { Loader2 } from "lucide-react"; @@ -467,6 +467,11 @@ export function CloudDriveView() { // hit the API while the file is queued/running. The backend has a // second atomic guard for multi-tab/API races. const knownStatus = lastVectorStatusRef.current.get(uuid); + if (knownStatus === "done") { + // 已入库文件不再允许重复入库(界面已隐藏入口,这里兜底多标签页/竞态)。 + emitAppToast("info", "该文件已入库,无需重复入库"); + return; + } if ( processingUuids.has(uuid) || knownStatus === "pending" || @@ -506,48 +511,6 @@ export function CloudDriveView() { [processingUuids] ); - const handleReprocess = useCallback( - async (uuid: string) => { - const knownStatus = lastVectorStatusRef.current.get(uuid); - if ( - processingUuids.has(uuid) || - knownStatus === "pending" || - knownStatus === "processing" - ) { - emitAppToast("info", "该文件正在入库中,请等待完成"); - return; - } - notifiedVectorRef.current.delete(uuid); - lastVectorStatusRef.current.set(uuid, "processing"); - setProcessingUuids((prev) => new Set([...prev, uuid])); - setVideos((prev) => - prev.map((v) => - v.uploadUuid === uuid ? { ...v, vectorStatus: "processing" } : v - ) - ); - setDetail((prev) => - prev && prev.uploadUuid === uuid ? { ...prev, vectorStatus: "processing" } : prev - ); - try { - await cloudApi.reprocessVideo(uuid); - const name = fileNameRef.current.get(uuid) || "文件"; - emitAppToast("info", "已提交重新入库,正在处理「" + name + "」"); - } catch (e) { - setProcessingUuids((prev) => { - const next = new Set(prev); - next.delete(uuid); - return next; - }); - lastVectorStatusRef.current.set(uuid, knownStatus || "done"); - emitAppToast( - "error", - e instanceof Error ? "重新入库失败:" + e.message : "重新入库失败,请稍后重试" - ); - } - }, - [processingUuids] - ); - // ── app-cloud handlers ── useEffect(() => { void cloudApi.getQuota().then(setQuota).catch(() => {}); @@ -669,8 +632,46 @@ export function CloudDriveView() { ? "全部文件" : findFolderName(folders, selectedFolderId) ?? "文件夹"; + const closeInspector = useCallback(() => { + setSelectedUuid(null); + setDetail(null); + setSelectedRefId(null); + }, []); + + // 点击右侧详情面板以外的空白区域 / 按 Esc 关闭详情面板。 + // 交互控件(按钮/链接/输入框/侧栏/面板本身)内部的点击不触发关闭, + // 因此点文件行仍是正常切换选中项。 + const handleBackgroundClick = useCallback( + (e: MouseEvent) => { + if (selectedUuid == null && selectedRefId == null) return; + const target = e.target as HTMLElement | null; + if (!target) return; + if ( + target.closest( + "button, a, input, textarea, select, label, [role='button'], aside" + ) + ) { + return; + } + closeInspector(); + }, + [selectedUuid, selectedRefId, closeInspector] + ); + + useEffect(() => { + if (selectedUuid == null && selectedRefId == null) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") closeInspector(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [selectedUuid, selectedRefId, closeInspector]); + return ( -
+
{/* 手机端文件夹抽屉遮罩(md 以上隐藏) */} {foldersOpen && (
{detail ? ( @@ -893,7 +894,6 @@ export function CloudDriveView() { detail={detail} processing={processingUuids.has(detail.uploadUuid)} onProcess={(uuid) => void handleProcess(uuid)} - onReprocess={(uuid) => void handleReprocess(uuid)} onDelete={(uuid) => setDeleteFileUuid(uuid)} onBack={() => setSelectedUuid(null)} onOpenFile={handleOpenFileDetail} diff --git a/frontendv2/components/cloud-drive/file-ai-panel.tsx b/frontendv2/components/cloud-drive/file-ai-panel.tsx index 8ee8f7c..132d5d8 100644 --- a/frontendv2/components/cloud-drive/file-ai-panel.tsx +++ b/frontendv2/components/cloud-drive/file-ai-panel.tsx @@ -16,7 +16,6 @@ import { Check, Loader2, MessageSquareText, - RefreshCw, Sparkles, } from "lucide-react"; import { @@ -39,13 +38,11 @@ interface AiState { interface FileAiPanelProps { uploadUuid: string; - /** 无摘要提示里的「重新入库」动作;缺省只显示文案 */ - onReprocess?: (uuid: string) => void; /** 相似文件点击后就地打开详情;缺省跳转 /cloud-drive/ */ onSelectFile?: (uuid: string) => void; } -export function FileAiPanel({ uploadUuid, onReprocess, onSelectFile }: FileAiPanelProps) { +export function FileAiPanel({ uploadUuid, onSelectFile }: FileAiPanelProps) { const [state, setState] = useState(null); const [reloadKey, setReloadKey] = useState(0); @@ -109,9 +106,7 @@ export function FileAiPanel({ uploadUuid, onReprocess, onSelectFile }: FileAiPan ) : summary ? ( ) : ( - onReprocess(uploadUuid) : undefined} - /> + )} @@ -246,20 +241,10 @@ function SummaryBody({ ); } -function NoSummary({ onReprocess }: { onReprocess?: () => void }) { +function NoSummary() { return (
-

摘要尚未生成,可重新入库

- {onReprocess ? ( - - ) : null} +

摘要尚未生成

); } diff --git a/frontendv2/components/cloud-drive/file-inspector.tsx b/frontendv2/components/cloud-drive/file-inspector.tsx index ab31621..73a308c 100644 --- a/frontendv2/components/cloud-drive/file-inspector.tsx +++ b/frontendv2/components/cloud-drive/file-inspector.tsx @@ -28,8 +28,6 @@ interface FileInspectorProps { detail: CloudVideoDetailResponse; processing: boolean; onProcess: (uuid: string) => void; - /** done 文件的「重新入库」:走 reprocess(删除旧向量后重跑),不是普通 process */ - onReprocess?: (uuid: string) => void; onDelete: (uuid: string) => void; onBack: () => void; /** 相似文件点击:就地打开另一个文件的详情 */ @@ -40,7 +38,6 @@ export function FileInspector({ detail, processing, onProcess, - onReprocess, onDelete, onBack, onOpenFile, @@ -93,19 +90,17 @@ export function FileInspector({ {/* Action */}
{done ? ( - + + 已入库 +
) : isVectorizable ? (
diff --git a/frontendv2/components/favorites/favorites-view.tsx b/frontendv2/components/favorites/favorites-view.tsx index cbcb2af..cfd8c6f 100644 --- a/frontendv2/components/favorites/favorites-view.tsx +++ b/frontendv2/components/favorites/favorites-view.tsx @@ -27,6 +27,7 @@ import { cn } from "@/lib/utils"; import { BilibiliBindModal } from "@/components/account/bilibili-bind-modal"; import { FolderCard } from "./folder-card"; import { KgPanel } from "./kg-panel"; +import { deriveFolderStatus } from "./status-helpers"; type LoadState = "loading" | "ready" | "error"; @@ -184,6 +185,21 @@ export function FavoritesView() { const handleBuild = useCallback(async () => { if (selectedIds.size === 0 || build) return; + // 已全部入库的收藏夹不允许重复提交构建(后端只会扫到 0 个待处理分P)。 + const allIndexed = folders + .filter((f) => selectedIds.has(f.media_id)) + .every( + (f) => + deriveFolderStatus( + statusMap[f.media_id]?.indexed_count, + f.media_count, + f.last_sync_at + ).kind === "indexed" + ); + if (allIndexed) { + setBuildError("所选收藏夹已全部入库,无需重复构建"); + return; + } setBuildError(null); const folderIds = Array.from(selectedIds); setBuild({ @@ -202,7 +218,7 @@ export function FavoritesView() { setBuild(null); setBuildError(e instanceof Error ? e.message : "发起构建失败"); } - }, [selectedIds, build, pollBuild]); + }, [selectedIds, build, pollBuild, folders, statusMap]); const cancelBuildPoll = useCallback(() => { if (buildTimer.current) { @@ -317,6 +333,17 @@ export function FavoritesView() { // ─────────────────────────────────────────────── const selectedCount = selectedIds.size; const building = build !== null; + const selectedFolders = folders.filter((f) => selectedIds.has(f.media_id)); + const allSelectedIndexed = + selectedFolders.length > 0 && + selectedFolders.every( + (f) => + deriveFolderStatus( + statusMap[f.media_id]?.indexed_count, + f.media_count, + f.last_sync_at + ).kind === "indexed" + ); return ( @@ -376,7 +403,9 @@ export function FavoritesView() { 已选 {selectedCount} 个收藏夹

- 将提取音频转写并写入向量库 + {allSelectedIndexed + ? "所选收藏夹已全部入库,无需重复构建" + : "将提取音频转写并写入向量库"}

)} diff --git a/frontendv2/components/lab/interactive-demo.tsx b/frontendv2/components/lab/interactive-demo.tsx new file mode 100644 index 0000000..50a821d --- /dev/null +++ b/frontendv2/components/lab/interactive-demo.tsx @@ -0,0 +1,82 @@ +"use client"; + +// Lab prototype page: a mocked conversation that renders the production +// interactive-artifact cards (same components the chat uses), so the demo and +// the real chat cannot drift apart. Open /lab/interactive to try it. + +import { InteractiveArtifact } from "@/components/chat/artifacts/interactive-artifact"; +import { DEMO_DIAGRAM, DEMO_PLOT } from "@/lib/lab/demo-specs"; + +function UserBubble({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function AssistantBlock({ children }: { children: React.ReactNode }) { + return ( +
+ + AI + +
{children}
+
+ ); +} + +export function InteractiveArtifactDemo() { + return ( +
+
+

Lab · 原型验证

+

对话里的交互式产物

+

+ 模型不再只会输出文字:它可以产出「可交互的组件」。下面两张卡片用的是正式聊天里的同一套渲染器—— + 流程图可以滚轮缩放、拖拽平移;函数图像可以拖动参数实时变形。点开「模型输出」能看到模型实际发出的结构化数据(而不是任意 HTML)。 +

+
+ {["结构化 spec(服务端校验)", "无 iframe / 无任意脚本执行", "可全屏、可下载 .drawio", "刷新后仍保留(已持久化)"].map((tag) => ( + + {tag} + + ))} +
+
+ + 把 MindBase 的检索链路画成一张流程图,我想自己拖着看细节。 + + +

+ 画好了。这张图可以直接滚轮缩放、按住拖拽,右上角「适应」会重新框选全图,也可以全屏看;需要编辑就点「.drawio」下载。 +

+ +
+ + 再用图像解释 y = a·sin(bx + c) + d 里每个参数的作用,最好能直接拖着看。 + + +

+ 下面这张图把 a/b/c/d 做成了滑块:拖动 a 看振幅、b 看频率、c 看相位、d 看上下平移,橙色曲线是它的导数。 +

+ +
+ +
+

它是怎么来的?

+

+ agent 侧 render_plot /{" "} + render_diagram 工具校验 spec 后推入 + ResultCollector;已有的 artifact SSE 帧把{" "} + {"{ kind, data }"} 带到前端, + ArtifactCard 按 kind 分发到这里; + 回合结束时同一份数据写入消息记录,刷新历史仍会显示。协议与安全边界见{" "} + plan/research-interactive-ui/。 +

+
+
+ ); +} diff --git a/frontendv2/components/route-guard.tsx b/frontendv2/components/route-guard.tsx index fd3aff6..20b039d 100644 --- a/frontendv2/components/route-guard.tsx +++ b/frontendv2/components/route-guard.tsx @@ -19,6 +19,8 @@ const PUBLIC_PREFIXES = [ "/notes/shared", "/cloud/s", "/quiz/shared", + // Lab prototypes (no user data) — open so they can be demoed before login. + "/lab", ]; function isPublicPath(pathname: string): boolean { diff --git a/frontendv2/lib/artifacts/drawio-export.ts b/frontendv2/lib/artifacts/drawio-export.ts new file mode 100644 index 0000000..b864d4f --- /dev/null +++ b/frontendv2/lib/artifacts/drawio-export.ts @@ -0,0 +1,88 @@ +// Export an interactive diagram artifact as a real .drawio file. +// +// We do not bundle the 4.9MB drawio viewer: instead the artifact stays a light +// SVG in chat, and users who need the full editor download the diagram as +// mxGraphModel XML, which draw.io / diagrams.net open natively. + +import type { DiagramSpec } from "./interactive-spec"; + +const TONE_COLORS: Record = { + default: { fill: "#ffffff", stroke: "#d2d2d7", font: "#1d1d1f" }, + accent: { fill: "#eef4fd", stroke: "#0071e3", font: "#0b4f9e" }, + success: { fill: "#edfaf1", stroke: "#34c759", font: "#1d7a3a" }, + warning: { fill: "#fff7e8", stroke: "#ff9f0a", font: "#8a5a00" }, +}; + +function esc(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function shapeStyle(shape: string | undefined, tone: string | undefined): string { + const color = TONE_COLORS[tone ?? "default"] ?? TONE_COLORS.default; + const base = `fillColor=${color.fill};strokeColor=${color.stroke};fontColor=${color.font};strokeWidth=1.5;html=0;whiteSpace=wrap;`; + switch (shape) { + case "ellipse": + return `ellipse;${base}`; + case "diamond": + return `rhombus;${base}`; + case "rect": + return `rounded=0;${base}`; + case "round": + default: + return `rounded=1;arcSize=24;${base}`; + } +} + +/** Serialize a diagram spec to draw.io's native mxGraphModel XML. */ +export function diagramToDrawioXml(spec: DiagramSpec): string { + const cells: string[] = [ + ' ', + ' ', + ]; + for (const node of spec.nodes) { + const w = node.w ?? 156; + const h = node.h ?? 56; + cells.push( + ` ` + + ``, + ); + } + spec.edges.forEach((edge, index) => { + cells.push( + ` ` + + '', + ); + }); + return [ + '', + ` `, + ' ', + ' ', + ...cells, + ' ', + ' ', + ' ', + '', + ].join("\n"); +} + +/** Browser-only: trigger a .drawio download. */ +export function downloadDrawioFile(spec: DiagramSpec): void { + const xml = diagramToDrawioXml(spec); + const blob = new Blob([xml], { type: "application/xml" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${(spec.title || "diagram").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 60)}.drawio`; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/frontendv2/lib/artifacts/expr.ts b/frontendv2/lib/artifacts/expr.ts new file mode 100644 index 0000000..45fda3b --- /dev/null +++ b/frontendv2/lib/artifacts/expr.ts @@ -0,0 +1,256 @@ +// Safe math-expression compiler for interactive artifacts. +// +// Interactive widgets receive an expression string from the model (e.g. +// "a*sin(b*x + c) + d"). We must never eval()/new Function() untrusted output, +// so this module parses a small grammar and compiles it to a closure tree. +// +// Supported: numbers, x, declared params, + - * / ^, unary +/-, +// parentheses, implicit multiplication (2x, 3sin(x)), and a whitelist of +// functions/constants. Anything else throws at compile time. + +export type EvalFn = (x: number, params: Record) => number; + +type Node = + | { k: "num"; v: number } + | { k: "var"; name: string } + | { k: "call"; name: string; args: Node[] } + | { k: "bin"; op: string; a: Node; b: Node } + | { k: "neg"; a: Node }; + +type Token = + | { t: "num"; v: number } + | { t: "ident"; v: string } + | { t: "op"; v: string }; + +const CONSTANTS: Record = { pi: Math.PI, e: Math.E }; + +const FUNCS: Record number }> = { + sin: { arity: 1, fn: Math.sin }, + cos: { arity: 1, fn: Math.cos }, + tan: { arity: 1, fn: Math.tan }, + asin: { arity: 1, fn: Math.asin }, + acos: { arity: 1, fn: Math.acos }, + atan: { arity: 1, fn: Math.atan }, + sinh: { arity: 1, fn: Math.sinh }, + cosh: { arity: 1, fn: Math.cosh }, + tanh: { arity: 1, fn: Math.tanh }, + sqrt: { arity: 1, fn: Math.sqrt }, + abs: { arity: 1, fn: Math.abs }, + exp: { arity: 1, fn: Math.exp }, + ln: { arity: 1, fn: Math.log }, + log: { arity: 1, fn: Math.log10 }, + floor: { arity: 1, fn: Math.floor }, + ceil: { arity: 1, fn: Math.ceil }, + round: { arity: 1, fn: Math.round }, + sign: { arity: 1, fn: Math.sign }, + min: { arity: -1, fn: Math.min }, + max: { arity: -1, fn: Math.max }, + pow: { arity: 2, fn: Math.pow }, +}; + +function tokenize(src: string): Token[] { + const out: Token[] = []; + let i = 0; + while (i < src.length) { + const ch = src[i]; + if (ch === " " || ch === "\t" || ch === "\n") { + i += 1; + continue; + } + if (/[0-9.]/.test(ch)) { + let j = i; + while (j < src.length && /[0-9.]/.test(src[j])) j += 1; + const raw = src.slice(i, j); + const v = Number(raw); + if (!Number.isFinite(v)) throw new Error(`无法解析数字 "${raw}"`); + out.push({ t: "num", v }); + i = j; + continue; + } + if (/[A-Za-z_]/.test(ch)) { + let j = i; + while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j += 1; + out.push({ t: "ident", v: src.slice(i, j).toLowerCase() }); + i = j; + continue; + } + if ("+-*/^(),".includes(ch)) { + out.push({ t: "op", v: ch }); + i += 1; + continue; + } + throw new Error(`不支持的字符 "${ch}"`); + } + return out; +} + +class Parser { + private pos = 0; + + constructor( + private readonly tokens: Token[], + private readonly params: ReadonlySet, + ) {} + + parse(): Node { + const node = this.expr(); + if (this.pos < this.tokens.length) { + throw new Error("表达式末尾有多余内容"); + } + return node; + } + + private peek(): Token | undefined { + return this.tokens[this.pos]; + } + + private eatOp(v: string): boolean { + const tok = this.peek(); + if (tok?.t === "op" && tok.v === v) { + this.pos += 1; + return true; + } + return false; + } + + private expectOp(v: string): void { + if (!this.eatOp(v)) throw new Error(`缺少 "${v}"`); + } + + private expr(): Node { + let node = this.term(); + for (;;) { + if (this.eatOp("+")) node = { k: "bin", op: "+", a: node, b: this.term() }; + else if (this.eatOp("-")) node = { k: "bin", op: "-", a: node, b: this.term() }; + else return node; + } + } + + private term(): Node { + let node = this.unary(); + for (;;) { + if (this.eatOp("*")) { + node = { k: "bin", op: "*", a: node, b: this.unary() }; + continue; + } + if (this.eatOp("/")) { + node = { k: "bin", op: "/", a: node, b: this.unary() }; + continue; + } + // Implicit multiplication: "2x", "3sin(x)", "(x+1)(x-1)". + if (this.startsPrimary()) { + node = { k: "bin", op: "*", a: node, b: this.unary() }; + continue; + } + return node; + } + } + + private startsPrimary(): boolean { + const tok = this.peek(); + if (!tok) return false; + if (tok.t === "num" || tok.t === "ident") return true; + return tok.t === "op" && tok.v === "("; + } + + private unary(): Node { + if (this.eatOp("-")) return { k: "neg", a: this.unary() }; + if (this.eatOp("+")) return this.unary(); + return this.power(); + } + + private power(): Node { + const base = this.primary(); + if (this.eatOp("^")) { + // Right-associative, and the exponent may be signed: 2^-x + return { k: "bin", op: "^", a: base, b: this.unary() }; + } + return base; + } + + private primary(): Node { + const tok = this.peek(); + if (!tok) throw new Error("表达式不完整"); + + if (tok.t === "num") { + this.pos += 1; + return { k: "num", v: tok.v }; + } + + if (tok.t === "ident") { + this.pos += 1; + const name = tok.v; + if (name in FUNCS) { + const fn = FUNCS[name]; + this.expectOp("("); + const args: Node[] = []; + if (!this.eatOp(")")) { + args.push(this.expr()); + while (this.eatOp(",")) args.push(this.expr()); + this.expectOp(")"); + } + if (fn.arity !== -1 && args.length !== fn.arity) { + throw new Error(`${name}() 需要 ${fn.arity} 个参数`); + } + return { k: "call", name, args }; + } + if (name === "x") return { k: "var", name }; + if (name in CONSTANTS) return { k: "num", v: CONSTANTS[name] }; + if (this.params.has(name)) return { k: "var", name }; + throw new Error(`未知变量 "${name}"(可用的参数:${[...this.params].join(", ") || "无"})`); + } + + if (tok.t === "op" && tok.v === "(") { + this.pos += 1; + const inner = this.expr(); + this.expectOp(")"); + return inner; + } + + throw new Error(`意外的符号 "${tok.v}"`); + } +} + +function compileNode(node: Node): EvalFn { + switch (node.k) { + case "num": + return () => node.v; + case "var": + if (node.name === "x") return (x) => x; + return (_x, params) => params[node.name] ?? 0; + case "neg": { + const inner = compileNode(node.a); + return (x, p) => -inner(x, p); + } + case "call": { + const fn = FUNCS[node.name].fn; + const args = node.args.map(compileNode); + return (x, p) => fn(...args.map((a) => a(x, p))); + } + case "bin": { + const a = compileNode(node.a); + const b = compileNode(node.b); + switch (node.op) { + case "+": + return (x, p) => a(x, p) + b(x, p); + case "-": + return (x, p) => a(x, p) - b(x, p); + case "*": + return (x, p) => a(x, p) * b(x, p); + case "/": + return (x, p) => a(x, p) / b(x, p); + case "^": + return (x, p) => Math.pow(a(x, p), b(x, p)); + default: + throw new Error(`不支持的运算符 "${node.op}"`); + } + } + } +} + +/** Compile one expression against the declared parameter names. */ +export function compileExpr(src: string, paramNames: readonly string[] = []): EvalFn { + const tokens = tokenize(src); + const node = new Parser(tokens, new Set(paramNames)).parse(); + return compileNode(node); +} diff --git a/frontendv2/lib/artifacts/interactive-spec.ts b/frontendv2/lib/artifacts/interactive-spec.ts new file mode 100644 index 0000000..4ca6aaa --- /dev/null +++ b/frontendv2/lib/artifacts/interactive-spec.ts @@ -0,0 +1,135 @@ +// Declarative specs for interactive chat artifacts (plot / diagram). +// +// The model emits a small JSON spec; the client validates it and renders a +// trusted React component (canvas / SVG). Nothing executable is involved, so +// these kinds can render in the chat page without an iframe sandbox. +// +// Limits mirror app-ts/src/artifacts/interactive.ts (server-side validation). + +export const SPEC_VERSION = 1; +export const MAX_SPEC_BYTES = 32 * 1024; +export const MAX_CURVES = 4; +export const MAX_PARAMS = 8; +export const MAX_NODES = 60; +export const MAX_EDGES = 120; + +export interface PlotParamSpec { + name: string; + label?: string; + min: number; + max: number; + step: number; + value: number; +} + +export interface PlotCurveSpec { + expr: string; + label?: string; + color?: string; +} + +export interface PlotSpec { + version?: number; + id?: string; + title: string; + subtitle?: string; + xMin: number; + xMax: number; + yMin?: number; + yMax?: number; + curves: PlotCurveSpec[]; + params?: PlotParamSpec[]; +} + +export type DiagramShape = "rect" | "round" | "diamond" | "ellipse"; +export type DiagramTone = "default" | "accent" | "success" | "warning"; + +export interface DiagramNodeSpec { + id: string; + label: string; + x: number; + y: number; + w?: number; + h?: number; + shape?: DiagramShape; + tone?: DiagramTone; +} + +export interface DiagramEdgeSpec { + from: string; + to: string; + label?: string; + dashed?: boolean; +} + +export interface DiagramSpec { + version?: number; + id?: string; + title: string; + nodes: DiagramNodeSpec[]; + edges: DiagramEdgeSpec[]; +} + +export type InteractiveSpec = PlotSpec | DiagramSpec; + +const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); +const record = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); + +/** Structural guard: enough to render safely; the server has already enforced the tight limits. */ +export function isPlotSpec(value: unknown): value is PlotSpec { + if (!record(value)) return false; + const s = value as unknown as PlotSpec; + return ( + typeof s.title === "string" && + finite(s.xMin) && + finite(s.xMax) && + s.xMax > s.xMin && + Array.isArray(s.curves) && + s.curves.length > 0 && + s.curves.length <= MAX_CURVES && + s.curves.every((c) => record(c) && typeof c.expr === "string" && c.expr.length > 0 && c.expr.length <= 300) && + (s.params === undefined || + (Array.isArray(s.params) && + s.params.length <= MAX_PARAMS && + s.params.every( + (p) => + record(p) && + typeof p.name === "string" && + finite(p.min) && + finite(p.max) && + finite(p.step) && + finite(p.value) && + p.max > p.min, + ))) + ); +} + +export function isDiagramSpec(value: unknown): value is DiagramSpec { + if (!record(value)) return false; + const s = value as unknown as DiagramSpec; + return ( + typeof s.title === "string" && + Array.isArray(s.nodes) && + s.nodes.length > 0 && + s.nodes.length <= MAX_NODES && + s.nodes.every( + (n) => + record(n) && + typeof n.id === "string" && + typeof n.label === "string" && + finite(n.x) && + finite(n.y), + ) && + Array.isArray(s.edges) && + s.edges.length <= MAX_EDGES && + s.edges.every((e) => record(e) && typeof e.from === "string" && typeof e.to === "string") + ); +} + +export function specByteSize(value: unknown): number { + try { + return new TextEncoder().encode(JSON.stringify(value)).length; + } catch { + return Number.POSITIVE_INFINITY; + } +} diff --git a/frontendv2/lib/chat-stream.ts b/frontendv2/lib/chat-stream.ts index 67a7db0..b358918 100644 --- a/frontendv2/lib/chat-stream.ts +++ b/frontendv2/lib/chat-stream.ts @@ -50,9 +50,16 @@ export function isTypedArtifact(artifact: ChatArtifact): artifact is ChatTypedAr /** Stable identity per artifact, used to de-duplicate within one turn. */ export function artifactKey(artifact: ChatArtifact): string { if (!isTypedArtifact(artifact)) return artifact.url || artifact.name || ""; + // Newer kinds (plot / diagram / academic_*) carry a stable server-generated + // id; prefer it so two large specs sharing a JSON prefix cannot collide. + const data = artifact.data; + if (data && typeof data === "object" && !Array.isArray(data)) { + const id = (data as Record).id; + if (typeof id === "string" && id.length > 0) return `${artifact.kind}:${id}`; + } let payload = ""; try { - payload = JSON.stringify(artifact.data) ?? ""; + payload = JSON.stringify(data) ?? ""; } catch { payload = ""; } @@ -192,6 +199,8 @@ export interface StreamCallbacks { onError?: (message: string, meta?: { code?: string; detail?: Record }) => void; onComplete?: () => void; onStep?: (step: StreamStep) => void; + // Routing decided (backend `route` frame). Kept for protocol compatibility + // and queue-slot clearing; the routed agent name is NOT shown in the UI. onRoute?: (agent: string) => void; onReset?: () => void; onArtifact?: (artifact: ChatArtifact) => void; diff --git a/frontendv2/lib/lab/demo-specs.ts b/frontendv2/lib/lab/demo-specs.ts new file mode 100644 index 0000000..1a80dad --- /dev/null +++ b/frontendv2/lib/lab/demo-specs.ts @@ -0,0 +1,43 @@ +// Demo payloads for the lab page — what the model would emit as artifact.data. +import type { DiagramSpec, PlotSpec } from "@/lib/artifacts/interactive-spec"; + +export const DEMO_PLOT: PlotSpec = { + title: "y = a·sin(b·x + c) + d", + subtitle: "拖动滑块改变参数,滚轮缩放、拖拽平移", + xMin: -6.5, + xMax: 6.5, + curves: [ + { expr: "a*sin(b*x + c) + d", label: "f(x)", color: "#0071e3" }, + { expr: "a*b*cos(b*x + c)", label: "f′(x)", color: "#ff9f0a" }, + ], + params: [ + { name: "a", label: "振幅 a", min: -3, max: 3, step: 0.05, value: 1.4 }, + { name: "b", label: "频率 b", min: 0.2, max: 4, step: 0.02, value: 1 }, + { name: "c", label: "相位 c", min: -Math.PI, max: Math.PI, step: 0.01, value: 0 }, + { name: "d", label: "偏移 d", min: -2, max: 2, step: 0.05, value: 0 }, + ], +}; + +export const DEMO_DIAGRAM: DiagramSpec = { + title: "MindBase 对话检索链路", + nodes: [ + { id: "user", label: "用户提问", x: 0, y: 96, w: 118, h: 52, shape: "round" }, + { id: "router", label: "AgentHarness\n路由 + ReAct", x: 150, y: 90, w: 152, h: 64, shape: "round", tone: "accent" }, + { id: "plan", label: "plan 工具\n步骤规划", x: 150, y: 0, w: 140, h: 48 }, + { id: "search", label: "vector_search\nMilvus 召回", x: 336, y: 10, w: 140, h: 52, tone: "success" }, + { id: "rerank", label: "Rerank 精排", x: 336, y: 92, w: 128, h: 48, tone: "success" }, + { id: "tool", label: "工具层\n笔记 / 白板 / 代码", x: 336, y: 176, w: 140, h: 52 }, + { id: "llm", label: "LLM 生成\n流式输出", x: 496, y: 90, w: 132, h: 56, shape: "round", tone: "accent" }, + { id: "ui", label: "交互式产物\n图表 · 流程图", x: 652, y: 88, w: 148, h: 60, shape: "round", tone: "warning" }, + ], + edges: [ + { from: "user", to: "router" }, + { from: "router", to: "plan", dashed: true }, + { from: "router", to: "search" }, + { from: "search", to: "rerank" }, + { from: "rerank", to: "llm" }, + { from: "router", to: "tool" }, + { from: "tool", to: "llm" }, + { from: "llm", to: "ui" }, + ], +}; diff --git a/frontendv2/lib/lab/spec.ts b/frontendv2/lib/lab/spec.ts new file mode 100644 index 0000000..bc38b91 --- /dev/null +++ b/frontendv2/lib/lab/spec.ts @@ -0,0 +1,138 @@ +// Declarative specs for interactive artifacts. +// +// The model never ships arbitrary HTML/JS for these kinds: it emits a small +// JSON spec that the client validates and renders with trusted components. +// This is the same "declarative spec" trade-off the MCP Apps / OpenUI ecosystem +// makes for widgets that do not need a full sandboxed app. + +export interface PlotParamSpec { + name: string; + label?: string; + min: number; + max: number; + step: number; + value: number; +} + +export interface PlotCurveSpec { + expr: string; + label?: string; + color?: string; +} + +export interface PlotSpec { + kind: "plot"; + title: string; + subtitle?: string; + xMin: number; + xMax: number; + yMin?: number; + yMax?: number; + curves: PlotCurveSpec[]; + params?: PlotParamSpec[]; +} + +export type DiagramShape = "rect" | "round" | "diamond" | "ellipse"; +export type DiagramTone = "default" | "accent" | "success" | "warning"; + +export interface DiagramNodeSpec { + id: string; + label: string; + x: number; + y: number; + w?: number; + h?: number; + shape?: DiagramShape; + tone?: DiagramTone; +} + +export interface DiagramEdgeSpec { + from: string; + to: string; + label?: string; + dashed?: boolean; +} + +export interface DiagramSpec { + kind: "diagram"; + title: string; + nodes: DiagramNodeSpec[]; + edges: DiagramEdgeSpec[]; +} + +export type InteractiveSpec = PlotSpec | DiagramSpec; + +export function isPlotSpec(value: unknown): value is PlotSpec { + if (!value || typeof value !== "object") return false; + const s = value as Partial; + return ( + s.kind === "plot" && + typeof s.title === "string" && + typeof s.xMin === "number" && + typeof s.xMax === "number" && + Array.isArray(s.curves) && + s.curves.length > 0 && + s.curves.every((c) => !!c && typeof (c as PlotCurveSpec).expr === "string") && + (s.params === undefined || Array.isArray(s.params)) + ); +} + +export function isDiagramSpec(value: unknown): value is DiagramSpec { + if (!value || typeof value !== "object") return false; + const s = value as Partial; + return ( + s.kind === "diagram" && + typeof s.title === "string" && + Array.isArray(s.nodes) && + s.nodes.length > 0 && + s.nodes.every((n) => !!n && typeof n.id === "string" && typeof n.label === "string") && + Array.isArray(s.edges) + ); +} + +// --------------------------------------------------------------------------- +// Demo payloads (what the model would emit as the artifact's `data`) +// --------------------------------------------------------------------------- + +export const DEMO_PLOT: PlotSpec = { + kind: "plot", + title: "y = a·sin(b·x + c) + d", + subtitle: "拖动滑块改变参数,滚轮缩放、拖拽平移", + xMin: -6.5, + xMax: 6.5, + curves: [ + { expr: "a*sin(b*x + c) + d", label: "f(x)", color: "#0071e3" }, + { expr: "a*b*cos(b*x + c)", label: "f′(x)", color: "#ff9f0a" }, + ], + params: [ + { name: "a", label: "振幅 a", min: -3, max: 3, step: 0.05, value: 1.4 }, + { name: "b", label: "频率 b", min: 0.2, max: 4, step: 0.02, value: 1 }, + { name: "c", label: "相位 c", min: -Math.PI, max: Math.PI, step: 0.01, value: 0 }, + { name: "d", label: "偏移 d", min: -2, max: 2, step: 0.05, value: 0 }, + ], +}; + +export const DEMO_DIAGRAM: DiagramSpec = { + kind: "diagram", + title: "MindBase 对话检索链路", + nodes: [ + { id: "user", label: "用户提问", x: 0, y: 96, w: 118, h: 52, shape: "round" }, + { id: "router", label: "AgentHarness\n路由 + ReAct", x: 150, y: 90, w: 152, h: 64, shape: "round", tone: "accent" }, + { id: "plan", label: "plan 工具\n步骤规划", x: 150, y: 0, w: 140, h: 48 }, + { id: "search", label: "vector_search\nMilvus 召回", x: 336, y: 10, w: 140, h: 52, tone: "success" }, + { id: "rerank", label: "Rerank 精排", x: 336, y: 92, w: 128, h: 48, tone: "success" }, + { id: "tool", label: "工具层\n笔记 / 白板 / 代码", x: 336, y: 176, w: 140, h: 52 }, + { id: "llm", label: "LLM 生成\n流式输出", x: 496, y: 90, w: 132, h: 56, shape: "round", tone: "accent" }, + { id: "ui", label: "交互式产物\n图表 · 流程图", x: 652, y: 88, w: 148, h: 60, shape: "round", tone: "warning" }, + ], + edges: [ + { from: "user", to: "router" }, + { from: "router", to: "plan", dashed: true }, + { from: "router", to: "search" }, + { from: "search", to: "rerank" }, + { from: "rerank", to: "llm" }, + { from: "router", to: "tool" }, + { from: "tool", to: "llm" }, + { from: "llm", to: "ui" }, + ], +}; diff --git a/proto/kg/v1/kg.proto b/proto/kg/v1/kg.proto index 91d99b6..6c4bfb8 100644 --- a/proto/kg/v1/kg.proto +++ b/proto/kg/v1/kg.proto @@ -5,6 +5,12 @@ // video.kg_status/kg_version 记账 + 收藏夹 scope 解析。本服务不感知 // uid 之外的任何业务语义,不发起任何 LLM/embedding 调用。 // +// 知识点来源(source)有两类,统一进同一张 Entity/RELATES 知识图: +// - 收藏夹视频分P:(:Video {bvid}),证据边 APPEARS_IN {page_index, quote} +// - 云盘文档:(:CloudDoc {uid, upload_uuid, title, content_hash})(节点由 +// 云盘 L1 路由图创建/复用),证据边 APPEARS_IN {quote} +// 因此云盘文档的知识点与视频共用一个实体/关系空间,可跨来源检索与关联。 +// // 过渡期共享存储:Python KgGraphRepository 与本服务双写同一 Neo4j // (同 schema、确定性 eid=sha1(name_lower)[:16],merge 键 name_lower)。 // name_lower 与 eid 由调用方(app-ts resolver / Python repository) @@ -49,6 +55,21 @@ service KgGraph { // 清空图谱(危险操作;Entity/Video 标签之外的数据不动)。 rpc ClearAll(ClearAllRequest) returns (ClearAllResponse); + // ── 云盘知识点写(CloudDoc knowledge;与视频共用一个实体/关系空间)── + + // 写一份云盘文档的知识点证据:MERGE (:CloudDoc {uid, upload_uuid}) + // (复用云盘 L1 路由图节点;title/content_hash 非空才覆盖),删除该文档 + // 已有 APPEARS_IN 证据边(delete-before-write),再写 + // (entity)-[:APPEARS_IN {quote}]->(cloudDoc)。同文档同实体去重保留最短 + // 非空引文(与 LinkAppearances 同语义,无 page_index)。 + rpc LinkCloudDocAppearances(LinkCloudDocAppearancesRequest) returns (LinkCloudDocAppearancesResponse); + // 删除一份云盘文档的全部知识点证据边,并回收因此变成孤儿的 Entity + // (文档删除/重抽取前清理;CloudDoc 节点本体归云盘路由图,不在此删除)。 + rpc DeleteCloudDocAppearances(DeleteCloudDocAppearancesRequest) returns (DeleteCloudDocAppearancesResponse); + // 云盘证据回捞:uid 必填(多租户隔离);upload_uuids 空 = 该用户全部 + // 云盘文档;否则限定在这些文档内。行内含 CloudDoc.title。 + rpc FetchCloudDocEvidence(FetchCloudDocEvidenceRequest) returns (FetchCloudDocEvidenceResponse); + // ── 图读(Neo4j;FetchEvidence 额外 join video 表标题)── // 一跳邻居展开(BFS 单步;多跳循环在调用方)。 @@ -208,6 +229,32 @@ message ClearAllRequest {} message ClearAllResponse {} +// ── 云盘知识点写 ───────────────────────────────────────────────────── + +message LinkCloudDocAppearancesRequest { + int64 uid = 1; + string upload_uuid = 2; + // 非空才覆盖 CloudDoc.title;空 = 保留路由图已有值。 + string title = 3; + // 非空才覆盖 CloudDoc.content_hash;空 = 保留路由图已有值。 + string content_hash = 4; + repeated Appearance appearances = 5; +} + +message LinkCloudDocAppearancesResponse { + int64 linked = 1; +} + +message DeleteCloudDocAppearancesRequest { + int64 uid = 1; + string upload_uuid = 2; +} + +message DeleteCloudDocAppearancesResponse { + int64 deleted = 1; + int64 entities_removed = 2; +} + // ── 图读 ───────────────────────────────────────────────────────────── message NeighborsRequest { @@ -353,6 +400,28 @@ message EdgesAmongResponse { repeated EdgeRow rows = 1; } +message FetchCloudDocEvidenceRequest { + repeated string eids = 1; + // 必填:多租户隔离按 CloudDoc.uid 过滤。 + int64 uid = 2; + // 空 = 该用户全部云盘文档;否则限定 upload_uuid 集合。 + repeated string upload_uuids = 3; + int32 limit = 4; +} + +message CloudDocEvidenceRow { + string eid = 1; + string name = 2; + string type = 3; + string upload_uuid = 4; + string quote = 5; + string title = 6; +} + +message FetchCloudDocEvidenceResponse { + repeated CloudDocEvidenceRow rows = 1; +} + message StatsRequest {} message StatsResponse { @@ -361,4 +430,7 @@ message StatsResponse { int64 evidence = 3; int64 videos = 4; int64 pending_pages = 5; -} + // 云盘知识点计数(全局,与 entities/relations 同口径)。 + int64 cloud_docs = 6; + int64 cloud_evidence = 7; +} \ No newline at end of file