Skip to content

Commit 0bf742d

Browse files
committed
Add support for adding and editing answers
1 parent 6414048 commit 0bf742d

File tree

12 files changed

+685
-157
lines changed

12 files changed

+685
-157
lines changed

MyApp.ServiceInterface/Data/QuestionsProvider.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ public class QuestionsProvider(ILogger<QuestionsProvider> log, IMessageProducer
1111
{
1212
public const int MostVotedScore = 10;
1313
public const int AcceptedScore = 9;
14+
public static List<string> ModelUserNames { get; } = [
15+
"phi", "gemma-2b", "qwen-4b", "codellama", "gemma", "deepseek-coder", "mistral", "mixtral"
16+
];
1417
public static Dictionary<string,int> ModelScores = new()
1518
{
1619
["phi"] = 1, //2.7B
@@ -99,6 +102,16 @@ await Task.WhenAll(
99102
r2.WriteFileAsync(virtualPath, contents),
100103
fs.WriteFileAsync(virtualPath, contents));
101104
}
105+
106+
public async Task DeleteFileAsync(string virtualPath)
107+
{
108+
fs.DeleteFile(virtualPath);
109+
await r2.AmazonS3.DeleteObjectAsync(new Amazon.S3.Model.DeleteObjectRequest
110+
{
111+
BucketName = r2.BucketName,
112+
Key = r2.SanitizePath(virtualPath),
113+
});
114+
}
102115

103116
public async Task WriteMetaAsync(Meta meta)
104117
{
@@ -136,6 +149,31 @@ public async Task<QuestionFiles> GetQuestionAsync(int id)
136149
return questionFiles;
137150
}
138151

152+
public async Task<IVirtualFile?> GetAnswerFileAsync(string refId)
153+
{
154+
if (refId.IndexOf('-') < 0)
155+
throw new ArgumentException("Invalid Answer Id", nameof(refId));
156+
157+
var postId = refId.LeftPart('-').ToInt();
158+
var userName = refId.RightPart('-');
159+
var answerPath = ModelUserNames.Contains(userName)
160+
? GetModelAnswerPath(postId, userName)
161+
: GetHumanAnswerPath(postId, userName);
162+
163+
var file = fs.GetFile(answerPath)
164+
?? r2.GetFile(answerPath);
165+
166+
if (file == null)
167+
{
168+
// After first edit AI Model is converted to h. (Post) answer
169+
var modelAnswerPath = GetHumanAnswerPath(postId, userName);
170+
file = fs.GetFile(modelAnswerPath)
171+
?? r2.GetFile(modelAnswerPath);
172+
}
173+
174+
return file;
175+
}
176+
139177
public async Task SaveQuestionAsync(Post post)
140178
{
141179
await SaveFileAsync(GetQuestionPath(post.Id), ToJson(post));
@@ -168,6 +206,61 @@ public async Task SaveRemoteFileAsync(string virtualPath, string contents)
168206
{
169207
await r2.WriteFileAsync(virtualPath, contents);
170208
}
209+
210+
public async Task SaveAnswerEditAsync(IVirtualFile existingAnswer, string userName, string body, string editReason)
211+
{
212+
var now = DateTime.UtcNow;
213+
var existingAnswerJson = await existingAnswer.ReadAllTextAsync();
214+
var tasks = new List<Task>();
215+
216+
var fileName = existingAnswer.VirtualPath.TrimStart('/').Replace("/", "");
217+
var postId = fileName.LeftPart('.').ToInt();
218+
string existingAnswerBy = "";
219+
var newAnswer = new Post
220+
{
221+
Id = postId,
222+
};
223+
224+
if (fileName.Contains(".a."))
225+
{
226+
existingAnswerBy = fileName.RightPart(".a.").LastLeftPart('.');
227+
var datePart = DateTime.UtcNow.ToString("yyMMdd-HHmmss");
228+
var editFilePath = existingAnswer.VirtualPath.LastLeftPart('/') + "/edit.a." + postId + "-" + userName + "_" + datePart + ".json";
229+
tasks.Add(SaveFileAsync(editFilePath, existingAnswerJson));
230+
tasks.Add(DeleteFileAsync(existingAnswer.VirtualPath));
231+
232+
var obj = (Dictionary<string,object>)JSON.parse(existingAnswerJson);
233+
newAnswer.CreationDate = obj.TryGetValue("created", out var oCreated) && oCreated is int created
234+
? DateTimeOffset.FromUnixTimeSeconds(created).DateTime
235+
: existingAnswer.LastModified;
236+
}
237+
else if (fileName.Contains(".h."))
238+
{
239+
existingAnswerBy = fileName.RightPart(".h.").LastLeftPart('.');
240+
var datePart = DateTime.UtcNow.ToString("yyMMdd-HHmmss");
241+
newAnswer = existingAnswerJson.FromJson<Post>();
242+
243+
// Just override the existing answer if it's the same user
244+
if (newAnswer.ModifiedBy != userName)
245+
{
246+
var editFilePath = existingAnswer.VirtualPath.LastLeftPart('/') + "/edit.h." + postId + "-" + userName + "_" + datePart + ".json";
247+
tasks.Add(SaveFileAsync(editFilePath, existingAnswerJson));
248+
}
249+
}
250+
else throw new ArgumentException($"Invalid Answer File {existingAnswer.Name}", nameof(existingAnswer));
251+
252+
newAnswer.Body = body;
253+
newAnswer.CreatedBy ??= existingAnswerBy;
254+
newAnswer.ModifiedBy = userName;
255+
newAnswer.LastEditDate = now;
256+
newAnswer.ModifiedReason = editReason;
257+
258+
var newFileName = $"{existingAnswer.Name.LeftPart('.')}.h.{existingAnswerBy}.json";
259+
var newFilePath = existingAnswer.VirtualPath.LastLeftPart('/') + "/" + newFileName;
260+
tasks.Add(SaveFileAsync(newFilePath, ToJson(newAnswer)));
261+
262+
await Task.WhenAll(tasks);
263+
}
171264

172265
public async Task DeleteQuestionFilesAsync(int id)
173266
{

MyApp.ServiceInterface/QuestionServices.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ public async Task<object> Any(AskQuestion request)
6161
};
6262
}
6363

64+
public async Task<object> Any(EditQuestion request)
65+
{
66+
return new EditQuestionResponse();
67+
}
68+
6469
public async Task<EmptyResponse> Any(DeleteQuestion request)
6570
{
6671
await questions.DeleteQuestionFilesAsync(request.Id);
@@ -99,6 +104,27 @@ public async Task<object> Any(AnswerQuestion request)
99104
return new AnswerQuestionResponse();
100105
}
101106

107+
/* /100/000
108+
* 001.a.model.json <OpenAI>
109+
* Edit 1:
110+
* 001.h.model.json <Post>
111+
* edit.a.001-model_20240301-1200.json // original model answer, Modified Date <OpenAI>
112+
* Edit 2:
113+
* 001.h.model.json <Post> #2
114+
* edit.a.001-model_20240301-130303.json // #1 edit model answer, Modified Date <Post>
115+
* edit.a.001-model_20240301-120101.json // #0 original model answer, Modified Date <OpenAI>
116+
*/
117+
public async Task<object> Any(EditAnswer request)
118+
{
119+
var answerFile = await questions.GetAnswerFileAsync(request.Id);
120+
if (answerFile == null)
121+
throw HttpError.NotFound("Answer does not exist");
122+
123+
await questions.SaveAnswerEditAsync(answerFile, GetUserName(), request.Body, request.EditReason);
124+
125+
return new EditAnswerResponse();
126+
}
127+
102128
public async Task<object> Any(GetQuestionFile request)
103129
{
104130
var questionFiles = await questions.GetQuestionFilesAsync(request.Id);
@@ -108,6 +134,29 @@ public async Task<object> Any(GetQuestionFile request)
108134
return new HttpResult(file, MimeTypes.Json);
109135
}
110136

137+
public async Task<object> Any(GetAnswerBody request)
138+
{
139+
var answerFile = await questions.GetAnswerFileAsync(request.Id);
140+
if (answerFile == null)
141+
throw HttpError.NotFound("Answer does not exist");
142+
143+
var json = await answerFile.ReadAllTextAsync();
144+
if (answerFile.Name.Contains(".a."))
145+
{
146+
var obj = (Dictionary<string,object>)JSON.parse(json);
147+
var choices = (List<object>) obj["choices"];
148+
var choice = (Dictionary<string,object>)choices[0];
149+
var message = (Dictionary<string,object>)choice["message"];
150+
var body = (string)message["content"];
151+
return new HttpResult(body, MimeTypes.PlainText);
152+
}
153+
else
154+
{
155+
var answer = json.FromJson<Post>();
156+
return new HttpResult(answer.Body, MimeTypes.PlainText);
157+
}
158+
}
159+
111160
public async Task Any(CreateWorkerAnswer request)
112161
{
113162
var json = request.Json;

MyApp.ServiceModel/Posts.cs

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Complete declarative AutoQuery services for Bookings CRUD example:
22
// https://docs.servicestack.net/autoquery-crud-bookings
33

4-
using System.ComponentModel.DataAnnotations;
54
using ServiceStack;
65
using ServiceStack.DataAnnotations;
76

@@ -54,7 +53,13 @@ public class Post
5453

5554
public string? RefId { get; set; }
5655

57-
[Ignore] public string? Body { get; set; }
56+
public string? Body { get; set; }
57+
58+
public string? ModifiedReason { get; set; }
59+
60+
public DateTime? LockedDate { get; set; }
61+
62+
public string? LockedReason { get; set; }
5863
}
5964

6065
public class PostJob
@@ -271,7 +276,6 @@ public class AskQuestion : IPost, IReturn<AskQuestionResponse>
271276
[Input(Type="hidden")]
272277
public string? RefId { get; set; }
273278
}
274-
275279
public class AskQuestionResponse
276280
{
277281
public int Id { get; set; }
@@ -280,6 +284,26 @@ public class AskQuestionResponse
280284
public ResponseStatus? ResponseStatus { get; set; }
281285
}
282286

287+
[ValidateIsAuthenticated]
288+
public class EditQuestion : IPost, IReturn<EditQuestionResponse>
289+
{
290+
[ValidateNotEmpty, ValidateMinimumLength(20), ValidateMaximumLength(120)]
291+
[Input(Type = "text", Help = "A summary of what your main question is asking"), FieldCss(Field="col-span-12")]
292+
public required string Title { get; set; }
293+
294+
[ValidateNotEmpty, ValidateMinimumLength(30), ValidateMaximumLength(32768)]
295+
[Input(Type="MarkdownInput", Help = "Include all information required for someone to identity and resolve your exact question"), FieldCss(Field="col-span-12", Input="h-60")]
296+
public required string Body { get; set; }
297+
298+
[ValidateNotEmpty, ValidateMinimumLength(2, Message = "At least 1 tag required"), ValidateMaximumLength(120)]
299+
[Input(Type = "tag", Help = "Up to 5 tags relevant to your question"), FieldCss(Field="col-span-12")]
300+
public required List<string> Tags { get; set; }
301+
}
302+
public class EditQuestionResponse
303+
{
304+
public ResponseStatus? ResponseStatus { get; set; }
305+
}
306+
283307
[ValidateIsAuthenticated]
284308
[Description("Your Answer")]
285309
public class AnswerQuestion : IPost, IReturn<AnswerQuestionResponse>
@@ -300,11 +324,37 @@ public class AnswerQuestionResponse
300324
public ResponseStatus? ResponseStatus { get; set; }
301325
}
302326

