From c4605db11bc3d3cd75362b833901ba33e22035f5 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Wed, 3 Apr 2024 21:31:07 +0800 Subject: [PATCH] implement FindSimilarQuestions --- MyApp.ServiceInterface/QuestionServices.cs | 30 ++++++++++-- MyApp.ServiceModel/Posts.cs | 12 +++++ MyApp/wwwroot/mjs/dtos.mjs | 43 +++++++++++++++++- MyApp/wwwroot/pages/Questions/Ask.mjs | 53 ++++++++++++++++++++-- 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/MyApp.ServiceInterface/QuestionServices.cs b/MyApp.ServiceInterface/QuestionServices.cs index dad4f6d..07c715a 100644 --- a/MyApp.ServiceInterface/QuestionServices.cs +++ b/MyApp.ServiceInterface/QuestionServices.cs @@ -1,10 +1,10 @@ using System.Net; +using System.Text.RegularExpressions; using MyApp.Data; using ServiceStack; using MyApp.ServiceModel; using ServiceStack.IO; using ServiceStack.OrmLite; -using ServiceStack.Text; namespace MyApp.ServiceInterface; @@ -24,12 +24,12 @@ private List ValidateQuestionTags(List? tags) throw new ArgumentException("Maximum of 5 tags allowed", nameof(tags)); return validTags; } - + public async Task Get(GetAllAnswers request) { var question = await questions.GetQuestionAsync(request.Id); var modelNames = question.Question?.Answers.Where(x => !string.IsNullOrEmpty(x.Model)).Select(x => x.Model).ToList(); - var humanAnswers = question.Question?.Answers.Where(x => string.IsNullOrEmpty(x.Model)).Select(x => x.Id.SplitOnFirst("-")[1]).ToList(); + var humanAnswers = question.Question?.Answers.Where(x => string.IsNullOrEmpty(x.Model)).Select(x => x.Id.LeftPart("-")).ToList(); modelNames?.AddRange(humanAnswers ?? []); var answers = question .GetAnswerFiles() @@ -41,6 +41,30 @@ public async Task Get(GetAllAnswers request) }; } + static Regex AlphaNumericRegex = new("[^a-zA-Z0-9]", RegexOptions.Compiled); + static Regex SingleWhiteSpaceRegex = new( @"\s+", RegexOptions.Multiline | RegexOptions.Compiled); + + public async Task Any(FindSimilarQuestions request) + { + var searchPhrase = AlphaNumericRegex.Replace(request.Text, " "); + searchPhrase = SingleWhiteSpaceRegex.Replace(searchPhrase, " ").Trim(); + if (searchPhrase.Length < 15) + throw new ArgumentException("Search text must be at least 20 characters", nameof(request.Text)); + + using var dbSearch = HostContext.AppHost.GetDbConnection(Databases.Search); + var q = dbSearch.From() + .Where("Body match {0} AND instr(RefId,'-') == 0", searchPhrase) + .OrderBy("rank") + .Limit(10); + + var results = await dbSearch.SelectAsync(q); + var posts = await Db.PopulatePostsAsync(results); + + return new FindSimilarQuestionsResponse + { + Results = posts + }; + } public async Task Any(AskQuestion request) { diff --git a/MyApp.ServiceModel/Posts.cs b/MyApp.ServiceModel/Posts.cs index 29f8bc3..80c414c 100644 --- a/MyApp.ServiceModel/Posts.cs +++ b/MyApp.ServiceModel/Posts.cs @@ -293,6 +293,18 @@ public class GetQuestionFile : IGet, IReturn public int Id { get; set; } } +public class FindSimilarQuestions : IGet, IReturn +{ + [ValidateNotEmpty, ValidateMinimumLength(20)] + public string Text { get; set; } +} + +public class FindSimilarQuestionsResponse +{ + public List Results { get; set; } + public ResponseStatus? ResponseStatus { get; set; } +} + [ValidateIsAuthenticated] public class AskQuestion : IPost, IReturn { diff --git a/MyApp/wwwroot/mjs/dtos.mjs b/MyApp/wwwroot/mjs/dtos.mjs index 4495357..bf860ed 100644 --- a/MyApp/wwwroot/mjs/dtos.mjs +++ b/MyApp/wwwroot/mjs/dtos.mjs @@ -1,5 +1,5 @@ /* Options: -Date: 2024-03-29 23:35:25 +Date: 2024-04-03 11:09:35 Version: 8.22 Tip: To override a DTO option, remove "//" prefix before updating BaseUrl: https://localhost:5001 @@ -374,6 +374,14 @@ export class CalculateLeaderboardResponse { /** @type {ModelWinRate[]} */ modelWinRate; } +export class FindSimilarQuestionsResponse { + /** @param {{results?:Post[],responseStatus?:ResponseStatus}} [init] */ + constructor(init) { Object.assign(this, init) } + /** @type {Post[]} */ + results; + /** @type {?ResponseStatus} */ + responseStatus; +} export class AskQuestionResponse { /** @param {{id?:number,slug?:string,redirectTo?:string,responseStatus?:ResponseStatus}} [init] */ constructor(init) { Object.assign(this, init) } @@ -601,6 +609,15 @@ export class CalculateLeaderBoard { getMethod() { return 'GET' } createResponse() { return new CalculateLeaderboardResponse() } } +export class FindSimilarQuestions { + /** @param {{text?:string}} [init] */ + constructor(init) { Object.assign(this, init) } + /** @type {string} */ + text; + getTypeName() { return 'FindSimilarQuestions' } + getMethod() { return 'GET' } + createResponse() { return new FindSimilarQuestionsResponse() } +} export class AskQuestion { /** @param {{title?:string,body?:string,tags?:string[],refId?:string}} [init] */ constructor(init) { Object.assign(this, init) } @@ -712,6 +729,17 @@ export class CreateWorkerAnswer { getMethod() { return 'POST' } createResponse() { return new IdResponse() } } +export class RankAnswers { + /** @param {{postId?:number,votes?:{ [index: string]: number; }}} [init] */ + constructor(init) { Object.assign(this, init) } + /** @type {number} */ + postId; + /** @type {{ [index: string]: number; }} */ + votes; + getTypeName() { return 'RankAnswers' } + getMethod() { return 'POST' } + createResponse() { return new IdResponse() } +} export class CreateComment { /** @param {{id?:string,body?:string}} [init] */ constructor(init) { Object.assign(this, init) } @@ -791,6 +819,19 @@ export class PostVote { getMethod() { return 'POST' } createResponse() { } } +export class CreateAvatar { + /** @param {{userName?:string,textColor?:string,bgColor?:string}} [init] */ + constructor(init) { Object.assign(this, init) } + /** @type {string} */ + userName; + /** @type {?string} */ + textColor; + /** @type {?string} */ + bgColor; + getTypeName() { return 'CreateAvatar' } + getMethod() { return 'GET' } + createResponse() { return '' } +} export class RenderComponent { /** @param {{ifQuestionModified?:number,regenerateMeta?:number,question?:QuestionAndAnswers,home?:RenderHome}} [init] */ constructor(init) { Object.assign(this, init) } diff --git a/MyApp/wwwroot/pages/Questions/Ask.mjs b/MyApp/wwwroot/pages/Questions/Ask.mjs index 1b743d3..98bff6b 100644 --- a/MyApp/wwwroot/pages/Questions/Ask.mjs +++ b/MyApp/wwwroot/pages/Questions/Ask.mjs @@ -1,13 +1,31 @@ import { ref, watchEffect, nextTick, onMounted } from "vue" import { queryString } from "@servicestack/client" import { useClient, useUtils } from "@servicestack/vue" -import { AskQuestion, PreviewMarkdown } from "dtos.mjs" +import { AskQuestion, PreviewMarkdown, FindSimilarQuestions } from "dtos.mjs" export default { template:` +

Preview

@@ -20,7 +38,9 @@ export default { const client = useClient() const autoform = ref() - const request = ref(new AskQuestion()) + const savedJson = localStorage.getItem('ask') + const saved = savedJson ? JSON.parse(savedJson) : {} + const request = ref(new AskQuestion(saved)) const qs = queryString(location.search) if (qs.title) request.value.title = qs.title if (qs.body) request.value.body = qs.body @@ -28,6 +48,8 @@ export default { if (qs.refId || qs.refid) request.value.refId = qs.refId || qs.refid const previewHtml = ref('') let allTags = localStorage.getItem('data:tags.txt')?.split('\n') || [] + const expandSimilar = ref(true) + const similarQuestions = ref([]) const { createDebounce } = useUtils() let lastBody = '' @@ -47,8 +69,30 @@ export default { watchEffect(async () => { debounceApi(request.value.body) }) + + watchEffect(async () => { + if ((request.value.title ?? '').trim().length > 19) { + findSimilarQuestions(request.value.title) + } + }) + + let lastJson = '' + watchEffect(async () => { + const json = JSON.stringify(request.value) + if (json === lastJson) return + localStorage.setItem('ask', json) + lastJson = json + }) + + async function findSimilarQuestions(text) { + const api = await client.api(new FindSimilarQuestions({ text })) + if (api.succeeded) { + similarQuestions.value = api.response.results || [] + } + } function onSuccess(r) { + localStorage.removeItem('ask') if (r.redirectTo) { location.href = r.redirectTo } @@ -56,7 +100,8 @@ export default { onMounted(async () => { if (allTags.length === 0) { - const txt = await (await fetch('/data/tags.txt')).text() + let txt = await (await fetch('/data/tags.txt')).text() + txt = txt.replace(/\r\n/g,'\n') localStorage.setItem('data:tags.txt', txt) allTags = txt.split('\n') } @@ -69,6 +114,6 @@ export default { } } - return { request, previewHtml, autoform, configureField, onSuccess } + return { request, previewHtml, autoform, expandSimilar, similarQuestions, configureField, onSuccess } } }