Phase 20R.1

This commit is contained in:
Nick Beckley 2026-07-04 22:32:59 +01:00
parent ebeca1e3e1
commit f728168173
12 changed files with 238 additions and 32 deletions

View File

@ -172,6 +172,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
request.SourceFileSizeBytes, request.SourceFileSizeBytes,
request.SourceWordCount, request.SourceWordCount,
request.SourceCharacterCount, request.SourceCharacterCount,
request.SourceParagraphCount,
request.SourceChapterCount, request.SourceChapterCount,
request.Model, request.Model,
request.PromptVersionsSummary request.PromptVersionsSummary

View File

@ -37,11 +37,23 @@ Do not create boundaries for ordinary paragraph changes, small action beats, dia
## Paragraph Numbering ## Paragraph Numbering
Treat the supplied chapter text as a sequence of non-empty paragraphs. The supplied chapter text is already split into numbered paragraphs.
The first non-empty paragraph is paragraph `1`. Each paragraph begins with an explicit marker such as:
Each `startParagraph` and `endParagraph` must refer to these paragraph numbers. ```text
[1] First paragraph text...
[2] Second paragraph text...
[3] Third paragraph text...
```
Use only the supplied paragraph numbers.
Each `startParagraph` and `endParagraph` must refer to those explicit paragraph numbers.
Never invent paragraph numbers.
Never return an `endParagraph` greater than the highest paragraph number supplied.
Scene boundaries must: Scene boundaries must:
@ -49,6 +61,7 @@ Scene boundaries must:
- cover the chapter in order; - cover the chapter in order;
- not overlap; - not overlap;
- not leave gaps; - not leave gaps;
- be contiguous and non-overlapping where possible;
- use increasing `sceneNumber` values starting at `1`. - use increasing `sceneNumber` values starting at `1`.
## Output Requirements ## Output Requirements

View File

@ -32,6 +32,7 @@ public sealed class StoryIntelligenceRunSaveRequest
public long? SourceFileSizeBytes { get; init; } public long? SourceFileSizeBytes { get; init; }
public int? SourceWordCount { get; init; } public int? SourceWordCount { get; init; }
public int? SourceCharacterCount { get; init; } public int? SourceCharacterCount { get; init; }
public int? SourceParagraphCount { get; init; }
public int? SourceChapterCount { get; init; } public int? SourceChapterCount { get; init; }
public int? SourceDetectedImagesCount { get; init; } public int? SourceDetectedImagesCount { get; init; }
public int? SourceDetectedTablesCount { get; init; } public int? SourceDetectedTablesCount { get; init; }
@ -69,6 +70,7 @@ public sealed class StoryIntelligenceRunQueueRequest
public long? SourceFileSizeBytes { get; init; } public long? SourceFileSizeBytes { get; init; }
public int? SourceWordCount { get; init; } public int? SourceWordCount { get; init; }
public int? SourceCharacterCount { get; init; } public int? SourceCharacterCount { get; init; }
public int? SourceParagraphCount { get; init; }
public int? SourceChapterCount { get; init; } public int? SourceChapterCount { get; init; }
public string Model { get; init; } = string.Empty; public string Model { get; init; } = string.Empty;
public string PromptVersionsSummary { get; init; } = string.Empty; public string PromptVersionsSummary { get; init; } = string.Empty;
@ -156,6 +158,7 @@ public sealed class StoryIntelligenceSavedRun
public long? SourceFileSizeBytes { get; init; } public long? SourceFileSizeBytes { get; init; }
public int? SourceWordCount { get; init; } public int? SourceWordCount { get; init; }
public int? SourceCharacterCount { get; init; } public int? SourceCharacterCount { get; init; }
public int? SourceParagraphCount { get; init; }
public int? SourceChapterCount { get; init; } public int? SourceChapterCount { get; init; }
public int? SourceDetectedImagesCount { get; init; } public int? SourceDetectedImagesCount { get; init; }
public int? SourceDetectedTablesCount { get; init; } public int? SourceDetectedTablesCount { get; init; }
@ -200,6 +203,7 @@ public sealed class StoryIntelligenceQueuedRun
public string? SourceText { get; init; } public string? SourceText { get; init; }
public int? SourceWordCount { get; init; } public int? SourceWordCount { get; init; }
public int? SourceCharacterCount { get; init; } public int? SourceCharacterCount { get; init; }
public int? SourceParagraphCount { get; init; }
public int? SourceChapterCount { get; init; } public int? SourceChapterCount { get; init; }
public string PromptVersion { get; init; } = string.Empty; public string PromptVersion { get; init; } = string.Empty;
public string? PromptVersionsSummary { get; init; } public string? PromptVersionsSummary { get; init; }

