LocalLens
Walkthrough

6. El workflow de la aplicación

LocalLensApp es donde se juntan los módulos de los cinco pasos anteriores.

src/locallens.ts exporta LocalLensApp, el único punto de entrada que usan tanto la CLI como el servidor. Posee el workflow — el orden en que pasan las cosas — y nada más.

El constructor

src/locallens.ts
export class LocalLensApp {
  constructor(
    private readonly store = new LocalLensStore(),
    private readonly qvac = new QvacGateway(),
  ) {}
  // …
}

Dos parámetros con defaults sensatos. Los tests pasan su propio store apuntado a un directorio temporal. La CLI y el servidor toman los defaults.

Creando un cerebro desde una carpeta

async createBrainFromFolder(input: CreateBrainFromFolderInput): Promise<Brain> {
  const folderPath = path.resolve(input.folderPath);
  const documents = await discoverTextDocuments(folderPath);

  return this.createBrainFromLocalDocuments(input.name.trim(), folderPath, documents);
}

El método público es corto. El trabajo real vive en el helper privado:

private async createBrainFromLocalDocuments(
  name: string,
  folderPath: string,
  documents: LocalDocument[],
): Promise<Brain> {
  if (!name) throw new AppError("Brain name is required.");
  if (documents.length === 0) {
    throw new AppError("Choose a folder with supported text files.");
  }

  const now = new Date().toISOString();
  const brain: Brain = {
    id: randomUUID(),
    name,
    folderPath,
    workspace: `locallens-${slugify(name)}-${randomUUID().slice(0, 8)}`,
    status: "indexing",
    fileCount: documents.length,
    chunkCount: 0,
    createdAt: now,
    updatedAt: now,
  };

  await this.store.saveBrain(brain);

  try {
    const chunks = await chunkDocuments(documents, { brainId: brain.id });
    await this.store.saveChunks(brain.id, chunks);
    await this.qvac.ingestChunks(brain.workspace, chunks);

    const indexed = {
      ...brain,
      status: "indexed" as const,
      chunkCount: chunks.length,
      updatedAt: new Date().toISOString(),
      lastIndexedAt: new Date().toISOString(),
    };
    await this.store.saveBrain(indexed);
    return indexed;
  } catch (error) {
    const failed = {
      ...brain,
      status: "error" as const,
      updatedAt: new Date().toISOString(),
      lastError: toErrorMessage(error),
    };
    await this.store.saveBrain(failed);
    throw error;
  }
}

La forma:

  1. Valida. ¿Nombre vacío? Throw. ¿Sin documentos? Throw.
  2. Guarda con status indexing. Permite que la UI muestre progreso, y deja un registro claro si el run crashea.
  3. Chunk → guardar chunks → ingest. Tres pasos, en orden.
  4. Guarda de nuevo con status indexed. Finaliza.
  5. En error, guarda con status error y re-throw. El store mantiene la falla visible para la UI.

Este es el único lugar en la app donde pasan transiciones de estado multi-paso. Eso es deliberado — mantener el lifecycle en un solo método significa que hay exactamente un solo lugar que leer para entenderlo.

Haciendo una pregunta

async askBrain(id: string, question: string): Promise<ChatAnswer> {
  const brain = await this.getBrain(id);
  const normalizedQuestion = question.trim();

  if (!normalizedQuestion) throw new AppError("Question is required.");
  if (brain.status !== "indexed") {
    throw new AppError("This brain is not ready. Remove it and create it again.", 409);
  }

  const hits = await this.qvac.search(brain.workspace, normalizedQuestion, 5);
  const history = buildGroundedHistory(normalizedQuestion, hits);
  let answer = "";

  for await (const token of this.qvac.answer(history)) answer += token;

  return {
    answer: answer.trim() || "This brain does not contain enough information to answer.",
    citations: hits.map(({ id: hitId, relativePath, chunkIndex, score }) => ({
      id: hitId,
      relativePath,
      chunkIndex,
      score,
    })),
  };
}

Tres cosas para notar:

  • Chequeo de status. Preguntarle a un cerebro que sigue indexando o que erroró devuelve un 409. La UI usa esto para mantener el input del chat deshabilitado.
  • Las citas se derivan de los hits. Cada SearchHit se mapea a una Citation (sin contenido de chunk) antes de devolver. Esa es la frontera de privacidad que evita que el texto del chunk deje el servidor.
  • Fallback de respuesta vacía. Si el modelo no produce nada, el caller recibe un mensaje plano en vez de un string vacío.

Camino del navegador

La variante del navegador es un wrapper delgado sobre el mismo helper privado:

async createBrainFromDocuments(input: CreateBrainFromFilesInput): Promise<Brain> {
  const name = input.name.trim();
  const folderName = sanitizeFolderName(input.folderName);
  const documents = browserDocumentsFromInput(input.documents);

  return this.createBrainFromLocalDocuments(name, `browser://${folderName}`, documents);
}

Usa browser://<folderName> como el folderPath del cerebro, así puedes saber de dónde vino sin tener nunca una ruta real del disco que el navegador nunca nos dio.

Borrando un cerebro

async deleteBrain(id: string): Promise<void> {
  const brain = await this.getBrain(id);
  await this.qvac.closeWorkspace(brain.workspace, true);
  await this.store.deleteBrain(id);
}

Dos fases, en orden: cierra el workspace de QVAC (borrando embeddings en disco), después tira la entrada JSON. Si QVAC tira un error, la entrada JSON se queda — que es el comportamiento correcto, porque deja un registro visible de que algo salió mal en lugar de una limpieza silenciosa a medias.

Shutdown

async close(): Promise<void> {
  await this.qvac.close();
}

Eso es todo lo que el workflow necesita exponer. El store no tiene nada que cerrar — un archivo JSON está bien cuando simplemente dejas de escribirle.

Dónde agregar features

Si tu feature involucra un paso nuevo en el lifecycle del cerebro, agrégalo acá. Si es una pieza nueva de datos, agrégala a domain.ts y enhébrala por los gateways relevantes. La clase del workflow es el único lugar donde se orquestan varios módulos.

Lo que sigue: la CLI, el consumidor más chico posible de esta clase.

On this page