chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/internal/base/pager"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
answercommon "github.com/apache/answer/internal/service/answer_common"
|
||||
"github.com/apache/answer/internal/service/comment"
|
||||
"github.com/apache/answer/internal/service/content"
|
||||
"github.com/apache/answer/internal/service/embedding"
|
||||
"github.com/apache/answer/internal/service/feature_toggle"
|
||||
questioncommon "github.com/apache/answer/internal/service/question_common"
|
||||
"github.com/apache/answer/internal/service/siteinfo_common"
|
||||
tagcommonser "github.com/apache/answer/internal/service/tag_common"
|
||||
usercommon "github.com/apache/answer/internal/service/user_common"
|
||||
"github.com/apache/answer/plugin"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
type MCPController struct {
|
||||
searchService *content.SearchService
|
||||
siteInfoService siteinfo_common.SiteInfoCommonService
|
||||
tagCommonService *tagcommonser.TagCommonService
|
||||
questioncommon *questioncommon.QuestionCommon
|
||||
commentRepo comment.CommentRepo
|
||||
userCommon *usercommon.UserCommon
|
||||
answerRepo answercommon.AnswerRepo
|
||||
featureToggleSvc *feature_toggle.FeatureToggleService
|
||||
embeddingService *embedding.EmbeddingService
|
||||
}
|
||||
|
||||
// NewMCPController new site info controller.
|
||||
func NewMCPController(
|
||||
searchService *content.SearchService,
|
||||
siteInfoService siteinfo_common.SiteInfoCommonService,
|
||||
tagCommonService *tagcommonser.TagCommonService,
|
||||
questioncommon *questioncommon.QuestionCommon,
|
||||
commentRepo comment.CommentRepo,
|
||||
userCommon *usercommon.UserCommon,
|
||||
answerRepo answercommon.AnswerRepo,
|
||||
featureToggleSvc *feature_toggle.FeatureToggleService,
|
||||
embeddingService *embedding.EmbeddingService,
|
||||
) *MCPController {
|
||||
return &MCPController{
|
||||
searchService: searchService,
|
||||
siteInfoService: siteInfoService,
|
||||
tagCommonService: tagCommonService,
|
||||
questioncommon: questioncommon,
|
||||
commentRepo: commentRepo,
|
||||
userCommon: userCommon,
|
||||
answerRepo: answerRepo,
|
||||
featureToggleSvc: featureToggleSvc,
|
||||
embeddingService: embeddingService,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPController) ensureMCPEnabled(ctx context.Context) error {
|
||||
if c.featureToggleSvc == nil {
|
||||
return nil
|
||||
}
|
||||
return c.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureMCP)
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPQuestionsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchCond(request)
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("get site general info failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
searchResp, err := c.searchService.Search(ctx, &schema.SearchDTO{
|
||||
Query: cond.ToQueryString() + " is:question",
|
||||
Page: 1,
|
||||
Size: 5,
|
||||
Order: "newest",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := make([]*schema.MCPSearchQuestionInfoResp, 0)
|
||||
for _, question := range searchResp.SearchResults {
|
||||
t := &schema.MCPSearchQuestionInfoResp{
|
||||
QuestionID: question.Object.QuestionID,
|
||||
Title: question.Object.Title,
|
||||
Content: question.Object.Excerpt,
|
||||
Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.Object.QuestionID),
|
||||
}
|
||||
resp = append(resp, t)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(resp)
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPQuestionDetailHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchQuestionDetail(request)
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("get site general info failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
|
||||
if err != nil {
|
||||
log.Errorf("get question failed: %v", err)
|
||||
return mcp.NewToolResultText("No question found."), nil
|
||||
}
|
||||
|
||||
resp := &schema.MCPSearchQuestionInfoResp{
|
||||
QuestionID: question.ID,
|
||||
Title: question.Title,
|
||||
Content: question.Content,
|
||||
Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.ID),
|
||||
}
|
||||
res, _ := json.Marshal(resp)
|
||||
return mcp.NewToolResultText(string(res)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchAnswerCond(request)
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("get site general info failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(cond.QuestionID) > 0 {
|
||||
answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID})
|
||||
if err != nil {
|
||||
log.Errorf("get answers failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
resp := make([]*schema.MCPSearchAnswerInfoResp, 0)
|
||||
for _, answer := range answerList {
|
||||
if answer.Status != entity.AnswerStatusAvailable {
|
||||
continue
|
||||
}
|
||||
t := &schema.MCPSearchAnswerInfoResp{
|
||||
QuestionID: answer.QuestionID,
|
||||
AnswerID: answer.ID,
|
||||
AnswerContent: answer.OriginalText,
|
||||
Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID),
|
||||
}
|
||||
resp = append(resp, t)
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
|
||||
answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID})
|
||||
if err != nil {
|
||||
log.Errorf("get answers failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
resp := make([]*schema.MCPSearchAnswerInfoResp, 0)
|
||||
for _, answer := range answerList {
|
||||
if answer.Status != entity.AnswerStatusAvailable {
|
||||
continue
|
||||
}
|
||||
t := &schema.MCPSearchAnswerInfoResp{
|
||||
QuestionID: answer.QuestionID,
|
||||
AnswerID: answer.ID,
|
||||
AnswerContent: answer.OriginalText,
|
||||
Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID),
|
||||
}
|
||||
resp = append(resp, t)
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPCommentsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchCommentCond(request)
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("get site general info failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dto := &comment.CommentQuery{
|
||||
PageCond: pager.PageCond{Page: 1, PageSize: 5},
|
||||
QueryCond: "newest",
|
||||
ObjectID: cond.ObjectID,
|
||||
}
|
||||
commentList, total, err := c.commentRepo.GetCommentPage(ctx, dto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return mcp.NewToolResultText("No comments found."), nil
|
||||
}
|
||||
|
||||
resp := make([]*schema.MCPSearchCommentInfoResp, 0)
|
||||
for _, comment := range commentList {
|
||||
t := &schema.MCPSearchCommentInfoResp{
|
||||
CommentID: comment.ID,
|
||||
Content: comment.OriginalText,
|
||||
ObjectID: comment.ObjectID,
|
||||
Link: fmt.Sprintf("%s/comments/%s", siteGeneral.SiteUrl, comment.ID),
|
||||
}
|
||||
resp = append(resp, t)
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPTagsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchTagCond(request)
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("get site general info failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, total, err := c.tagCommonService.GetTagPage(ctx, 1, 10, &entity.Tag{DisplayName: cond.TagName}, "newest")
|
||||
if err != nil {
|
||||
log.Errorf("get tags failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
res := strings.Builder{}
|
||||
res.WriteString("No tags found.\n")
|
||||
return mcp.NewToolResultText(res.String()), nil
|
||||
}
|
||||
|
||||
resp := make([]*schema.MCPSearchTagResp, 0)
|
||||
for _, tag := range tags {
|
||||
t := &schema.MCPSearchTagResp{
|
||||
TagName: tag.SlugName,
|
||||
DisplayName: tag.DisplayName,
|
||||
Description: tag.OriginalText,
|
||||
Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName),
|
||||
}
|
||||
resp = append(resp, t)
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPTagDetailsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchTagCond(request)
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("get site general info failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tag, exist, err := c.tagCommonService.GetTagBySlugName(ctx, cond.TagName)
|
||||
if err != nil {
|
||||
log.Errorf("get tag failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if !exist {
|
||||
return mcp.NewToolResultText("Tag not found."), nil
|
||||
}
|
||||
|
||||
resp := &schema.MCPSearchTagResp{
|
||||
TagName: tag.SlugName,
|
||||
DisplayName: tag.DisplayName,
|
||||
Description: tag.OriginalText,
|
||||
Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName),
|
||||
}
|
||||
res, _ := json.Marshal(resp)
|
||||
return mcp.NewToolResultText(string(res)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPUserDetailsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchUserCond(request)
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("get site general info failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, exist, err := c.userCommon.GetUserBasicInfoByUserName(ctx, cond.Username)
|
||||
if err != nil {
|
||||
log.Errorf("get user failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if !exist {
|
||||
return mcp.NewToolResultText("User not found."), nil
|
||||
}
|
||||
|
||||
resp := &schema.MCPSearchUserInfoResp{
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Avatar: user.Avatar,
|
||||
Link: fmt.Sprintf("%s/users/%s", siteGeneral.SiteUrl, user.Username),
|
||||
}
|
||||
res, _ := json.Marshal(resp)
|
||||
return mcp.NewToolResultText(string(res)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPSemanticSearchHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSemanticSearchCond(request)
|
||||
if len(cond.Query) == 0 {
|
||||
return mcp.NewToolResultText("Query is required for semantic search."), nil
|
||||
}
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("get site general info failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results, err := c.embeddingService.SearchSimilar(ctx, cond.Query, cond.TopK)
|
||||
if err != nil {
|
||||
log.Errorf("semantic search failed: %v", err)
|
||||
return mcp.NewToolResultText("Semantic search is not available. Embedding may not be configured."), nil
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return mcp.NewToolResultText("No semantically similar content found."), nil
|
||||
}
|
||||
|
||||
resp := make([]*schema.MCPSemanticSearchResp, 0, len(results))
|
||||
for _, r := range results {
|
||||
var meta plugin.VectorSearchMetadata
|
||||
_ = json.Unmarshal([]byte(r.Metadata), &meta)
|
||||
|
||||
item := &schema.MCPSemanticSearchResp{
|
||||
ObjectID: r.ObjectID,
|
||||
ObjectType: r.ObjectType,
|
||||
Score: r.Score,
|
||||
}
|
||||
|
||||
// Compose link from metadata
|
||||
if r.ObjectType == "answer" && meta.AnswerID != "" {
|
||||
item.Link = fmt.Sprintf("%s/questions/%s/%s", siteGeneral.SiteUrl, meta.QuestionID, meta.AnswerID)
|
||||
} else {
|
||||
item.Link = fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, meta.QuestionID)
|
||||
}
|
||||
|
||||
// Query content from DB using IDs stored in metadata
|
||||
if r.ObjectType == "question" {
|
||||
question, qErr := c.questioncommon.Info(ctx, meta.QuestionID, "")
|
||||
if qErr != nil {
|
||||
log.Warnf("get question %s for semantic search failed: %v", meta.QuestionID, qErr)
|
||||
} else {
|
||||
item.Title = question.Title
|
||||
item.Content = question.Content
|
||||
}
|
||||
|
||||
// Fetch answers by ID from metadata
|
||||
for _, a := range meta.Answers {
|
||||
answerEntity, exist, aErr := c.answerRepo.GetAnswer(ctx, a.AnswerID)
|
||||
if aErr != nil || !exist {
|
||||
continue
|
||||
}
|
||||
answerItem := &schema.MCPSemanticSearchAnswer{
|
||||
AnswerID: a.AnswerID,
|
||||
Content: answerEntity.OriginalText,
|
||||
}
|
||||
// Fetch comments on this answer from DB
|
||||
for _, ac := range a.Comments {
|
||||
cmt, cExist, cErr := c.commentRepo.GetComment(ctx, ac.CommentID)
|
||||
if cErr == nil && cExist {
|
||||
answerItem.Comments = append(answerItem.Comments, &schema.MCPSemanticSearchComment{
|
||||
CommentID: ac.CommentID,
|
||||
Content: cmt.OriginalText,
|
||||
})
|
||||
}
|
||||
}
|
||||
item.Answers = append(item.Answers, answerItem)
|
||||
}
|
||||
|
||||
// Fetch question comments from DB
|
||||
for _, qc := range meta.Comments {
|
||||
cmt, cExist, cErr := c.commentRepo.GetComment(ctx, qc.CommentID)
|
||||
if cErr == nil && cExist {
|
||||
item.Comments = append(item.Comments, &schema.MCPSemanticSearchComment{
|
||||
CommentID: qc.CommentID,
|
||||
Content: cmt.OriginalText,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if r.ObjectType == "answer" {
|
||||
// Fetch question title for context
|
||||
question, qErr := c.questioncommon.Info(ctx, meta.QuestionID, "")
|
||||
if qErr == nil {
|
||||
item.Title = question.Title
|
||||
}
|
||||
|
||||
// Fetch answer content from DB
|
||||
if meta.AnswerID != "" {
|
||||
answerEntity, exist, aErr := c.answerRepo.GetAnswer(ctx, meta.AnswerID)
|
||||
if aErr == nil && exist {
|
||||
item.Content = answerEntity.OriginalText
|
||||
}
|
||||
} else if len(meta.Answers) > 0 {
|
||||
answerEntity, exist, aErr := c.answerRepo.GetAnswer(ctx, meta.Answers[0].AnswerID)
|
||||
if aErr == nil && exist {
|
||||
item.Content = answerEntity.OriginalText
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch answer comments from DB
|
||||
if len(meta.Answers) > 0 {
|
||||
for _, ac := range meta.Answers[0].Comments {
|
||||
cmt, cExist, cErr := c.commentRepo.GetComment(ctx, ac.CommentID)
|
||||
if cErr == nil && cExist {
|
||||
item.Comments = append(item.Comments, &schema.MCPSemanticSearchComment{
|
||||
CommentID: ac.CommentID,
|
||||
Content: cmt.OriginalText,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp = append(resp, item)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(resp)
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user