Skip to content

Commit 2a6df98

Browse files
committed
feat: add first-turn AI title summary with billing, settings, and admin config APIs
- generate and stream chat titles in parallel with the first web chat turn - add title summary billing/config resolution services and user config endpoints - support admin and user title summary settings with backend-provided default template - default title summary mode to truncate and hide prompt template in truncate mode - split admin global config loading into per-key APIs and remove shared config state helper - add unit tests for title summary config resolution
1 parent 928e775 commit 2a6df98

26 files changed

Lines changed: 1409 additions & 327 deletions
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using Chats.BE.Services.TitleSummary;
2+
using Chats.DB;
3+
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Logging.Abstractions;
6+
7+
namespace Chats.BE.UnitTest.Services.TitleSummary;
8+
9+
public sealed class TitleSummaryConfigServiceTests
10+
{
11+
[Fact]
12+
public void Resolve_UserConfigWithoutAdmin_UsesConfiguredModeAndDefaultTemplate()
13+
{
14+
IServiceScopeFactory scopeFactory = CreateScopeFactory();
15+
TitleSummaryConfigService service = new(scopeFactory, NullLogger<TitleSummaryConfigService>.Instance);
16+
17+
ResolvedTitleSummaryConfig resolved = service.Resolve(
18+
adminConfig: null,
19+
userConfig: new TitleSummaryConfig
20+
{
21+
ModelMode = TitleSummaryModelMode.Current,
22+
ModelId = null,
23+
PromptTemplate = null,
24+
});
25+
26+
Assert.True(resolved.Enabled);
27+
Assert.Equal(TitleSummaryModelMode.Current, resolved.ModelMode);
28+
Assert.Null(resolved.ModelId);
29+
Assert.Equal(TitleSummaryConfigService.DefaultPromptTemplate, resolved.PromptTemplate);
30+
}
31+
32+
[Fact]
33+
public void Resolve_EmptyUserTemplate_InheritsAdminTemplate()
34+
{
35+
IServiceScopeFactory scopeFactory = CreateScopeFactory();
36+
TitleSummaryConfigService service = new(scopeFactory, NullLogger<TitleSummaryConfigService>.Instance);
37+
38+
ResolvedTitleSummaryConfig resolved = service.Resolve(
39+
adminConfig: new TitleSummaryConfig
40+
{
41+
ModelMode = TitleSummaryModelMode.Specified,
42+
ModelId = 100,
43+
PromptTemplate = "admin-template",
44+
},
45+
userConfig: new TitleSummaryConfig
46+
{
47+
ModelMode = TitleSummaryModelMode.Specified,
48+
ModelId = 200,
49+
PromptTemplate = string.Empty,
50+
});
51+
52+
Assert.True(resolved.Enabled);
53+
Assert.Equal(TitleSummaryModelMode.Specified, resolved.ModelMode);
54+
Assert.Equal<short?>(200, resolved.ModelId);
55+
Assert.Equal("admin-template", resolved.PromptTemplate);
56+
}
57+
58+
[Fact]
59+
public void Resolve_UserTruncateOverride_ClearsModelSelection()
60+
{
61+
IServiceScopeFactory scopeFactory = CreateScopeFactory();
62+
TitleSummaryConfigService service = new(scopeFactory, NullLogger<TitleSummaryConfigService>.Instance);
63+
64+
ResolvedTitleSummaryConfig resolved = service.Resolve(
65+
adminConfig: new TitleSummaryConfig
66+
{
67+
ModelMode = TitleSummaryModelMode.Specified,
68+
ModelId = 100,
69+
PromptTemplate = "admin-template",
70+
},
71+
userConfig: new TitleSummaryConfig
72+
{
73+
ModelMode = TitleSummaryModelMode.Truncate,
74+
ModelId = 200,
75+
PromptTemplate = null,
76+
});
77+
78+
Assert.True(resolved.Enabled);
79+
Assert.Equal(TitleSummaryModelMode.Truncate, resolved.ModelMode);
80+
Assert.Null(resolved.ModelId);
81+
Assert.Equal("admin-template", resolved.PromptTemplate);
82+
}
83+
84+
[Fact]
85+
public void BuildPrompt_ReplacesPlaceholders_AndTruncatesMiddle()
86+
{
87+
string longSystemPrompt = new('a', 1200);
88+
string longUserPrompt = new('b', 1200);
89+
90+
string prompt = ChatTitleSummaryService.BuildPrompt(
91+
"S={{systemPrompt}}\nU={{userPrompt}}",
92+
longSystemPrompt,
93+
longUserPrompt);
94+
95+
Assert.DoesNotContain("{{systemPrompt}}", prompt);
96+
Assert.DoesNotContain("{{userPrompt}}", prompt);
97+
Assert.Contains("...", prompt);
98+
Assert.Contains("S=", prompt);
99+
Assert.Contains("U=", prompt);
100+
}
101+
102+
private static IServiceScopeFactory CreateScopeFactory()
103+
{
104+
ServiceCollection services = new();
105+
services.AddDbContext<ChatsDB>(options =>
106+
options.UseInMemoryDatabase(Guid.NewGuid().ToString("N")));
107+
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
108+
}
109+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using Chats.BE.Services.TitleSummary;
2+
3+
namespace Chats.BE.Controllers.Admin.GlobalConfigs.Dtos;
4+
5+
public sealed class TitleSummaryAdminSettingsDto
6+
{
7+
public TitleSummaryConfig? Config { get; init; }
8+
9+
public required string DefaultPromptTemplate { get; init; }
10+
}

src/BE/web/Controllers/Admin/GlobalConfigs/GlobalConfigController.cs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,43 @@
22
using Chats.BE.Controllers.Admin.Common;
33
using Chats.BE.Controllers.Admin.GlobalConfigs.Dtos;
44
using Chats.BE.Services.Configs;
5+
using Chats.BE.Services.TitleSummary;
56
using Microsoft.AspNetCore.Mvc;
67
using Microsoft.EntityFrameworkCore;
78
using System.Text.Json;
89

910
namespace Chats.BE.Controllers.Admin.GlobalConfigs;
1011

1112
[Route("api/admin/global-configs"), AuthorizeAdmin]
12-
public class GlobalConfigController(ChatsDB db, IRequestTraceConfigProvider requestTraceConfigProvider) : ControllerBase
13+
public class GlobalConfigController(
14+
ChatsDB db,
15+
IRequestTraceConfigProvider requestTraceConfigProvider,
16+
TitleSummaryConfigService titleSummaryConfigService) : ControllerBase
1317
{
14-
[HttpGet]
15-
public async Task<GlobalConfigDto[]> GetGlobalConfigs(CancellationToken cancellationToken)
18+
[HttpGet("{id}")]
19+
public async Task<ActionResult<GlobalConfigDto?>> GetGlobalConfig([FromRoute] string id, CancellationToken cancellationToken)
1620
{
17-
GlobalConfigDto[] data = await db.Configs
21+
GlobalConfigDto? data = await db.Configs
22+
.Where(x => x.Key == id)
1823
.Select(x => new GlobalConfigDto()
1924
{
2025
Key = x.Key,
2126
Value = x.Value,
2227
Description = x.Description,
2328
})
24-
.ToArrayAsync(cancellationToken);
25-
return data;
29+
.SingleOrDefaultAsync(cancellationToken);
30+
return Ok(data);
31+
}
32+
33+
[HttpGet("title-summary")]
34+
public async Task<ActionResult<TitleSummaryAdminSettingsDto>> GetTitleSummarySettings(CancellationToken cancellationToken)
35+
{
36+
TitleSummaryConfig? config = await titleSummaryConfigService.GetAdminConfig(cancellationToken);
37+
return Ok(new TitleSummaryAdminSettingsDto
38+
{
39+
Config = config,
40+
DefaultPromptTemplate = TitleSummaryConfigService.DefaultPromptTemplate,
41+
});
2642
}
2743

2844
[HttpPut]

src/BE/web/Controllers/Chats/Chats/ChatController.cs

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
using Chats.BE.Services.CodeInterpreter;
3131
using Chats.BE.Services.Options;
3232
using Chats.BE.Services.RequestTracing;
33+
using Chats.BE.Services.TitleSummary;
3334
using Microsoft.Extensions.Options;
3435

3536
namespace Chats.BE.Controllers.Chats.Chats;
@@ -50,6 +51,7 @@ public async Task<IActionResult> RegenerateOneMessage(
5051
[FromServices] ChatConfigService chatConfigService,
5152
[FromServices] DBFileService dBFileService,
5253
[FromServices] CodeInterpreterExecutor codeInterpreter,
54+
[FromServices] ChatTitleSummaryService chatTitleSummaryService,
5355
CancellationToken cancellationToken)
5456
{
5557
if (!ModelState.IsValid)
@@ -59,7 +61,7 @@ public async Task<IActionResult> RegenerateOneMessage(
5961

6062
return await ChatPrivate(
6163
req.Decrypt(idEncryption),
62-
db, currentUser, logger, idEncryption, chatRunService, userModelManager, fup, chatConfigService, dBFileService, codeInterpreter,
64+
db, currentUser, logger, idEncryption, chatRunService, userModelManager, fup, chatConfigService, dBFileService, codeInterpreter, chatTitleSummaryService,
6365
cancellationToken);
6466
}
6567

@@ -76,6 +78,7 @@ public async Task<IActionResult> RegenerateAllMessage(
7678
[FromServices] ChatConfigService chatConfigService,
7779
[FromServices] DBFileService dBFileService,
7880
[FromServices] CodeInterpreterExecutor codeInterpreter,
81+
[FromServices] ChatTitleSummaryService chatTitleSummaryService,
7982
CancellationToken cancellationToken)
8083
{
8184
if (!ModelState.IsValid)
@@ -85,7 +88,7 @@ public async Task<IActionResult> RegenerateAllMessage(
8588

8689
return await ChatPrivate(
8790
req.Decrypt(idEncryption),
88-
db, currentUser, logger, idEncryption, chatRunService, userModelManager, fup, chatConfigService, dBFileService, codeInterpreter,
91+
db, currentUser, logger, idEncryption, chatRunService, userModelManager, fup, chatConfigService, dBFileService, codeInterpreter, chatTitleSummaryService,
8992
cancellationToken);
9093
}
9194

@@ -102,6 +105,7 @@ public async Task<IActionResult> GeneralChat(
102105
[FromServices] ChatConfigService chatConfigService,
103106
[FromServices] DBFileService dBFileService,
104107
[FromServices] CodeInterpreterExecutor codeInterpreter,
108+
[FromServices] ChatTitleSummaryService chatTitleSummaryService,
105109
CancellationToken cancellationToken)
106110
{
107111
if (!ModelState.IsValid)
@@ -116,7 +120,7 @@ public async Task<IActionResult> GeneralChat(
116120

117121
return await ChatPrivate(
118122
req.Decrypt(idEncryption),
119-
db, currentUser, logger, idEncryption, chatRunService, userModelManager, fup, chatConfigService, dBFileService, codeInterpreter,
123+
db, currentUser, logger, idEncryption, chatRunService, userModelManager, fup, chatConfigService, dBFileService, codeInterpreter, chatTitleSummaryService,
120124
cancellationToken);
121125
}
122126

@@ -132,6 +136,7 @@ private async Task<IActionResult> ChatPrivate(
132136
ChatConfigService chatConfigService,
133137
DBFileService dbFileService,
134138
CodeInterpreterExecutor codeInterpreter,
139+
ChatTitleSummaryService chatTitleSummaryService,
135140
CancellationToken cancellationToken)
136141
{
137142
cancellationToken = default; // disallow cancellation token for now for better user experience
@@ -269,7 +274,7 @@ private async Task<IActionResult> ChatPrivate(
269274
string stopId = stopService.CreateAndCombineCancellationToken(ref cancellationToken);
270275
await YieldResponse(new StopIdLine(stopId));
271276

272-
Channel<SseResponseLine>[] channels = [.. toGenerateSpans.Select(x => Channel.CreateUnbounded<SseResponseLine>())];
277+
List<Channel<SseResponseLine>> channels = [.. toGenerateSpans.Select(x => Channel.CreateUnbounded<SseResponseLine>())];
273278
Dictionary<ImageChatSegment, TaskCompletionSource<DBFile>> imageFileCache = [];
274279
Dictionary<string, TaskCompletionSource<DBFile>> fileCache = new(StringComparer.Ordinal);
275280
// Ensure Model navigation is populated on the controller thread to avoid cross-thread mutation of tracked entities.
@@ -278,7 +283,7 @@ private async Task<IActionResult> ChatPrivate(
278283
span.ChatConfig.Model = userModels[span.ChatConfig.ModelId].Model;
279284
}
280285

281-
Task[] streamTasks = [.. toGenerateSpans.Select((span, index) => ProcessChatSpan(
286+
List<Task> streamTasks = [.. toGenerateSpans.Select((span, index) => ProcessChatSpan(
282287
currentUser,
283288
logger,
284289
chatRunService,
@@ -299,18 +304,30 @@ private async Task<IActionResult> ChatPrivate(
299304
loggerFactory,
300305
cancellationToken))];
301306

307+
bool hasDedicatedTitleStream = false;
302308
if (isEmptyChat && req is GeneralChatRequest generalChatRequest)
303309
{
304-
string text = generalChatRequest.UserMessage
310+
ChatSpan firstSpan = toGenerateSpans
311+
.OrderBy(x => x.SpanId)
312+
.First();
313+
TextContentRequestItem firstTextItem = generalChatRequest.UserMessage
305314
.OfType<TextContentRequestItem>()
306-
.Single()
307-
.Text;
308-
chat.Title = text[..Math.Min(50, text.Length)];
315+
.First();
316+
Channel<SseResponseLine> titleChannel = Channel.CreateUnbounded<SseResponseLine>();
317+
channels.Add(titleChannel);
318+
streamTasks.Add(chatTitleSummaryService.StreamTitleAsync(
319+
chat.Id,
320+
firstSpan.ChatConfig.SystemPrompt,
321+
userModels[firstSpan.ChatConfig.ModelId],
322+
firstTextItem.Text,
323+
titleChannel.Writer,
324+
cancellationToken));
325+
hasDedicatedTitleStream = true;
309326
}
310327

311328
bool dbUserMessageYield = false;
312329
FileService fs = null!;
313-
await foreach (SseResponseLine line in MergeChannels(channels).Reader.ReadAllAsync(CancellationToken.None))
330+
await foreach (SseResponseLine line in MergeChannels([.. channels]).Reader.ReadAllAsync(CancellationToken.None))
314331
{
315332
if (line is TempStartTurn startTurn)
316333
{
@@ -413,6 +430,12 @@ await clientInfoManager.GetClientInfoId(),
413430
fileCache.Remove(tempFileGeneratedLine.Token);
414431
}
415432
}
433+
else if (line is SetTitleInternal setTitle)
434+
{
435+
chat.Title = setTitle.Title;
436+
chat.UpdatedAt = DateTime.UtcNow;
437+
await db.SaveChangesAsync(CancellationToken.None);
438+
}
416439
else
417440
{
418441
await YieldResponse(line);
@@ -426,7 +449,7 @@ await clientInfoManager.GetClientInfoId(),
426449
await Task.WhenAll(streamTasks);
427450

428451
// yield title
429-
if (isEmptyChat) await YieldTitle(chat.Title);
452+
if (isEmptyChat && !hasDedicatedTitleStream) await YieldTitle(chat.Title);
430453
return new EmptyResult();
431454
}
432455

src/BE/web/Controllers/Chats/Chats/Dtos/SseResponseLine.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ public sealed record TempStartTurn(
173173
[property: JsonPropertyName("r")] ChatTurn Turn
174174
) : SseResponseLine;
175175

176+
public sealed record SetTitleInternal(
177+
string Title
178+
) : SseResponseLine;
179+
176180
#endregion
177181

178182
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
@@ -197,4 +201,4 @@ public sealed record ToolCompletedToolProgressDelta : ToolProgressDelta
197201
{
198202
[JsonPropertyName("result")]
199203
public required Result<string> Result { get; init; }
200-
}
204+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Chats.BE.Controllers.Chats.UserConfigs.Dtos;
4+
5+
public sealed record TitleSummaryDefaultTemplateDto
6+
{
7+
[JsonPropertyName("promptTemplate")]
8+
public required string PromptTemplate { get; init; }
9+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using Chats.BE.Services.TitleSummary;
2+
using System.Text.Json.Serialization;
3+
4+
namespace Chats.BE.Controllers.Chats.UserConfigs.Dtos;
5+
6+
public sealed record TitleSummarySettingsDto
7+
{
8+
[JsonPropertyName("adminConfig")]
9+
public TitleSummaryConfig? AdminConfig { get; init; }
10+
11+
[JsonPropertyName("userConfig")]
12+
public TitleSummaryConfig? UserConfig { get; init; }
13+
14+
[JsonPropertyName("resolvedConfig")]
15+
public required ResolvedTitleSummaryConfig ResolvedConfig { get; init; }
16+
}

0 commit comments

Comments
 (0)