LocalLens
Walkthrough

6. O workflow da aplicação

LocalLensApp é onde os módulos dos cinco passos anteriores se juntam.

src/locallens.ts exporta LocalLensApp, o único ponto de entrada que a CLI e o servidor usam. Ele possui o workflow — a ordem das coisas — e nada mais.

O construtor

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

Dois parâmetros com defaults sensatos. Testes passam seu próprio store apontado para um diretório temp. A CLI e o servidor usam os defaults.

Criando um brain a partir de uma pasta

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);
}

O método público é curto. O trabalho real mora no 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;
  }
}

O formato:

  1. Validar. Nome vazio? Lança erro. Sem documentos? Lança erro.
  2. Salvar com status indexing. Permite a UI mostrar progresso, e deixa um registro claro se a execução crashar.
  3. Chunk → save chunks → ingest. Três passos, em ordem.
  4. Salvar de novo com status indexed. Finaliza.
  5. Em erro, salvar com status error e relançar. O store mantém a falha visível para a UI.

Esse é o único lugar no app onde transições de estado multi-passo acontecem. Isso é deliberado — manter o ciclo de vida em um método só significa que existe exatamente um lugar para ler para entender.

Fazendo uma pergunta

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,
    })),
  };
}

Três coisas para notar:

  • Checagem de status. Perguntar para um brain que ainda está indexando ou em erro retorna 409. A UI usa isso para manter o input de chat desabilitado.
  • Citações são derivadas dos hits. Cada SearchHit é mapeado para um Citation (sem conteúdo de chunk) antes de retornar. Essa é a fronteira de privacidade que mantém o texto do chunk fora do servidor.
  • Fallback de resposta vazia. Se o modelo não produz nada, o chamador recebe uma mensagem simples em vez de uma string vazia.

Caminho do navegador

A variante do navegador é um wrapper fino em volta do mesmo 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 o folderPath do brain, então você consegue saber de onde veio sem nunca segurar um caminho real de disco que o navegador nunca nos deu.

Apagando um brain

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

Duas fases, em ordem: fechar o workspace QVAC (apagando embeddings em disco), depois dropar a entrada JSON. Se o QVAC lança erro, a entrada JSON fica — esse é o comportamento certo, porque deixa um registro visível de que algo deu errado em vez de um half-cleanup silencioso.

Shutdown

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

Isso é tudo que o workflow precisa expor. O store não tem nada para fechar — um arquivo JSON está bem quando você simplesmente para de escrever nele.

Onde adicionar features

Se sua feature envolve um passo novo no ciclo de vida do brain, adiciona aqui. Se é um pedaço novo de dado, adiciona em domain.ts e passa pelos gateways relevantes. A classe de workflow é o único lugar onde múltiplos módulos são orquestrados.

A seguir: a CLI, o menor consumidor possível dessa classe.

On this page