diff --git a/Apis/api/Controllers/CaptchaController.cs b/Apis/api/Controllers/CaptchaController.cs index 949d69a..53a715d 100644 --- a/Apis/api/Controllers/CaptchaController.cs +++ b/Apis/api/Controllers/CaptchaController.cs @@ -29,8 +29,10 @@ namespace Api.Controllers /// /// Returns the public reCAPTCHA site key used by the client to render the widget. /// + /// 200 OK with the configured public site key as a plain string. [HttpGet] - [SwaggerOperation(Summary = "Get captcha site key")] + [SwaggerOperation(Summary = "Get captcha public key", Description = "Returns the public reCAPTCHA site key required by the frontend to render the challenge widget.")] + [SwaggerResponse(StatusCodes.Status200OK, "Public site key returned")] [ProducesResponseType(StatusCodes.Status200OK)] public IActionResult GetSiteKey() { @@ -38,13 +40,20 @@ namespace Api.Controllers } /// - /// Verify a captcha token and return the verification verdict. + /// Verifies a reCAPTCHA token submitted by the client and returns the full verification verdict. /// + /// The verification request containing the token and optional expected action name. + /// Cancellation token. + /// + /// 200 OK with the full captcha verdict when verification passes; + /// 400 Bad Request with an if the token is missing or verification fails. + /// [HttpPost("verify")] - [SwaggerOperation(Summary = "Verify captcha token")] + [SwaggerOperation(Summary = "Verify captcha token", Description = "Verifies a reCAPTCHA token and returns the provider verdict including the score.")] + [SwaggerResponse(StatusCodes.Status200OK, "Token verified successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Token missing or verification failed", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Captcha verification failed or token missing", typeof(ErrorResponse))] public async Task Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct) { if (req is null || string.IsNullOrWhiteSpace(req.Token)) diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 8ae87cf..9946e98 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -48,10 +48,18 @@ public sealed class CvMatcherController : ControllerBase } /// - /// Upload a CV PDF to the cv-matcher-api. + /// Proxies a CV PDF upload to the internal cv-matcher-api for indexing. + /// Validates the reCAPTCHA token and GDPR consent before forwarding. + /// Caches the uploaded file locally so it can be attached to the match result email. /// - /// The uploaded CV request. + /// Multipart form containing the CV PDF, captcha token, and GDPR consent flag. /// Cancellation token. + /// + /// 200 OK with the document ID and cache status from cv-matcher-api; + /// 400 Bad Request if the file is missing or captcha verification fails; + /// 499 if the client cancelled the request; + /// 502 Bad Gateway if the upstream cv-matcher-api call fails. + /// [HttpPost("upload")] [RequestSizeLimit(8 * 1024 * 1024)] [ProducesResponseType(StatusCodes.Status200OK)] @@ -109,10 +117,18 @@ public sealed class CvMatcherController : ControllerBase } /// - /// Proxy a job matching request to the cv-matcher-api. + /// Proxies a CV-to-job match request to the internal cv-matcher-api. + /// Validates the reCAPTCHA token, then forwards the request and emails the scored result to the user. + /// When an email is provided, also creates a one-time job-search token and appends the search link to the email. /// - /// Job match request payload containing CV document id or job description/url. + /// Match request containing the CV document ID, a job URL or inline description, and an optional recipient email. /// Cancellation token. + /// + /// 200 OK with the score, strengths, and gaps; + /// 400 Bad Request if captcha verification fails; + /// 499 if the client cancelled the request; + /// 502 Bad Gateway if the upstream cv-matcher-api call fails. + /// [HttpPost("match-job")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] @@ -182,8 +198,20 @@ public sealed class CvMatcherController : ControllerBase } } + /// + /// Validates a one-time job-search token and kicks off the background job search. + /// Returns a self-contained HTML page intended to be opened directly in the browser via the link in the match email. + /// + /// The one-time UUID token from the job-search link query string. + /// Cancellation token. + /// + /// 200 OK with an HTML page indicating whether the search was started, the token was already used, expired, or invalid. + /// Always returns 200 — error states are communicated via the HTML page content, not the HTTP status code. + /// [HttpGet("job-search/start")] - [SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a simple HTML confirmation page.")] + [SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a self-contained HTML confirmation page.")] + [SwaggerResponse(StatusCodes.Status200OK, "HTML page returned for all token states (started, already used, expired, invalid)")] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task StartJobSearch([FromQuery] string t, CancellationToken ct) { try diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index 6adf28f..58765de 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -66,7 +66,6 @@ namespace Api.Controllers { try { - // Use default file name from settings if not provided if (string.IsNullOrWhiteSpace(fileName)) { fileName = _fileStorageSettings.DefaultFileName; @@ -80,43 +79,30 @@ namespace Api.Controllers _logger.LogInformation("Using default file name from settings: {FileName}", fileName); } - // Get the file storage path (relative to solution folder) var fileStoragePath = _fileStorageSettings.Path; - // If path is not absolute, make it relative to the solution root if (!Path.IsPathRooted(fileStoragePath)) { var solutionRoot = Directory.GetCurrentDirectory(); - // Go up from api folder to solution root if needed if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase)) - { solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot; - } fileStoragePath = Path.Combine(solutionRoot, fileStoragePath); } - // Sanitize fileName to prevent directory traversal attacks var sanitizedFileName = Path.GetFileName(fileName); var filePath = Path.Combine(fileStoragePath, sanitizedFileName); - // Verify file exists if (!System.IO.File.Exists(filePath)) { _logger.LogWarning("File not found: {FilePath}", filePath); return NotFound(new ErrorResponse { Error = "File not found", Code = "file_not_found" }); } - var fileInfo = new FileInfo(filePath); - var fileLength = fileInfo.Length; + var fileLength = new FileInfo(filePath).Length; - // Determine content type if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType)) - { contentType = "application/octet-stream"; - } - // Send email notification asynchronously (fire and forget with error handling) - // This is done before streaming to ensure notification is sent for both full and range downloads var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); _ = Task.Run(async () => { @@ -130,19 +116,13 @@ namespace Api.Controllers } }); - // Check if this is a range request var rangeHeader = Request.Headers[HeaderNames.Range].ToString(); - if (!string.IsNullOrEmpty(rangeHeader)) - { return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName); - } - // Full file download _logger.LogInformation("Starting full file download: {FileName} ({FileSize} bytes)", sanitizedFileName, fileLength); var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true); - Response.Headers.Append(HeaderNames.AcceptRanges, "bytes"); Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString()); @@ -167,34 +147,25 @@ namespace Api.Controllers { try { - // Parse range header (format: "bytes=start-end") var range = rangeHeader.Replace("bytes=", "").Split('-'); long startByte = 0; long endByte = fileLength - 1; if (!string.IsNullOrEmpty(range[0])) - { startByte = long.Parse(range[0]); - } if (range.Length > 1 && !string.IsNullOrEmpty(range[1])) - { endByte = long.Parse(range[1]); - } - // Validate range if (startByte > endByte || startByte >= fileLength) { _logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength); return StatusCode(StatusCodes.Status416RangeNotSatisfiable); } - // Adjust end byte if it exceeds file length if (endByte >= fileLength) - { endByte = fileLength - 1; - } var contentLength = endByte - startByte + 1; @@ -202,20 +173,16 @@ namespace Api.Controllers "Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)", fileName, startByte, endByte, fileLength, contentLength); - // Open file stream and seek to start position var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true); stream.Seek(startByte, SeekOrigin.Begin); - // Set response headers for partial content Response.StatusCode = StatusCodes.Status206PartialContent; Response.Headers.Append(HeaderNames.AcceptRanges, "bytes"); Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}"); Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString()); Response.ContentType = contentType; - // Stream the requested range await StreamRangeAsync(stream, Response.Body, contentLength); - await stream.DisposeAsync(); return new EmptyResult(); @@ -241,9 +208,7 @@ namespace Api.Controllers var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration)); if (bytesRead == 0) - { - break; // End of stream - } + break; await destination.WriteAsync(buffer.AsMemory(0, bytesRead)); totalBytesRead += bytesRead; diff --git a/Apis/api/Controllers/HealthController.cs b/Apis/api/Controllers/HealthController.cs index 913424f..5c0a2b4 100644 --- a/Apis/api/Controllers/HealthController.cs +++ b/Apis/api/Controllers/HealthController.cs @@ -17,9 +17,11 @@ namespace Api.Controllers public sealed class HealthController : ControllerBase { /// - /// Returns the deployed API version. + /// Returns the deployed API version baked into the assembly at build time. + /// The version format is 1.0.0-build.{yyyyMMddHHmmss} as defined in api.csproj. + /// Used by the web frontend to display the running build in the page footer. /// - /// 200 OK with JSON payload: { "version": "1.0.0-build.20250522103045" } + /// 200 OK with JSON payload: { "version": "1.0.0-build.20250522103045" }. // GET api/health/version [HttpGet("version")] [SwaggerOperation(Summary = "API version", Description = "Returns the deployed API assembly version.")] diff --git a/Apis/cv-matcher-api/Controllers/CvController.cs b/Apis/cv-matcher-api/Controllers/CvController.cs index 87804aa..7e54f97 100644 --- a/Apis/cv-matcher-api/Controllers/CvController.cs +++ b/Apis/cv-matcher-api/Controllers/CvController.cs @@ -8,6 +8,10 @@ using Shared.Models.Responses; namespace Api.Controllers; +/// +/// Internal endpoints for CV indexing and job-matching operations. +/// Routes are prefixed with api/cv. Protected by the internal API key middleware — not reachable from the public internet. +/// [ApiController] [Route("api/cv")] public sealed class CvController : ControllerBase @@ -21,11 +25,21 @@ public sealed class CvController : ControllerBase _logger = logger; } + /// + /// Uploads and indexes a CV PDF into the RAG vector store. + /// Returns from cache immediately if an identical document was previously indexed. + /// + /// Multipart form containing the CV PDF file. + /// Cancellation token. + /// + /// 200 OK with a containing the document ID and whether it was a cache hit; + /// 400 Bad Request if the file is missing or the request is otherwise invalid. + /// [HttpPost("upload")] [RequestSizeLimit(10 * 1024 * 1024)] - [SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it for matching.")] - [SwaggerResponse(StatusCodes.Status200OK, "CV uploaded and indexed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid upload request")] + [SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it into the RAG vector store. Returns from cache if the same document was previously uploaded.")] + [SwaggerResponse(StatusCodes.Status200OK, "CV indexed successfully", typeof(CvUploadResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "File missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> Upload([FromForm] UploadFileRequest request, CancellationToken ct) @@ -45,10 +59,19 @@ public sealed class CvController : ControllerBase } } + /// + /// Returns the top matching job documents for a previously indexed CV using semantic vector search. + /// + /// The request containing the CV document ID and the maximum number of results to return. + /// Cancellation token. + /// + /// 200 OK with a containing the ranked list of matching jobs; + /// 400 Bad Request if the CV document ID is missing or invalid. + /// [HttpPost("find-jobs")] - [SwaggerOperation(Summary = "Find matching jobs", Description = "Finds top matching jobs for a previously uploaded CV document.")] - [SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid find jobs request")] + [SwaggerOperation(Summary = "Find matching jobs", Description = "Performs semantic search over indexed job documents to find the best matches for a given CV.")] + [SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned", typeof(FindJobsResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "CV document ID missing or invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> FindJobs([FromBody] FindJobsRequest request, CancellationToken ct) @@ -67,10 +90,21 @@ public sealed class CvController : ControllerBase } } + /// + /// Scores a CV against a single job using LLM analysis. + /// Fetches and extracts job text from the provided URL if no inline description is supplied, + /// then runs a deep semantic match and returns a score with strengths and gaps. + /// + /// The match request: CV document ID plus either a job URL or an inline job description. + /// Cancellation token. + /// + /// 200 OK with a containing the score (0–100), strengths, gaps, and cache status; + /// 400 Bad Request if required fields are missing or the request is invalid. + /// [HttpPost("match-job")] - [SwaggerOperation(Summary = "Match CV to one job", Description = "Computes detailed match analysis between a CV and a single job description or URL.")] - [SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid match job request")] + [SwaggerOperation(Summary = "Match CV to one job", Description = "Scores a CV against a job URL or description using LLM analysis and returns a match score with strengths and gaps.")] + [SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully", typeof(JobMatchResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Required fields missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> MatchJob([FromBody] MatchJobRequest request, CancellationToken ct) diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index a646526..1bb13a1 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -3,9 +3,14 @@ using CvMatcher.Models.Requests; using CvMatcher.Models.Responses; using Microsoft.AspNetCore.Mvc; using Shared.Models.Responses; +using Swashbuckle.AspNetCore.Annotations; namespace Api.Controllers; +/// +/// Internal endpoints for managing one-click job-search tokens and sessions. +/// Routes are prefixed with api/cv/job-search. Protected by the internal API key middleware — not reachable from the public internet. +/// [ApiController] [Route("api/cv/job-search")] public sealed class JobSearchController : ControllerBase @@ -19,7 +24,26 @@ public sealed class JobSearchController : ControllerBase _logger = logger; } + /// + /// Creates a one-time job-search token linked to a CV document and email address. + /// Called by api immediately after a successful CV match when an email is provided. + /// The token is embedded in the job-search link sent to the user's email. + /// + /// The CV document ID and the recipient email address. + /// Cancellation token. + /// + /// 200 OK with a containing the generated token ID; + /// 400 Bad Request if CvDocumentId or Email is missing; + /// 500 Internal Server Error if token creation fails. + /// [HttpPost("token")] + [SwaggerOperation(Summary = "Create job search token", Description = "Creates a one-time token that lets the user start a background job search by clicking the link in their match email.")] + [SwaggerResponse(StatusCodes.Status200OK, "Token created successfully", typeof(CreateJobSearchTokenResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "CvDocumentId or Email missing", typeof(ErrorResponse))] + [SwaggerResponse(StatusCodes.Status500InternalServerError, "Token creation failed", typeof(ErrorResponse))] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] public async Task> CreateToken( [FromBody] CreateJobSearchTokenRequest request, CancellationToken ct) @@ -39,7 +63,24 @@ public sealed class JobSearchController : ControllerBase } } + /// + /// Validates the one-time token, marks it as used, and enqueues a JobSearchSession with status Pending. + /// Called by api when the user clicks the job-search link in their match email. + /// The cv-search-job worker picks up the pending session and runs the search. + /// + /// The UUID token extracted from the email link. + /// Cancellation token. + /// + /// 200 OK with a whose Status is one of + /// Started, AlreadyUsed, or Expired; + /// 500 Internal Server Error if the session cannot be created. + /// [HttpPost("token/{tokenId}/start")] + [SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and creates a Pending job search session for the cv-search-job worker to process.")] + [SwaggerResponse(StatusCodes.Status200OK, "Search status returned (Started, AlreadyUsed, or Expired)", typeof(StartJobSearchResponse))] + [SwaggerResponse(StatusCodes.Status500InternalServerError, "Session creation failed", typeof(ErrorResponse))] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] public async Task> Start(string tokenId, CancellationToken ct) { try diff --git a/Apis/rag-api/Controllers/RagController.cs b/Apis/rag-api/Controllers/RagController.cs index 0354752..ecf07c6 100644 --- a/Apis/rag-api/Controllers/RagController.cs +++ b/Apis/rag-api/Controllers/RagController.cs @@ -7,6 +7,10 @@ using Shared.Models.Responses; namespace Api.Controllers; +/// +/// Internal endpoints for indexing documents into the vector store and performing semantic search. +/// Routes are prefixed with api/rag. Protected by the internal API key middleware — not reachable from the public internet. +/// [ApiController] [Route("api/rag")] public sealed class RagController : ControllerBase @@ -20,11 +24,22 @@ public sealed class RagController : ControllerBase _logger = logger; } + /// + /// Indexes a PDF file or plain-text document into the vector store via multipart/form-data. + /// Chunks the content, generates embeddings, and stores them for semantic retrieval. + /// Returns immediately from cache if an identical document was previously indexed. + /// + /// The indexing request: either a PDF file or raw text, plus optional title, source URL, and document type. + /// Cancellation token. + /// + /// 200 OK with an containing the document ID, chunk count, and cache status; + /// 400 Bad Request if neither a file nor text is provided, or the request is otherwise invalid. + /// [HttpPost("documents")] [RequestSizeLimit(10 * 1024 * 1024)] - [SwaggerOperation(Summary = "Index document (multipart)", Description = "Indexes a PDF file or raw text document using multipart/form-data payload.")] - [SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid indexing request")] + [SwaggerOperation(Summary = "Index document (multipart)", Description = "Indexes a PDF or plain-text document via multipart/form-data. Returns from cache if the same content was previously indexed.")] + [SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully", typeof(IndexDocumentResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Neither file nor text provided, or request is invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> IndexDocument( @@ -62,10 +77,20 @@ public sealed class RagController : ControllerBase } } + /// + /// Indexes a plain-text document sent as JSON into the vector store. + /// Returns immediately from cache if an identical document was previously indexed. + /// + /// The indexing request containing the raw text and optional title, source URL, and document type. + /// Cancellation token. + /// + /// 200 OK with an containing the document ID, chunk count, and cache status; + /// 400 Bad Request if the text is empty or the request is otherwise invalid. + /// [HttpPost("documents/json")] - [SwaggerOperation(Summary = "Index document (JSON)", Description = "Indexes a text document sent as JSON.")] - [SwaggerResponse(StatusCodes.Status200OK, "JSON document indexed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid JSON indexing request")] + [SwaggerOperation(Summary = "Index document (JSON)", Description = "Indexes a plain-text document sent as JSON. Returns from cache if the same content was previously indexed.")] + [SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully", typeof(IndexDocumentResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Text missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> IndexJsonDocument([FromBody] IndexDocumentRequest request, CancellationToken ct) @@ -86,10 +111,20 @@ public sealed class RagController : ControllerBase } } + /// + /// Performs semantic (vector) search over indexed documents. + /// Embeds the query, retrieves the closest chunks by cosine similarity, and returns the ranked results. + /// + /// The search request: query text, optional document type filter, and maximum result count. + /// Cancellation token. + /// + /// 200 OK with a containing the ranked matching chunks with scores and metadata; + /// 400 Bad Request if the query is empty or the request is otherwise invalid. + /// [HttpPost("search")] - [SwaggerOperation(Summary = "Semantic search", Description = "Performs semantic retrieval over indexed documents.")] - [SwaggerResponse(StatusCodes.Status200OK, "Search results returned")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid search request")] + [SwaggerOperation(Summary = "Semantic search", Description = "Embeds the query and retrieves the closest document chunks by vector similarity.")] + [SwaggerResponse(StatusCodes.Status200OK, "Search results returned", typeof(SearchResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Query missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> Search([FromBody] SearchRequest request, CancellationToken ct) @@ -109,10 +144,19 @@ public sealed class RagController : ControllerBase } } + /// + /// Returns the stored details for a previously indexed document, including its extracted text and metadata. + /// + /// The document ID returned when the document was indexed. + /// Cancellation token. + /// + /// 200 OK with a containing the document text and metadata; + /// 404 Not Found if no document with the given ID exists in the store. + /// [HttpGet("documents/{id}")] - [SwaggerOperation(Summary = "Get document details", Description = "Returns indexed document details for the provided document id.")] - [SwaggerResponse(StatusCodes.Status200OK, "Document details returned")] - [SwaggerResponse(StatusCodes.Status404NotFound, "Document was not found")] + [SwaggerOperation(Summary = "Get document details", Description = "Returns the stored text and metadata for a previously indexed document.")] + [SwaggerResponse(StatusCodes.Status200OK, "Document details returned", typeof(RagDocumentDetailsResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Document not found", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] public async Task> GetDocument(string id, CancellationToken ct)