View File

@ -39,7 +39,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile); var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile);
var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile); var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile);
var clientStatus = client.GetConfigurationStatus(); var clientStatus = client.GetConfigurationStatus();
var paragraphs = SplitParagraphs(form.ChapterText); var paragraphs = StoryIntelligenceParagraphs.Split(form.ChapterText);
var chapterContextJson = BuildChapterContextJson(form); var chapterContextJson = BuildChapterContextJson(form);
try try
@ -50,7 +50,8 @@ public sealed class ChapterStoryIntelligenceDryRunService(
} }
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, form.ChapterText); var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText);
var chapterResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken); var chapterResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken);
var chapterJson = ExtractOutputText(chapterResult.RawResponseText); var chapterJson = ExtractOutputText(chapterResult.RawResponseText);
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions); var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
@ -313,12 +314,6 @@ public sealed class ChapterStoryIntelligenceDryRunService(
return outputText.Trim(); return outputText.Trim();
} }
private static List<string> SplitParagraphs(string chapterText)
=> chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries)
.Select(paragraph => paragraph.Trim())
.Where(paragraph => !string.IsNullOrWhiteSpace(paragraph))
.ToList();
private static string Preview(string value) private static string Preview(string value)
{ {
var cleaned = value.ReplaceLineEndings(" ").Trim(); var cleaned = value.ReplaceLineEndings(" ").Trim();

View File

@ -66,7 +66,7 @@ public sealed class PersistedStoryIntelligenceRunner(
} }
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken); await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
var paragraphs = SplitParagraphs(run.SourceText); var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText);
if (paragraphs.Count == 0) if (paragraphs.Count == 0)
{ {
throw new StoryIntelligenceRunFailureException( throw new StoryIntelligenceRunFailureException(
@ -76,7 +76,8 @@ public sealed class PersistedStoryIntelligenceRunner(
var chapterContextJson = BuildChapterContextJson(run); var chapterContextJson = BuildChapterContextJson(run);
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, run.SourceText); var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText);
currentFailureStage = StoryIntelligenceFailureStages.ChapterStructure; currentFailureStage = StoryIntelligenceFailureStages.ChapterStructure;
await repository.UpdateProgressAsync( await repository.UpdateProgressAsync(
@ -386,12 +387,6 @@ public sealed class PersistedStoryIntelligenceRunner(
: outputText.Trim(); : outputText.Trim();
} }
private static List<string> SplitParagraphs(string chapterText)
=> chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries)
.Select(paragraph => paragraph.Trim())
.Where(paragraph => !string.IsNullOrWhiteSpace(paragraph))
.ToList();
private static string SerializeParsed<T>(T? value) private static string SerializeParsed<T>(T? value)
=> value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions); => value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions);

View File

@ -70,6 +70,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
ChapterText = sourceText, ChapterText = sourceText,
SourceWordCount = selected.SourceWordCount ?? CountWords(sourceText), SourceWordCount = selected.SourceWordCount ?? CountWords(sourceText),
SourceCharacterCount = selected.SourceCharacterCount ?? sourceText.Length, SourceCharacterCount = selected.SourceCharacterCount ?? sourceText.Length,
SourceParagraphCount = StoryIntelligenceParagraphs.Split(sourceText).Count,
SourceChapterCount = 1, SourceChapterCount = 1,
Model = clientStatus.Model, Model = clientStatus.Model,
PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1" PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1"

View File

@ -73,6 +73,7 @@ public sealed class StoryIntelligenceFullChapterTestService(
SourceFileSizeBytes = source.SourceFileSizeBytes, SourceFileSizeBytes = source.SourceFileSizeBytes,
SourceWordCount = preflight.WordCount, SourceWordCount = preflight.WordCount,
SourceCharacterCount = preflight.CharacterCount, SourceCharacterCount = preflight.CharacterCount,
SourceParagraphCount = preflight.ParagraphCount,
SourceChapterCount = 1, SourceChapterCount = 1,
Model = clientStatus.Model, Model = clientStatus.Model,
PromptVersionsSummary = PromptVersionsSummary PromptVersionsSummary = PromptVersionsSummary
@ -117,7 +118,7 @@ public sealed class StoryIntelligenceFullChapterTestService(
{ {
var characterCount = text.Length; var characterCount = text.Length;
var wordCount = CountWords(text); var wordCount = CountWords(text);
var paragraphCount = SplitParagraphs(text).Count; var paragraphCount = StoryIntelligenceParagraphs.Split(text).Count;
var estimatedInputTokens = EstimateTokens(text); var estimatedInputTokens = EstimateTokens(text);
var estimatedPipelineInputTokens = estimatedInputTokens * 2; var estimatedPipelineInputTokens = estimatedInputTokens * 2;
var warnings = BuildWarnings(text, characterCount, paragraphCount); var warnings = BuildWarnings(text, characterCount, paragraphCount);
@ -211,12 +212,6 @@ public sealed class StoryIntelligenceFullChapterTestService(
? 0 ? 0
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
private static IReadOnlyList<string> SplitParagraphs(string value)
=> value.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries)
.Select(paragraph => paragraph.Trim())
.Where(paragraph => !string.IsNullOrWhiteSpace(paragraph))
.ToList();
private static int EstimateTokens(string value) private static int EstimateTokens(string value)
=> Math.Max(1, (int)Math.Ceiling(value.Length / 4m)); => Math.Max(1, (int)Math.Ceiling(value.Length / 4m));

View File

@ -517,7 +517,8 @@ public sealed class ChapterStructureDryRunService(
var promptVersion = versions.GetPromptVersion(ChapterPromptFile); var promptVersion = versions.GetPromptVersion(ChapterPromptFile);
var clientStatus = client.GetConfigurationStatus(); var clientStatus = client.GetConfigurationStatus();
var contextJson = BuildChapterContextJson(form); var contextJson = BuildChapterContextJson(form);
var paragraphCount = CountParagraphs(form.ChapterText); var paragraphs = StoryIntelligenceParagraphs.Split(form.ChapterText);
var paragraphCount = paragraphs.Count;
try try
{ {
@ -527,7 +528,8 @@ public sealed class ChapterStructureDryRunService(
} }
var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var completedPrompt = BuildChapterPrompt(template, contextJson, form.ChapterText); var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
var completedPrompt = BuildChapterPrompt(template, contextJson, numberedChapterText);
var result = await client.ExecutePromptAsync(completedPrompt, promptVersion, cancellationToken); var result = await client.ExecutePromptAsync(completedPrompt, promptVersion, cancellationToken);
var chapterJson = ExtractOutputText(result.RawResponseText); var chapterJson = ExtractOutputText(result.RawResponseText);
var parsedChapter = DeserializeChapter(chapterJson); var parsedChapter = DeserializeChapter(chapterJson);
@ -649,10 +651,6 @@ public sealed class ChapterStructureDryRunService(
private static ChapterStructureModel? DeserializeChapter(string chapterJson) private static ChapterStructureModel? DeserializeChapter(string chapterJson)
=> JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions); => JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
private static int CountParagraphs(string chapterText)
=> chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries)
.Count(paragraph => !string.IsNullOrWhiteSpace(paragraph));
private static string FormatDuration(TimeSpan duration) private static string FormatDuration(TimeSpan duration)
=> duration.TotalSeconds < 1 => duration.TotalSeconds < 1
? $"{duration.TotalMilliseconds:0} ms" ? $"{duration.TotalMilliseconds:0} ms"

View File

@ -0,0 +1,33 @@
using System.Text;
namespace PlotLine.Services;
public static class StoryIntelligenceParagraphs
{
public static IReadOnlyList<string> Split(string? chapterText)
=> (chapterText ?? string.Empty)
.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries)
.Select(paragraph => paragraph.Trim())
.Where(paragraph => !string.IsNullOrWhiteSpace(paragraph))
.ToList();
public static string Number(IReadOnlyList<string> paragraphs)
{
var builder = new StringBuilder();
for (var index = 0; index < paragraphs.Count; index++)
{
if (index > 0)
{
builder.AppendLine();
builder.AppendLine();
}
builder.Append('[')
.Append(index + 1)
.Append("] ")
.Append(paragraphs[index]);
}
return builder.ToString();
}
}

View File

@ -81,6 +81,7 @@ public sealed class StoryIntelligenceResultPersistenceService(
ChapterText = form.ChapterText, ChapterText = form.ChapterText,
SourceWordCount = CountWords(form.ChapterText), SourceWordCount = CountWords(form.ChapterText),
SourceCharacterCount = form.ChapterText.Length, SourceCharacterCount = form.ChapterText.Length,
SourceParagraphCount = StoryIntelligenceParagraphs.Split(form.ChapterText).Count,
SourceChapterCount = 1, SourceChapterCount = 1,
Model = clientStatus.Model, Model = clientStatus.Model,
PromptVersionsSummary = promptVersionsSummary PromptVersionsSummary = promptVersionsSummary

View File

@ -0,0 +1,102 @@
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceParagraphCount') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceParagraphCount int NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText
@UserID int,
@ProjectID int = NULL,
@BookID int = NULL,
@ChapterID int = NULL,
@ChapterNumber decimal(9, 2) = NULL,
@SourceType nvarchar(50),
@SourceLabel nvarchar(300),
@SourceText nvarchar(max),
@SourceFileName nvarchar(260) = NULL,
@SourceFileSizeBytes bigint = NULL,
@SourceWordCount int = NULL,
@SourceCharacterCount int = NULL,
@SourceParagraphCount int = NULL,
@SourceChapterCount int = NULL,
@Model nvarchar(100),
@PromptVersionsSummary nvarchar(500)
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.StoryIntelligenceRuns
(
UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, SourceText,
SourceFileName, SourceFileSizeBytes, SourceWordCount, SourceCharacterCount, SourceParagraphCount,
SourceChapterCount, Model, PromptVersion, PromptVersionsSummary, StartedUtc, CurrentStage, CurrentMessage,
CompletedScenes, FailedScenes
)
VALUES
(
@UserID, @ProjectID, @BookID, @ChapterID, @ChapterNumber, N'Pending', @SourceType, @SourceLabel, @SourceText,
@SourceFileName, @SourceFileSizeBytes, @SourceWordCount, @SourceCharacterCount, @SourceParagraphCount,
@SourceChapterCount, @Model, @PromptVersionsSummary, @PromptVersionsSummary, SYSUTCDATETIME(), N'Pending',
N'Queued for Story Intelligence processing.', 0, 0
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_GetAdmin
@StoryIntelligenceRunID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
r.StoryIntelligenceRunID,
r.UserID,
r.ProjectID,
p.ProjectName AS ProjectTitle,
r.BookID,
b.BookTitle,
r.ChapterID,
r.ChapterNumber,
r.Status,
r.SourceType,
r.SourceFileName,
r.SourceFileSizeBytes,
r.SourceWordCount,
r.SourceCharacterCount,
r.SourceParagraphCount,
r.SourceChapterCount,
r.SourceDetectedImagesCount,
r.SourceDetectedTablesCount,
r.SourceDetectedFootnotesCount,
r.SourceDetectedCommentsCount,
r.CurrentStage,
r.CurrentMessage,
r.TotalDetectedScenes,
r.CompletedScenes,
r.FailedScenes,
r.CancellationRequestedUtc,
r.CancelledUtc,
r.PromptVersion,
r.PromptVersionsSummary,
r.Model,
r.StartedUtc,
r.CompletedUtc,
r.FailureStage,
r.TotalInputTokens,
r.TotalOutputTokens,
r.TotalTokens,
r.TotalDurationMs,
r.EstimatedCostGBP,
r.EstimatedCostUSD,
r.ErrorMessage,
r.ErrorDetail,
r.CreatedUtc,
r.UpdatedUtc
FROM dbo.StoryIntelligenceRuns r
LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID
LEFT JOIN dbo.Books b ON b.BookID = r.BookID
WHERE r.StoryIntelligenceRunID = @StoryIntelligenceRunID;
END;
GO

View File

@ -93,6 +93,8 @@ else
<dd>@Display(run.SourceWordCount)</dd> <dd>@Display(run.SourceWordCount)</dd>
<dt>Source character count</dt> <dt>Source character count</dt>
<dd>@Display(run.SourceCharacterCount)</dd> <dd>@Display(run.SourceCharacterCount)</dd>
<dt>Submitted paragraph count</dt>
<dd>@Display(run.SourceParagraphCount)</dd>
<dt>Source chapter count</dt> <dt>Source chapter count</dt>
<dd>@Display(run.SourceChapterCount)</dd> <dd>@Display(run.SourceChapterCount)</dd>
<dt>Detected document features</dt> <dt>Detected document features</dt>
@ -169,6 +171,18 @@ else
</dl> </dl>
<label class="form-label" for="chapterParsedJson">Parsed JSON</label> <label class="form-label" for="chapterParsedJson">Parsed JSON</label>
<dl>
<dt>Highest returned endParagraph</dt>
<dd>@Display(HighestReturnedEndParagraph(chapter))</dd>
<dt>Returned scene boundary ranges</dt>
<dd>@DisplayBoundaryRanges(chapter)</dd>
</dl>
@if (ReturnedEndParagraphExceedsSubmitted(chapter, run))
{
<div class="alert alert-warning">
The model returned paragraph numbers beyond the submitted chapter. This usually means the prompt was not using explicit numbered paragraphs.
</div>
}
<textarea class="form-control font-monospace" id="chapterParsedJson" rows="10" readonly>@chapter.ParsedJson</textarea> <textarea class="form-control font-monospace" id="chapterParsedJson" rows="10" readonly>@chapter.ParsedJson</textarea>
<label class="form-label mt-3" for="chapterOutputJson">Extracted output JSON</label> <label class="form-label mt-3" for="chapterOutputJson">Extracted output JSON</label>
<textarea class="form-control font-monospace" id="chapterOutputJson" rows="10" readonly>@chapter.OutputTextJson</textarea> <textarea class="form-control font-monospace" id="chapterOutputJson" rows="10" readonly>@chapter.OutputTextJson</textarea>
@ -300,4 +314,58 @@ else
return "Scene failed. See parsed JSON for details."; return "Scene failed. See parsed JSON for details.";
} }
private static int? HighestReturnedEndParagraph(StoryIntelligenceSavedChapterResult chapter)
=> ReadBoundaryRanges(chapter).Select(range => range.End).DefaultIfEmpty().Max() is var max && max > 0 ? max : null;
private static string DisplayBoundaryRanges(StoryIntelligenceSavedChapterResult chapter)
{
var ranges = ReadBoundaryRanges(chapter).ToList();
return ranges.Count == 0
? "None"
: string.Join(", ", ranges.Select(range => $"{range.Start}-{range.End}"));
}
private static bool ReturnedEndParagraphExceedsSubmitted(StoryIntelligenceSavedChapterResult chapter, StoryIntelligenceSavedRun run)
{
var highest = HighestReturnedEndParagraph(chapter);
var submitted = run.SourceParagraphCount;
return highest.HasValue && submitted.HasValue && highest.Value > submitted.Value;
}
private static IReadOnlyList<(int Start, int End)> ReadBoundaryRanges(StoryIntelligenceSavedChapterResult chapter)
{
if (string.IsNullOrWhiteSpace(chapter.ParsedJson))
{
return [];
}
try
{
using var document = System.Text.Json.JsonDocument.Parse(chapter.ParsedJson);
if (!document.RootElement.TryGetProperty("sceneBoundaries", out var boundaries)
|| boundaries.ValueKind != System.Text.Json.JsonValueKind.Array)
{
return [];
}
var ranges = new List<(int Start, int End)>();
foreach (var boundary in boundaries.EnumerateArray())
{
var start = boundary.TryGetProperty("startParagraph", out var startElement) && startElement.TryGetInt32(out var startValue)
? startValue
: 0;
var end = boundary.TryGetProperty("endParagraph", out var endElement) && endElement.TryGetInt32(out var endValue)
? endValue
: 0;
ranges.Add((start, end));
}
return ranges;
}
catch (System.Text.Json.JsonException)
{
return [];
}
}
} }