491 lines
16 KiB
Go
491 lines
16 KiB
Go
/*
|
|
* 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
|
|
}
|
|
}
|