Vortholm — документация

Готовые примеры

Клиенты на Python и TypeScript, справочник всех запросов и машиночитаемое описание для программы проверки запросов.

Страница содержит справочник запросов, готовые клиенты на Python и TypeScript и машиночитаемое описание запросов.

Интерактивной песочницы нет. Сохраните машиночитаемое описание в файл и откройте его в программе для проверки запросов или используйте для генерации клиента.

Справочник запросов

Всё, что доступно по ключу. Адрес папки везде — тот id, который вернул /mine.

ЗапросЧто делаетПодробно
GET /api/admin/library-folders/mineПапки, разрешённые этому ключуС чего начать
GET /api/admin/library-folders/{id}Карточка папки: имя, описание, число файловОстальные запросы
GET /api/admin/library-folders/{id}/contentsДерево подпапокОстальные запросы
GET /api/admin/library-folders/{id}/filesФайлы папки: страницы, отбор по пути, сортировкаОстальные запросы
POST /api/admin/library-folders/{id}/filesЗагрузка, до 50 файлов за разЗагрузка файлов
DELETE /api/admin/library-folders/{id}/files/{fileId}Убрать файл из папкиОстальные запросы
POST /api/admin/library-folders/{id}/reindex-failedПовторить обработку файлов с ошибкойОстальные запросы
POST /api/admin/library-folders/{id}/reindex-allОбработать заново всю папкуОстальные запросы

Ничего, кроме перечисленного, ключу не отвечает: остальные запросы администраторской части портал отклоняет с кодом 403, даже когда ключ действителен.

Python

Клиент использует только библиотеку requests. Он получает список папок, загружает папку с диска, ждёт окончания обработки и удаляет ненужные файлы.

import json
import os
import time
from pathlib import Path

import requests


class VortholmLibrary:
    """Клиент базы знаний портала. Работает только с папками своего ключа."""

    UPLOAD_BATCH = 50           # предел одного запроса на стороне портала

    def __init__(self, base_url: str, key: str, timeout: int = 300):
        self.base = base_url.rstrip("/") + "/api/admin/library-folders"
        self.session = requests.Session()
        self.session.headers["Authorization"] = f"Bearer {key}"
        self.timeout = timeout

    # --- служебное -------------------------------------------------------

    def _call(self, method: str, path: str = "", **kwargs):
        response = self.session.request(
            method, f"{self.base}{path}", timeout=self.timeout, **kwargs
        )
        if not response.ok:
            # Портал всегда отвечает одним и тем же конвертом: {"error": {...}}
            try:
                error = response.json()["error"]
            except Exception:
                raise RuntimeError(f"HTTP {response.status_code}: {response.text[:200]}")
            raise RuntimeError(
                f"HTTP {response.status_code} {error.get('code')}: {error.get('message')} "
                f"{json.dumps(error.get('details', {}), ensure_ascii=False)}"
            )
        return response.json()

    # --- чтение ----------------------------------------------------------

    def folders(self):
        return self._call("GET", "/mine")["data"]

    def files(self, folder_id: str, path: str | None = None, page_size: int = 100):
        """Все файлы папки, страница за страницей. Больше 100 за раз портал не отдаст."""
        page = 1
        while True:
            params = {"page": page, "pageSize": page_size}
            if path is not None:
                params["path"] = path
            body = self._call("GET", f"/{folder_id}/files", params=params)
            yield from body["data"]
            if page >= body["pagination"]["totalPages"]:
                return
            page += 1

    # --- запись ----------------------------------------------------------

    @staticmethod
    def _relative(path: Path, root: Path | None) -> str:
        if root is None:
            return ""
        inside = path.parent.relative_to(root)
        return "" if inside == Path(".") else inside.as_posix()

    def upload(self, folder_id: str, paths: list[Path], root: Path | None = None):
        """Заливает файлы пачками по 50, сохраняя структуру каталогов."""
        totals = {"uploaded": 0, "deduplicated": 0, "replaced": 0, "alreadyInFolder": 0}
        skipped = []

        for start in range(0, len(paths), self.UPLOAD_BATCH):
            batch = paths[start : start + self.UPLOAD_BATCH]
            # Порядок relativePaths обязан совпадать с порядком files.
            # Пустая строка означает корень папки.
            relative = [self._relative(p, root) for p in batch]
            files = [("files", (p.name, p.open("rb"))) for p in batch]
            try:
                body = self._call(
                    "POST",
                    f"/{folder_id}/files",
                    files=files,
                    data={"relativePaths": json.dumps(relative)},
                )["data"]
            finally:
                for _, (_, handle) in files:
                    handle.close()

            for counter in totals:
                totals[counter] += body.get(counter, 0)
            skipped.extend(body.get("skippedFiles", []))

        return totals, skipped

    def delete(self, folder_id: str, file_id: str):
        self._call("DELETE", f"/{folder_id}/files/{file_id}")

    def reindex_failed(self, folder_id: str):
        """Ставит в очередь заново те файлы, на которых обработка сорвалась."""
        return self._call("POST", f"/{folder_id}/reindex-failed")["data"]["updated"]

    # --- ожидание --------------------------------------------------------

    def wait_until_ready(self, folder_id: str, poll_seconds: int = 30, limit: int = 120):
        """Ждёт, пока в папке не останется файлов в работе или в очереди."""
        for _ in range(limit):
            waiting = [
                f for f in self.files(folder_id)
                if f["indexStatus"] in ("pending", "processing")
            ]
            if not waiting:
                return True
            time.sleep(poll_seconds)
        return False


