LocalLens
ウォークスルー

6. アプリケーションワークフロー

LocalLensApp は、ここまでの 5 ステップで作ったモジュールを束ねる場所。

src/locallens.tsLocalLensApp を export します。CLI とサーバーが 共通して使うエントリポイントです。このクラスが持つのはワークフロー、 つまり処理の順序であり、それ以外は持ちません。

コンストラクタ

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

妥当なデフォルト値を持つ 2 つのパラメータ。テストでは自前のストアを temp ディレクトリに向けて渡します。CLI とサーバーはデフォルトを使います。

フォルダから brain を作る

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

public メソッドは短いです。本体は private ヘルパーにあります。

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

その形は次のとおりです。

  1. 検証する。 名前が空? throw。ドキュメントなし? throw。
  2. indexing ステータスで保存する。 UI で進捗を表示できますし、 処理が落ちても明確な記録が残ります。
  3. チャンク化 → チャンク保存 → 取り込み。 順序つきの 3 ステップ。
  4. indexed で再度保存する。 完了。
  5. エラーが出たら error で保存して再 throw。失敗が UI から見える 状態でストアに残ります。

このメソッドは、アプリ内で複数ステップの状態遷移が起きる唯一の場所です。 これは意図的な設計で、ライフサイクルを 1 つのメソッドに集約することで、 それを理解するために読むべき場所が 1 つだけになります。

質問する

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

押さえておくべき点が 3 つあります。

  • ステータスチェック。 インデックス中またはエラー状態の brain に 質問すると 409 を返します。UI はこれを使ってチャット入力を無効化 しておけます。
  • 引用はヒットから派生する。SearchHit は返却前に Citation (チャンク本文なし)にマップされます。これがチャンクテキストを サーバー外に出さないためのプライバシー境界です。
  • 空回答のフォールバック。 モデルが何も生成しなかった場合、呼び出し元には 空文字列ではなくプレーンなメッセージが返ります。

ブラウザパス

ブラウザ版は同じ private ヘルパーを薄くラップしているだけです。

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

brain の folderPath として browser://<folderName> を使うので、 ブラウザが渡してこなかった本物のディスクパスを保持することなく、由来を 表現できます。

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

2 段階で、順序どおり。まず QVAC ワークスペースを閉じる(ディスク上の embedding が削除される)、次に JSON エントリを落とす。QVAC が throw した 場合、JSON エントリは残ります。これは正しい挙動です。何かおかしいことが あった、という記録が残るほうが、片付け途中の静かな失敗より良いからです。

シャットダウン

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

ワークフローが公開しないといけないのはこれだけです。ストアには閉じる ものがありません。JSON ファイルは書き込みをやめれば十分です。

機能はどこに足すか

brain のライフサイクルに新しいステップが入る機能なら、ここに追加します。 新しいデータが入る機能なら、domain.ts に型を追加して、関連する ゲートウェイに通します。複数モジュールが協調するのは、ワークフロー クラスが唯一の場所です。

次は CLI、このクラスの最小消費者です。

On this page