327+
[ValidateIsAuthenticated]
328+
[Description("Your Answer")]
329+
public class EditAnswer : IPost, IReturn<EditAnswerResponse>
330+
{
331+
[Input(Type="hidden")]
332+
[ValidateNotEmpty]
333+
public required string Id { get; set; }
334+
335+
[ValidateNotEmpty, ValidateMinimumLength(30), ValidateMaximumLength(32768)]
336+
[Input(Type="MarkdownInput", Label=""), FieldCss(Field="col-span-12", Input="h-60")]
337+
public required string Body { get; set; }
338+
339+
[Input(Type="text", Placeholder="Short summary of this edit (e.g. corrected spelling, grammar, improved formatting)"),FieldCss(Field = "col-span-12")]
340+
[ValidateNotEmpty, ValidateMinimumLength(4)]
341+
public required string EditReason { get; set; }
342+
}
343+
public class EditAnswerResponse
344+
{
345+
public ResponseStatus? ResponseStatus { get; set; }
346+
}
347+
303348
public class PreviewMarkdown : IPost, IReturn<string>
304349
{
305350
public string Markdown { get; set; }
306351
}
307352

353+
public class GetAnswerBody : IGet, IReturn<string>
354+
{
355+
public string Id { get; set; }
356+
}
357+
308358
[ValidateHasRole(Roles.Moderator)]
309359
public class DeleteQuestion : IGet, IReturn<EmptyResponse>
310360
{

MyApp/Components/App.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
})
3333
</script>
3434
<script src="lib/js/highlight.js"></script>
35-
<script>hljs.highlightAll()</script>
35+
<script src="lib/js/default.js"></script>
3636
</body>
3737