if __name__ == "__main__":
    library = VortholmLibrary(os.environ["PORTAL_URL"], os.environ["PORTAL_KEY"])

    for folder in library.folders():
        print(folder["id"], folder["name"], folder["fileCount"])

    folder_id = library.folders()[0]["id"]
    root = Path("./выгрузка")
    documents = sorted(p for p in root.rglob("*") if p.is_file())

    totals, skipped = library.upload(folder_id, documents, root=root)
    print("залито:", totals)
    for item in skipped:
        print("пропущен:", item["filename"], item["reason"])

    library.wait_until_ready(folder_id)

TypeScript

Клиент работает в Node 20 и новее без сторонних библиотек: fetch, FormData и Blob входят в Node.

type Folder = {
  id: string;
  name: string;
  description: string | null;
  type: string;
  fileCount: number;
};

type UploadResult = {
  uploaded: number;
  deduplicated: number;
  replaced: number;
  alreadyInFolder: number;
  files: Array<{ id: string; filename: string; relativePath: string }>;
  skippedFiles: Array<{ filename: string; reason: string }>;
};

const UPLOAD_BATCH = 50; // предел одного запроса на стороне портала

export class VortholmLibrary {
  private readonly base: string;

  constructor(baseUrl: string, private readonly key: string) {
    this.base = `${baseUrl.replace(/\/$/, '')}/api/admin/library-folders`;
  }

  private async call<T>(method: string, path = '', body?: BodyInit): Promise<T> {
    const response = await fetch(`${this.base}${path}`, {
      method,
      headers: { Authorization: `Bearer ${this.key}` },
      body,
    });

    if (!response.ok) {
      // Единый конверт ошибки: { error: { code, message, details } }
      const text = await response.text();
      let detail = text.slice(0, 200);
      try {
        const { error } = JSON.parse(text) as { error: { code: string; message: string } };
        detail = `${error.code}: ${error.message}`;
      } catch {
        /* тело не разобралось — покажем как есть */
      }
      throw new Error(`HTTP ${response.status} ${detail}`);
    }

    return (await response.json()) as T;
  }

  async folders(): Promise<Folder[]> {
    const { data } = await this.call<{ data: Folder[] }>('GET', '/mine');
    return data;
  }

  /** Заливает файлы пачками по 50 и складывает счётчики всех пачек. */
  async upload(
    folderId: string,
    files: Array<{ name: string; relativePath?: string; content: Blob }>,
  ): Promise<UploadResult> {
    const total: UploadResult = {
      uploaded: 0,
      deduplicated: 0,
      replaced: 0,
      alreadyInFolder: 0,
      files: [],
      skippedFiles: [],
    };

    for (let start = 0; start < files.length; start += UPLOAD_BATCH) {
      const batch = files.slice(start, start + UPLOAD_BATCH);
      const form = new FormData();
      // Порядок relativePaths обязан совпадать с порядком files.
      form.append('relativePaths', JSON.stringify(batch.map((f) => f.relativePath ?? '')));
      for (const file of batch) form.append('files', file.content, file.name);

      const { data } = await this.call<{ data: UploadResult }>(
        'POST',
        `/${folderId}/files`,
        form,
      );

      total.uploaded += data.uploaded;
      total.deduplicated += data.deduplicated;
      total.replaced += data.replaced;
      total.alreadyInFolder += data.alreadyInFolder;
      total.files.push(...data.files);
      total.skippedFiles.push(...data.skippedFiles);
    }

    return total;
  }

  async remove(folderId: string, fileId: string): Promise<void> {
    await this.call('DELETE', `/${folderId}/files/${fileId}`);
  }
}

Вызов:

import { readFile } from 'node:fs/promises';

const library = new VortholmLibrary(process.env.PORTAL_URL!, process.env.PORTAL_KEY!);
const [folder] = await library.folders();

const result = await library.upload(folder.id, [
  {
    name: 'договор-2026-014.pdf',
    relativePath: '2026/01',
    content: new Blob([await readFile('./договор-2026-014.pdf')]),
  },
]);

console.log(result.uploaded, result.deduplicated, result.skippedFiles);

Повторная отправка того же файла не создаёт копию

Если отправить один и тот же файл дважды, портал узнает содержимое и не создаст вторую копию: файл попадёт в счётчик deduplicated, а не uploaded. После обрыва связи запрос можно повторить целиком. Проверяйте результат повторного запроса отдельно, не складывая его счётчики с первым.

Единственное, что повтор действительно меняет, — файл с тем же именем, но другим содержимым: он заменяет прежний, и это видно в счётчике replaced. Прежняя версия из папки исчезает.

