2025-06-30 18:56:48 +02:00
using System.Text ;
using System.Text.Json ;
2025-05-02 23:09:50 +02:00
namespace AIStudio.Tools.Services ;
public sealed partial class RustService
{
2026-08-10 16:37:44 +02:00
/// <summary>
/// How long one file extraction may take.
/// </summary>
/// <remarks>
/// Reading a large file from a slow network share is legitimately slow, so this is well above
/// the default HTTP client timeout. It still bounds the operation, because an unbounded read
/// would keep the caller waiting forever.
/// </remarks>
private static readonly TimeSpan EXTRACTION_TIMEOUT = TimeSpan . FromMinutes ( 10 );
public async Task < FileExtractionResult > ReadArbitraryFileData ( string path , int maxChunks , bool extractImages = false )
2025-05-02 23:09:50 +02:00
{
2025-06-30 21:51:02 +02:00
var streamId = Guid . NewGuid (). ToString ();
var requestUri = $"/retrieval/fs/extract?path={Uri.EscapeDataString(path)}&stream_id={streamId}&extract_images={extractImages}" ;
2025-06-30 18:56:48 +02:00
2026-08-10 16:37:44 +02:00
using var timeoutTokenSource = new CancellationTokenSource ( EXTRACTION_TIMEOUT );
var cancellationToken = timeoutTokenSource . Token ;
2025-05-02 23:09:50 +02:00
2025-06-30 18:56:48 +02:00
var resultBuilder = new StringBuilder ();
2026-08-10 16:37:44 +02:00
var failedPages = new List < int >();
var hasPartialFailure = false ;
var failureCode = FileExtractionErrorCode . NONE ;
string? failureMessage = null ;
string? detectedFormat = null ;
2025-06-30 18:56:48 +02:00
2025-07-01 18:34:14 +02:00
try
2025-06-30 18:56:48 +02:00
{
2026-08-10 16:37:44 +02:00
using var request = new HttpRequestMessage ( HttpMethod . Get , requestUri );
using var response = await this . extractionHttp . SendAsync ( request , HttpCompletionOption . ResponseHeadersRead , cancellationToken );
if (! response . IsSuccessStatusCode )
{
var responseBody = await response . Content . ReadAsStringAsync ( cancellationToken );
this . logger ?. LogError (
"Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'" ,
response . StatusCode ,
response . ReasonPhrase ,
path ,
responseBody );
return FileExtractionResult . Failed ( FileExtractionErrorCode . REQUEST_FAILED , $"The runtime answered with the status {(int)response.StatusCode} ({response.ReasonPhrase})." );
}
await using var stream = await response . Content . ReadAsStreamAsync ( cancellationToken );
2025-07-01 18:34:14 +02:00
using var reader = new StreamReader ( stream );
var chunkCount = 0 ;
2026-08-10 16:37:44 +02:00
while ( chunkCount < maxChunks )
2025-06-30 18:56:48 +02:00
{
2026-08-10 16:37:44 +02:00
// We read line by line instead of checking EndOfStream: the latter blocks on a
// network stream and cannot be cancelled, which would defeat the timeout above.
var line = await reader . ReadLineAsync ( cancellationToken );
if ( line is null )
break ;
2025-07-01 18:34:14 +02:00
if ( string . IsNullOrWhiteSpace ( line ))
continue ;
if (! line . StartsWith ( "data:" , StringComparison . InvariantCulture ))
continue ;
var jsonContent = line [ 5. .];
try
2025-06-30 18:56:48 +02:00
{
2025-07-01 18:34:14 +02:00
var sseEvent = JsonSerializer . Deserialize < ContentStreamSseEvent >( jsonContent );
2026-08-10 16:37:44 +02:00
if ( sseEvent is null )
continue ;
var processedEvent = ContentStreamSseHandler . ProcessEvent ( sseEvent , extractImages );
if ( processedEvent . Error is not null )
2025-07-01 18:34:14 +02:00
{
2026-08-10 16:37:44 +02:00
var error = processedEvent . Error ;
//
// A notice is not a failure: the file was read completely, we only learned
// something about it worth telling the user. It must not change the outcome.
//
if ( error . IsNotice )
{
this . logger ?. LogInformation (
"The runtime reported a notice while reading '{Path}': code={ErrorCode}, detectedFormat='{DetectedFormat}', message='{Message}'" ,
path ,
error . ParsedCode ,
error . DetectedFormat ,
error . Message );
detectedFormat ??= error . DetectedFormat ;
chunkCount ++;
continue ;
}
this . logger ?. LogError (
"The runtime reported a failure while reading '{Path}': code={ErrorCode}, page={PageNumber}, partial={IsPartialFailure}, detectedFormat='{DetectedFormat}', message='{Message}'" ,
path ,
error . ParsedCode ,
error . PageNumber ,
error . IsPartialFailure ,
error . DetectedFormat ,
error . Message );
2025-07-01 18:34:14 +02:00
2026-08-10 16:37:44 +02:00
//
// A partial failure costs us one part of the file, e.g. a single PDF page,
// but keeps the rest usable. Any other failure means what we collected is
// not the document the user picked, so we must not pass it on as content.
//
if ( error . IsPartialFailure )
{
hasPartialFailure = true ;
if ( error . PageNumber is { } pageNumber )
failedPages . Add ( pageNumber );
}
else if ( failureCode is FileExtractionErrorCode . NONE )
{
failureCode = error . ParsedCode ;
failureMessage = error . Message ;
detectedFormat = error . DetectedFormat ;
}
2025-07-01 18:34:14 +02:00
}
2026-08-10 16:37:44 +02:00
else if ( processedEvent . Content is not null )
resultBuilder . AppendLine ( processedEvent . Content );
chunkCount ++;
2025-07-01 18:34:14 +02:00
}
2026-08-10 16:37:44 +02:00
catch ( JsonException e )
2025-07-01 18:34:14 +02:00
{
2026-08-10 16:37:44 +02:00
this . logger ?. LogError ( e , "Failed to deserialize SSE event while reading '{Path}': {JsonContent}" , path , jsonContent );
if ( failureCode is FileExtractionErrorCode . NONE )
{
failureCode = FileExtractionErrorCode . INVALID_RESPONSE ;
failureMessage = "The runtime sent a response the app was not able to read." ;
}
2025-06-30 18:56:48 +02:00
}
}
2025-07-01 18:34:14 +02:00
}
2026-08-10 16:37:44 +02:00
catch ( OperationCanceledException ) when ( timeoutTokenSource . IsCancellationRequested )
{
this . logger ?. LogError ( "Reading the file '{Path}' timed out after {Timeout}." , path , EXTRACTION_TIMEOUT );
return FileExtractionResult . Failed ( FileExtractionErrorCode . TIMEOUT , $"Reading the file timed out after {EXTRACTION_TIMEOUT.TotalMinutes:0} minutes." );
}
catch ( Exception e )
2025-07-01 18:34:14 +02:00
{
this . logger ?. LogError ( e , "Error reading file data from stream: {Path}" , path );
2026-08-10 16:37:44 +02:00
return FileExtractionResult . Failed ( FileExtractionErrorCode . INTERNAL , e . Message );
2025-07-01 18:34:14 +02:00
}
finally
{
var finalContentChunk = ContentStreamSseHandler . Clear ( streamId );
if (! string . IsNullOrWhiteSpace ( finalContentChunk ))
resultBuilder . AppendLine ( finalContentChunk );
2025-06-30 18:56:48 +02:00
}
2026-08-10 16:37:44 +02:00
if ( failureCode is not FileExtractionErrorCode . NONE )
return FileExtractionResult . Failed ( failureCode , failureMessage , detectedFormat );
var content = resultBuilder . ToString ();
//
// Nothing failed, yet nothing came out either. We report this as a failure as well:
// handing an empty document to the AI looks like a file without content, and the user
// would never learn that reading the file did not work.
//
if ( string . IsNullOrWhiteSpace ( content ))
{
this . logger ?. LogWarning ( "Reading the file '{Path}' produced no content at all." , path );
return FileExtractionResult . Failed ( FileExtractionErrorCode . NO_CONTENT , "Reading the file produced no content." );
}
return hasPartialFailure
? FileExtractionResult . Partial ( content , failedPages , detectedFormat )
: FileExtractionResult . Success ( content , detectedFormat );
2025-05-02 23:09:50 +02:00
}
}