UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

204 lines • 9.53 kB
import diagnostics_channel from 'node:diagnostics_channel'; import { chunkDocuments, ingestChunks } from '../rag/ingest.js'; import { publishRagMediaFailed, publishRagMediaIngested, publishRagMediaRemoved, publishRagMediaSkipped, } from './diagnostics.js'; import { envelopePayload, isUploadCompleteEvent, UPLOAD_COMPLETE_CHANNEL, } from './media-events.js'; import { defaultTextExtractor, normalizeContentType, UnsupportedContentTypeError, } from './text-extractor.js'; /** Thrown by {@link MediaRagIngestion.subscribe} when no `resolve` seam was configured. */ export class MediaRagResolveRequiredError extends Error { constructor() { super('mediaRagIngestion: subscribe() needs a `resolve` in the config to turn an upload.complete ' + 'payload ({ id, disk, key }) into a MediaRef with contentType/owner metadata.'); this.name = 'MediaRagResolveRequiredError'; } } /** * Bridges `@adonis-agora/media` uploads into the agent's RAG stack: fetch the bytes → extract text by * content type → chunk → embed → upsert into the configured {@link VectorStore}, tagging every chunk * with `{ mediaId, ownerType, ownerId, collection, tenantRef }` so retrieval can be scoped per * tenant/owner/collection. Two triggers, both opt-in and no-op until wired: * * - {@link ingestMedia} — call it explicitly with a {@link MediaRef} (e.g. from your own upload flow). * - {@link subscribe} — subscribe to the media library's `agora:media:upload.complete` diagnostics * channel and auto-ingest each finished upload (needs a `resolve` seam). * * Everything is structural: no import of `@adonis-agora/media`, and `node:diagnostics_channel` is a * Node builtin, so there is no hard dependency on `@adonis-agora/diagnostics` for the subscribe path. */ export class MediaRagIngestion { config; extractor; contentTypes; inFlight = new Set(); teardown = null; constructor(config) { this.config = config; this.extractor = config.extractor ?? defaultTextExtractor(); this.contentTypes = config.contentTypes !== undefined && config.contentTypes.length > 0 ? new Set(config.contentTypes.map((type) => normalizeContentType(type))) : null; } /** * Ingest one media file: content-type filter → size gate → read bytes → extract → remove-then-chunk * → embed → upsert. Unsupported/oversized/empty files are skipped (not errors). Chunk metadata carries * `{ mediaId, ownerType, ownerId, collection, tenantRef }` (only the fields present on `ref`). */ async ingestMedia(ref) { const contentType = normalizeContentType(ref.contentType); if (this.contentTypes !== null && !this.contentTypes.has(contentType)) { publishRagMediaSkipped({ mediaId: ref.id, contentType, reason: 'unsupported-type' }); return { status: 'skipped', reason: 'unsupported-type' }; } if (this.config.maxBytes !== undefined && ref.size !== undefined && ref.size > this.config.maxBytes) { publishRagMediaSkipped({ mediaId: ref.id, contentType, reason: 'too-large' }); return { status: 'skipped', reason: 'too-large' }; } let text; try { const bytes = await this.config.media.disk(ref.disk).getBytes(ref.key); text = await this.extractor.extract(Buffer.from(bytes), ref.contentType); } catch (error) { if (error instanceof UnsupportedContentTypeError) { publishRagMediaSkipped({ mediaId: ref.id, contentType, reason: 'unsupported-type' }); return { status: 'skipped', reason: 'unsupported-type' }; } throw error; } if (text.trim() === '') { publishRagMediaSkipped({ mediaId: ref.id, contentType, reason: 'empty-text' }); return { status: 'skipped', reason: 'empty-text' }; } // remove-then-ingest so a re-upload that shrinks to fewer chunks doesn't leave a stale tail. await this.config.store.remove(ref.id); const document = { id: ref.id, text, source: ref.key, metadata: { mediaId: ref.id, ...(ref.ownerType !== undefined ? { ownerType: ref.ownerType } : {}), ...(ref.ownerId !== undefined ? { ownerId: ref.ownerId } : {}), ...(ref.collection !== undefined ? { collection: ref.collection } : {}), ...(ref.tenantRef !== undefined ? { tenantRef: ref.tenantRef } : {}), }, }; const chunks = chunkDocuments([document], this.config.chunk ?? {}); const count = await ingestChunks(chunks, { embedder: this.config.embedder, store: this.config.store, }); publishRagMediaIngested({ mediaId: ref.id, chunks: count, ...(ref.ownerType !== undefined ? { ownerType: ref.ownerType } : {}), ...(ref.ownerId !== undefined ? { ownerId: ref.ownerId } : {}), ...(ref.collection !== undefined ? { collection: ref.collection } : {}), ...(ref.tenantRef !== undefined ? { tenantRef: ref.tenantRef } : {}), }); return { status: 'ingested', chunks: count }; } /** Drop a media document's chunks from the vector store — the delete half of keeping RAG in sync. */ async removeMedia(mediaId) { await this.config.store.remove(mediaId); publishRagMediaRemoved({ mediaId }); } /** * Guard + resolve + ingest one `upload.complete` diagnostics payload. Public so a caller can await it * directly (deterministic tests) instead of going through the subscription. A no-op when `resolve` * isn't configured, the payload isn't a valid `upload.complete`, or `resolve` returns `null`. */ async handleUploadComplete(payload) { if (this.config.resolve === undefined || !isUploadCompleteEvent(payload)) { return undefined; } const ref = await this.config.resolve(payload); if (ref === null) { return undefined; } return this.ingestMedia(ref); } /** * Subscribe to the media library's `agora:media:upload.complete` channel and auto-ingest each finished * upload. Idempotent (a second call is a no-op while already subscribed). Returns — and stores — an * unsubscribe function; ingestion errors are caught and published on `agora:rag:media.failed` so a bad * file never breaks the channel. Requires a `resolve` seam ({@link MediaRagResolveRequiredError}). */ subscribe() { if (this.config.resolve === undefined) { throw new MediaRagResolveRequiredError(); } if (this.teardown !== null) { return this.teardown; } const channel = diagnostics_channel.channel(UPLOAD_COMPLETE_CHANNEL); const listener = (message) => { this.track(this.ingestFromEvent(envelopePayload(message))); }; channel.subscribe(listener); const teardown = () => { channel.unsubscribe(listener); this.teardown = null; }; this.teardown = teardown; return teardown; } /** Unsubscribe from the media channel (if subscribed) and await every in-flight auto-ingestion. */ async unsubscribe() { this.teardown?.(); await this.settle(); } /** Await every in-flight auto-ingestion — for graceful shutdown and deterministic tests. */ async settle() { await Promise.allSettled([...this.inFlight]); } /** Whether the auto-subscriber is currently attached to the media channel. */ get subscribed() { return this.teardown !== null; } /** Handle one channel event with the same error boundary the subscriber uses. */ async ingestFromEvent(payload) { const mediaId = isUploadCompleteEvent(payload) ? payload.id : 'unknown'; try { await this.handleUploadComplete(payload); } catch (error) { publishRagMediaFailed({ mediaId, error: errorMessage(error) }); } } track(promise) { const tracked = promise.finally(() => { this.inFlight.delete(tracked); }); this.inFlight.add(tracked); } } /** * Build a media→RAG ingestion bridge (idiomatic factory). Wire it in a provider's `boot()`: * * ```ts * import { mediaRagIngestion } from '@adonis-agora/agent/rag-media' * * const ingestion = mediaRagIngestion({ * media: await app.container.make('media.manager'), * embedder: myEmbedder, * store: myVectorStore, * contentTypes: ['text/plain', 'text/markdown', 'application/pdf'], * extractor: defaultTextExtractor().register('application/pdf', myPdfExtractor), * resolve: async ({ id, disk, key }) => lookupMediaRecord(id), // → MediaRef * }) * const off = ingestion.subscribe() // auto-ingest on upload.complete * ``` * * Or trigger it explicitly: `await ingestion.ingestMedia(ref)`. Everything is opt-in and no-op until * wired — nothing runs, subscribes, or imports the media/pdf peers unless you call it. */ export function mediaRagIngestion(config) { return new MediaRagIngestion(config); } function errorMessage(error) { return error instanceof Error ? error.message : String(error); } //# sourceMappingURL=media-rag-ingestion.js.map