Машиночитаемое описание

Сохраните текст ниже в vortholm-library.yaml и откройте своим инструментом — тем, который рисует справочник по такому файлу, собирает коллекцию запросов или генерирует клиент.

openapi: 3.1.0
info:
  title: Vortholm — база знаний
  version: "1"
  description: >-
    Загрузка и разбор файлов базы знаний портала. Доступ по ключу вида sk_forge_...,
    только к папкам, разрешённым этому ключу.
servers:
  - url: https://portal.example.com/api/admin
    description: адрес администраторской части портала
security:
  - bearerAuth: []
paths:
  /library-folders/mine:
    get:
      summary: Папки, разрешённые ключу
      responses:
        "200":
          description: Список папок
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Folder" }
                  meta:
                    type: object
                    properties:
                      total: { type: integer }
        "401": { $ref: "#/components/responses/Error" }
  /library-folders/{folderId}:
    get:
      summary: Карточка папки
      parameters:
        - $ref: "#/components/parameters/FolderId"
      responses:
        "200":
          description: Папка
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Folder" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /library-folders/{folderId}/contents:
    get:
      summary: Содержимое папки по указанному пути
      parameters:
        - $ref: "#/components/parameters/FolderId"
        - { name: path, in: query, schema: { type: string }, description: подпапка; без него корень }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: pageSize, in: query, schema: { type: integer, default: 50, maximum: 200 } }
      responses:
        "200": { description: Подпапки и файлы указанного пути }
  /library-folders/{folderId}/files:
    get:
      summary: Файлы папки
      parameters:
        - $ref: "#/components/parameters/FolderId"
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: pageSize, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: path, in: query, schema: { type: string }, description: подпапка }
        - { name: search, in: query, schema: { type: string }, description: по имени и описанию }
        - name: sortBy
          in: query
          schema: { type: string, enum: [filename, indexStatus], default: filename }
        - name: sortOrder
          in: query
          schema: { type: string, enum: [asc, desc] }
      responses:
        "200":
          description: Страница списка файлов
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/File" }
                  pagination:
                    type: object
                    properties:
                      page: { type: integer }
                      pageSize: { type: integer }
                      total: { type: integer }
                      totalPages: { type: integer }
    post:
      summary: Загрузка файлов (до 50 за запрос)
      parameters:
        - $ref: "#/components/parameters/FolderId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                files:
                  type: array
                  maxItems: 50
                  items: { type: string, format: binary }
                relativePaths:
                  type: string
                  description: >-
                    JSON-массив строк той же длины и в том же порядке, что files.
                    Пустая строка — корень папки.
              required: [files]
      responses:
        "201":
          description: Результат загрузки
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/UploadResult" }
        "400": { $ref: "#/components/responses/Error" }
        "413": { $ref: "#/components/responses/Error" }
  /library-folders/{folderId}/files/{fileId}:
    delete:
      summary: Убрать файл из папки
      parameters:
        - $ref: "#/components/parameters/FolderId"
        - { name: fileId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Файл убран из папки
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success: { type: boolean }
        "404": { $ref: "#/components/responses/Error" }
  /library-folders/{folderId}/reindex-failed:
    post:
      summary: Повторить обработку файлов с ошибкой
      parameters:
        - $ref: "#/components/parameters/FolderId"
      responses:
        "200": { $ref: "#/components/responses/Updated" }
  /library-folders/{folderId}/reindex-all:
    post:
      summary: Обработать заново всю папку
      parameters:
        - $ref: "#/components/parameters/FolderId"
      responses:
        "200": { $ref: "#/components/responses/Updated" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Authorization: Bearer sk_forge_..."
  parameters:
    FolderId:
      name: folderId
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    Updated:
      description: Сколько файлов поставлено в очередь
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: object
                properties:
                  updated: { type: integer }
    Error:
      description: Ошибка
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code: { type: string }
                  message: { type: string }
                  details: { type: object }
  schemas:
    Folder:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        type: { type: string }
        fileCount: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    File:
      type: object
      description: строка списка файлов; linkId — связь «файл в этой папке», id — сам файл
      properties:
        linkId: { type: string, format: uuid }
        id: { type: string, format: uuid }
        filename: { type: string }
        fileSize: { type: integer }
        mimeType: { type: string }
        indexStatus:
          type: string
          enum: [pending, processing, done, failed, flagged]
        indexError: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
    UploadResult:
      type: object
      properties:
        uploaded: { type: integer }
        deduplicated: { type: integer }
        replaced: { type: integer }
        alreadyInFolder: { type: integer }
        files:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              filename: { type: string }
              relativePath: { type: string }
              deduplicated: { type: boolean }
              alreadyInFolder: { type: boolean }
              replaced: { type: boolean }
        skippedFiles:
          type: array
          items:
            type: object
            properties:
              filename: { type: string }
              reason: { type: string }

Описание собрано по этому разделу документации и обновляется вместе с ним. Портал его не отдаёт и не проверяет. При расхождении сверяйтесь со страницами «Загрузка файлов» и «Остальные запросы».

На этой странице