diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..764fdde --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + offline-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check shell syntax + run: bash -n scripts/*.sh tests/*.sh + - name: Run offline tests + run: | + for test in tests/*.sh; do + echo "== $test" + bash "$test" + done diff --git a/README.md b/README.md index b7d6c6a..19d5f73 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ## Зависимости -Требует доступ к [TasK API](https://task.ai-aid.pro) ([документация](https://docs.ai-aid.pro/redocly)). Для получения доступа — [напишите команде](https://task.ai-aid.pro/ru/team). +Требует `bash`, `curl`, `jq`, GNU `realpath` и доступ к [TasK API](https://task.ai-aid.pro) ([документация](https://docs.ai-aid.pro/redocly)). Для получения доступа — [напишите команде](https://task.ai-aid.pro/ru/team). ## Установка @@ -28,7 +28,19 @@ git clone https://github.com/prikotov/knowledge-extraction.git .agents/skills/kn } ``` -Скрипты ищут его, поднимаясь от своего расположения вверх по дереву папок до первого совпадения. +Разместите `.task_token.json` в рабочем проекте, из которого запускаете Skill. +Скрипты используют текущий каталог (`$PWD`) как начальную точку и ищут токен +в нём и родительских каталогах. Для явного выбора каталога можно задать +`KNOWLEDGE_EXTRACTION_WORKSPACE=/path/to/project`. + +Необязательный `.task_config.json` — конфигурация Skill. Сейчас он поддерживает +переопределение адреса API: + +```json +{ + "api_url": "https://api.ai-aid.pro/v1" +} +``` ## Как работает @@ -89,24 +101,28 @@ Skill использует методы TasK API: ### Напрямую через скрипты +Команды выполняются из корня рабочего проекта. После установки, показанной выше, +путь к скриптам будет `.agents/skills/knowledge-extraction/scripts/`: + ```bash + # Загрузить материал -./scripts/ingest.sh --source-url https://habr.com/ru/articles/1061876/ +.agents/skills/knowledge-extraction/scripts/ingest.sh --source-url https://habr.com/ru/articles/1061876/ # Загрузить локальный файл (PDF, видео, аудио и т. п.) -./scripts/ingest.sh --source-file "/path/to/video.mp4" +.agents/skills/knowledge-extraction/scripts/ingest.sh --source-file "/path/to/video.mp4" # Начать диалог -./scripts/chat.sh --source-url https://habr.com/ru/articles/1061876/ +.agents/skills/knowledge-extraction/scripts/chat.sh --source-url https://habr.com/ru/articles/1061876/ # Продолжить диалог -./scripts/chat.sh --chat --question "Какие убеждения разбирает автор?" +.agents/skills/knowledge-extraction/scripts/chat.sh --chat --question "Какие убеждения разбирает автор?" # Поиск по чанкам -./scripts/search.sh --source-url https://habr.com/ru/articles/1061876/ --query "локус контроля" +.agents/skills/knowledge-extraction/scripts/search.sh --source-url https://habr.com/ru/articles/1061876/ --query "локус контроля" # Проверить статус обработки -./scripts/ingest.sh --check +.agents/skills/knowledge-extraction/scripts/ingest.sh --check # При ошибке API chat.sh завершится с ошибкой и напечатает, например: # HTTP 422: Need to top up balance. @@ -121,7 +137,3 @@ Skill использует методы TasK API: ## Лицензия MIT - ---- - -> Постановка задачи, ревью — [Dmitry Prikotov](https://prikotov.pro/), реализация — deepseek-v4-pro в [pi](https://pi.dev) (интересно было посмотреть как deepseek-v4-pro справится с написанием скила, я страдал) diff --git a/SKILL.md b/SKILL.md index 5dcd553..53c862e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,19 +1,15 @@ --- name: knowledge-extraction description: >- - Извлекает структурированные знания из любых внешних материалов (статьи,видео, документы). - Используй когда пользователь просит: изучить материал, дать резюме, - собрать саммари, сделать обзор, рассказать о чём статья/видео, разобрать - статью, найти цитаты или тезисы, подготовить краткое содержание, - поговорить с документом. + Извлекает структурированные знания из URL и локальных файлов без помещения всего + материала в контекст. Используй для статей, видео, аудио и документов, + когда пользователь просит изучить материал, сделать резюме или обзор, найти + подтверждённые цитаты и тезисы, поговорить с документом или работать с набором + связанных материалов как с единым контекстом. --- # Knowledge Extraction -## Когда использовать - -- Убедись, что на уровне статьи есть `.task_token.json` и `.task_config.json` (доступ к TasK API). - ## Как использовать ```text @@ -25,13 +21,13 @@ ingest → chat (поговорить с документом — приори ```bash # Загрузить один источник (ждёт готовности) -./scripts/ingest.sh --source-url +scripts/ingest.sh --source-url # Загрузить локальный файл (multipart/form-data) -./scripts/ingest.sh --source-file "/path/to/video.mp4" +scripts/ingest.sh --source-file "/path/to/video.mp4" # Проверить статусы всех источников (без ожидания) -./scripts/ingest.sh --check +scripts/ingest.sh --check ``` Опции: @@ -55,16 +51,16 @@ YouTube обрабатывается дольше — транскрибация ```bash # Новый диалог — вернёт chat_uuid=... первой строкой -./scripts/chat.sh --source +scripts/chat.sh --source # Или с несколькими источниками -./scripts/chat.sh --source --source +scripts/chat.sh --source --source # Или без источников — используются все source'ы проекта -./scripts/chat.sh +scripts/chat.sh # Продолжить диалог — указать chat_uuid из вывода предыдущего вызова -./scripts/chat.sh --chat --question "..." +scripts/chat.sh --chat --question "..." ``` Опции: @@ -78,11 +74,14 @@ YouTube обрабатывается дольше — транскрибация | `--title` | Нет | Заголовок первого source или «Диалог с материалом» | | `--project` | Нет | Из `.task_project.json` | +Если вопрос относится ко всему набору материалов, не передавай `--source`: чат +использует все sources проекта и сам подберёт релевантные фрагменты. + Агент ведёт диалог, а не кидает все вопросы разом: -1. `./scripts/chat.sh --source-url ... --title "Дороничев — прыжок веры"` — первый вопрос с осмысленным именем чата +1. `scripts/chat.sh --source-url ... --title "Дороничев — прыжок веры"` — первый вопрос с осмысленным именем чата 2. Читает ответ, видит пробелы или интересные нити -3. `./scripts/chat.sh --chat --question "..."` — уточняющий вопрос в том же чате +3. `scripts/chat.sh --chat --question "..."` — уточняющий вопрос в том же чате 4. Повторяет, пока не извлечёт нужное **Не создавай новый чат без необходимости.** Если диалог уже начат — продолжай его через `--chat `. Новый чат — только для нового материала или новой темы. @@ -109,10 +108,10 @@ YouTube обрабатывается дольше — транскрибация ```bash # Один запрос — один вызов -./scripts/search.sh --source --query "запрос" +scripts/search.sh --source --query "запрос" # Прочитал результат, видишь пробел — следующий запрос -./scripts/search.sh --source --query "уточняющий запрос" +scripts/search.sh --source --query "уточняющий запрос" ``` Опции: @@ -126,9 +125,9 @@ YouTube обрабатывается дольше — транскрибация Агент ведёт поиск итеративно, как диалог: -1. `./scripts/search.sh --query "основной тезис"` — первый запрос +1. `scripts/search.sh --query "основной тезис"` — первый запрос 2. Читает чанки, видит пробелы -3. `./scripts/search.sh --query "уточнение по теме X"` — следующий запрос +3. `scripts/search.sh --query "уточнение по теме X"` — следующий запрос 4. Повторяет, пока не соберёт достаточно фактов ### Шаг 3: Оформить результат @@ -138,41 +137,48 @@ YouTube обрабатывается дольше — транскрибация - **Саммари** — суть + ключевые тезисы с цитатами - **Факты** — утверждения автора с подтверждающими фрагментами - **Рекомендации** — выводы и практические следствия из материала -- **Дайджест-карточка** — добавить авторский отклик и связи, сохранить в `research/саммари/` + +Соблюдай достоверность: + +- Отделяй утверждения автора от собственных выводов. +- Не выдавай пересказ за точную цитату. +- Для точной цитаты найди исходный чанк через `search.sh` и сверь формулировку. +- Ссылайся на исходный URL или имя локального файла. +- Если материал не подтверждает ответ или источники противоречат друг другу, скажи об этом явно. ### Примеры -**Полный цикл для дайджеста:** +**Полный цикл извлечения:** ```bash # 1. Загрузить -./scripts/ingest.sh --source-url "https://habr.com/ru/articles/1061876/" +scripts/ingest.sh --source-url "https://habr.com/ru/articles/1061876/" # 2. Начать диалог (вернёт chat_uuid=...) -./scripts/chat.sh --source-url "https://habr.com/ru/articles/1061876/" +scripts/chat.sh --source-url "https://habr.com/ru/articles/1061876/" # 3. Уточнить -./scripts/chat.sh --chat --question "Какие 4 убеждения разбирает автор?" +scripts/chat.sh --chat --question "Какие 4 убеждения разбирает автор?" -# 4. Сохранить ответы, добавить отклик и связи +# 4. Оформить подтверждённый материалом ответ ``` -**Диалог (без дайджеста):** +**Диалог:** ```bash -./scripts/ingest.sh --source-url "https://habr.com/ru/articles/1061876/" -./scripts/chat.sh --source-url "https://habr.com/ru/articles/1061876/" +scripts/ingest.sh --source-url "https://habr.com/ru/articles/1061876/" +scripts/chat.sh --source-url "https://habr.com/ru/articles/1061876/" # → читаем ответ, задаём уточняющие вопросы через --chat ``` **Поиск чанков (запасной режим):** ```bash -./scripts/search.sh --source-url "https://habr.com/ru/articles/1061876/" \ +scripts/search.sh --source-url "https://habr.com/ru/articles/1061876/" \ --query "теория локуса контроля Роттера" # → читаем чанки, видим что не раскрыта тема таланта -./scripts/search.sh --source-url "https://habr.com/ru/articles/1061876/" \ +scripts/search.sh --source-url "https://habr.com/ru/articles/1061876/" \ --query "талант врождённый или приобретённый" ``` @@ -184,18 +190,16 @@ YouTube обрабатывается дольше — транскрибация **`search.sh`** — чанки с номерами и текстом в markdown. -Для дайджест-карточки агент добавляет к выводу `chat.sh` или `search.sh` поля «Что откликнулось» и «Связи» и сохраняет в `research/саммари/<порядковый-номер>-.md`. - ## Локальные файлы -Один файл на уровне статьи — `.task_project.json`. Хранит UUID проекта, маппинг URL → source UUID и статус каждого источника (`pending` / `processing` / `ready` / `failed`). Создаётся автоматически при первом запуске `ingest.sh`. +Один файл на уровне рабочего проекта — `.task_project.json`. Хранит UUID проекта, маппинг URL → source UUID и статус каждого источника (`pending` / `processing` / `ready` / `failed`). Создаётся автоматически при первом запуске `ingest.sh`. Статусы обновляются: - При загрузке — `ingest.sh` сохраняет актуальный статус после ожидания - Без ожидания — `ingest.sh --check` сверяет кеш с API и показывает изменения ```bash -$ ./scripts/ingest.sh --check +$ scripts/ingest.sh --check youtube.com/watch?v=... → processing ✦ github.com/.../wsff.md → ready Изменений: 1 diff --git a/scripts/chat.sh b/scripts/chat.sh index b4e88aa..76a6ba3 100755 --- a/scripts/chat.sh +++ b/scripts/chat.sh @@ -10,29 +10,12 @@ set -euo pipefail -die() { echo "[ERROR] $*" >&2; exit 1; } -info() { echo "[INFO] $*" >&2; } -warn() { echo "[WARN] $*" >&2; } - # ─── config ──────────────────────────────────────── -TASK_API_URL="${TASK_API_URL:-https://api.ai-aid.pro/v1}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# Ищем корень статьи: идём вверх, пока не найдём .task_project.json или .task_config.json -ARTICLE_DIR="$SCRIPT_DIR" -while [ "$ARTICLE_DIR" != "/" ] && [ ! -f "$ARTICLE_DIR/.task_project.json" ] && [ ! -f "$ARTICLE_DIR/.task_config.json" ]; do - ARTICLE_DIR="$(dirname "$ARTICLE_DIR")" -done - -if [ -f "$ARTICLE_DIR/.task_config.json" ]; then - TASK_API_URL=$(jq -r '.api_url // empty' "$ARTICLE_DIR/.task_config.json") -fi -if [ -f "$ARTICLE_DIR/.task_token.json" ]; then - TASK_API_TOKEN=$(jq -r '.access_token // empty' "$ARTICLE_DIR/.task_token.json") -else - die ".task_token.json не найден. Создайте файл с access_token в корне проекта." -fi +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" +load_task_environment # ─── args ────────────────────────────────────────── @@ -45,26 +28,23 @@ TITLE="" while [ $# -gt 0 ]; do case "$1" in - --source-url) URL="$2"; shift 2 ;; - --source) SOURCE_UUIDS+=("$2"); shift 2 ;; - --project) PROJECT_UUID="$2"; shift 2 ;; - --question) QUESTION="$2"; shift 2 ;; - --chat) CHAT_UUID="$2"; shift 2 ;; - --title) TITLE="$2"; shift 2 ;; + --source-url) [ $# -ge 2 ] || die "Для --source-url нужен URL"; URL="$2"; shift 2 ;; + --source) [ $# -ge 2 ] || die "Для --source нужен UUID"; SOURCE_UUIDS+=("$2"); shift 2 ;; + --project) [ $# -ge 2 ] || die "Для --project нужен UUID"; PROJECT_UUID="$2"; shift 2 ;; + --question) [ $# -ge 2 ] || die "Для --question нужен текст"; QUESTION="$2"; shift 2 ;; + --chat) [ $# -ge 2 ] || die "Для --chat нужен UUID"; CHAT_UUID="$2"; shift 2 ;; + --title) [ $# -ge 2 ] || die "Для --title нужен текст"; TITLE="$2"; shift 2 ;; *) die "Неизвестный аргумент: $1" ;; esac done # ─── resolve source ──────────────────────────────── -PROJECT_FILE="$ARTICLE_DIR/.task_project.json" -normalize_url() { echo "$1" | sed -E 's|^https?://||; s|^www\.||; s|/+$||' | tr '[:upper:]' '[:lower:]'; } - [ -z "$PROJECT_UUID" ] && [ -f "$PROJECT_FILE" ] && PROJECT_UUID=$(jq -r '.uuid // empty' "$PROJECT_FILE") # --source-url → resolve to UUID from cache if [ -n "$URL" ]; then - RESOLVED=$(jq -r --arg url "$(normalize_url "$URL")" '.sources[$url].uuid // empty' "$PROJECT_FILE") + RESOLVED=$(cache_source_uuid "$(normalize_url "$URL")" "$URL") if [ -n "$RESOLVED" ]; then SOURCE_UUIDS+=("$RESOLVED") elif [ -z "$CHAT_UUID" ]; then @@ -73,27 +53,14 @@ if [ -n "$URL" ]; then fi # Убрать дубликаты -SOURCE_UUIDS=($(printf '%s\n' "${SOURCE_UUIDS[@]}" | sort -u)) +if [ ${#SOURCE_UUIDS[@]} -gt 0 ]; then + UNIQUE_SOURCE_UUIDS=() + while IFS= read -r uuid; do UNIQUE_SOURCE_UUIDS+=("$uuid"); done < <(printf '%s\n' "${SOURCE_UUIDS[@]}" | sort -u) + SOURCE_UUIDS=("${UNIQUE_SOURCE_UUIDS[@]}") +fi # ─── API wrapper ─────────────────────────────────── -api() { - local method="$1" path="$2" data="${3:-}" body headers code rc detail - body=$(mktemp); headers=$(mktemp) - if [ -n "$data" ]; then - code=$(curl -sS -o "$body" -D "$headers" -w '%{http_code}' -X "$method" "${TASK_API_URL}${path}" -H 'Content-Type: application/json' -H "Authorization: Bearer $TASK_API_TOKEN" -d "$data" 2>/dev/null) || rc=$? - else - code=$(curl -sS -o "$body" -D "$headers" -w '%{http_code}' -X "$method" "${TASK_API_URL}${path}" -H 'Content-Type: application/json' -H "Authorization: Bearer $TASK_API_TOKEN" 2>/dev/null) || rc=$? - fi - if [ "${rc:-0}" -ne 0 ] || [ "$code" = "000" ]; then rm -f "$body" "$headers"; echo 'TasK API request failed.' >&2; return 1; fi - if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then - detail=$(jq -r 'if type == "object" and (.detail | type == "string") then .detail else empty end' "$body" 2>/dev/null || true) - if [ -n "$detail" ]; then echo "HTTP $code: $detail" >&2; else echo "HTTP $code: TasK API returned an unexpected error." >&2; fi - rm -f "$body" "$headers"; return 1 - fi - cat "$body"; rm -f "$body" "$headers" -} - # The messages endpoint must be a successful SSE response; never parse error text as SSE. api_sse() { local path="$1" data="$2" body headers code rc content_type @@ -125,7 +92,7 @@ if [ -z "$CHAT_UUID" ]; then [ -z "$PROJECT_UUID" ] && die "Не указан project (--project или .task_project.json)" # Метаданные sources — нужны для title и вопроса по умолчанию - SRC_META=$(api GET "/projects/${PROJECT_UUID}/sources") || die "Не удалось получить sources" + SRC_META=$(api_json GET "/projects/${PROJECT_UUID}/sources") || die "Не удалось получить sources" # Первый вопрос по умолчанию if [ -z "$QUESTION" ]; then @@ -166,7 +133,7 @@ if [ -z "$CHAT_UUID" ]; then CHAT_DATA=$(echo "$CHAT_DATA" | jq --argjson srcs "$SRC_JSON" '. + $srcs') fi - CHAT_JSON=$(api POST "/chats" "$CHAT_DATA") || die "Не удалось создать чат" + CHAT_JSON=$(api_json POST "/chats" "$CHAT_DATA") || die "Не удалось создать чат" CHAT_UUID=$(echo "$CHAT_JSON" | jq -r '.uuid // empty') [ -z "$CHAT_UUID" ] && die "Не удалось создать чат" echo "chat_uuid=$CHAT_UUID" diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..6774fdc --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +# Shared runtime helpers for the knowledge-extraction scripts. + +die() { echo "[ERROR] $*" >&2; exit 1; } +info() { echo "[INFO] $*" >&2; } +warn() { echo "[WARN] $*" >&2; } + +resolve_workspace() { + local start="${KNOWLEDGE_EXTRACTION_WORKSPACE:-$PWD}" dir fallback="" + dir=$(cd "$start" 2>/dev/null && pwd) || die "Рабочий каталог не найден: $start" + while [ "$dir" != / ]; do + if [ -f "$dir/.task_token.json" ]; then + printf '%s\n' "$dir" + return + fi + if [ -z "$fallback" ] && { [ -f "$dir/.task_config.json" ] || [ -f "$dir/.task_project.json" ]; }; then + fallback="$dir" + fi + dir=$(dirname "$dir") + done + if [ -n "$fallback" ]; then printf '%s\n' "$fallback"; return; fi + cd "$start" 2>/dev/null && pwd +} + +load_task_environment() { + ARTICLE_DIR=$(resolve_workspace) + PROJECT_FILE="$ARTICLE_DIR/.task_project.json" + TASK_API_URL="${TASK_API_URL:-https://api.ai-aid.pro/v1}" + command -v curl >/dev/null || die "Не найден curl" + command -v jq >/dev/null || die "Не найден jq" + + if [ -f "$ARTICLE_DIR/.task_config.json" ]; then + jq -e 'type == "object"' "$ARTICLE_DIR/.task_config.json" >/dev/null 2>&1 \ + || die ".task_config.json содержит некорректный JSON" + local configured_url + configured_url=$(jq -r '.api_url // empty' "$ARTICLE_DIR/.task_config.json") + [ -n "$configured_url" ] && TASK_API_URL="$configured_url" + fi + + [ -f "$ARTICLE_DIR/.task_token.json" ] \ + || die ".task_token.json не найден в $ARTICLE_DIR. Создайте файл с access_token в корне рабочего проекта." + TASK_API_TOKEN=$(jq -er '.access_token | select(type == "string" and length > 0)' "$ARTICLE_DIR/.task_token.json" 2>/dev/null) \ + || die ".task_token.json должен содержать непустой access_token" +} + +# Preserve path and query case. Only scheme and authority are case-insensitive. +normalize_url() { + local value="$1" scheme authority rest + if [[ "$value" =~ ^([Hh][Tt][Tt][Pp][Ss]?)://([^/]+)(.*)$ ]]; then + scheme=$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]') + authority=$(printf '%s' "${BASH_REMATCH[2]}" | tr '[:upper:]' '[:lower:]') + rest="${BASH_REMATCH[3]}" + printf '%s://%s%s\n' "$scheme" "$authority" "$rest" + else + printf '%s\n' "$value" + fi +} + +canonical_file() { realpath -m "$1"; } + +cache_source_uuid() { + local key="$1" original="$2" + [ -f "$PROJECT_FILE" ] || return 0 + jq -r --arg key "$key" --arg original "$original" ' + .sources[$key].uuid // + ([.sources[]? | select(.url == $original) | .uuid][0] // empty) + ' "$PROJECT_FILE" +} + +# Successful response body goes to stdout. Safe diagnostics go to stderr. +api_json() { + local method="$1" path="$2" data="${3:-}" body headers code rc=0 detail + body=$(mktemp); headers=$(mktemp) + if [ -n "$data" ]; then + code=$(curl -sS -o "$body" -D "$headers" -w '%{http_code}' -X "$method" \ + "${TASK_API_URL}${path}" -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TASK_API_TOKEN" -d "$data" 2>/dev/null) || rc=$? + else + code=$(curl -sS -o "$body" -D "$headers" -w '%{http_code}' -X "$method" \ + "${TASK_API_URL}${path}" -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TASK_API_TOKEN" 2>/dev/null) || rc=$? + fi + if [ "$rc" -ne 0 ] || [ "$code" = 000 ]; then + rm -f "$body" "$headers"; echo 'TasK API request failed.' >&2; return 1 + fi + if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then + detail=$(jq -r 'if type == "object" and (.detail | type == "string") then .detail else empty end' "$body" 2>/dev/null || true) + if [ -n "$detail" ]; then echo "HTTP $code: $detail" >&2; else echo "HTTP $code: TasK API returned an unexpected error." >&2; fi + rm -f "$body" "$headers"; return 1 + fi + cat "$body" + rm -f "$body" "$headers" +} diff --git a/scripts/ingest.sh b/scripts/ingest.sh index b13fc86..bb5ad2c 100755 --- a/scripts/ingest.sh +++ b/scripts/ingest.sh @@ -4,15 +4,10 @@ # ./ingest.sh --check [--project ] set -euo pipefail -die() { echo "[ERROR] $*" >&2; exit 1; } -info() { echo "[INFO] $*" >&2; } -TASK_API_URL="${TASK_API_URL:-https://api.ai-aid.pro/v1}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ARTICLE_DIR="$SCRIPT_DIR" -while [ "$ARTICLE_DIR" != / ] && [ ! -f "$ARTICLE_DIR/.task_project.json" ] && [ ! -f "$ARTICLE_DIR/.task_config.json" ]; do ARTICLE_DIR="$(dirname "$ARTICLE_DIR")"; done -[ -f "$ARTICLE_DIR/.task_config.json" ] && TASK_API_URL=$(jq -r '.api_url // empty' "$ARTICLE_DIR/.task_config.json") -[ -f "$ARTICLE_DIR/.task_token.json" ] || die ".task_token.json не найден. Создайте файл с access_token в корне проекта." -TASK_API_TOKEN=$(jq -r '.access_token // empty' "$ARTICLE_DIR/.task_token.json") +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" +load_task_environment URL=""; SOURCE_FILE=""; PROJECT_UUID=""; CHECK_ONLY=false while [ $# -gt 0 ]; do @@ -29,24 +24,6 @@ if [ -n "$URL" ] && [ -f "$URL" ] && [ -z "$SOURCE_FILE" ]; then SOURCE_FILE="$U [ -n "$URL" ] && [ -n "$SOURCE_FILE" ] && die "Укажите только --source-url или --source-file" ! "$CHECK_ONLY" && [ -z "$URL" ] && [ -z "$SOURCE_FILE" ] && die "Нужен --source-url или --source-file (или --check)" -# Successful response body goes to stdout. Every failure is safe diagnostic on stderr. -api() { - local method="$1" path="$2" data="${3:-}" body headers code rc detail - body=$(mktemp); headers=$(mktemp) - if [ -n "$data" ]; then - code=$(curl -sS -o "$body" -D "$headers" -w '%{http_code}' -X "$method" "${TASK_API_URL}${path}" -H 'Content-Type: application/json' -H "Authorization: Bearer $TASK_API_TOKEN" -d "$data" 2>/dev/null) || rc=$? - else - code=$(curl -sS -o "$body" -D "$headers" -w '%{http_code}' -X "$method" "${TASK_API_URL}${path}" -H 'Content-Type: application/json' -H "Authorization: Bearer $TASK_API_TOKEN" 2>/dev/null) || rc=$? - fi - if [ "${rc:-0}" -ne 0 ] || [ "$code" = 000 ]; then rm -f "$body" "$headers"; echo 'TasK API request failed.' >&2; return 1; fi - if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then - detail=$(jq -r 'if type == "object" and (.detail | type == "string") then .detail else empty end' "$body" 2>/dev/null || true) - if [ -n "$detail" ]; then echo "HTTP $code: $detail" >&2; else echo "HTTP $code: TasK API returned an unexpected error." >&2; fi - rm -f "$body" "$headers"; return 1 - fi - cat "$body"; rm -f "$body" "$headers" -} -normalize_url() { echo "$1" | sed -E 's|^https?://||; s|^www\.||; s|/+$||' | tr '[:upper:]' '[:lower:]'; } api_file() { local path="$1" file="$2" body headers code rc detail body=$(mktemp); headers=$(mktemp) @@ -59,13 +36,12 @@ api_file() { fi cat "$body"; rm -f "$body" "$headers" } -normalize_file() { realpath -m "$1" | tr '[:upper:]' '[:lower:]'; } # Retrieves every API page. The endpoint's pagination.total is the stop condition. all_sources() { local offset=0 limit=100 total=-1 page count pages=() while :; do - page=$(api GET "/projects/${PROJECT_UUID}/sources?limit=${limit}&offset=${offset}") || return 1 + page=$(api_json GET "/projects/${PROJECT_UUID}/sources?limit=${limit}&offset=${offset}") || return 1 pages+=("$page") count=$(jq '.items | length' <<<"$page") total=$(jq -r '.pagination.total // empty' <<<"$page") @@ -77,19 +53,29 @@ all_sources() { printf '%s\n' "${pages[@]}" | jq -s '{items: [.[].items[]?]}' } -api GET '/projects' >/dev/null || die 'TasK API недоступен' -PROJECT_FILE="$ARTICLE_DIR/.task_project.json" +"$CHECK_ONLY" && [ ! -f "$PROJECT_FILE" ] && [ -z "$PROJECT_UUID" ] \ + && die "Для --check нужен существующий .task_project.json или --project" +api_json GET '/projects' >/dev/null || die 'TasK API недоступен' [ -f "$PROJECT_FILE" ] || echo '{"uuid":"","sources":{}}' > "$PROJECT_FILE" -[ -z "$PROJECT_UUID" ] && PROJECT_UUID=$(jq -r '.uuid // empty' "$PROJECT_FILE") +CACHED_PROJECT_UUID=$(jq -r '.uuid // empty' "$PROJECT_FILE") +if [ -n "$PROJECT_UUID" ] && [ -n "$CACHED_PROJECT_UUID" ] && [ "$PROJECT_UUID" != "$CACHED_PROJECT_UUID" ]; then + die "--project не совпадает с UUID в .task_project.json" +fi +[ -z "$PROJECT_UUID" ] && PROJECT_UUID="$CACHED_PROJECT_UUID" +"$CHECK_ONLY" && [ -z "$PROJECT_UUID" ] && die "Для --check нужен существующий .task_project.json или --project" PROJECT_TITLE=$(basename "$ARTICLE_DIR") -if [ -z "$PROJECT_UUID" ]; then +if [ -n "$PROJECT_UUID" ]; then + jq --arg uuid "$PROJECT_UUID" '.uuid=$uuid' "$PROJECT_FILE" > "$PROJECT_FILE.tmp" && mv "$PROJECT_FILE.tmp" "$PROJECT_FILE" +else info "Создаю проект: $PROJECT_TITLE" - PROJECT_JSON=$(api POST '/projects' "$(jq -n --arg title "$PROJECT_TITLE" '{title:$title,description:"Дайджест-подборка"}')") || PROJECT_JSON='' + PROJECT_JSON=$(api_json POST '/projects' "$(jq -n --arg title "$PROJECT_TITLE" '{title:$title,description:"Материалы для извлечения знаний"}')") \ + || die 'Не удалось создать проект' PROJECT_UUID=$(jq -r '.uuid // empty' <<<"$PROJECT_JSON") if [ -z "$PROJECT_UUID" ]; then - info 'Проект уже существует, ищу…'; PROJECTS_JSON=$(api GET /projects) || die 'Не удалось получить проекты' + info 'Проект уже существует, ищу…' + PROJECTS_JSON=$(api_json GET /projects) || die 'Не удалось получить проекты' PROJECT_UUID=$(jq -r --arg t "$PROJECT_TITLE" '.items[] | select(.title==$t) | .uuid // empty' <<<"$PROJECTS_JSON" | head -n1) - [ -n "$PROJECT_UUID" ] || die 'Не удалось найти или создать проект' + [ -n "$PROJECT_UUID" ] || die 'TasK API не вернул UUID созданного проекта' fi jq --arg uuid "$PROJECT_UUID" '.uuid=$uuid' "$PROJECT_FILE" > "$PROJECT_FILE.tmp" && mv "$PROJECT_FILE.tmp" "$PROJECT_FILE" fi @@ -128,16 +114,17 @@ if "$CHECK_ONLY"; then echo "Изменений: $((MERGED_UPDATED + MERGED_IMPORTED))"; exit 0 fi -if [ -n "$SOURCE_FILE" ]; then SOURCE_VALUE=$(realpath -m "$SOURCE_FILE"); NORM_URL=$(normalize_file "$SOURCE_FILE"); else SOURCE_VALUE="$URL"; NORM_URL=$(normalize_url "$URL"); fi -SOURCE_UUID=$(jq -r --arg url "$NORM_URL" '.sources[$url].uuid // empty' "$PROJECT_FILE") +if [ -n "$SOURCE_FILE" ]; then SOURCE_VALUE=$(canonical_file "$SOURCE_FILE"); NORM_URL="$SOURCE_VALUE"; else SOURCE_VALUE="$URL"; NORM_URL=$(normalize_url "$URL"); fi +SOURCE_UUID=$(cache_source_uuid "$NORM_URL" "$SOURCE_VALUE") SOURCES_JSON=$(all_sources) || die 'Не удалось получить sources' MERGED_IMPORTED=0; MERGED_UPDATED=0; merge_sources "$SOURCES_JSON" >/dev/null -if [ -z "$SOURCE_UUID" ] && [ -z "$SOURCE_FILE" ]; then SOURCE_UUID=$(jq -r --arg url "$NORM_URL" '.items[] | select((.uri // "" | sub("^https?://";"") | sub("^www\\.";"") | sub("/+$";"") | ascii_downcase)==$url) | .uuid' <<<"$SOURCES_JSON" | head -n1); fi +SOURCE_UUID=$(cache_source_uuid "$NORM_URL" "$SOURCE_VALUE") +if [ -z "$SOURCE_UUID" ] && [ -z "$SOURCE_FILE" ]; then SOURCE_UUID=$(jq -r --arg url "$URL" '.items[] | select((.uri // .url // "") == $url) | .uuid' <<<"$SOURCES_JSON" | head -n1); fi if [ -z "$SOURCE_UUID" ]; then if [ -n "$SOURCE_FILE" ]; then info "Загружаю файл: $SOURCE_VALUE"; SOURCE_JSON=$(api_file "/projects/${PROJECT_UUID}/source-files" "$SOURCE_FILE") || die 'Не удалось загрузить файл' else - info "Загружаю: $URL"; SOURCE_JSON=$(api POST "/projects/${PROJECT_UUID}/source-urls" "$(jq -n --arg url "$URL" '{uri:$url}')") || die 'Не удалось загрузить source' + info "Загружаю: $URL"; SOURCE_JSON=$(api_json POST "/projects/${PROJECT_UUID}/source-urls" "$(jq -n --arg url "$URL" '{uri:$url}')") || die 'Не удалось загрузить source' fi SOURCE_UUID=$(jq -r '.sourceUuid // empty' <<<"$SOURCE_JSON"); [ -n "$SOURCE_UUID" ] || die 'Не удалось загрузить source' jq --arg url "$NORM_URL" --arg uuid "$SOURCE_UUID" --arg src_url "$SOURCE_VALUE" --arg date "$(date +%Y-%m-%d)" '.sources[$url]={uuid:$uuid,url:$src_url,title:"",status:"pending",last_used:$date}' "$PROJECT_FILE" > "$PROJECT_FILE.tmp" && mv "$PROJECT_FILE.tmp" "$PROJECT_FILE" @@ -150,5 +137,5 @@ for i in $(seq 1 120); do case "$STATUS" in ready) info "✓ Готов (попытка $i)"; break;; failed|error) die "Source в ошибке: $STATUS";; *) sleep 5;; esac [ "$i" -eq 120 ] && die "Source не готов за 120 попыток (~10 мин). Статус: $STATUS. Проверьте позже через --check." done -DOCS_JSON=$(api GET "/projects/${PROJECT_UUID}/sources/${SOURCE_UUID}/documents") || die 'Не удалось получить documents' +DOCS_JSON=$(api_json GET "/projects/${PROJECT_UUID}/sources/${SOURCE_UUID}/documents") || die 'Не удалось получить documents' echo "project_uuid=$PROJECT_UUID"; echo "source_uuid=$SOURCE_UUID"; echo "documents=$(jq '.items | length' <<<"$DOCS_JSON")"; echo "url=$SOURCE_VALUE" diff --git a/scripts/search.sh b/scripts/search.sh index a1b5fd5..6af5272 100755 --- a/scripts/search.sh +++ b/scripts/search.sh @@ -8,28 +8,12 @@ set -euo pipefail -die() { echo "[ERROR] $*" >&2; exit 1; } -info() { echo "[INFO] $*" >&2; } - # ─── config ──────────────────────────────────────── -TASK_API_URL="${TASK_API_URL:-https://api.ai-aid.pro/v1}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# Ищем корень статьи: идём вверх, пока не найдём .task_project.json или .task_config.json -ARTICLE_DIR="$SCRIPT_DIR" -while [ "$ARTICLE_DIR" != "/" ] && [ ! -f "$ARTICLE_DIR/.task_project.json" ] && [ ! -f "$ARTICLE_DIR/.task_config.json" ]; do - ARTICLE_DIR="$(dirname "$ARTICLE_DIR")" -done - -if [ -f "$ARTICLE_DIR/.task_config.json" ]; then - TASK_API_URL=$(jq -r '.api_url // empty' "$ARTICLE_DIR/.task_config.json") -fi -if [ -f "$ARTICLE_DIR/.task_token.json" ]; then - TASK_API_TOKEN=$(jq -r '.access_token // empty' "$ARTICLE_DIR/.task_token.json") -else - die ".task_token.json не найден. Создайте файл с access_token в корне проекта." -fi +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" +load_task_environment # ─── args ────────────────────────────────────────── @@ -40,10 +24,10 @@ QUERY="" while [ $# -gt 0 ]; do case "$1" in - --source-url) URL="$2"; shift 2 ;; - --source) SOURCE_UUID="$2"; shift 2 ;; - --project) PROJECT_UUID="$2"; shift 2 ;; - --query) QUERY="$2"; shift 2 ;; + --source-url) [ $# -ge 2 ] || die "Для --source-url нужен URL"; URL="$2"; shift 2 ;; + --source) [ $# -ge 2 ] || die "Для --source нужен UUID"; SOURCE_UUID="$2"; shift 2 ;; + --project) [ $# -ge 2 ] || die "Для --project нужен UUID"; PROJECT_UUID="$2"; shift 2 ;; + --query) [ $# -ge 2 ] || die "Для --query нужен текст"; QUERY="$2"; shift 2 ;; *) die "Неизвестный аргумент: $1" ;; esac done @@ -52,38 +36,20 @@ done # ─── resolve source ──────────────────────────────── -PROJECT_FILE="$ARTICLE_DIR/.task_project.json" -normalize_url() { echo "$1" | sed -E 's|^https?://||; s|^www\.||; s|/+$||' | tr '[:upper:]' '[:lower:]'; } - [ -z "$PROJECT_UUID" ] && [ -f "$PROJECT_FILE" ] && PROJECT_UUID=$(jq -r '.uuid // empty' "$PROJECT_FILE") -[ -z "$SOURCE_UUID" ] && [ -n "$URL" ] && SOURCE_UUID=$(jq -r --arg url "$(normalize_url "$URL")" '.sources[$url].uuid // empty' "$PROJECT_FILE") +[ -z "$SOURCE_UUID" ] && [ -n "$URL" ] && SOURCE_UUID=$(cache_source_uuid "$(normalize_url "$URL")" "$URL") [ -z "$PROJECT_UUID" ] && die "Не указан project (--project или .task_project.json)" [ -z "$SOURCE_UUID" ] && die "Не указан source (--source или --source-url)" # ─── API wrapper ─────────────────────────────────── -api() { - local method="$1" path="$2" data="${3:-}" - local tmpfile http_code curl_rc - tmpfile=$(mktemp) - http_code=$(curl -sS -o "$tmpfile" -w "%{http_code}" \ - -X "$method" "${TASK_API_URL}${path}" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TASK_API_TOKEN" \ - ${data:+-d "$data"}) || curl_rc=$? - cat "$tmpfile"; rm -f "$tmpfile" - [ "${curl_rc:-0}" -ne 0 ] && return 1 - [ "$http_code" = "000" ] && return 1 - [ "$http_code" -ge 400 ] && return 1 - return 0 -} - # ─── Search ──────────────────────────────────────── info "Поиск: «${QUERY}»" -RESULT=$(api POST "/projects/${PROJECT_UUID}/chunks/search" \ - "$(jq -n --arg q "$QUERY" --arg src "$SOURCE_UUID" '{query: $q, limit: 10, sourceUuids: [$src]}')") || true +RESULT=$(api_json POST "/projects/${PROJECT_UUID}/chunks/search" \ + "$(jq -n --arg q "$QUERY" --arg src "$SOURCE_UUID" '{query: $q, limit: 10, sourceUuids: [$src]}')") \ + || die "Не удалось выполнить поиск по чанкам" CHUNKS=$(echo "$RESULT" | jq '.chunks // []') CHUNK_COUNT=$(echo "$CHUNKS" | jq 'length') diff --git a/tests/api-client-resilience.sh b/tests/api-client-resilience.sh index de7b7be..c7fe0c5 100755 --- a/tests/api-client-resilience.sh +++ b/tests/api-client-resilience.sh @@ -29,19 +29,19 @@ PY start() { MODE="$1" python3 "$TMP/server.py" > "$PORT_FILE" & SERVER_PID=$!; for _ in $(seq 1 20); do [ -s "$PORT_FILE" ] && break; sleep .05; done; PORT=$(cat "$PORT_FILE"); } stop() { kill "$SERVER_PID"; wait "$SERVER_PID" 2>/dev/null || true; unset SERVER_PID; : > "$PORT_FILE"; } start ok -TASK_API_URL="http://127.0.0.1:$PORT/v1" "$TMP/scripts/ingest.sh" --check --project p > "$TMP/check.out" +(cd "$TMP" && TASK_API_URL="http://127.0.0.1:$PORT/v1" ./scripts/ingest.sh --check --project p > "$TMP/check.out") jq -e '(.sources | length == 2 and ([.[] | .uuid] | sort == ["u1","u2"])) and .sources.old.note == "keep" and .sources.old.status == "ready"' "$TMP/.task_project.json" >/dev/null grep -q 'импортирован из API' "$TMP/check.out" stop -if TASK_API_URL="http://127.0.0.1:1/v1" "$TMP/scripts/chat.sh" --chat c --question q >"$TMP/out" 2>"$TMP/err"; then echo 'transport unexpectedly succeeded' >&2; exit 1; fi +if (cd "$TMP" && TASK_API_URL="http://127.0.0.1:1/v1" ./scripts/chat.sh --chat c --question q >"$TMP/out" 2>"$TMP/err"); then echo 'transport unexpectedly succeeded' >&2; exit 1; fi grep -q 'TasK API request failed.' "$TMP/err" for mode in 422 500 badtype; do start "$mode" - if TASK_API_URL="http://127.0.0.1:$PORT/v1" "$TMP/scripts/chat.sh" --chat c --question q >"$TMP/out" 2>"$TMP/err"; then echo "$mode unexpectedly succeeded" >&2; exit 1; fi + if (cd "$TMP" && TASK_API_URL="http://127.0.0.1:$PORT/v1" ./scripts/chat.sh --chat c --question q >"$TMP/out" 2>"$TMP/err"); then echo "$mode unexpectedly succeeded" >&2; exit 1; fi case "$mode" in 422) grep -q 'HTTP 422: Need to top up balance.' "$TMP/err";; 500) grep -q 'HTTP 500: TasK API returned an unexpected error.' "$TMP/err";; badtype) grep -q 'unexpected response type' "$TMP/err";; esac stop done start ok -TASK_API_URL="http://127.0.0.1:$PORT/v1" "$TMP/scripts/chat.sh" --chat c --question q > "$TMP/out" +(cd "$TMP" && TASK_API_URL="http://127.0.0.1:$PORT/v1" ./scripts/chat.sh --chat c --question q > "$TMP/out") grep -q Hello "$TMP/out" echo 'api-client-resilience: ok' diff --git a/tests/local-file-upload.sh b/tests/local-file-upload.sh index 7671d87..16d54f8 100755 --- a/tests/local-file-upload.sh +++ b/tests/local-file-upload.sh @@ -2,7 +2,7 @@ set -euo pipefail unset ALL_PROXY HTTPS_PROXY HTTP_PROXY ROOT=$(cd "$(dirname "$0")/.." && pwd); TMP=$(mktemp -d); trap 'kill "${PID:-}" 2>/dev/null || true; rm -rf "$TMP"' EXIT -cp -R "$ROOT/scripts" "$TMP/scripts"; echo '{"access_token":"test"}' > "$TMP/.task_token.json"; echo '{"uuid":"p","sources":{}}' > "$TMP/.task_project.json"; echo content > "$TMP/video sample.mp4" +cp -R "$ROOT/scripts" "$TMP/scripts"; echo '{"access_token":"test"}' > "$TMP/.task_token.json"; echo '{"uuid":"","sources":{}}' > "$TMP/.task_project.json"; echo content > "$TMP/video sample.mp4" cat > "$TMP/server.py" <<'PY' from http.server import BaseHTTPRequestHandler,HTTPServer import json @@ -19,6 +19,7 @@ class H(BaseHTTPRequestHandler): s=HTTPServer(('127.0.0.1',0),H); print(s.server_port,flush=True); s.serve_forever() PY python3 "$TMP/server.py" > "$TMP/port" & PID=$!; until [ -s "$TMP/port" ]; do sleep .05; done -TASK_API_URL="http://127.0.0.1:$(cat "$TMP/port")/v1" "$TMP/scripts/ingest.sh" --source-file "$TMP/video sample.mp4" > "$TMP/out" +(cd "$TMP" && TASK_API_URL="http://127.0.0.1:$(cat "$TMP/port")/v1" ./scripts/ingest.sh --project p --source-file "$TMP/video sample.mp4" > "$TMP/out") grep -q 'source_uuid=file-1' "$TMP/out"; jq -e '.sources[] | select(.uuid=="file-1" and .status=="ready")' "$TMP/.task_project.json" >/dev/null +jq -e '.uuid == "p"' "$TMP/.task_project.json" >/dev/null echo 'local-file-upload: ok' diff --git a/tests/workspace-and-search.sh b/tests/workspace-and-search.sh new file mode 100755 index 0000000..c5d1a9f --- /dev/null +++ b/tests/workspace-and-search.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# First-run workspace discovery, URL identity, CLI validation, and search errors. +set -euo pipefail +unset ALL_PROXY HTTPS_PROXY HTTP_PROXY +ROOT=$(cd "$(dirname "$0")/.." && pwd); TMP=$(mktemp -d) +cleanup() { kill "${PID:-}" 2>/dev/null || true; rm -rf "$TMP"; }; trap cleanup EXIT +cp -R "$ROOT/scripts" "$TMP/scripts"; mkdir -p "$TMP/nested/work" +echo '{"access_token":"test"}' > "$TMP/.task_token.json" +echo '{"unrelated_setting":true}' > "$TMP/.task_config.json" + +cat > "$TMP/server.py" <<'PY' +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +class H(BaseHTTPRequestHandler): + def log_message(self,*x): pass + def reply(self,n,b): self.send_response(n); self.send_header('Content-Type','application/json'); self.end_headers(); self.wfile.write(b.encode()) + def do_POST(self): + size=int(self.headers.get('Content-Length','0')); body=json.loads(self.rfile.read(size) or '{}') + if body.get('query') == 'fail': return self.reply(422,'{"detail":"Search unavailable."}') + self.reply(200,'{"chunks":[{"chunkNumber":1,"text":"Found from workspace"}]}') +s=HTTPServer(('127.0.0.1',0),H); print(s.server_port,flush=True); s.serve_forever() +PY +python3 "$TMP/server.py" > "$TMP/port" & PID=$!; until [ -s "$TMP/port" ]; do sleep .05; done +API="http://127.0.0.1:$(cat "$TMP/port")/v1" + +# The token is the only first-run marker and is found from a nested caller directory. +(cd "$TMP/nested/work" && TASK_API_URL="$API" "$TMP/scripts/search.sh" --project p --source s --query ok > "$TMP/out") +grep -q 'Found from workspace' "$TMP/out" + +if (cd "$TMP/nested/work" && TASK_API_URL="$API" "$TMP/scripts/search.sh" --project p --source s --query fail >"$TMP/out" 2>"$TMP/err"); then + echo 'search error unexpectedly succeeded' >&2; exit 1 +fi +grep -q 'HTTP 422: Search unavailable.' "$TMP/err" +grep -q 'Не удалось выполнить поиск' "$TMP/err" + +if (cd "$TMP" && ./scripts/search.sh --query >"$TMP/out" 2>"$TMP/err"); then + echo 'missing argument unexpectedly succeeded' >&2; exit 1 +fi +grep -q 'Для --query нужен текст' "$TMP/err" + +# URL path/query and local path case are significant. +PROJECT_FILE=/dev/null source "$TMP/scripts/common.sh" +[ "$(normalize_url 'HTTPS://Example.COM/File?Key=A')" = 'https://example.com/File?Key=A' ] +[ "$(normalize_url 'https://example.com/file?Key=A')" != "$(normalize_url 'https://example.com/File?Key=A')" ] + +echo 'workspace-and-search: ok' diff --git a/todo/TASK-knowledge-extraction-api-client-resilience.todo.md b/todo/TASK-knowledge-extraction-api-client-resilience.todo.md deleted file mode 100644 index 72ae5d2..0000000 --- a/todo/TASK-knowledge-extraction-api-client-resilience.todo.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -type: fix -created: 2026-07-31 -value: V2 -complexity: C2 -priority: P1 -author: Архитектор (codex) -assignee: -branch: task/knowledge-extraction-api-client-resilience -pr: -status: done ---- - -# TASK-knowledge-extraction-api-client-resilience: Синхронизировать sources и показывать ошибки TasK API - -## 0. Простое описание (Human Brief) - -### Проблема простыми словами (Problem) - -Скрипты knowledge-extraction не показывают sources, добавленные без локального JSON-кеша, а `chat.sh` скрывает ответ API с ошибкой — например, `422 Need to top up balance` выглядит как пустой ответ. - -### Варианты или путь решения (Solution Sketch) - -Получать полный список sources текущего project из TasK API, синхронизировать его с локальным JSON по UUID и централизованно обрабатывать HTTP-ответ до разбора SSE. - -### Ожидаемый результат (Expected Result) - -`ingest.sh --check` показывает все sources project, а `chat.sh` выводит безопасное сообщение с HTTP-кодом и `detail` вместо пустого результата. - -## 1. Concept and Goal (Концепция и Цель) - -### Story (User Story) - -Как пользователь навыка, я хочу видеть актуальные sources и понятные ошибки TasK API, чтобы не принимать пустой вывод за отсутствие данных. - -### Goal (Цель по SMART) - -Исправить shell-клиент TasK API: получить все страницы sources, сделать UUID API источником истины для cache и печатать диагностируемую ошибку до SSE parsing. Измеримость — offline shell-тесты pagination, cache merge, `422 JSON`, `500 text/plain`, transport failure, valid/invalid SSE; срок определяет планирование. - -## 2. Context and Scope (Контекст и границы) - -- **Где делаем:** `scripts/ingest.sh`, `scripts/chat.sh`, `SKILL.md` этого repository. -- **Текущее поведение:** `--check` итерирует только `.task_project.json`; HTTP-wrapper печатает body до проверки статуса; `chat.sh` без проверки передаёт любой ответ в `parse_sse`. -- **Контракт API:** `GET /v1/projects/{projectUuid}/sources` может быть paginated; `POST /v1/chats/{chatUuid}/messages` при успехе отдаёт SSE. -- **Границы (Out of Scope):** изменения TasK API и его баланса, автоматическое пополнение, хранение token/Authorization header в Git или вывод секретов, исправление project-specific materialization documents в TasK. - -## 3. Requirements (Требования, MoSCoW) - -### 🔴 Must Have - -- [x] Реализовать получение всех страниц `GET /projects/{projectUuid}/sources`: задавать `limit`, увеличивать `offset` и завершать цикл после `pagination.total`. -- [x] Синхронизировать `.task_project.json` по `sourceUuid`: импортировать sources, отсутствующие локально, и обновлять cache; не удалять локальную запись и не перезаписывать пользовательские поля без явно заданной policy. -- [x] При конфликте нормализованных URL хранить обе записи, если `sourceUuid` различается; не угадывать, что это один source. -- [x] Ввести единый HTTP-wrapper: успешный body — stdout, диагностика — stderr; network error и любой non-2xx возвращают non-zero. -- [x] Для JSON error вывести `HTTP : `; для не-JSON/пустого body — безопасное общее сообщение без token и Authorization header. -- [x] Перед `parse_sse` проверить 2xx и `Content-Type`, совместимый с `text/event-stream`; иной ответ не разбирать как SSE. -- [x] Добавить offline shell-тесты без реального TasK token/API. - -### 🟡 Should Have - -- [x] Отмечать в выводе `--check` source, импортированный из API, отдельно от изменения статуса. -- [x] Обновить `SKILL.md` с синхронизацией cache и примером quota error. - -### 🟢 Could Have - -- [ ] Добавить `--verbose` с диагностическими HTTP-полями, исключая секреты. - -### ⚫ Won't Have - -- [x] Не использовать fallback, маскирующий ошибки, и не продолжать SSE parsing после неуспешного HTTP-ответа. -- [x] Не печатать token, Authorization header или исходный curl command с секретами. -- [x] Не менять API TasK в этой задаче. - -## 4. Implementation Plan (План реализации) - -1. [x] Выделить тестируемые функции HTTP response handling и pagination/cache merge. -2. [x] Реализовать получение всех pages и idempotent merge в `ingest.sh --check`. -3. [x] Реализовать HTTP/SSE guard в `chat.sh` и единый безопасный формат ошибок. -4. [x] Поднять local fixture HTTP server в shell-тестах и покрыть все обязательные сценарии. -5. [x] Обновить `SKILL.md`. - -## 5. Definition of Done (Критерии приёмки) - -- [x] Source, созданный веб-интерфейсом или другой сессией, появляется после `ingest.sh --check`. -- [x] Две страницы API дают полный и недублирующийся cache по UUID. -- [x] `422 {"detail":"Need to top up balance."}` печатает `HTTP 422: Need to top up balance.` в stderr и завершает `chat.sh` с non-zero code. -- [x] `500 text/plain`, пустое error body и network failure дают безопасную ошибку и не вызывают `parse_sse`. -- [x] Валидный SSE сохраняет текущее поведение вывода ответа. -- [x] Offline shell-тесты и `bash -n scripts/ingest.sh scripts/chat.sh` проходят. - -## 6. Verification (Самопроверка) - -```bash -bash -n scripts/ingest.sh scripts/chat.sh -# Запустить добавленные offline shell-тесты с fixture HTTP server. -``` - -## 7. Risks and Dependencies (Риски и зависимости) - -- Формат pagination и SSE Content-Type нужно подтвердить фактическим API response; не подменять их guessed defaults. -- `.task_project.json` является пользовательским cache: merge не должен стирать данные при временной неполноте API response. -- Related: TasK task `TASK-source-reuse-project-documents` исправляет отсутствие documents в новом project, но не блокирует эту клиентскую задачу. - -## 8. Sources (Источники) - -- `scripts/ingest.sh` -- `scripts/chat.sh` -- `SKILL.md` -- TasK API production report от 2026-07-31. - -## Change History (История изменений) - -| Дата | Автор | Изменение | -|---|---|---| -| 2026-07-31 | Аналитик (codex) | Исходная постановка в TasK repository. | -| 2026-07-31 | Архитектор (codex) | Доработан HTTP/pagination контракт и перенесён в repository реализации. | diff --git a/todo/TASK-knowledge-extraction-local-file-upload.todo.md b/todo/TASK-knowledge-extraction-local-file-upload.todo.md deleted file mode 100644 index 566f1c7..0000000 --- a/todo/TASK-knowledge-extraction-local-file-upload.todo.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -type: feature -created: 2026-08-01 -value: V2 -complexity: C2 -priority: P1 -author: Аналитик (deepseek) -assignee: -branch: task/knowledge-extraction-local-file-upload -pr: -status: done ---- - -# TASK-knowledge-extraction-local-file-upload: Поддержка загрузки локальных файлов в TasK - -## 0. Простое описание (Human Brief) - -### Проблема простыми словами (Problem) - -`ingest.sh` принимает только `--source-url` и постит `{"uri": ...}` в `/source-urls`. Локальные файлы (видео, аудио, PDF, DJVU, HTML) загрузить им нельзя, хотя README и SKILL.md обещают поддержку видео- и аудиофайлов через Whisper-транскрибацию. Для локального mp4 пришлось вручную: создать проект через API, загрузить файл curl'ом в `POST /v1/projects/{projectUuid}/source-files` (multipart) и вручную заполнить `.task_project.json` — скрипт не сделал ни шага. Также `ingest.sh` требует `--source-url` при первом запуске, поэтому `.task_project.json` не создаётся «автоматически при первом запуске», как заявлено в SKILL.md, если материал — файл. - -### Варианты или путь решения (Solution Sketch) - -Добавить в `ingest.sh` флаг `--source-file `: определять существующий локальный путь, загружать multipart-запросом в `/source-files`, обрабатывать ответ `{sourceUuid, status}` и заполнять кеш тем же путём, что и URL-режим. Опционально — автоопределение: если `--source-url` указывает на существующий файл, трактовать его как файл. - -### Ожидаемый результат (Expected Result) - -`./scripts/ingest.sh --source-file /path/to/video.mp4` создаёт/переиспользует проект, загружает файл, ждёт готовности и выводит `project_uuid=...`, `source_uuid=...`, `documents=N` — без ручных curl-запросов. - -## 1. Concept and Goal (Концепция и Цель) - -### Story (User Story) - -Как пользователь навыка, я хочу загружать локальные видео и документы в TasK одной командой, чтобы не писать curl-запросы к API и не заполнять кеш вручную. - -### Goal (Цель по SMART) - -Расширить shell-клиент TasK API: добавить файловый режим загрузки через существующий эндпоинт `source-files`, единый кеш с URL-режимом, документировать его в SKILL.md/README. Измеримость — успешная загрузка локального mp4 через `ingest.sh` с кешем и последующим `chat.sh` без ручных вызовов API. - -## 2. Context and Scope (Контекст и границы) - -- **Где делаем:** `scripts/ingest.sh`, опционально `scripts/chat.sh`, `README.md`, `SKILL.md` этого repository. -- **Текущее поведение:** `ingest.sh` принимает только `--source-url`, для файлов не предусмотрен; `.task_project.json` инициализируется только в URL-потоке; `chat.sh --source-url` резолвит кеш по `normalize_url`, что для локальных путей не документировано. -- **Контракт API:** `POST /v1/projects/{projectUuid}/source-files` — multipart/form-data, поле `file` (binary), ответ `CreateResponseDto` = `{sourceUuid, status}`; статусы `pending` / `processing` / `ready` / `failed` — те же, что у URL-загрузки. -- **Границы (Out of Scope):** изменения TasK API, загрузка каталогов/нескольких файлов одной командой, прогресс-бар загрузки, изменения транскрибации на стороне TasK. - -## 3. Requirements (Требования, MoSCoW) - -### 🔴 Must Have - -- [x] Добавить `--source-file ` в `ingest.sh`: проверка существования файла, загрузка multipart в `/source-files`, разбор `{sourceUuid, status}`. -- [x] Заполнять `.task_project.json` в файловом режиме так же, как в URL-режиме: ключ кеша — нормализованный путь, запись `{uuid, url, title, status, last_used}`. -- [x] Не требовать `--source-url`, если передан `--source-file` (валидация аргументов учитывает оба режима). -- [x] После загрузки ждать готовности и обновлять статус в кеше — тем же циклом, что в URL-режиме. -- [x] Обновить usage-комментарий в шапке `ingest.sh` и README/SKILL.md: как грузить локальный файл. - -### 🟡 Should Have - -- [x] Автоопределение: если `--source-url` указывает на существующий локальный файл — трактовать как файл (или явная ошибка с подсказкой `--source-file`). -- [x] В SKILL.md — раздел про локальные файлы: пример `--source-file`, порядок (путь → multipart → ожидание готовности), заметка про время транскрибации. -- [x] В `chat.sh` документировать/поддержать резолв локального пути из кеша (или инструкцию использовать `--source ` из вывода ingest). - -### 🟢 Could Have - -- [ ] Выводить размер файла и/или имя в info-логах при загрузке. -- [ ] Поддержка нескольких `--source-file` за один вызов. - -### ⚫ Won't Have - -- [x] Не вводить новый формат кеша и не ломать обратную совместимость с существующим `.task_project.json`. -- [x] Не хранить пути/секреты за пределами `.task_project.json` и не печатать token. -- [x] Не менять TasK API и не добавлять fallback-эндпоинты. - -## 4. Implementation Plan (План реализации) - -1. [ ] Разобрать аргументы: разрешить `--source-file`, обновить валидацию «URL или файл обязателен». -2. [ ] Реализовать multipart-загрузку через `curl -F file=@...` в `/projects/{uuid}/source-files`, разобрать `sourceUuid`. -3. [ ] Переиспользовать существующие секции project/source/wait/out, параметризовав способ создания source. -4. [ ] Проверить повторный запуск с тем же путём: не дублировать source (поиск по кешу/API). -5. [ ] Обновить README.md и SKILL.md. - -## 5. Definition of Done (Критерии приёмки) - -- [x] `./scripts/ingest.sh --source-file "/path/to/video.mp4"` с пустым `.task_project.json` создаёт проект, загружает файл, дожидается `ready` и выводит `project_uuid=...`, `source_uuid=...`, `documents=N`. -- [x] В `.task_project.json` появляется запись source с ключом-путём, статус после ожидания — `ready`. -- [x] `--source-url` без `--source-file` и наоборот работают как раньше; без обоих — понятная ошибка. -- [x] Повторный `ingest.sh --source-file <тот же путь>` не создаёт дубликат source. -- [x] `./scripts/chat.sh --source ` отвечает по загруженному файлу. -- [x] `bash -n scripts/ingest.sh` проходит; README/SKILL.md описывают `--source-file`. - -## 6. Verification (Самопроверка) - -```bash -bash -n scripts/ingest.sh -# Загрузить небольшой локальный файл (например, mp3/mp4 или PDF) в тестовый проект, -# убедиться в готовности и ответе chat.sh по source_uuid. -``` - -## 7. Risks and Dependencies (Risks and dependencies) - -- Ответ `CreateResponseDto` подтверждён по OpenAPI (`docs.ai-aid.pro/api.json`), но формат ошибок multipart-загрузки (413/415/422) нужно проверить фактическим ответом API. -- Локальные пути с пробелами и не-ASCII символами (например, «Рабочий стол») должны корректно передаваться в `curl -F`. -- Ключ кеша для локального файла — новый случай для `normalize_url`: нужна явная политика (путь как есть в lower-case), чтобы `--check` и повторный ingest находили source. -- Related: TasK task `TASK-knowledge-extraction-api-client-resilience` синхронизирует sources из API и централизует HTTP-ошибки; файловый режим должен быть совместим с ним по кешу. - -## 8. Sources (Источники) - -- `scripts/ingest.sh` — текущая реализация (только URL-режим). -- `README.md`, `SKILL.md` — заявленная поддержка видео/аудиофайлов без реализации в скриптах. -- TasK OpenAPI `docs.ai-aid.pro/api.json`: `POST /v1/projects/{projectUuid}/source-files`, `CreateResponseDto`. -- Наблюдение из реального использования 2026-08-01: загрузка локального mp4 потребовала ручного curl и ручного заполнения `.task_project.json`. - -## Change History (История изменений) - -| Дата | Автор | Изменение | -|---|---|---| -| 2026-08-01 | Аналитик (deepseek) | Исходная постановка: локальные файлы не поддерживаются ingest.sh, несмотря на заявленную поддержку; кеш и проект пришлось создавать вручную. |