namespace AIStudio.Tools.Web;
///
/// Reads a text document fetched from the web, such as plain text, JSON, XML, or CSV.
///
///
/// The text is returned as the server sent it. There is no main part to extract from a JSON
/// response, and converting it to Markdown would only take away what the model needs: exact
/// keys, quotes, and indentation. Only the line endings are unified, and the byte order mark
/// is dropped, because it is not part of the text.
/// The text is not filtered for prompt injections here, just like an extracted HTML page is
/// not. The callers filter it once they have cut it down to what reaches the model. The
/// runtime's filter also decodes the escapes of JSON and XML, which a model reads fluently.
///
internal static class WebTextContentExtractor
{
private const char BYTE_ORDER_MARK = '\uFEFF';
///
/// Takes the text of a document. Throws an InvalidOperationException when the body is not
/// text, whatever the server declared.
///
/// The response body as text.
/// The media type the server declared, for the error message.
/// Where the document was found after every redirect, for the error message.
/// The document, with its text as the content and no metadata.
public static ExtractedWebPage Extract(string body, string mediaType, Uri finalUrl)
{
//
// Text holds no NUL characters, while nearly every binary format does. A server naming
// a PDF or an archive text/plain is common enough, and its bytes decoded as text would
// reach the model as noise.
//
if (body.Contains('\0'))
throw new InvalidOperationException($"The response of '{finalUrl}' is declared as '{mediaType}' but does not contain readable text.");
var text = body
.TrimStart(BYTE_ORDER_MARK)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.TrimEnd();
// Only blank lines are dropped at the start. The indentation of the first line carries
// meaning in YAML or in source code:
text = TrimLeadingBlankLines(text);
return new ExtractedWebPage
{
Title = string.Empty,
Description = string.Empty,
Authors = [],
PublishedTime = string.Empty,
ModifiedTime = string.Empty,
Language = string.Empty,
SiteName = string.Empty,
CanonicalUrl = null,
Markdown = text,
Outline = [],
};
}
private static string TrimLeadingBlankLines(string text)
{
var start = 0;
while (true)
{
var lineEnd = text.IndexOf('\n', start);
if (lineEnd < 0 || !string.IsNullOrWhiteSpace(text[start..lineEnd]))
return text[start..];
start = lineEnd + 1;
}
}
}