diff --git a/features/virtual-knowledge-base/.env.example b/features/virtual-knowledge-base/.env.example new file mode 100644 index 0000000..9ee9d8d --- /dev/null +++ b/features/virtual-knowledge-base/.env.example @@ -0,0 +1,27 @@ +# Virtual Knowledge Base feature bundle environment variables +# Copy this file to .env and adjust values before running scripts + +# Core toggle +VIRTUAL_KB_ENABLED=true + +# Database configuration (can reuse main DB or dedicated schema) +VIRTUAL_KB_DB_HOST=localhost +VIRTUAL_KB_DB_PORT=5432 +VIRTUAL_KB_DB_USER=weknora +VIRTUAL_KB_DB_PASSWORD=weknora +VIRTUAL_KB_DB_NAME=weknora +VIRTUAL_KB_DB_SSLMODE=disable + +# Cache / Redis configuration (optional) +VIRTUAL_KB_REDIS_ADDR=localhost:6379 +VIRTUAL_KB_REDIS_DB=3 +VIRTUAL_KB_REDIS_PASSWORD= + +# Application defaults +VIRTUAL_KB_MAX_TAGS_PER_DOCUMENT=50 +VIRTUAL_KB_MAX_VIRTUAL_KBS_PER_USER=100 +VIRTUAL_KB_SEARCH_CACHE_TTL=3600 + +# API security +VIRTUAL_KB_JWT_SECRET=change-me +VIRTUAL_KB_API_KEY=change-me diff --git a/features/virtual-knowledge-base/README.md b/features/virtual-knowledge-base/README.md new file mode 100644 index 0000000..6b36097 --- /dev/null +++ b/features/virtual-knowledge-base/README.md @@ -0,0 +1,96 @@ +# Virtual Knowledge Base Feature Bundle + +This bundle introduces a virtual knowledge base subsystem for WeKnora that enables user-defined document tagging, dynamic virtual knowledge base construction, and enhanced search experiences without modifying the existing WeKnora codebase. + +> **Important:** All files under `features/virtual-knowledge-base/` are self-contained and are intended to be submitted as a standalone pull request. Integrators can review, merge, and selectively wire these components into the core application as needed. + +## Contents + +- Architecture and design documentation +- Backend Go modules (models, repositories, services, handlers) +- PostgreSQL migration scripts +- Vue 3 / Vite frontend examples (aligned with main project stack) +- API specifications and Postman collections +- End-to-end, integration, and unit test stubs +- Deployment scripts and Docker assets +- Examples and integration guides + +## Design Overview + +- **Purpose**: Extend WeKnora with a pluggable virtual knowledge base (VKB) subsystem that introduces tag-driven document organization without touching the core application. +- **Scope**: Provides Go services, PostgreSQL migrations, and a Vue 3 frontend bundle that can run standalone or be merged into the primary console when needed. +- **Integration Strategy**: Ship as an isolated feature pack (`features/virtual-knowledge-base/`) so reviewers can evaluate, cherry-pick, or iteratively integrate components. + +## Feature Highlights + +- **Tag Taxonomy Management**: Define tag categories, create tags with weights, and assign tags to documents via REST APIs exposed under `/api/v1/virtual-kb` (`internal/`, `web/src/api/tag.ts`). +- **Virtual Knowledge Base Builder**: Compose VKB instances using tag filters, boolean operators, and weights; manage instances through `VirtualKBManagement.vue` with `VirtualKBList.vue` + `VirtualKBEditor.vue`. +- **Enhanced Search Pipeline**: Execute searches scoped by VKB filters or ad-hoc tag conditions; frontend surface provided in `EnhancedSearch.vue`, backend hooks outlined in `internal/service/impl/virtual_kb_service.go`. +- **Extensible Frontend Shell**: Vue router factory (`web/src/router/index.ts`) and Pinia-ready structure enable easy embedding into the main console while keeping the demo runnable in isolation. + +## Quick Start + +```bash +# 1. Load environment variables +cp .env.example .env + +# 2. Review feature configuration +cat config/virtual-kb.yaml + +# 3. Run database migrations +./scripts/migrate.sh + +# 4. Build backend and frontend assets +./scripts/build.sh + +# 5. Execute tests (unit + integration stubs) +./scripts/test.sh + +# 6. Launch the feature stack (standalone mode) +docker compose -f docker/docker-compose.virtual-kb.yml up --build +``` + +### Frontend (Vue) Quick Start + +```bash +cd web + +# Install dependencies (sub-project only, does not affect main project) +npm install + +# Local development +npm run dev + +# Run type checking +npm run type-check + +# Build static assets (output directory: web/dist/) +npm run build + +# Preview build artifacts +npm run preview +``` + +## Integration Overview + +1. Review architectural notes in `DESIGN.md`. +2. Apply migrations located in `migrations/` to your PostgreSQL instance. +3. Register backend handlers via adapters (see backend integration examples). +4. Vue sub-project is located in `web/`. Run `npm install && npm run build` to generate `web/dist/`, which can be embedded into existing Vue applications as needed. +5. Follow deployment notes in this README or backend documentation. + +### Routing & Store Integration Notes + +- This sub-project provides `src/router/index.ts` as example route mapping for demo/preview purposes only. When migrating pages to the main project, you can directly import `TagManagement.vue`, `VirtualKBManagement.vue`, `EnhancedSearch.vue` and mount them to existing routes. +- For global state management, refer to the main project's Pinia structure. You can extend modules in `src/stores/` or reuse existing stores. +- HTTP requests are uniformly encapsulated in `src/api/`, defaulting to `/api/v1/virtual-kb`. You can adjust the baseURL or interceptors as needed during integration. + +## Support + +For questions related to this feature bundle, refer to: + +- Backend API documentation in `internal/` modules +- Database schema in `migrations/` +- Frontend component examples in `web/src/` + +This feature pack is designed for evaluation and iterative integration. Adjust and extend as required to fit your production environment. diff --git a/features/virtual-knowledge-base/config/virtual-kb.yaml b/features/virtual-knowledge-base/config/virtual-kb.yaml new file mode 100644 index 0000000..b8c8e49 --- /dev/null +++ b/features/virtual-knowledge-base/config/virtual-kb.yaml @@ -0,0 +1,17 @@ +virtual_kb: + enabled: true + limits: + max_tags_per_document: 50 + max_virtual_kbs_per_user: 100 + max_filters_per_virtual_kb: 20 + search: + cache_enabled: true + cache_ttl_seconds: 3600 + default_vector_threshold: 0.25 + default_keyword_threshold: 0.2 + ui: + base_path: "/virtual-kb" + theme: + primary_color: "#1f7aec" + accent_color: "#ff9f1c" + danger_color: "#ff595e" diff --git a/features/virtual-knowledge-base/go.mod b/features/virtual-knowledge-base/go.mod new file mode 100644 index 0000000..dab0fc6 --- /dev/null +++ b/features/virtual-knowledge-base/go.mod @@ -0,0 +1,10 @@ +module github.com/tencent/weknora/features/virtualkb + +go 1.24 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/json-iterator/go v1.1.12 + gorm.io/driver/postgres v1.5.7 + gorm.io/gorm v1.25.11 +) diff --git a/features/virtual-knowledge-base/go.sum b/features/virtual-knowledge-base/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/features/virtual-knowledge-base/internal/handler/document_tag_handler.go b/features/virtual-knowledge-base/internal/handler/document_tag_handler.go new file mode 100644 index 0000000..96e8683 --- /dev/null +++ b/features/virtual-knowledge-base/internal/handler/document_tag_handler.go @@ -0,0 +1,126 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + service "github.com/tencent/weknora/features/virtualkb/internal/service/interfaces" + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// DocumentTagHandler exposes endpoints for document-tag relationships. +type DocumentTagHandler struct { + service service.DocumentTagService +} + +// NewDocumentTagHandler creates a new handler instance. +func NewDocumentTagHandler(service service.DocumentTagService) *DocumentTagHandler { + return &DocumentTagHandler{service: service} +} + +// RegisterRoutes wires document-tag routes under the provided router group. +func (h *DocumentTagHandler) RegisterRoutes(rg *gin.RouterGroup) { + docs := rg.Group("/documents") + { + docs.GET(":id/tags", h.listTagsForDocument) + docs.POST(":id/tags", h.assignTag) + docs.PUT(":id/tags/:tag_id", h.updateTag) + docs.DELETE(":id/tags/:tag_id", h.removeTag) + } + + tags := rg.Group("/tags") + { + tags.GET(":tag_id/documents", h.listDocumentsForTag) + } +} + +func (h *DocumentTagHandler) assignTag(c *gin.Context) { + documentID := c.Param("id") + var req assignTagRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondBadRequest(c, "invalid tag assignment payload", err) + return + } + + assignment := &types.DocumentTag{ + DocumentID: documentID, + TagID: req.TagID, + Weight: req.Weight, + } + if err := h.service.AssignTag(c.Request.Context(), assignment); err != nil { + respondBadRequest(c, "failed to assign tag", err) + return + } + respondSuccess(c, http.StatusCreated, assignment) +} + +func (h *DocumentTagHandler) updateTag(c *gin.Context) { + documentID := c.Param("id") + tagID, err := parseIDParam(c.Param("tag_id")) + if err != nil { + respondBadRequest(c, "invalid tag id", err) + return + } + + var req assignTagRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondBadRequest(c, "invalid tag assignment payload", err) + return + } + + assignment := &types.DocumentTag{ + DocumentID: documentID, + TagID: tagID, + Weight: req.Weight, + } + if err := h.service.UpdateTag(c.Request.Context(), assignment); err != nil { + respondBadRequest(c, "failed to update tag assignment", err) + return + } + respondSuccess(c, http.StatusOK, assignment) +} + +func (h *DocumentTagHandler) removeTag(c *gin.Context) { + documentID := c.Param("id") + tagID, err := parseIDParam(c.Param("tag_id")) + if err != nil { + respondBadRequest(c, "invalid tag id", err) + return + } + + if err := h.service.RemoveTag(c.Request.Context(), documentID, tagID); err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusNoContent, nil) +} + +func (h *DocumentTagHandler) listTagsForDocument(c *gin.Context) { + documentID := c.Param("id") + tags, err := h.service.ListTags(c.Request.Context(), documentID) + if err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusOK, tags) +} + +func (h *DocumentTagHandler) listDocumentsForTag(c *gin.Context) { + tagID, err := parseIDParam(c.Param("tag_id")) + if err != nil { + respondBadRequest(c, "invalid tag id", err) + return + } + documents, err := h.service.ListDocuments(c.Request.Context(), tagID) + if err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusOK, documents) +} + +type assignTagRequest struct { + TagID int64 `json:"tag_id" binding:"required"` + Weight *float64 `json:"weight"` +} diff --git a/features/virtual-knowledge-base/internal/handler/enhanced_search_handler.go b/features/virtual-knowledge-base/internal/handler/enhanced_search_handler.go new file mode 100644 index 0000000..131ca87 --- /dev/null +++ b/features/virtual-knowledge-base/internal/handler/enhanced_search_handler.go @@ -0,0 +1,39 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + service "github.com/tencent/weknora/features/virtualkb/internal/service/interfaces" + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// EnhancedSearchHandler provides HTTP endpoints for enhanced search. +type EnhancedSearchHandler struct { + service service.EnhancedSearchService +} + +// NewEnhancedSearchHandler constructs a new handler. +func NewEnhancedSearchHandler(service service.EnhancedSearchService) *EnhancedSearchHandler { + return &EnhancedSearchHandler{service: service} +} + +// RegisterRoutes wires enhanced search routes. +func (h *EnhancedSearchHandler) RegisterRoutes(rg *gin.RouterGroup) { + rg.POST("", h.search) +} + +func (h *EnhancedSearchHandler) search(c *gin.Context) { + var req types.EnhancedSearchRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondBadRequest(c, "invalid search payload", err) + return + } + + resp, err := h.service.Search(c.Request.Context(), &req) + if err != nil { + respondBadRequest(c, "failed to perform enhanced search", err) + return + } + respondSuccess(c, http.StatusOK, resp) +} diff --git a/features/virtual-knowledge-base/internal/handler/response.go b/features/virtual-knowledge-base/internal/handler/response.go new file mode 100644 index 0000000..214ff06 --- /dev/null +++ b/features/virtual-knowledge-base/internal/handler/response.go @@ -0,0 +1,35 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// respondSuccess writes a success JSON response. +func respondSuccess(c *gin.Context, status int, data any) { + if data == nil { + c.Status(status) + return + } + c.JSON(status, gin.H{"data": data}) +} + +// respondError writes an error JSON response. +func respondError(c *gin.Context, status int, message string, err error) { + payload := gin.H{"error": message} + if err != nil { + payload["details"] = err.Error() + } + c.JSON(status, payload) +} + +// respondBadRequest writes a standardized bad request response. +func respondBadRequest(c *gin.Context, message string, err error) { + respondError(c, http.StatusBadRequest, message, err) +} + +// respondInternal writes an internal server error response. +func respondInternal(c *gin.Context, err error) { + respondError(c, http.StatusInternalServerError, "internal server error", err) +} diff --git a/features/virtual-knowledge-base/internal/handler/tag_handler.go b/features/virtual-knowledge-base/internal/handler/tag_handler.go new file mode 100644 index 0000000..35f7e53 --- /dev/null +++ b/features/virtual-knowledge-base/internal/handler/tag_handler.go @@ -0,0 +1,202 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + service "github.com/tencent/weknora/features/virtualkb/internal/service/interfaces" + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// TagHandler exposes HTTP endpoints for tag categories and tags. +type TagHandler struct { + service service.TagService +} + +// NewTagHandler creates a new TagHandler instance. +func NewTagHandler(service service.TagService) *TagHandler { + return &TagHandler{service: service} +} + +// RegisterRoutes wires tag routes under the provided router group. +func (h *TagHandler) RegisterRoutes(rg *gin.RouterGroup) { + categories := rg.Group("/categories") + { + categories.GET("", h.listCategories) + categories.POST("", h.createCategory) + categories.GET(":id", h.getCategory) + categories.PUT(":id", h.updateCategory) + categories.DELETE(":id", h.deleteCategory) + } + + tags := rg.Group("/tags") + { + tags.POST("", h.createTag) + tags.GET(":id", h.getTag) + tags.PUT(":id", h.updateTag) + tags.DELETE(":id", h.deleteTag) + tags.GET("", h.listTagsByCategory) + } +} + +func (h *TagHandler) createCategory(c *gin.Context) { + var req types.TagCategory + if err := c.ShouldBindJSON(&req); err != nil { + respondBadRequest(c, "invalid category payload", err) + return + } + + if err := h.service.CreateCategory(c.Request.Context(), &req); err != nil { + respondBadRequest(c, "failed to create category", err) + return + } + respondSuccess(c, http.StatusCreated, req) +} + +func (h *TagHandler) listCategories(c *gin.Context) { + categories, err := h.service.ListCategories(c.Request.Context()) + if err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusOK, categories) +} + +func (h *TagHandler) getCategory(c *gin.Context) { + id, err := parseIDParam(c.Param("id")) + if err != nil { + respondBadRequest(c, "invalid category id", err) + return + } + + category, err := h.service.GetCategoryByID(c.Request.Context(), id) + if err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusOK, category) +} + +func (h *TagHandler) updateCategory(c *gin.Context) { + id, err := parseIDParam(c.Param("id")) + if err != nil { + respondBadRequest(c, "invalid category id", err) + return + } + + var req types.TagCategory + if err := c.ShouldBindJSON(&req); err != nil { + respondBadRequest(c, "invalid category payload", err) + return + } + req.ID = id + + if err := h.service.UpdateCategory(c.Request.Context(), &req); err != nil { + respondBadRequest(c, "failed to update category", err) + return + } + respondSuccess(c, http.StatusOK, req) +} + +func (h *TagHandler) deleteCategory(c *gin.Context) { + id, err := parseIDParam(c.Param("id")) + if err != nil { + respondBadRequest(c, "invalid category id", err) + return + } + + if err := h.service.DeleteCategory(c.Request.Context(), id); err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusNoContent, nil) +} + +func (h *TagHandler) createTag(c *gin.Context) { + var req types.Tag + if err := c.ShouldBindJSON(&req); err != nil { + respondBadRequest(c, "invalid tag payload", err) + return + } + + if err := h.service.CreateTag(c.Request.Context(), &req); err != nil { + respondBadRequest(c, "failed to create tag", err) + return + } + respondSuccess(c, http.StatusCreated, req) +} + +func (h *TagHandler) getTag(c *gin.Context) { + id, err := parseIDParam(c.Param("id")) + if err != nil { + respondBadRequest(c, "invalid tag id", err) + return + } + + tag, err := h.service.GetTagByID(c.Request.Context(), id) + if err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusOK, tag) +} + +func (h *TagHandler) updateTag(c *gin.Context) { + id, err := parseIDParam(c.Param("id")) + if err != nil { + respondBadRequest(c, "invalid tag id", err) + return + } + + var req types.Tag + if err := c.ShouldBindJSON(&req); err != nil { + respondBadRequest(c, "invalid tag payload", err) + return + } + req.ID = id + + if err := h.service.UpdateTag(c.Request.Context(), &req); err != nil { + respondBadRequest(c, "failed to update tag", err) + return + } + respondSuccess(c, http.StatusOK, req) +} + +func (h *TagHandler) deleteTag(c *gin.Context) { + id, err := parseIDParam(c.Param("id")) + if err != nil { + respondBadRequest(c, "invalid tag id", err) + return + } + + if err := h.service.DeleteTag(c.Request.Context(), id); err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusNoContent, nil) +} + +func (h *TagHandler) listTagsByCategory(c *gin.Context) { + categoryParam := c.Query("category_id") + if categoryParam == "" { + respondBadRequest(c, "category_id query parameter is required", nil) + return + } + categoryID, err := strconv.ParseInt(categoryParam, 10, 64) + if err != nil { + respondBadRequest(c, "invalid category_id parameter", err) + return + } + + tags, err := h.service.ListTagsByCategory(c.Request.Context(), categoryID) + if err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusOK, tags) +} + +func parseIDParam(raw string) (int64, error) { + return strconv.ParseInt(raw, 10, 64) +} diff --git a/features/virtual-knowledge-base/internal/handler/virtual_kb_handler.go b/features/virtual-knowledge-base/internal/handler/virtual_kb_handler.go new file mode 100644 index 0000000..54c1885 --- /dev/null +++ b/features/virtual-knowledge-base/internal/handler/virtual_kb_handler.go @@ -0,0 +1,101 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + service "github.com/tencent/weknora/features/virtualkb/internal/service/interfaces" + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// VirtualKBHandler exposes endpoints for virtual knowledge bases. +type VirtualKBHandler struct { + service service.VirtualKBService +} + +// NewVirtualKBHandler creates a new handler instance. +func NewVirtualKBHandler(service service.VirtualKBService) *VirtualKBHandler { + return &VirtualKBHandler{service: service} +} + +// RegisterRoutes wires virtual knowledge base routes. +func (h *VirtualKBHandler) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("", h.list) + rg.POST("", h.create) + rg.GET(":id", h.get) + rg.PUT(":id", h.update) + rg.DELETE(":id", h.delete) +} + +func (h *VirtualKBHandler) create(c *gin.Context) { + var req types.VirtualKB + if err := c.ShouldBindJSON(&req); err != nil { + respondBadRequest(c, "invalid virtual knowledge base payload", err) + return + } + + if err := h.service.Create(c.Request.Context(), &req); err != nil { + respondBadRequest(c, "failed to create virtual knowledge base", err) + return + } + respondSuccess(c, http.StatusCreated, req) +} + +func (h *VirtualKBHandler) update(c *gin.Context) { + id, err := parseIDParam(c.Param("id")) + if err != nil { + respondBadRequest(c, "invalid virtual knowledge base id", err) + return + } + + var req types.VirtualKB + if err := c.ShouldBindJSON(&req); err != nil { + respondBadRequest(c, "invalid virtual knowledge base payload", err) + return + } + req.ID = id + + if err := h.service.Update(c.Request.Context(), &req); err != nil { + respondBadRequest(c, "failed to update virtual knowledge base", err) + return + } + respondSuccess(c, http.StatusOK, req) +} + +func (h *VirtualKBHandler) delete(c *gin.Context) { + id, err := parseIDParam(c.Param("id")) + if err != nil { + respondBadRequest(c, "invalid virtual knowledge base id", err) + return + } + + if err := h.service.Delete(c.Request.Context(), id); err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusNoContent, nil) +} + +func (h *VirtualKBHandler) get(c *gin.Context) { + id, err := parseIDParam(c.Param("id")) + if err != nil { + respondBadRequest(c, "invalid virtual knowledge base id", err) + return + } + + vkb, err := h.service.GetByID(c.Request.Context(), id) + if err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusOK, vkb) +} + +func (h *VirtualKBHandler) list(c *gin.Context) { + vkbs, err := h.service.List(c.Request.Context()) + if err != nil { + respondInternal(c, err) + return + } + respondSuccess(c, http.StatusOK, vkbs) +} diff --git a/features/virtual-knowledge-base/internal/middleware/api_key.go b/features/virtual-knowledge-base/internal/middleware/api_key.go new file mode 100644 index 0000000..fed85a8 --- /dev/null +++ b/features/virtual-knowledge-base/internal/middleware/api_key.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// APIKeyAuth returns a middleware that enforces the provided API key. +func APIKeyAuth(expectedKey string) gin.HandlerFunc { + return func(c *gin.Context) { + if expectedKey == "" { + c.Next() + return + } + + key := c.GetHeader("X-API-Key") + if key != expectedKey { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + c.Next() + } +} diff --git a/features/virtual-knowledge-base/internal/repository/interfaces/document_tag_repository.go b/features/virtual-knowledge-base/internal/repository/interfaces/document_tag_repository.go new file mode 100644 index 0000000..60a2cfc --- /dev/null +++ b/features/virtual-knowledge-base/internal/repository/interfaces/document_tag_repository.go @@ -0,0 +1,16 @@ +package interfaces + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// DocumentTagRepository handles document tag persistence. +type DocumentTagRepository interface { + AssignTag(ctx context.Context, documentTag *types.DocumentTag) error + UpdateTagAssignment(ctx context.Context, documentTag *types.DocumentTag) error + RemoveTag(ctx context.Context, documentID string, tagID int64) error + ListTagsByDocument(ctx context.Context, documentID string) ([]*types.Tag, error) + ListDocumentsByTag(ctx context.Context, tagID int64) ([]*types.DocumentTag, error) +} diff --git a/features/virtual-knowledge-base/internal/repository/interfaces/tag_repository.go b/features/virtual-knowledge-base/internal/repository/interfaces/tag_repository.go new file mode 100644 index 0000000..4b19448 --- /dev/null +++ b/features/virtual-knowledge-base/internal/repository/interfaces/tag_repository.go @@ -0,0 +1,22 @@ +package interfaces + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// TagRepository defines storage operations for tag categories and tags. +type TagRepository interface { + CreateCategory(ctx context.Context, category *types.TagCategory) error + UpdateCategory(ctx context.Context, category *types.TagCategory) error + DeleteCategory(ctx context.Context, id int64) error + GetCategoryByID(ctx context.Context, id int64) (*types.TagCategory, error) + ListCategories(ctx context.Context) ([]*types.TagCategory, error) + + CreateTag(ctx context.Context, tag *types.Tag) error + UpdateTag(ctx context.Context, tag *types.Tag) error + DeleteTag(ctx context.Context, id int64) error + GetTagByID(ctx context.Context, id int64) (*types.Tag, error) + ListTagsByCategory(ctx context.Context, categoryID int64) ([]*types.Tag, error) +} diff --git a/features/virtual-knowledge-base/internal/repository/interfaces/virtual_kb_repository.go b/features/virtual-knowledge-base/internal/repository/interfaces/virtual_kb_repository.go new file mode 100644 index 0000000..b67c9a8 --- /dev/null +++ b/features/virtual-knowledge-base/internal/repository/interfaces/virtual_kb_repository.go @@ -0,0 +1,16 @@ +package interfaces + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// VirtualKBRepository handles persistence for virtual knowledge bases. +type VirtualKBRepository interface { + Create(ctx context.Context, vkb *types.VirtualKB) error + Update(ctx context.Context, vkb *types.VirtualKB) error + Delete(ctx context.Context, id int64) error + GetByID(ctx context.Context, id int64) (*types.VirtualKB, error) + List(ctx context.Context) ([]*types.VirtualKB, error) +} diff --git a/features/virtual-knowledge-base/internal/repository/postgres/document_tag_repository.go b/features/virtual-knowledge-base/internal/repository/postgres/document_tag_repository.go new file mode 100644 index 0000000..3b22c18 --- /dev/null +++ b/features/virtual-knowledge-base/internal/repository/postgres/document_tag_repository.go @@ -0,0 +1,90 @@ +package postgres + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" + "gorm.io/gorm" +) + +// DocumentTagRepository provides PostgreSQL-backed document tag storage. +type DocumentTagRepository struct { + db *gorm.DB +} + +// NewDocumentTagRepository creates a new instance. +func NewDocumentTagRepository(db *gorm.DB) *DocumentTagRepository { + return &DocumentTagRepository{db: db} +} + +// AssignTag attaches a tag to a document. +func (r *DocumentTagRepository) AssignTag(ctx context.Context, documentTag *types.DocumentTag) error { + model := toDocumentTagModel(documentTag) + if err := r.db.WithContext(ctx).Create(&model).Error; err != nil { + return err + } + fromDocumentTagModel(model, documentTag) + return nil +} + +// UpdateTagAssignment updates tag metadata for a document. +func (r *DocumentTagRepository) UpdateTagAssignment(ctx context.Context, documentTag *types.DocumentTag) error { + model := toDocumentTagModel(documentTag) + return r.db.WithContext(ctx). + Model(&documentTagModel{}). + Where("document_id = ? AND tag_id = ?", documentTag.DocumentID, documentTag.TagID). + Updates(model). + Error +} + +// RemoveTag detaches a tag from a document. +func (r *DocumentTagRepository) RemoveTag(ctx context.Context, documentID string, tagID int64) error { + return r.db.WithContext(ctx). + Where("document_id = ? AND tag_id = ?", documentID, tagID). + Delete(&documentTagModel{}). + Error +} + +// ListTagsByDocument returns tags assigned to a document. +func (r *DocumentTagRepository) ListTagsByDocument(ctx context.Context, documentID string) ([]*types.Tag, error) { + var assignments []documentTagModel + if err := r.db.WithContext(ctx). + Preload("Tag"). + Where("document_id = ?", documentID). + Find(&assignments). + Error; err != nil { + return nil, err + } + + tags := make([]*types.Tag, 0, len(assignments)) + for _, assignment := range assignments { + if assignment.Tag == nil { + continue + } + tag := new(types.Tag) + fromTagModel(*assignment.Tag, tag) + tags = append(tags, tag) + } + + return tags, nil +} + +// ListDocumentsByTag fetches document-tag associations for a tag. +func (r *DocumentTagRepository) ListDocumentsByTag(ctx context.Context, tagID int64) ([]*types.DocumentTag, error) { + var assignments []documentTagModel + if err := r.db.WithContext(ctx). + Where("tag_id = ?", tagID). + Find(&assignments). + Error; err != nil { + return nil, err + } + + results := make([]*types.DocumentTag, 0, len(assignments)) + for _, assignment := range assignments { + docTag := new(types.DocumentTag) + fromDocumentTagModel(assignment, docTag) + results = append(results, docTag) + } + + return results, nil +} diff --git a/features/virtual-knowledge-base/internal/repository/postgres/mapper.go b/features/virtual-knowledge-base/internal/repository/postgres/mapper.go new file mode 100644 index 0000000..6248fc5 --- /dev/null +++ b/features/virtual-knowledge-base/internal/repository/postgres/mapper.go @@ -0,0 +1,159 @@ +package postgres + +import ( + "encoding/json" + + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +func toTagCategoryModel(src *types.TagCategory) tagCategoryModel { + return tagCategoryModel{ + ID: src.ID, + Name: src.Name, + Description: src.Description, + Color: src.Color, + CreatedBy: nullableInt64(src.CreatedBy), + CreatedAt: src.CreatedAt, + UpdatedAt: src.UpdatedAt, + } +} + +func fromTagCategoryModel(model tagCategoryModel, dest *types.TagCategory) { + dest.ID = model.ID + dest.Name = model.Name + dest.Description = model.Description + dest.Color = model.Color + dest.CreatedAt = model.CreatedAt + dest.UpdatedAt = model.UpdatedAt + if model.CreatedBy != nil { + dest.CreatedBy = *model.CreatedBy + } +} + +func toTagModel(src *types.Tag) tagModel { + return tagModel{ + ID: src.ID, + CategoryID: src.CategoryID, + Name: src.Name, + Value: src.Value, + Weight: src.Weight, + Description: src.Description, + CreatedBy: nullableInt64(src.CreatedBy), + CreatedAt: src.CreatedAt, + UpdatedAt: src.UpdatedAt, + } +} + +func fromTagModel(model tagModel, dest *types.Tag) { + dest.ID = model.ID + dest.CategoryID = model.CategoryID + dest.Name = model.Name + dest.Value = model.Value + dest.Weight = model.Weight + dest.Description = model.Description + dest.CreatedAt = model.CreatedAt + dest.UpdatedAt = model.UpdatedAt + if model.CreatedBy != nil { + dest.CreatedBy = *model.CreatedBy + } +} + +func toDocumentTagModel(src *types.DocumentTag) documentTagModel { + return documentTagModel{ + ID: src.ID, + DocumentID: src.DocumentID, + TagID: src.TagID, + Weight: src.Weight, + CreatedBy: nullableInt64(src.CreatedBy), + CreatedAt: src.CreatedAt, + UpdatedAt: src.UpdatedAt, + } +} + +func fromDocumentTagModel(model documentTagModel, dest *types.DocumentTag) { + dest.ID = model.ID + dest.DocumentID = model.DocumentID + dest.TagID = model.TagID + dest.Weight = model.Weight + dest.CreatedAt = model.CreatedAt + dest.UpdatedAt = model.UpdatedAt + if model.CreatedBy != nil { + dest.CreatedBy = *model.CreatedBy + } +} + +func toVirtualKBModel(src *types.VirtualKB) (virtualKBModel, error) { + var config any + if src.Config != nil { + bytes, err := json.Marshal(src.Config) + if err != nil { + return virtualKBModel{}, err + } + config = json.RawMessage(bytes) + } + + filters := make([]virtualKBFilterModel, 0, len(src.Filters)) + for _, filter := range src.Filters { + filters = append(filters, virtualKBFilterModel{ + ID: filter.ID, + VirtualKBID: filter.VirtualKBID, + TagCategoryID: filter.TagCategoryID, + Operator: filter.Operator, + Weight: filter.Weight, + TagIDs: filter.TagIDs, + }) + } + + return virtualKBModel{ + ID: src.ID, + Name: src.Name, + Description: src.Description, + CreatedBy: nullableInt64(src.CreatedBy), + Config: config, + CreatedAt: src.CreatedAt, + UpdatedAt: src.UpdatedAt, + Filters: filters, + }, nil +} + +func fromVirtualKBModel(model virtualKBModel, dest *types.VirtualKB) error { + dest.ID = model.ID + dest.Name = model.Name + dest.Description = model.Description + dest.CreatedAt = model.CreatedAt + dest.UpdatedAt = model.UpdatedAt + if model.CreatedBy != nil { + dest.CreatedBy = *model.CreatedBy + } + + if model.Config != nil { + bytes, err := json.Marshal(model.Config) + if err != nil { + return err + } + if err := json.Unmarshal(bytes, &dest.Config); err != nil { + return err + } + } + + dest.Filters = make([]types.VirtualKBFilter, 0, len(model.Filters)) + for _, filter := range model.Filters { + dest.Filters = append(dest.Filters, types.VirtualKBFilter{ + ID: filter.ID, + VirtualKBID: filter.VirtualKBID, + TagCategoryID: filter.TagCategoryID, + Operator: filter.Operator, + Weight: filter.Weight, + TagIDs: filter.TagIDs, + }) + } + + return nil +} + +func nullableInt64(v int64) *int64 { + if v == 0 { + return nil + } + return &v +} diff --git a/features/virtual-knowledge-base/internal/repository/postgres/models.go b/features/virtual-knowledge-base/internal/repository/postgres/models.go new file mode 100644 index 0000000..f159be3 --- /dev/null +++ b/features/virtual-knowledge-base/internal/repository/postgres/models.go @@ -0,0 +1,73 @@ +package postgres + +import "time" + +// tagCategoryModel maps to tag_categories table. +type tagCategoryModel struct { + ID int64 `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name" + Description string `gorm:"column:description" + Color string `gorm:"column:color" + CreatedBy *int64 `gorm:"column:created_by" + CreatedAt time.Time `gorm:"column:created_at" + UpdatedAt time.Time `gorm:"column:updated_at" +} + +func (tagCategoryModel) TableName() string { return "tag_categories" } + +// tagModel maps to tags table. +type tagModel struct { + ID int64 `gorm:"column:id;primaryKey"` + CategoryID int64 `gorm:"column:category_id" + Name string `gorm:"column:name" + Value string `gorm:"column:value" + Weight float64 `gorm:"column:weight" + Description string `gorm:"column:description" + CreatedBy *int64 `gorm:"column:created_by" + CreatedAt time.Time `gorm:"column:created_at" + UpdatedAt time.Time `gorm:"column:updated_at" +} + +func (tagModel) TableName() string { return "tags" } + +// documentTagModel maps to document_tags table. +type documentTagModel struct { + ID int64 `gorm:"column:id;primaryKey"` + DocumentID string `gorm:"column:document_id" + TagID int64 `gorm:"column:tag_id" + Weight *float64 `gorm:"column:weight" + CreatedBy *int64 `gorm:"column:created_by" + CreatedAt time.Time `gorm:"column:created_at" + UpdatedAt time.Time `gorm:"column:updated_at" + Tag *tagModel `gorm:"foreignKey:TagID" +} + +func (documentTagModel) TableName() string { return "document_tags" } + +// virtualKBModel maps to virtual_knowledge_bases table. +type virtualKBModel struct { + ID int64 `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name" + Description string `gorm:"column:description" + CreatedBy *int64 `gorm:"column:created_by" + Config any `gorm:"column:config" + CreatedAt time.Time `gorm:"column:created_at" + UpdatedAt time.Time `gorm:"column:updated_at" + Filters []virtualKBFilterModel `gorm:"foreignKey:VirtualKBID"` +} + +func (virtualKBModel) TableName() string { return "virtual_knowledge_bases" } + +// virtualKBFilterModel maps to virtual_kb_filters table. +type virtualKBFilterModel struct { + ID int64 `gorm:"column:id;primaryKey"` + VirtualKBID int64 `gorm:"column:virtual_kb_id" + TagCategoryID int64 `gorm:"column:tag_category_id" + Operator string `gorm:"column:operator" + Weight float64 `gorm:"column:weight" + TagIDs []int64 `gorm:"column:tag_ids;type:integer[]" + CreatedAt time.Time `gorm:"column:created_at" + UpdatedAt time.Time `gorm:"column:updated_at" +} + +func (virtualKBFilterModel) TableName() string { return "virtual_kb_filters" } diff --git a/features/virtual-knowledge-base/internal/repository/postgres/tag_repository.go b/features/virtual-knowledge-base/internal/repository/postgres/tag_repository.go new file mode 100644 index 0000000..992336d --- /dev/null +++ b/features/virtual-knowledge-base/internal/repository/postgres/tag_repository.go @@ -0,0 +1,136 @@ +package postgres + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" + "gorm.io/gorm" +) + +// TagRepository provides PostgreSQL-backed storage for tag categories and tags. +type TagRepository struct { + db *gorm.DB +} + +// NewTagRepository instantiates a tag repository. +func NewTagRepository(db *gorm.DB) *TagRepository { + return &TagRepository{db: db} +} + +// CreateCategory persists a new tag category. +func (r *TagRepository) CreateCategory(ctx context.Context, category *types.TagCategory) error { + model := toTagCategoryModel(category) + if err := r.db.WithContext(ctx).Create(&model).Error; err != nil { + return err + } + fromTagCategoryModel(model, category) + return nil +} + +// UpdateCategory updates an existing tag category. +func (r *TagRepository) UpdateCategory(ctx context.Context, category *types.TagCategory) error { + model := toTagCategoryModel(category) + return r.db.WithContext(ctx).Model(&tagCategoryModel{}). + Where("id = ?", category.ID). + Updates(model). + Error +} + +// DeleteCategory removes a tag category by ID. +func (r *TagRepository) DeleteCategory(ctx context.Context, id int64) error { + return r.db.WithContext(ctx). + Where("id = ?", id). + Delete(&tagCategoryModel{}). + Error +} + +// GetCategoryByID fetches a tag category by identifier. +func (r *TagRepository) GetCategoryByID(ctx context.Context, id int64) (*types.TagCategory, error) { + var model tagCategoryModel + if err := r.db.WithContext(ctx). + First(&model, id). + Error; err != nil { + return nil, err + } + category := new(types.TagCategory) + fromTagCategoryModel(model, category) + return category, nil +} + +// ListCategories retrieves all tag categories. +func (r *TagRepository) ListCategories(ctx context.Context) ([]*types.TagCategory, error) { + var models []tagCategoryModel + if err := r.db.WithContext(ctx). + Order("id ASC"). + Find(&models). + Error; err != nil { + return nil, err + } + categories := make([]*types.TagCategory, 0, len(models)) + for _, m := range models { + category := new(types.TagCategory) + fromTagCategoryModel(m, category) + categories = append(categories, category) + } + return categories, nil +} + +// CreateTag persists a new tag. +func (r *TagRepository) CreateTag(ctx context.Context, tag *types.Tag) error { + model := toTagModel(tag) + if err := r.db.WithContext(ctx).Create(&model).Error; err != nil { + return err + } + fromTagModel(model, tag) + return nil +} + +// UpdateTag updates an existing tag. +func (r *TagRepository) UpdateTag(ctx context.Context, tag *types.Tag) error { + model := toTagModel(tag) + return r.db.WithContext(ctx). + Model(&tagModel{}). + Where("id = ?", tag.ID). + Updates(model). + Error +} + +// DeleteTag removes a tag by ID. +func (r *TagRepository) DeleteTag(ctx context.Context, id int64) error { + return r.db.WithContext(ctx). + Where("id = ?", id). + Delete(&tagModel{}). + Error +} + +// GetTagByID fetches a tag using its identifier. +func (r *TagRepository) GetTagByID(ctx context.Context, id int64) (*types.Tag, error) { + var model tagModel + if err := r.db.WithContext(ctx). + First(&model, id). + Error; err != nil { + return nil, err + } + tag := new(types.Tag) + fromTagModel(model, tag) + return tag, nil +} + +// ListTagsByCategory returns all tags for a category. +func (r *TagRepository) ListTagsByCategory(ctx context.Context, categoryID int64) ([]*types.Tag, error) { + var models []tagModel + if err := r.db.WithContext(ctx). + Where("category_id = ?", categoryID). + Order("id ASC"). + Find(&models). + Error; err != nil { + return nil, err + } + tags := make([]*types.Tag, 0, len(models)) + for _, m := range models { + tag := new(types.Tag) + fromTagModel(m, tag) + tags = append(tags, tag) + } + return tags, nil +} diff --git a/features/virtual-knowledge-base/internal/repository/postgres/virtual_kb_repository.go b/features/virtual-knowledge-base/internal/repository/postgres/virtual_kb_repository.go new file mode 100644 index 0000000..717a0ed --- /dev/null +++ b/features/virtual-knowledge-base/internal/repository/postgres/virtual_kb_repository.go @@ -0,0 +1,118 @@ +package postgres + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" + "gorm.io/gorm" +) + +// VirtualKBRepository provides PostgreSQL persistence for virtual knowledge bases. +type VirtualKBRepository struct { + db *gorm.DB +} + +// NewVirtualKBRepository creates a new repository instance. +func NewVirtualKBRepository(db *gorm.DB) *VirtualKBRepository { + return &VirtualKBRepository{db: db} +} + +// Create inserts a new virtual knowledge base and its filters. +func (r *VirtualKBRepository) Create(ctx context.Context, vkb *types.VirtualKB) error { + model, err := toVirtualKBModel(vkb) + if err != nil { + return err + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&model).Error; err != nil { + return err + } + + fromVirtualKBModel(model, vkb) + return nil + }) +} + +// Update modifies an existing virtual knowledge base and replaces its filters. +func (r *VirtualKBRepository) Update(ctx context.Context, vkb *types.VirtualKB) error { + model, err := toVirtualKBModel(vkb) + if err != nil { + return err + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&virtualKBModel{}). + Where("id = ?", vkb.ID). + Updates(map[string]any{ + "name": model.Name, + "description": model.Description, + "config": model.Config, + }).Error; err != nil { + return err + } + + if err := tx.Where("virtual_kb_id = ?", vkb.ID). + Delete(&virtualKBFilterModel{}). + Error; err != nil { + return err + } + + for _, filter := range model.Filters { + filter.VirtualKBID = vkb.ID + if err := tx.Create(&filter).Error; err != nil { + return err + } + } + + return nil + }) +} + +// Delete removes a virtual knowledge base and its filters. +func (r *VirtualKBRepository) Delete(ctx context.Context, id int64) error { + return r.db.WithContext(ctx). + Where("id = ?", id). + Delete(&virtualKBModel{}). + Error +} + +// GetByID fetches a virtual knowledge base by ID. +func (r *VirtualKBRepository) GetByID(ctx context.Context, id int64) (*types.VirtualKB, error) { + var model virtualKBModel + if err := r.db.WithContext(ctx). + Preload("Filters"). + First(&model, id). + Error; err != nil { + return nil, err + } + + vkb := new(types.VirtualKB) + if err := fromVirtualKBModel(model, vkb); err != nil { + return nil, err + } + return vkb, nil +} + +// List returns all virtual knowledge bases. +func (r *VirtualKBRepository) List(ctx context.Context) ([]*types.VirtualKB, error) { + var models []virtualKBModel + if err := r.db.WithContext(ctx). + Preload("Filters"). + Order("id ASC"). + Find(&models). + Error; err != nil { + return nil, err + } + + results := make([]*types.VirtualKB, 0, len(models)) + for _, model := range models { + vkb := new(types.VirtualKB) + if err := fromVirtualKBModel(model, vkb); err != nil { + return nil, err + } + results = append(results, vkb) + } + + return results, nil +} diff --git a/features/virtual-knowledge-base/internal/router/virtual_kb_routes.go b/features/virtual-knowledge-base/internal/router/virtual_kb_routes.go new file mode 100644 index 0000000..a73217a --- /dev/null +++ b/features/virtual-knowledge-base/internal/router/virtual_kb_routes.go @@ -0,0 +1,33 @@ +package router + +import ( + "github.com/gin-gonic/gin" + handler "github.com/tencent/weknora/features/virtualkb/internal/handler" + "github.com/tencent/weknora/features/virtualkb/internal/middleware" + service "github.com/tencent/weknora/features/virtualkb/internal/service/interfaces" +) + +// RegisterVirtualKBRoutes wires all virtual knowledge base feature routes. +func RegisterVirtualKBRoutes(r *gin.Engine, deps Dependencies) { + group := r.Group("/api/v1/virtual-kb") + group.Use(middleware.APIKeyAuth(deps.APIKey)) + + tagHandler := handler.NewTagHandler(deps.TagService) + documentTagHandler := handler.NewDocumentTagHandler(deps.DocumentTagService) + virtualKBHandler := handler.NewVirtualKBHandler(deps.VirtualKBService) + enhancedSearchHandler := handler.NewEnhancedSearchHandler(deps.EnhancedSearchService) + + tagHandler.RegisterRoutes(group) + documentTagHandler.RegisterRoutes(group) + virtualKBHandler.RegisterRoutes(group.Group("/instances")) + enhancedSearchHandler.RegisterRoutes(group.Group("/search")) +} + +// Dependencies collects runtime dependencies required by the router. +type Dependencies struct { + APIKey string + TagService service.TagService + DocumentTagService service.DocumentTagService + VirtualKBService service.VirtualKBService + EnhancedSearchService service.EnhancedSearchService +} diff --git a/features/virtual-knowledge-base/internal/service/impl/document_tag_service.go b/features/virtual-knowledge-base/internal/service/impl/document_tag_service.go new file mode 100644 index 0000000..8361bfb --- /dev/null +++ b/features/virtual-knowledge-base/internal/service/impl/document_tag_service.go @@ -0,0 +1,75 @@ +package impl + +import ( + "context" + "errors" + + docRepo "github.com/tencent/weknora/features/virtualkb/internal/repository/interfaces" + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// DocumentTagService provides document tagging business logic. +type DocumentTagService struct { + repo docRepo.DocumentTagRepository +} + +// NewDocumentTagService creates a new instance. +func NewDocumentTagService(repository docRepo.DocumentTagRepository) *DocumentTagService { + return &DocumentTagService{repo: repository} +} + +// AssignTag associates a tag with a document. +func (s *DocumentTagService) AssignTag(ctx context.Context, assignment *types.DocumentTag) error { + if err := validateDocumentTag(assignment); err != nil { + return err + } + return s.repo.AssignTag(ctx, assignment) +} + +// UpdateTag updates metadata for a document-tag association. +func (s *DocumentTagService) UpdateTag(ctx context.Context, assignment *types.DocumentTag) error { + if err := validateDocumentTag(assignment); err != nil { + return err + } + return s.repo.UpdateTagAssignment(ctx, assignment) +} + +// RemoveTag detaches a tag from a document. +func (s *DocumentTagService) RemoveTag(ctx context.Context, documentID string, tagID int64) error { + if documentID == "" { + return errors.New("document id is required") + } + if tagID == 0 { + return errors.New("tag id is required") + } + return s.repo.RemoveTag(ctx, documentID, tagID) +} + +// ListTags lists tags associated with a document. +func (s *DocumentTagService) ListTags(ctx context.Context, documentID string) ([]*types.Tag, error) { + if documentID == "" { + return nil, errors.New("document id is required") + } + return s.repo.ListTagsByDocument(ctx, documentID) +} + +// ListDocuments lists documents associated with a tag. +func (s *DocumentTagService) ListDocuments(ctx context.Context, tagID int64) ([]*types.DocumentTag, error) { + if tagID == 0 { + return nil, errors.New("tag id is required") + } + return s.repo.ListDocumentsByTag(ctx, tagID) +} + +func validateDocumentTag(assignment *types.DocumentTag) error { + if assignment == nil { + return errors.New("document tag assignment is required") + } + if assignment.DocumentID == "" { + return errors.New("document id is required") + } + if assignment.TagID == 0 { + return errors.New("tag id is required") + } + return nil +} diff --git a/features/virtual-knowledge-base/internal/service/impl/enhanced_search_service.go b/features/virtual-knowledge-base/internal/service/impl/enhanced_search_service.go new file mode 100644 index 0000000..cf3cd75 --- /dev/null +++ b/features/virtual-knowledge-base/internal/service/impl/enhanced_search_service.go @@ -0,0 +1,121 @@ +package impl + +import ( + "context" + "errors" + "math" + + docRepo "github.com/tencent/weknora/features/virtualkb/internal/repository/interfaces" + vkbRepo "github.com/tencent/weknora/features/virtualkb/internal/repository/interfaces" + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// EnhancedSearchService provides tag-weighted search capabilities. +type EnhancedSearchService struct { + docRepo docRepo.DocumentTagRepository + vkbRepo vkbRepo.VirtualKBRepository +} + +// NewEnhancedSearchService constructs a search service. +func NewEnhancedSearchService(docRepository docRepo.DocumentTagRepository, vkbRepository vkbRepo.VirtualKBRepository) *EnhancedSearchService { + return &EnhancedSearchService{docRepo: docRepository, vkbRepo: vkbRepository} +} + +// Search performs an enhanced search with tag weighting. +func (s *EnhancedSearchService) Search(ctx context.Context, request *types.EnhancedSearchRequest) (*types.EnhancedSearchResponse, error) { + if request == nil { + return nil, errors.New("search request is required") + } + if request.VirtualKBID == nil && len(request.TagFilters) == 0 { + return nil, errors.New("either virtual_kb_id or tag_filters must be provided") + } + + filters := request.TagFilters + if request.VirtualKBID != nil { + vkb, err := s.vkbRepo.GetByID(ctx, *request.VirtualKBID) + if err != nil { + return nil, err + } + filters = vkb.Filters + } + + scores := make(map[string]float64) + for _, filter := range filters { + for _, tagID := range filter.TagIDs { + documents, err := s.docRepo.ListDocumentsByTag(ctx, tagID) + if err != nil { + return nil, err + } + + for _, doc := range documents { + weight := filter.Weight + if doc.Weight != nil { + weight *= *doc.Weight + } + scores[doc.DocumentID] += weight + } + } + } + + limit := request.Limit + if limit <= 0 { + limit = 20 + } + + top := topScores(scores, limit) + return &types.EnhancedSearchResponse{Results: top}, nil +} + +func topScores(scores map[string]float64, limit int) []types.DocumentScore { + list := make([]types.DocumentScore, 0, len(scores)) + for docID, score := range scores { + if math.IsNaN(score) || math.IsInf(score, 0) { + continue + } + list = append(list, types.DocumentScore{DocumentID: docID, Score: score}) + } + + if len(list) <= limit { + return list + } + + partialSelect(list, limit) + return list[:limit] +} + +func partialSelect(items []types.DocumentScore, limit int) { + if limit >= len(items) { + return + } + heapify(items) + for i := len(items) - 1; i >= len(items)-limit; i-- { + items[0], items[i] = items[i], items[0] + siftDown(items, 0, i) + } +} + +func heapify(items []types.DocumentScore) { + for i := len(items)/2 - 1; i >= 0; i-- { + siftDown(items, i, len(items)) + } +} + +func siftDown(items []types.DocumentScore, root, length int) { + for { + left := 2*root + 1 + right := left + 1 + largest := root + + if left < length && items[left].Score > items[largest].Score { + largest = left + } + if right < length && items[right].Score > items[largest].Score { + largest = right + } + if largest == root { + return + } + items[root], items[largest] = items[largest], items[root] + root = largest + } +} diff --git a/features/virtual-knowledge-base/internal/service/impl/tag_service.go b/features/virtual-knowledge-base/internal/service/impl/tag_service.go new file mode 100644 index 0000000..b17e404 --- /dev/null +++ b/features/virtual-knowledge-base/internal/service/impl/tag_service.go @@ -0,0 +1,129 @@ +package impl + +import ( + "context" + "errors" + "strings" + + repo "github.com/tencent/weknora/features/virtualkb/internal/repository/interfaces" + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// TagService provides business logic for tag categories and tags. +type TagService struct { + repo repo.TagRepository +} + +// NewTagService creates a new TagService instance. +func NewTagService(repository repo.TagRepository) *TagService { + return &TagService{repo: repository} +} + +// CreateCategory creates a new tag category after validation. +func (s *TagService) CreateCategory(ctx context.Context, category *types.TagCategory) error { + if err := validateCategory(category); err != nil { + return err + } + return s.repo.CreateCategory(ctx, category) +} + +// UpdateCategory updates an existing tag category. +func (s *TagService) UpdateCategory(ctx context.Context, category *types.TagCategory) error { + if category.ID == 0 { + return errors.New("category id is required") + } + if err := validateCategory(category); err != nil { + return err + } + return s.repo.UpdateCategory(ctx, category) +} + +// DeleteCategory removes a category. +func (s *TagService) DeleteCategory(ctx context.Context, id int64) error { + if id == 0 { + return errors.New("category id is required") + } + return s.repo.DeleteCategory(ctx, id) +} + +// GetCategoryByID fetches a category by identifier. +func (s *TagService) GetCategoryByID(ctx context.Context, id int64) (*types.TagCategory, error) { + if id == 0 { + return nil, errors.New("category id is required") + } + return s.repo.GetCategoryByID(ctx, id) +} + +// ListCategories returns all categories. +func (s *TagService) ListCategories(ctx context.Context) ([]*types.TagCategory, error) { + return s.repo.ListCategories(ctx) +} + +// CreateTag creates a new tag. +func (s *TagService) CreateTag(ctx context.Context, tag *types.Tag) error { + if err := validateTag(tag); err != nil { + return err + } + return s.repo.CreateTag(ctx, tag) +} + +// UpdateTag updates tag information. +func (s *TagService) UpdateTag(ctx context.Context, tag *types.Tag) error { + if tag.ID == 0 { + return errors.New("tag id is required") + } + if err := validateTag(tag); err != nil { + return err + } + return s.repo.UpdateTag(ctx, tag) +} + +// DeleteTag removes a tag by identifier. +func (s *TagService) DeleteTag(ctx context.Context, id int64) error { + if id == 0 { + return errors.New("tag id is required") + } + return s.repo.DeleteTag(ctx, id) +} + +// GetTagByID fetches a tag by identifier. +func (s *TagService) GetTagByID(ctx context.Context, id int64) (*types.Tag, error) { + if id == 0 { + return nil, errors.New("tag id is required") + } + return s.repo.GetTagByID(ctx, id) +} + +// ListTagsByCategory lists tags for a category. +func (s *TagService) ListTagsByCategory(ctx context.Context, categoryID int64) ([]*types.Tag, error) { + if categoryID == 0 { + return nil, errors.New("category id is required") + } + return s.repo.ListTagsByCategory(ctx, categoryID) +} + +func validateCategory(category *types.TagCategory) error { + if category == nil { + return errors.New("category is required") + } + if strings.TrimSpace(category.Name) == "" { + return errors.New("category name is required") + } + return nil +} + +func validateTag(tag *types.Tag) error { + if tag == nil { + return errors.New("tag is required") + } + if tag.CategoryID == 0 { + return errors.New("tag category_id is required") + } + if strings.TrimSpace(tag.Name) == "" { + return errors.New("tag name is required") + } + if strings.TrimSpace(tag.Value) == "" { + return errors.New("tag value is required") + } + return nil +} diff --git a/features/virtual-knowledge-base/internal/service/impl/virtual_kb_service.go b/features/virtual-knowledge-base/internal/service/impl/virtual_kb_service.go new file mode 100644 index 0000000..381fa13 --- /dev/null +++ b/features/virtual-knowledge-base/internal/service/impl/virtual_kb_service.go @@ -0,0 +1,84 @@ +package impl + +import ( + "context" + "errors" + "strings" + + vkbRepo "github.com/tencent/weknora/features/virtualkb/internal/repository/interfaces" + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// VirtualKBService provides business logic around virtual knowledge bases. +type VirtualKBService struct { + repo vkbRepo.VirtualKBRepository +} + +// NewVirtualKBService creates a new instance. +func NewVirtualKBService(repository vkbRepo.VirtualKBRepository) *VirtualKBService { + return &VirtualKBService{repo: repository} +} + +// Create validates and persists a virtual knowledge base definition. +func (s *VirtualKBService) Create(ctx context.Context, vkb *types.VirtualKB) error { + if err := validateVirtualKB(vkb); err != nil { + return err + } + return s.repo.Create(ctx, vkb) +} + +// Update modifies an existing virtual knowledge base definition. +func (s *VirtualKBService) Update(ctx context.Context, vkb *types.VirtualKB) error { + if vkb.ID == 0 { + return errors.New("virtual kb id is required") + } + if err := validateVirtualKB(vkb); err != nil { + return err + } + return s.repo.Update(ctx, vkb) +} + +// Delete removes a virtual knowledge base by identifier. +func (s *VirtualKBService) Delete(ctx context.Context, id int64) error { + if id == 0 { + return errors.New("virtual kb id is required") + } + return s.repo.Delete(ctx, id) +} + +// GetByID fetches a virtual knowledge base by ID. +func (s *VirtualKBService) GetByID(ctx context.Context, id int64) (*types.VirtualKB, error) { + if id == 0 { + return nil, errors.New("virtual kb id is required") + } + return s.repo.GetByID(ctx, id) +} + +// List returns all virtual knowledge bases. +func (s *VirtualKBService) List(ctx context.Context) ([]*types.VirtualKB, error) { + return s.repo.List(ctx) +} + +func validateVirtualKB(vkb *types.VirtualKB) error { + if vkb == nil { + return errors.New("virtual kb is required") + } + if strings.TrimSpace(vkb.Name) == "" { + return errors.New("virtual kb name is required") + } + if len(vkb.Filters) == 0 { + return errors.New("virtual kb requires at least one filter") + } + for _, filter := range vkb.Filters { + if filter.TagCategoryID == 0 { + return errors.New("filter tag_category_id is required") + } + if len(filter.TagIDs) == 0 { + return errors.New("filter tag_ids are required") + } + if strings.TrimSpace(filter.Operator) == "" { + return errors.New("filter operator is required") + } + } + return nil +} diff --git a/features/virtual-knowledge-base/internal/service/interfaces/document_tag_service.go b/features/virtual-knowledge-base/internal/service/interfaces/document_tag_service.go new file mode 100644 index 0000000..130f467 --- /dev/null +++ b/features/virtual-knowledge-base/internal/service/interfaces/document_tag_service.go @@ -0,0 +1,16 @@ +package interfaces + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// DocumentTagService manages document-tag assignments. +type DocumentTagService interface { + AssignTag(ctx context.Context, assignment *types.DocumentTag) error + UpdateTag(ctx context.Context, assignment *types.DocumentTag) error + RemoveTag(ctx context.Context, documentID string, tagID int64) error + ListTags(ctx context.Context, documentID string) ([]*types.Tag, error) + ListDocuments(ctx context.Context, tagID int64) ([]*types.DocumentTag, error) +} diff --git a/features/virtual-knowledge-base/internal/service/interfaces/enhanced_search_service.go b/features/virtual-knowledge-base/internal/service/interfaces/enhanced_search_service.go new file mode 100644 index 0000000..161b4a1 --- /dev/null +++ b/features/virtual-knowledge-base/internal/service/interfaces/enhanced_search_service.go @@ -0,0 +1,12 @@ +package interfaces + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// EnhancedSearchService executes permission-aware and tag-aware searches. +type EnhancedSearchService interface { + Search(ctx context.Context, request *types.EnhancedSearchRequest) (*types.EnhancedSearchResponse, error) +} diff --git a/features/virtual-knowledge-base/internal/service/interfaces/tag_service.go b/features/virtual-knowledge-base/internal/service/interfaces/tag_service.go new file mode 100644 index 0000000..d0b0718 --- /dev/null +++ b/features/virtual-knowledge-base/internal/service/interfaces/tag_service.go @@ -0,0 +1,22 @@ +package interfaces + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// TagService exposes tag category and tag operations. +type TagService interface { + CreateCategory(ctx context.Context, category *types.TagCategory) error + UpdateCategory(ctx context.Context, category *types.TagCategory) error + DeleteCategory(ctx context.Context, id int64) error + GetCategoryByID(ctx context.Context, id int64) (*types.TagCategory, error) + ListCategories(ctx context.Context) ([]*types.TagCategory, error) + + CreateTag(ctx context.Context, tag *types.Tag) error + UpdateTag(ctx context.Context, tag *types.Tag) error + DeleteTag(ctx context.Context, id int64) error + GetTagByID(ctx context.Context, id int64) (*types.Tag, error) + ListTagsByCategory(ctx context.Context, categoryID int64) ([]*types.Tag, error) +} diff --git a/features/virtual-knowledge-base/internal/service/interfaces/virtual_kb_service.go b/features/virtual-knowledge-base/internal/service/interfaces/virtual_kb_service.go new file mode 100644 index 0000000..0f92331 --- /dev/null +++ b/features/virtual-knowledge-base/internal/service/interfaces/virtual_kb_service.go @@ -0,0 +1,16 @@ +package interfaces + +import ( + "context" + + "github.com/tencent/weknora/features/virtualkb/internal/types" +) + +// VirtualKBService handles virtual knowledge base lifecycle operations. +type VirtualKBService interface { + Create(ctx context.Context, vkb *types.VirtualKB) error + Update(ctx context.Context, vkb *types.VirtualKB) error + Delete(ctx context.Context, id int64) error + GetByID(ctx context.Context, id int64) (*types.VirtualKB, error) + List(ctx context.Context) ([]*types.VirtualKB, error) +} diff --git a/features/virtual-knowledge-base/internal/types/document_tag.go b/features/virtual-knowledge-base/internal/types/document_tag.go new file mode 100644 index 0000000..b9c14db --- /dev/null +++ b/features/virtual-knowledge-base/internal/types/document_tag.go @@ -0,0 +1,14 @@ +package types + +import "time" + +// DocumentTag links documents to tags. +type DocumentTag struct { + ID int64 `json:"id"` + DocumentID string `json:"document_id"` + TagID int64 `json:"tag_id"` + Weight *float64 `json:"weight"` + CreatedBy int64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/features/virtual-knowledge-base/internal/types/search.go b/features/virtual-knowledge-base/internal/types/search.go new file mode 100644 index 0000000..89a639c --- /dev/null +++ b/features/virtual-knowledge-base/internal/types/search.go @@ -0,0 +1,19 @@ +package types + +// EnhancedSearchRequest describes an enhanced search query. +type EnhancedSearchRequest struct { + VirtualKBID *int64 `json:"virtual_kb_id"` + TagFilters []VirtualKBFilter `json:"tag_filters"` + Limit int `json:"limit"` +} + +// DocumentScore represents a weighted document result. +type DocumentScore struct { + DocumentID string `json:"document_id"` + Score float64 `json:"score"` +} + +// EnhancedSearchResponse aggregates search results. +type EnhancedSearchResponse struct { + Results []DocumentScore `json:"results"` +} diff --git a/features/virtual-knowledge-base/internal/types/tag.go b/features/virtual-knowledge-base/internal/types/tag.go new file mode 100644 index 0000000..8a3e8cd --- /dev/null +++ b/features/virtual-knowledge-base/internal/types/tag.go @@ -0,0 +1,16 @@ +package types + +import "time" + +// Tag describes metadata applied to documents. +type Tag struct { + ID int64 `json:"id"` + CategoryID int64 `json:"category_id"` + Name string `json:"name"` + Value string `json:"value"` + Weight float64 `json:"weight"` + Description string `json:"description"` + CreatedBy int64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/features/virtual-knowledge-base/internal/types/tag_category.go b/features/virtual-knowledge-base/internal/types/tag_category.go new file mode 100644 index 0000000..cdc4928 --- /dev/null +++ b/features/virtual-knowledge-base/internal/types/tag_category.go @@ -0,0 +1,14 @@ +package types + +import "time" + +// TagCategory represents a user-defined grouping dimension. +type TagCategory struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + CreatedBy int64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/features/virtual-knowledge-base/internal/types/virtual_kb.go b/features/virtual-knowledge-base/internal/types/virtual_kb.go new file mode 100644 index 0000000..d3b04a3 --- /dev/null +++ b/features/virtual-knowledge-base/internal/types/virtual_kb.go @@ -0,0 +1,25 @@ +package types + +import "time" + +// VirtualKB represents a virtual knowledge base definition. +type VirtualKB struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Filters []VirtualKBFilter `json:"filters"` + Config map[string]any `json:"config"` + CreatedBy int64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// VirtualKBFilter describes a single tag-based rule. +type VirtualKBFilter struct { + ID int64 `json:"id"` + VirtualKBID int64 `json:"virtual_kb_id"` + TagCategoryID int64 `json:"tag_category_id"` + TagIDs []int64 `json:"tag_ids"` + Operator string `json:"operator"` + Weight float64 `json:"weight"` +} diff --git a/features/virtual-knowledge-base/migrations/001_create_tag_categories.sql b/features/virtual-knowledge-base/migrations/001_create_tag_categories.sql new file mode 100644 index 0000000..3f1eeb2 --- /dev/null +++ b/features/virtual-knowledge-base/migrations/001_create_tag_categories.sql @@ -0,0 +1,13 @@ +-- Migration 001: create tag_categories table +CREATE TABLE IF NOT EXISTS tag_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL UNIQUE, + description TEXT, + color VARCHAR(32), + created_by BIGINT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_tag_categories_name + ON tag_categories (name); diff --git a/features/virtual-knowledge-base/migrations/002_create_tags.sql b/features/virtual-knowledge-base/migrations/002_create_tags.sql new file mode 100644 index 0000000..e24a293 --- /dev/null +++ b/features/virtual-knowledge-base/migrations/002_create_tags.sql @@ -0,0 +1,16 @@ +-- Migration 002: create tags table +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + category_id INTEGER NOT NULL REFERENCES tag_categories(id) ON DELETE CASCADE, + name VARCHAR(128) NOT NULL, + value VARCHAR(128) NOT NULL, + weight DOUBLE PRECISION DEFAULT 1.0, + description TEXT, + created_by BIGINT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE (category_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_tags_category + ON tags (category_id); diff --git a/features/virtual-knowledge-base/migrations/003_create_document_tags.sql b/features/virtual-knowledge-base/migrations/003_create_document_tags.sql new file mode 100644 index 0000000..b00c455 --- /dev/null +++ b/features/virtual-knowledge-base/migrations/003_create_document_tags.sql @@ -0,0 +1,17 @@ +-- Migration 003: create document_tags table +CREATE TABLE IF NOT EXISTS document_tags ( + id SERIAL PRIMARY KEY, + document_id UUID NOT NULL, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + weight DOUBLE PRECISION, + created_by BIGINT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE (document_id, tag_id) +); + +CREATE INDEX IF NOT EXISTS idx_document_tags_document + ON document_tags (document_id); + +CREATE INDEX IF NOT EXISTS idx_document_tags_tag + ON document_tags (tag_id); diff --git a/features/virtual-knowledge-base/migrations/004_create_virtual_knowledge_bases.sql b/features/virtual-knowledge-base/migrations/004_create_virtual_knowledge_bases.sql new file mode 100644 index 0000000..d25f6f8 --- /dev/null +++ b/features/virtual-knowledge-base/migrations/004_create_virtual_knowledge_bases.sql @@ -0,0 +1,11 @@ +-- Migration 004: create virtual_knowledge_bases table +CREATE TABLE IF NOT EXISTS virtual_knowledge_bases ( + id SERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description TEXT, + created_by BIGINT, + config JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE (name) +); diff --git a/features/virtual-knowledge-base/migrations/005_create_virtual_kb_filters.sql b/features/virtual-knowledge-base/migrations/005_create_virtual_kb_filters.sql new file mode 100644 index 0000000..6293b36 --- /dev/null +++ b/features/virtual-knowledge-base/migrations/005_create_virtual_kb_filters.sql @@ -0,0 +1,14 @@ +-- Migration 005: create virtual_kb_filters table +CREATE TABLE IF NOT EXISTS virtual_kb_filters ( + id SERIAL PRIMARY KEY, + virtual_kb_id INTEGER NOT NULL REFERENCES virtual_knowledge_bases(id) ON DELETE CASCADE, + tag_category_id INTEGER NOT NULL REFERENCES tag_categories(id) ON DELETE CASCADE, + operator VARCHAR(16) NOT NULL DEFAULT 'OR', + weight DOUBLE PRECISION DEFAULT 1.0, + tag_ids INTEGER[] NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_virtual_kb_filters_vkb + ON virtual_kb_filters (virtual_kb_id); diff --git a/features/virtual-knowledge-base/web/package.json b/features/virtual-knowledge-base/web/package.json new file mode 100644 index 0000000..bcb0ef7 --- /dev/null +++ b/features/virtual-knowledge-base/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "virtual-kb-vue", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "type-check": "vue-tsc --build" + }, + "dependencies": { + "axios": "^1.8.4", + "pinia": "^3.0.1", + "tdesign-icons-vue-next": "^0.4.1", + "tdesign-vue-next": "^1.11.5", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-vue": "6.0.0", + "typescript": "~5.8.0", + "vite": "7.0.4", + "vue-tsc": "^2.2.8" + } +} diff --git a/features/virtual-knowledge-base/web/src/App.vue b/features/virtual-knowledge-base/web/src/App.vue new file mode 100644 index 0000000..0dc7f1f --- /dev/null +++ b/features/virtual-knowledge-base/web/src/App.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/api/documentTag.ts b/features/virtual-knowledge-base/web/src/api/documentTag.ts new file mode 100644 index 0000000..22bc947 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/api/documentTag.ts @@ -0,0 +1,21 @@ +import { apiClient } from "./http"; +import type { DocumentTagAssignment, Tag } from "./tag"; + +export const fetchDocumentTags = async (documentID: string) => { + const { data } = await apiClient.get<{ data: Tag[] }>(`/documents/${documentID}/tags`); + return data.data; +}; + +export const assignTagToDocument = async (documentID: string, payload: { tag_id: number; weight?: number | null }) => { + const { data } = await apiClient.post<{ data: DocumentTagAssignment }>(`/documents/${documentID}/tags`, payload); + return data.data; +}; + +export const updateDocumentTag = async (documentID: string, tagID: number, payload: { tag_id: number; weight?: number | null }) => { + const { data } = await apiClient.put<{ data: DocumentTagAssignment }>(`/documents/${documentID}/tags/${tagID}`, payload); + return data.data; +}; + +export const removeTagFromDocument = async (documentID: string, tagID: number) => { + await apiClient.delete(`/documents/${documentID}/tags/${tagID}`); +}; diff --git a/features/virtual-knowledge-base/web/src/api/http.ts b/features/virtual-knowledge-base/web/src/api/http.ts new file mode 100644 index 0000000..83ea553 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/api/http.ts @@ -0,0 +1,17 @@ +import axios from "axios"; + +export const apiClient = axios.create({ + baseURL: "/api/v1/virtual-kb", + timeout: 15000, + headers: { + "Content-Type": "application/json", + }, +}); + +export const setAPIKey = (apiKey?: string) => { + if (apiKey) { + apiClient.defaults.headers.common["X-API-Key"] = apiKey; + } else { + delete apiClient.defaults.headers.common["X-API-Key"]; + } +}; diff --git a/features/virtual-knowledge-base/web/src/api/search.ts b/features/virtual-knowledge-base/web/src/api/search.ts new file mode 100644 index 0000000..daa0e8c --- /dev/null +++ b/features/virtual-knowledge-base/web/src/api/search.ts @@ -0,0 +1,28 @@ +import { apiClient } from "./http"; + +export interface SearchTagFilter { + tag_category_id: number; + tag_ids: number[]; + operator: "AND" | "OR" | "NOT"; + weight: number; +} + +export interface EnhancedSearchRequest { + virtual_kb_id?: number; + tag_filters?: SearchTagFilter[]; + limit?: number; +} + +export interface DocumentScore { + document_id: string; + score: number; +} + +export interface EnhancedSearchResponse { + results: DocumentScore[]; +} + +export const enhancedSearch = async (payload: EnhancedSearchRequest) => { + const { data } = await apiClient.post<{ data: EnhancedSearchResponse }>("/search", payload); + return data.data; +}; diff --git a/features/virtual-knowledge-base/web/src/api/tag.ts b/features/virtual-knowledge-base/web/src/api/tag.ts new file mode 100644 index 0000000..453afc4 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/api/tag.ts @@ -0,0 +1,65 @@ +import { apiClient } from "./http"; + +export interface TagCategory { + id: number; + name: string; + description?: string; + color?: string; + created_at?: string; + updated_at?: string; +} + +export interface Tag { + id: number; + category_id: number; + name: string; + value: string; + weight: number; + description?: string; + created_at?: string; + updated_at?: string; +} + +export interface DocumentTagAssignment { + document_id: string; + tag_id: number; + weight?: number | null; +} + +export const fetchTagCategories = async () => { + const { data } = await apiClient.get<{ data: TagCategory[] }>("/categories"); + return data.data; +}; + +export const createTagCategory = async (payload: Partial) => { + const { data } = await apiClient.post<{ data: TagCategory }>("/categories", payload); + return data.data; +}; + +export const updateTagCategory = async (id: number, payload: Partial) => { + const { data } = await apiClient.put<{ data: TagCategory }>(`/categories/${id}`, payload); + return data.data; +}; + +export const deleteTagCategory = async (id: number) => { + await apiClient.delete(`/categories/${id}`); +}; + +export const fetchTagsByCategory = async (categoryID: number) => { + const { data } = await apiClient.get<{ data: Tag[] }>("/tags", { params: { category_id: categoryID } }); + return data.data; +}; + +export const createTag = async (payload: Partial) => { + const { data } = await apiClient.post<{ data: Tag }>("/tags", payload); + return data.data; +}; + +export const updateTag = async (id: number, payload: Partial) => { + const { data } = await apiClient.put<{ data: Tag }>(`/tags/${id}`, payload); + return data.data; +}; + +export const deleteTag = async (id: number) => { + await apiClient.delete(`/tags/${id}`); +}; diff --git a/features/virtual-knowledge-base/web/src/api/virtualKB.ts b/features/virtual-knowledge-base/web/src/api/virtualKB.ts new file mode 100644 index 0000000..b68699a --- /dev/null +++ b/features/virtual-knowledge-base/web/src/api/virtualKB.ts @@ -0,0 +1,57 @@ +import { apiClient } from "./http"; + +export type FilterOperator = "AND" | "OR" | "NOT"; + +export interface VirtualKBFilter { + id?: number; + virtual_kb_id?: number; + tag_category_id: number; + tag_ids: number[]; + operator: FilterOperator; + weight: number; +} + +export interface VirtualKB { + id: number; + name: string; + description?: string; + filters: VirtualKBFilter[]; + config?: Record; + created_at?: string; + updated_at?: string; +} + +export interface VirtualKBCreateRequest { + name: string; + description?: string; + filters: VirtualKBFilter[]; + config?: Record; +} + +export interface VirtualKBUpdateRequest extends VirtualKBCreateRequest { + id: number; +} + +export const fetchVirtualKBs = async () => { + const { data } = await apiClient.get<{ data: VirtualKB[] }>("/instances"); + return data.data; +}; + +export const fetchVirtualKB = async (id: number) => { + const { data } = await apiClient.get<{ data: VirtualKB }>(`/instances/${id}`); + return data.data; +}; + +export const createVirtualKB = async (payload: VirtualKBCreateRequest) => { + const { data } = await apiClient.post<{ data: VirtualKB }>("/instances", payload); + return data.data; +}; + +export const updateVirtualKB = async (id: number, payload: VirtualKBUpdateRequest) => { + const { data } = await apiClient.put<{ data: VirtualKB }>(`/instances/${id}`, payload); + return data.data; +}; + +export const deleteVirtualKB = async (id: number) => { + await apiClient.delete(`/instances/${id}`); +}; diff --git a/features/virtual-knowledge-base/web/src/components/common/ErrorState.vue b/features/virtual-knowledge-base/web/src/components/common/ErrorState.vue new file mode 100644 index 0000000..76c3ab6 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/components/common/ErrorState.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/components/common/LoadingState.vue b/features/virtual-knowledge-base/web/src/components/common/LoadingState.vue new file mode 100644 index 0000000..daca2de --- /dev/null +++ b/features/virtual-knowledge-base/web/src/components/common/LoadingState.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/components/search/EnhancedSearchPanel.vue b/features/virtual-knowledge-base/web/src/components/search/EnhancedSearchPanel.vue new file mode 100644 index 0000000..1e07a53 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/components/search/EnhancedSearchPanel.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/components/search/EnhancedSearchResults.vue b/features/virtual-knowledge-base/web/src/components/search/EnhancedSearchResults.vue new file mode 100644 index 0000000..e672f05 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/components/search/EnhancedSearchResults.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/components/tag/DocumentTagging.vue b/features/virtual-knowledge-base/web/src/components/tag/DocumentTagging.vue new file mode 100644 index 0000000..199cbbf --- /dev/null +++ b/features/virtual-knowledge-base/web/src/components/tag/DocumentTagging.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/components/tag/TagCategoryManager.vue b/features/virtual-knowledge-base/web/src/components/tag/TagCategoryManager.vue new file mode 100644 index 0000000..529ce79 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/components/tag/TagCategoryManager.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/components/tag/TagEditor.vue b/features/virtual-knowledge-base/web/src/components/tag/TagEditor.vue new file mode 100644 index 0000000..66c16df --- /dev/null +++ b/features/virtual-knowledge-base/web/src/components/tag/TagEditor.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/components/virtualKB/VirtualKBEditor.vue b/features/virtual-knowledge-base/web/src/components/virtualKB/VirtualKBEditor.vue new file mode 100644 index 0000000..c4e6d1f --- /dev/null +++ b/features/virtual-knowledge-base/web/src/components/virtualKB/VirtualKBEditor.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/components/virtualKB/VirtualKBList.vue b/features/virtual-knowledge-base/web/src/components/virtualKB/VirtualKBList.vue new file mode 100644 index 0000000..6a0ce40 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/components/virtualKB/VirtualKBList.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/main.ts b/features/virtual-knowledge-base/web/src/main.ts new file mode 100644 index 0000000..1ba15d4 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/main.ts @@ -0,0 +1,16 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import TDesign from "tdesign-vue-next"; + +import App from "./App.vue"; +import { createVirtualKBRouter } from "./router"; + +import "tdesign-vue-next/es/style/index.css"; + +const app = createApp(App); + +app.use(createPinia()); +app.use(TDesign); +app.use(createVirtualKBRouter()); + +app.mount("#app"); diff --git a/features/virtual-knowledge-base/web/src/router/index.ts b/features/virtual-knowledge-base/web/src/router/index.ts new file mode 100644 index 0000000..4db4dfa --- /dev/null +++ b/features/virtual-knowledge-base/web/src/router/index.ts @@ -0,0 +1,33 @@ +import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router"; + +// NOTE: +// 1. This router is scoped to the feature bundle for preview/demo purposes. +// 2. Integrators can mount the views into the main application's router by +// importing the page components (e.g. `TagManagement.vue`) directly and +// wiring them to existing layouts/route hierarchies. See `README.md` for +// migration examples. + +const routes: RouteRecordRaw[] = [ + { + path: "/", + redirect: "/tags", + }, + { + path: "/tags", + component: () => import("@views/TagManagement.vue"), + }, + { + path: "/virtual-kbs", + component: () => import("@views/VirtualKBManagement.vue"), + }, + { + path: "/search", + component: () => import("@views/EnhancedSearch.vue"), + }, +]; + +export const createVirtualKBRouter = () => + createRouter({ + history: createWebHashHistory(), + routes, + }); diff --git a/features/virtual-knowledge-base/web/src/utils/useAsync.ts b/features/virtual-knowledge-base/web/src/utils/useAsync.ts new file mode 100644 index 0000000..b908117 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/utils/useAsync.ts @@ -0,0 +1,34 @@ +import { ref } from "vue"; + +export function useAsync(initialData: T) { + const data = ref(initialData); + const loading = ref(false); + const error = ref(null); + + const run = async (fn: () => Promise) => { + loading.value = true; + error.value = null; + try { + const result = await fn(); + data.value = result; + return result; + } catch (err) { + error.value = (err as Error).message ?? "Request failed"; + throw err; + } finally { + loading.value = false; + } + }; + + const setData = (value: T) => { + data.value = value; + }; + + return { + data, + loading, + error, + run, + setData, + }; +} diff --git a/features/virtual-knowledge-base/web/src/views/EnhancedSearch.vue b/features/virtual-knowledge-base/web/src/views/EnhancedSearch.vue new file mode 100644 index 0000000..7da1134 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/views/EnhancedSearch.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/views/TagManagement.vue b/features/virtual-knowledge-base/web/src/views/TagManagement.vue new file mode 100644 index 0000000..a4c9d13 --- /dev/null +++ b/features/virtual-knowledge-base/web/src/views/TagManagement.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/src/views/VirtualKBManagement.vue b/features/virtual-knowledge-base/web/src/views/VirtualKBManagement.vue new file mode 100644 index 0000000..3a1586e --- /dev/null +++ b/features/virtual-knowledge-base/web/src/views/VirtualKBManagement.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/features/virtual-knowledge-base/web/tsconfig.json b/features/virtual-knowledge-base/web/tsconfig.json new file mode 100644 index 0000000..bc6139c --- /dev/null +++ b/features/virtual-knowledge-base/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "jsx": "preserve", + "strict": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@components/*": ["src/components/*"], + "@views/*": ["src/views/*"], + "@api/*": ["src/api/*"], + "@types/*": ["src/types/*"], + "@utils/*": ["src/utils/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["dist", "node_modules"] +} diff --git a/features/virtual-knowledge-base/web/vite.config.ts b/features/virtual-knowledge-base/web/vite.config.ts new file mode 100644 index 0000000..109b042 --- /dev/null +++ b/features/virtual-knowledge-base/web/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import path from "path"; + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + "@components": path.resolve(__dirname, "src/components"), + "@views": path.resolve(__dirname, "src/views"), + "@stores": path.resolve(__dirname, "src/stores"), + "@api": path.resolve(__dirname, "src/api"), + "@types": path.resolve(__dirname, "src/types"), + "@utils": path.resolve(__dirname, "src/utils"), + }, + }, + server: { + port: 4173, + open: false, + }, + build: { + outDir: "dist", + }, + base: "./", +});