mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 18:32:12 +00:00
Adjusted alert window design and implemented drag & drop security checks
This commit is contained in:
parent
ac3f903733
commit
f1654889b5
@ -5575,8 +5575,20 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINGGUIDELINEDIALOG::T4250996615"] = "P
|
|||||||
-- Chat attachment
|
-- Chat attachment
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1071345316"] = "Chat attachment"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1071345316"] = "Chat attachment"
|
||||||
|
|
||||||
-- Danger detected
|
-- The file '{0}' could not be checked for prompt injection and was not attached.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1081126836"] = "Danger detected"
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2404630365"] = "The file '{0}' could not be checked for prompt injection and was not attached."
|
||||||
|
|
||||||
|
-- Security warning
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1862129203"] = "Security warning"
|
||||||
|
|
||||||
|
-- AI Studio has reliably detected and blocked the suspicious content. Your applications and data remain protected. Please review the content you uploaded.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T3661810823"] = "AI Studio has reliably detected and blocked the suspicious content. Your applications and data remain protected. Please review the content you uploaded."
|
||||||
|
|
||||||
|
-- Security active
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T4212328947"] = "Security active"
|
||||||
|
|
||||||
|
-- Security notice
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T4004397997"] = "Security notice"
|
||||||
|
|
||||||
-- Content source
|
-- Content source
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1129278507"] = "Content source"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1129278507"] = "Content source"
|
||||||
@ -7339,9 +7351,6 @@ UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2936083926"] = "AI Studio could
|
|||||||
-- Writer
|
-- Writer
|
||||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2979224202"] = "Writer"
|
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2979224202"] = "Writer"
|
||||||
|
|
||||||
-- Prompt Injection Detected
|
|
||||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T3580322580"] = "Prompt Injection Detected"
|
|
||||||
|
|
||||||
-- Show details
|
-- Show details
|
||||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T3692372066"] = "Show details"
|
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T3692372066"] = "Show details"
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ using AIStudio.Tools.PluginSystem;
|
|||||||
using AIStudio.Tools.Rust;
|
using AIStudio.Tools.Rust;
|
||||||
using AIStudio.Tools.Services;
|
using AIStudio.Tools.Services;
|
||||||
using AIStudio.Tools.Validation;
|
using AIStudio.Tools.Validation;
|
||||||
|
using AIStudio.Tools.Security;
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
@ -86,7 +87,10 @@ public partial class AttachDocuments : MSGComponentBase
|
|||||||
/// <summary>Creates and persists a draft owner after media import confirmation.</summary>
|
/// <summary>Creates and persists a draft owner after media import confirmation.</summary>
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<string, Task<ChatThread?>> EnsureOwnerChatAsync { get; set; } = _ => Task.FromResult<ChatThread?>(null);
|
public Func<string, Task<ChatThread?>> EnsureOwnerChatAsync { get; set; } = _ => Task.FromResult<ChatThread?>(null);
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PromptInjectionGuardService PromptInjectionGuardService { get; init; } = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private ILogger<AttachDocuments> Logger { get; set; } = null!;
|
private ILogger<AttachDocuments> Logger { get; set; } = null!;
|
||||||
|
|
||||||
@ -457,10 +461,7 @@ public partial class AttachDocuments : MSGComponentBase
|
|||||||
if (!canAddRegularFiles)
|
if (!canAddRegularFiles)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider))
|
await this.TryAddFileAsync(path);
|
||||||
continue;
|
|
||||||
|
|
||||||
this.DocumentPaths.Add(FileAttachment.FromPath(path));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaPaths.Count is 0)
|
if (mediaPaths.Count is 0)
|
||||||
@ -529,4 +530,34 @@ public partial class AttachDocuments : MSGComponentBase
|
|||||||
|
|
||||||
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
|
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private async Task<bool> TryAddFileAsync(string filePath)
|
||||||
|
{
|
||||||
|
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, filePath, this.ValidateMediaFileTypes, this.Provider))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var attachment = FileAttachment.FromPath(filePath);
|
||||||
|
if (attachment.Type is FileAttachmentType.DOCUMENT && this.PromptInjectionGuardService.IsProtectionEnabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileContent = await this.RustService.ReadArbitraryFileData(filePath, int.MaxValue);
|
||||||
|
await this.PromptInjectionGuardService.EnsureSafeForLlmAsync(fileContent, PromptInjectionSource.ChatAttachment(filePath));
|
||||||
|
}
|
||||||
|
catch (PromptInjectionBlockedException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
this.Logger.LogError(exception, "File attachment '{FilePath}' could not be checked for prompt injection and will not be attached.", filePath);
|
||||||
|
await this.MessageBus.SendError(new(
|
||||||
|
Icons.Material.Filled.Cancel,
|
||||||
|
string.Format(T("The file '{0}' could not be checked for prompt injection and was not attached."), attachment.FileName)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.DocumentPaths.Add(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<MudStack Spacing="0">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.h5">
|
<MudText Typo="Typo.h5">
|
||||||
@T("Danger detected")
|
@T("Security warning")
|
||||||
</MudText>
|
</MudText>
|
||||||
|
|
||||||
<MudText Typo="Typo.body2"
|
<MudText Typo="Typo.body2"
|
||||||
@ -26,6 +26,35 @@
|
|||||||
</MudText>
|
</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mt-4">
|
||||||
|
<Content>
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@T("Typical attacks on AI systems (e.g. prompt injection) hide instructions within untrusted content to trick an AI model into ignoring its intended rules or performing unintended actions.")
|
||||||
|
</MudText>
|
||||||
|
</Content>
|
||||||
|
</MudAlert>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-6 mb-4" Elevation="0" Style="background:#f0fff0;border:1px solid #90ee90;">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||||
|
<MudAvatar Size="Size.Large"
|
||||||
|
Color="Color.Success"
|
||||||
|
Variant="Variant.Filled">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Check"/>
|
||||||
|
</MudAvatar>
|
||||||
|
|
||||||
|
<MudStack Spacing="0">
|
||||||
|
<MudText Typo="Typo.h5">
|
||||||
|
@T("Security active")
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body2"
|
||||||
|
Color="Color.Success">
|
||||||
|
@T("AI Studio has reliably detected and blocked the suspicious content. Your applications and data remain protected. Please review the content you uploaded.")
|
||||||
|
</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudDivider />
|
<MudDivider />
|
||||||
@ -41,12 +70,6 @@
|
|||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
<MudCollapse Expanded="@showPromptInjectionInformation">
|
<MudCollapse Expanded="@showPromptInjectionInformation">
|
||||||
<MudText Typo="Typo.body2" Class="mb-4">
|
|
||||||
<strong>
|
|
||||||
@T("Typical attacks on AI systems (e.g. prompt injection) hide instructions within untrusted content to trick an AI model into ignoring its intended rules or performing unintended actions.")
|
|
||||||
</strong>
|
|
||||||
</MudText>
|
|
||||||
|
|
||||||
<MudPaper Outlined="true"
|
<MudPaper Outlined="true"
|
||||||
Class="pa-4 mb-4">
|
Class="pa-4 mb-4">
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
|
|||||||
@ -367,7 +367,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
|||||||
};
|
};
|
||||||
|
|
||||||
var dialogReference = await this.DialogService.ShowAsync<PromptInjectionAlertDialog>(
|
var dialogReference = await this.DialogService.ShowAsync<PromptInjectionAlertDialog>(
|
||||||
T("Prompt Injection Detected"),
|
T("Security notice"),
|
||||||
dialogParameters,
|
dialogParameters,
|
||||||
DialogOptions.BLOCKING_FULLSCREEN);
|
DialogOptions.BLOCKING_FULLSCREEN);
|
||||||
|
|
||||||
|
|||||||
@ -4278,15 +4278,28 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Möchten Sie
|
|||||||
-- Chat attachment
|
-- Chat attachment
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1071345316"] = "Dateianhang"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1071345316"] = "Dateianhang"
|
||||||
|
|
||||||
-- Danger detected
|
-- The file '{0}' could not be checked for prompt injection and was not attached.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1081126836"] = "Gefahr erkannt"
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2404630365"] = "Die Datei '{0}' konnte nicht auf Prompt-Injection überprüft werden und wurde nicht angehängt."
|
||||||
|
|
||||||
|
-- Security warning
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1862129203"] = "Sicherheitswarnung"
|
||||||
|
|
||||||
|
|
||||||
|
-- AI Studio has reliably detected and blocked the suspicious content. Your applications and data remain protected. Please review the content you uploaded.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T3661810823"] = "AI Studio hat den verdächtigen Inhalt zuverlässig erkannt und blockiert. Ihre Anwendungen und Daten bleiben geschützt. Bitte überprüfen Sie den von Ihnen hochgeladenen Inhalt."
|
||||||
|
|
||||||
|
-- Security active
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T4212328947"] = "Sicherheit aktiv"
|
||||||
|
|
||||||
|
-- Typical attacks on AI systems
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T4221674400"] = "Typische Angriffe auf KI-Systeme (z.B. Prompt Injection) verbergen Anweisungen in nicht vertrauenswürdigen Inhalten, um ein KI-Modell dazu zu bringen, seine vorgesehenen Regeln zu ignorieren oder ungewollte Aktionen auszuführen."
|
||||||
|
|
||||||
|
-- Security notice
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T4004397997"] = "Sicherheitshinweis"
|
||||||
|
|
||||||
-- Content source
|
-- Content source
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1129278507"] = "Datei"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1129278507"] = "Datei"
|
||||||
|
|
||||||
-- Prompt injection hides instructions in untrusted content to make an AI model ignore its intended rules or perform unintended actions.
|
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T4221674400"] = "Typische Angriffe auf KI-Systeme (z.B. Prompt Injection) verbergen Anweisungen in nicht vertrauenswürdigen Inhalten, um ein KI-Modell dazu zu bringen, seine vorgesehenen Regeln zu ignorieren oder ungewollte Aktionen auszuführen."
|
|
||||||
|
|
||||||
-- Attempt to override instructions
|
-- Attempt to override instructions
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T161976090"] = "Versuch, Anweisungen zu überschreiben"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T161976090"] = "Versuch, Anweisungen zu überschreiben"
|
||||||
|
|
||||||
@ -4371,8 +4384,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T4245463281"]
|
|||||||
-- Checks web content, file attachments, retrieval context, and similar external input for prompt-injection patterns before it is sent to a model or agent.
|
-- Checks web content, file attachments, retrieval context, and similar external input for prompt-injection patterns before it is sent to a model or agent.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T69192586"] = "Prüft Webinhalte, Dateianhänge, Abrufkontext und ähnliche externe Eingaben auf Prompt-Injection-Muster, bevor sie an ein Modell oder einen Agenten gesendet werden."
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T69192586"] = "Prüft Webinhalte, Dateianhänge, Abrufkontext und ähnliche externe Eingaben auf Prompt-Injection-Muster, bevor sie an ein Modell oder einen Agenten gesendet werden."
|
||||||
|
|
||||||
-- Prompt Injection Detected
|
|
||||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T3580322580"] = "Prompt-Injection erkannt"
|
|
||||||
|
|
||||||
-- AI Studio blocked content from '{0}' because it looks like a prompt-injection attempt.
|
-- AI Studio blocked content from '{0}' because it looks like a prompt-injection attempt.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SECURITY::PROMPTINJECTIONGUARDSERVICE::T3560909296"] = "AI Studio hat Inhalte von '{0}' blockiert, da es sich um einen möglichen Prompt-Injection-Versuch handelt."
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SECURITY::PROMPTINJECTIONGUARDSERVICE::T3560909296"] = "AI Studio hat Inhalte von '{0}' blockiert, da es sich um einen möglichen Prompt-Injection-Versuch handelt."
|
||||||
|
|||||||
@ -12,9 +12,11 @@ public sealed class PromptInjectionGuardService(
|
|||||||
|
|
||||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PromptInjectionGuardService).Namespace, nameof(PromptInjectionGuardService));
|
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PromptInjectionGuardService).Namespace, nameof(PromptInjectionGuardService));
|
||||||
|
|
||||||
|
public bool IsProtectionEnabled => settingsManager.ConfigurationData.Chat.EnablePromptInjectionProtection;
|
||||||
|
|
||||||
public async Task<string> EnsureSafeForLlmAsync(string text, PromptInjectionSource source)
|
public async Task<string> EnsureSafeForLlmAsync(string text, PromptInjectionSource source)
|
||||||
{
|
{
|
||||||
if (!settingsManager.ConfigurationData.Chat.EnablePromptInjectionProtection || string.IsNullOrWhiteSpace(text))
|
if (!this.IsProtectionEnabled || string.IsNullOrWhiteSpace(text))
|
||||||
return text;
|
return text;
|
||||||
|
|
||||||
var result = scanner.Scan(text, source);
|
var result = scanner.Scan(text, source);
|
||||||
|
|||||||
@ -9,7 +9,7 @@ public sealed class PromptInjectionScanner(ILogger<PromptInjectionScanner> logge
|
|||||||
private const int MAX_DECODED_CANDIDATES_PER_ENCODING = 12;
|
private const int MAX_DECODED_CANDIDATES_PER_ENCODING = 12;
|
||||||
private const int MAX_DECODED_TEXT_LENGTH = 12_000;
|
private const int MAX_DECODED_TEXT_LENGTH = 12_000;
|
||||||
private const int MAX_FINDINGS = 8;
|
private const int MAX_FINDINGS = 8;
|
||||||
private const int SNIPPET_RADIUS = 80;
|
private const int MAX_SNIPPET_LENGTH = 240;
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<(int Length, char First, char Last), string[]> TYPOGLYCEMIA_KEYWORDS =
|
private static readonly IReadOnlyDictionary<(int Length, char First, char Last), string[]> TYPOGLYCEMIA_KEYWORDS =
|
||||||
CreateTypoglycemiaKeywordIndex();
|
CreateTypoglycemiaKeywordIndex();
|
||||||
@ -70,8 +70,8 @@ public sealed class PromptInjectionScanner(ILogger<PromptInjectionScanner> logge
|
|||||||
if (!match.Success)
|
if (!match.Success)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var snippet = ExtractSnippet(text, match.Index, match.Length, SNIPPET_RADIUS);
|
var snippet = ExtractSnippet(text, match.Index, match.Length);
|
||||||
AddFinding(findings, findingKeys, new(rule.Id, rule.Category, stage, snippet));
|
AddFinding(findings, findingKeys, new(rule.Id, rule.Category, snippet));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,8 +131,8 @@ public sealed class PromptInjectionScanner(ILogger<PromptInjectionScanner> logge
|
|||||||
if (!IsTypoglycemiaVariant(token, keyword))
|
if (!IsTypoglycemiaVariant(token, keyword))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var snippet = ExtractSnippet(text, match.Index, match.Length, SNIPPET_RADIUS);
|
var snippet = ExtractSnippet(text, match.Index, match.Length);
|
||||||
AddFinding(findings, findingKeys, new($"typoglycemia:{keyword}", "evasion", "typoglycemia", snippet));
|
AddFinding(findings, findingKeys, new($"typoglycemia:{keyword}", "evasion", snippet));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -265,17 +265,94 @@ public sealed class PromptInjectionScanner(ILogger<PromptInjectionScanner> logge
|
|||||||
|
|
||||||
private static void AddFinding(List<PromptInjectionFinding> findings, HashSet<string> findingKeys, PromptInjectionFinding finding)
|
private static void AddFinding(List<PromptInjectionFinding> findings, HashSet<string> findingKeys, PromptInjectionFinding finding)
|
||||||
{
|
{
|
||||||
var key = $"{finding.RuleId}|{finding.DetectionStage}|{finding.Snippet}";
|
var key = $"{finding.Category}|{finding.Snippet}";
|
||||||
if (findingKeys.Add(key))
|
if (findingKeys.Add(key))
|
||||||
findings.Add(finding);
|
findings.Add(finding);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExtractSnippet(string text, int index, int length, int radius)
|
private static string ExtractSnippet(string text, int index, int length)
|
||||||
{
|
{
|
||||||
var start = Math.Max(0, index - radius);
|
var matchStart = Math.Clamp(index, 0, text.Length);
|
||||||
var end = Math.Min(text.Length, index + length + radius);
|
var matchEnd = Math.Clamp(index + length, matchStart, text.Length);
|
||||||
var snippet = text[start..end].Replace('\r', ' ').Replace('\n', ' ').Trim();
|
var sentenceStart = FindSentenceStart(text, matchStart);
|
||||||
return snippet.Length > radius * 2 ? $"{snippet[..(radius * 2)]}..." : snippet;
|
var sentenceEnd = FindSentenceEnd(text, matchEnd);
|
||||||
|
|
||||||
|
while (sentenceStart < matchStart && char.IsWhiteSpace(text[sentenceStart]))
|
||||||
|
sentenceStart++;
|
||||||
|
|
||||||
|
while (sentenceEnd > matchEnd && char.IsWhiteSpace(text[sentenceEnd - 1]))
|
||||||
|
sentenceEnd--;
|
||||||
|
|
||||||
|
if (sentenceEnd - sentenceStart <= MAX_SNIPPET_LENGTH)
|
||||||
|
return NormalizeSnippet(text[sentenceStart..sentenceEnd]);
|
||||||
|
|
||||||
|
var matchLength = matchEnd - matchStart;
|
||||||
|
if (matchLength >= MAX_SNIPPET_LENGTH - 6)
|
||||||
|
return NormalizeSnippet(text[matchStart..matchEnd]);
|
||||||
|
|
||||||
|
var contextBudget = MAX_SNIPPET_LENGTH - 6 - matchLength;
|
||||||
|
var leftAvailable = matchStart - sentenceStart;
|
||||||
|
var rightAvailable = sentenceEnd - matchEnd;
|
||||||
|
var leftLength = Math.Min(leftAvailable, contextBudget / 2);
|
||||||
|
var rightLength = Math.Min(rightAvailable, contextBudget - leftLength);
|
||||||
|
var remainingBudget = contextBudget - leftLength - rightLength;
|
||||||
|
|
||||||
|
leftLength += Math.Min(leftAvailable - leftLength, remainingBudget);
|
||||||
|
remainingBudget = contextBudget - leftLength - rightLength;
|
||||||
|
rightLength += Math.Min(rightAvailable - rightLength, remainingBudget);
|
||||||
|
|
||||||
|
var snippetStart = matchStart - leftLength;
|
||||||
|
var snippetEnd = matchEnd + rightLength;
|
||||||
|
var snippet = NormalizeSnippet(text[snippetStart..snippetEnd]);
|
||||||
|
var prefix = snippetStart > sentenceStart ? "..." : string.Empty;
|
||||||
|
var suffix = snippetEnd < sentenceEnd ? "..." : string.Empty;
|
||||||
|
return $"{prefix}{snippet}{suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindSentenceStart(string text, int matchStart)
|
||||||
|
{
|
||||||
|
for (var index = matchStart - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
if (IsSentenceBoundary(text[index]))
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindSentenceEnd(string text, int matchEnd)
|
||||||
|
{
|
||||||
|
for (var index = matchEnd; index < text.Length; index++)
|
||||||
|
{
|
||||||
|
if (IsSentenceBoundary(text[index]))
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSentenceBoundary(char character) => character is '.' or '!' or '?' or '\r' or '\n';
|
||||||
|
|
||||||
|
private static string NormalizeSnippet(ReadOnlySpan<char> snippet)
|
||||||
|
{
|
||||||
|
var normalized = new StringBuilder(snippet.Length);
|
||||||
|
var previousCharacterWasWhitespace = false;
|
||||||
|
foreach (var character in snippet)
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(character))
|
||||||
|
{
|
||||||
|
if (normalized.Length > 0 && !previousCharacterWasWhitespace)
|
||||||
|
normalized.Append(' ');
|
||||||
|
|
||||||
|
previousCharacterWasWhitespace = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.Append(character);
|
||||||
|
previousCharacterWasWhitespace = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.ToString().Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyDictionary<(int Length, char First, char Last), string[]> CreateTypoglycemiaKeywordIndex()
|
private static IReadOnlyDictionary<(int Length, char First, char Last), string[]> CreateTypoglycemiaKeywordIndex()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user