mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-06-01 17:16:48 +02:00
Surfaces two silent failure modes that the prior binary "any-chunks-in-Qdrant ⇒ embedded" check could not distinguish from healthy ingestion: - **Warning A — Zero-chunk file** (file_size > 100 MB, chunks = 0) Fires on video-only / image-only ZIMs (`lrnselfreliance_en_all`, TED talks, etc.) that the pipeline completes "successfully" with no extractable text. AI Assistant literally cannot reference these. - **Warning B — Partial-embed stall** (chunks < 50% of expected from the ratio registry). Surfaces the simple_wiki "266 of 600,000 chunks" case observed during NOMAD1 ingestion testing — previously these looked identical to fully-completed embeds in the UI. Both warnings render only when their condition is met (silent by default; noisy only on real problems). Base is `feat/kb-ratio-registry` (#891) because Warning B's "expected chunks" estimate comes from `KbRatioRegistry.estimateChunks()`. GitHub fast-forwards to `rc` once #891 merges. - `app/utils/kb_warning_decision.ts` — pure `decideWarnings(inputs)` with thresholds (`100 MB`, `0.5×`) as exported constants. 10 unit tests cover the healthy case, both warnings, the under/at/over boundary, the registry-miss suppression, and the video-only registry case (`expectedChunks: 0` correctly skips Warning B). - `RagService.computeFileWarnings()` — single Qdrant scroll tallies chunks per source, filesystem walk fills in zero-chunk files, ratio registry estimates the expectation, decision function emits. - New endpoint `GET /api/rag/file-warnings` returns `Record<source, FileWarning[]>` (sources with no warnings are omitted, so the frontend can `warnings[source] ?? []` for clean defaults). - KB modal: warnings render inline under the file name as amber-tinted pills. Polled every 30s alongside the existing health check. - Warning C — chunks skipped due to length. PR #890 (#881 fix) prevents the silent drop at the embed boundary, so the underlying condition shouldn't fire anymore. If we still want to surface "we truncated N chunks to fit", that needs separate `skipped_count` tracking in EmbedFileJob — a Phase 2 follow-up. - Suppressing Warning B during active mid-ingestion. The user can cross- reference the Processing Queue to know it's in-flight; suppressing warnings while a job runs would mask real stalls where the job died mid-batch. Will revisit when per-card status is wired through. - Use of `kb_ingest_state.chunks_embedded` (#888) as the chunk count source. This PR uses Qdrant scroll directly so it can land independently of #888. - 10 new unit tests on `decideWarnings`, all pass - Type-check clean - Hot-patch + browser smoke test deferred until #891 lands (the ratio registry needs to exist in the DB for `estimateChunks()` to return non-null estimates — without it, only Warning A fires which is still useful but Warning B stays dormant)
146 lines
5.3 KiB
TypeScript
146 lines
5.3 KiB
TypeScript
import { RagService } from '#services/rag_service'
|
|
import { EmbedFileJob } from '#jobs/embed_file_job'
|
|
import KbRatioRegistry from '#models/kb_ratio_registry'
|
|
import { inject } from '@adonisjs/core'
|
|
import type { HttpContext } from '@adonisjs/core/http'
|
|
import app from '@adonisjs/core/services/app'
|
|
import { randomBytes } from 'node:crypto'
|
|
import { sanitizeFilename } from '../utils/fs.js'
|
|
import { basename } from 'node:path'
|
|
import { deleteFileSchema, estimateBatchSchema, getJobStatusSchema } from '#validators/rag'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
|
|
@inject()
|
|
export default class RagController {
|
|
constructor(private ragService: RagService) { }
|
|
|
|
public async upload({ request, response }: HttpContext) {
|
|
const uploadedFile = request.file('file')
|
|
if (!uploadedFile) {
|
|
return response.status(400).json({ error: 'No file uploaded' })
|
|
}
|
|
|
|
const randomSuffix = randomBytes(6).toString('hex')
|
|
const sanitizedName = sanitizeFilename(uploadedFile.clientName)
|
|
|
|
const fileName = `${sanitizedName}-${randomSuffix}.${uploadedFile.extname || 'txt'}`
|
|
const fullPath = app.makePath(RagService.UPLOADS_STORAGE_PATH, fileName)
|
|
|
|
await uploadedFile.move(app.makePath(RagService.UPLOADS_STORAGE_PATH), {
|
|
name: fileName,
|
|
})
|
|
|
|
// Dispatch background job for embedding
|
|
const result = await EmbedFileJob.dispatch({
|
|
filePath: fullPath,
|
|
fileName,
|
|
})
|
|
|
|
return response.status(202).json({
|
|
message: result.message,
|
|
jobId: result.jobId,
|
|
fileName,
|
|
filePath: `/${RagService.UPLOADS_STORAGE_PATH}/${fileName}`,
|
|
alreadyProcessing: !result.created,
|
|
})
|
|
}
|
|
|
|
public async getActiveJobs({ response }: HttpContext) {
|
|
const jobs = await EmbedFileJob.listActiveJobs()
|
|
return response.status(200).json(jobs)
|
|
}
|
|
|
|
public async getJobStatus({ request, response }: HttpContext) {
|
|
const reqData = await request.validateUsing(getJobStatusSchema)
|
|
|
|
const fullPath = app.makePath(RagService.UPLOADS_STORAGE_PATH, reqData.filePath)
|
|
const status = await EmbedFileJob.getStatus(fullPath)
|
|
|
|
if (!status.exists) {
|
|
return response.status(404).json({ error: 'Job not found for this file' })
|
|
}
|
|
|
|
return response.status(200).json(status)
|
|
}
|
|
|
|
public async getStoredFiles({ response }: HttpContext) {
|
|
const files = await this.ragService.getStoredFiles()
|
|
return response.status(200).json({ files })
|
|
}
|
|
|
|
public async getFileWarnings({ response }: HttpContext) {
|
|
const warnings = await this.ragService.computeFileWarnings()
|
|
return response.status(200).json({ warnings })
|
|
}
|
|
|
|
public async deleteFile({ request, response }: HttpContext) {
|
|
const { source } = await request.validateUsing(deleteFileSchema)
|
|
const result = await this.ragService.deleteFileBySource(source)
|
|
if (!result.success) {
|
|
return response.status(500).json({ error: result.message })
|
|
}
|
|
return response.status(200).json({ message: result.message })
|
|
}
|
|
|
|
public async getFailedJobs({ response }: HttpContext) {
|
|
const jobs = await EmbedFileJob.listFailedJobs()
|
|
return response.status(200).json(jobs)
|
|
}
|
|
|
|
public async cleanupFailedJobs({ response }: HttpContext) {
|
|
const result = await EmbedFileJob.cleanupFailedJobs()
|
|
return response.status(200).json({
|
|
message: `Cleaned up ${result.cleaned} failed job${result.cleaned !== 1 ? 's' : ''}${result.filesDeleted > 0 ? `, deleted ${result.filesDeleted} file${result.filesDeleted !== 1 ? 's' : ''}` : ''}.`,
|
|
...result,
|
|
})
|
|
}
|
|
|
|
public async scanAndSync({ response }: HttpContext) {
|
|
try {
|
|
const syncResult = await this.ragService.scanAndSyncStorage()
|
|
return response.status(200).json(syncResult)
|
|
} catch (error) {
|
|
logger.error({ err: error }, '[RagController] Error scanning and syncing storage')
|
|
return response.status(500).json({ error: 'Error scanning and syncing storage' })
|
|
}
|
|
}
|
|
|
|
public async reembedAll({ response }: HttpContext) {
|
|
try {
|
|
const result = await this.ragService.reembedAll()
|
|
return response.status(200).json(result)
|
|
} catch (error) {
|
|
logger.error({ err: error }, '[RagController] Error during re-embed all')
|
|
return response.status(500).json({ error: 'Error during re-embed all' })
|
|
}
|
|
}
|
|
|
|
public async resetAndRebuild({ response }: HttpContext) {
|
|
try {
|
|
const result = await this.ragService.resetAndRebuild()
|
|
return response.status(200).json(result)
|
|
} catch (error) {
|
|
logger.error({ err: error }, '[RagController] Error during reset and rebuild')
|
|
return response.status(500).json({ error: 'Error during reset and rebuild' })
|
|
}
|
|
}
|
|
|
|
public async health({ response }: HttpContext) {
|
|
const result = await this.ragService.checkQdrantHealth()
|
|
return response.status(200).json(result)
|
|
}
|
|
|
|
public async estimateBatch({ request, response }: HttpContext) {
|
|
const { files } = await request.validateUsing(estimateBatchSchema)
|
|
// The registry matches on basename prefixes; if a caller passes a full path
|
|
// (e.g. /app/storage/zim/wikipedia_en_simple_…), strip directories first so
|
|
// patterns like `wikipedia_en_simple_` still match.
|
|
const normalized = files.map((f) => ({
|
|
filename: basename(f.filename),
|
|
sizeBytes: f.sizeBytes,
|
|
}))
|
|
const result = await KbRatioRegistry.estimateBatch(normalized)
|
|
return response.status(200).json(result)
|
|
}
|
|
}
|