3838
</html>

MyApp/Components/Pages/Questions/LiveAnswers.razor

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@
6767
</div>
6868
</div>
6969
}
70+
else
71+
{
72+
<div class="float-right">
73+
<a href=@QuestionPath class="whitespace-nowrap font-medium text-indigo-700 dark:text-indigo-300 hover:text-indigo-500">
74+
full question page
75+
<span aria-hidden="true"> &rarr;</span>
76+
</a>
77+
</div>
78+
}
7079

7180
<div id="answers" class="mt-8">
7281
@if (question.Answers.Count > 0)

MyApp/Components/Shared/QuestionPost.razor

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,21 @@
2121
{
2222
<span>asked</span>
2323
}
24-
<b>@((DateTime.UtcNow - Question.Post.CreationDate).TimeAgo())</b>
24+
<b class="ml-1">@((DateTime.UtcNow - Question.Post.CreationDate).TimeAgo())</b>
2525
</div>
2626
@if (Question.Post.LastEditDate != null)
2727
{
2828
<div>
2929
<b>@((DateTime.UtcNow - Question.Post.LastEditDate.Value).TimeAgo())</b>
3030
</div>
3131
}
32-
<div>
33-
<span>viewed</span>
34-
<b>@($"{Question.ViewCount:n0}") times</b>
35-
</div>
32+
@if (Question.ViewCount > 1)
33+
{
34+
<div>
35+
<span>viewed</span>
36+
<b>@($"{Question.ViewCount:n0}") times</b>
37+
</div>
38+
}
3639
</div>
3740
@if (Question.Post.CreatedBy != null)
3841
{
@@ -103,7 +106,7 @@
103106
</article>
104107

105108
<div id="answers" class="mt-16">
106-
@if (@Question.Answers.Count > 0)
109+
@if (Question.Answers.Count > 0)
107110
{
108111
<h3 class="text-2xl font-semibold">
109112
@Question.Answers.Count Answers
@@ -112,7 +115,7 @@
112115
<div>
113116
@foreach (var answer in Question.Answers)
114117
{
115-
<div class="py-8 border-b border-gray-200 dark:border-gray-700">
118+
<div data-answer=@answer.Id class="py-8 border-b border-gray-200 dark:border-gray-700">
116119
<div class="flex">
117120
<div class="md:w-32 pr-2">
118121
<div id=@answer.Id class="voting flex flex-col items-center">
@@ -128,12 +131,16 @@
128131
<div class="hidden md:block text-center whitespace-nowrap text-xs xl:text-sm font-semibold">@userName</div>
129132
</div>
130133
</div>
131-
<div class="xl:flex-grow prose">
134+
<div id=@($"preview-{answer.Id}") class="preview xl:flex-grow prose">
132135
@BlazorHtml.Raw(Markdown.GenerateHtml(answer.Choices.FirstOrDefault()?.Message.Content))
133136
</div>
137+
<div id=@($"edit-{answer.Id}") class="edit w-full pl-2 hidden"></div>
134138
</div>
135-
<div class="mt-4 flex justify-end text-gray-700 dark:text-gray-200 text-sm">
136-
answered <time class="ml-2" datetime="@Markdown.GetDateTimestamp(DateTimeOffset.FromUnixTimeSeconds(answer.Created).DateTime)">@Markdown.GetDateLabel(DateTimeOffset.FromUnixTimeSeconds(answer.Created).DateTime)</time>
139+
<div class="mt-6 flex justify-between">
140+
<div class="pl-10 md:pl-20 xl:pl-32"><span class="edit-link cursor-pointer select-none text-indigo-700 dark:text-indigo-300 hover:text-indigo-500"></span></div>
141+
<div class="text-gray-700 dark:text-gray-200 text-sm">
142+
answered <time class="ml-1" datetime="@Markdown.GetDateTimestamp(DateTimeOffset.FromUnixTimeSeconds(answer.Created).DateTime)">@Markdown.GetDateLabel(DateTimeOffset.FromUnixTimeSeconds(answer.Created).DateTime)</time>
143+
</div>
137144
</div>
138145
</div>
139146
@if (Question.GetAnswerComments(answer.Id).Count > 0)

MyApp/Configure.Renderer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public async Task Any(RenderComponent request)
8686
foreach (var remoteFile in remoteFiles.Files)
8787
{
8888
var localFile = localFiles.Files.FirstOrDefault(x => x.Name == remoteFile.Name);
89-
if (localFile == null || localFile.LastModified < remoteFile.LastModified)
89+
if (localFile == null || localFile.Length != remoteFile.Length)
9090
{
9191
log.LogInformation("Saving local file for {State} {Path}", localFile == null ? "new" : "modified", remoteFile.VirtualPath);
9292
var remoteContents = await remoteFile.ReadAllTextAsync();

MyApp/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
services.AddIdentityCore<ApplicationUser>(options =>
3939
{
4040
options.SignIn.RequireConfirmedAccount = true;
41-
options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._";
41+
options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-.";
4242
})
4343
.AddRoles<IdentityRole>()
4444
.AddEntityFrameworkStores<ApplicationDbContext>()

0 commit comments

Comments
 (0)