docs, proxy support, k8s fixes, test scripts
Some checks are pending
CI / lint-backend (push) Waiting to run
CI / lint-frontend (push) Waiting to run
CI / build-backend (push) Blocked by required conditions
CI / build-frontend (push) Blocked by required conditions

This commit is contained in:
ed0ss 2026-06-29 15:57:30 +03:00
parent 696c555740
commit cca3457f52
14 changed files with 746 additions and 20 deletions

View file

@ -14,6 +14,10 @@ class Settings(BaseSettings):
openrouter_embedding_model: str = "nvidia/llama-nemotron-embed-vl-1b-v2:free" openrouter_embedding_model: str = "nvidia/llama-nemotron-embed-vl-1b-v2:free"
openrouter_llm_model: str = "openrouter/owl-alpha" openrouter_llm_model: str = "openrouter/owl-alpha"
proxy_url: str = ""
proxy_login: str = ""
proxy_pass: str = ""
jwt_secret: str = "change-me-in-production" jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 1440 jwt_expire_minutes: int = 1440

View file

@ -17,14 +17,22 @@ _client: httpx.AsyncClient | None = None
def get_client() -> httpx.AsyncClient: def get_client() -> httpx.AsyncClient:
global _client global _client
if _client is None: if _client is None:
_client = httpx.AsyncClient( kwargs: dict = {
base_url=OPENROUTER_BASE, "base_url": OPENROUTER_BASE,
headers={ "headers": {
"Authorization": f"Bearer {settings.openrouter_api_key}", "Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
timeout=60, "timeout": 60,
) }
if settings.proxy_url:
proxy = settings.proxy_url
if not proxy.startswith(("http://", "https://")):
proxy = "http://" + proxy
if settings.proxy_login and settings.proxy_pass:
proxy = proxy.replace("://", f"://{settings.proxy_login}:{settings.proxy_pass}@")
kwargs["proxy"] = proxy
_client = httpx.AsyncClient(**kwargs)
return _client return _client

View file

@ -9,6 +9,7 @@ from qdrant_client.http.models import (
VectorParams, VectorParams,
Distance, Distance,
) )
from qdrant_client.http.exceptions import UnexpectedResponse
from app.config import settings from app.config import settings
@ -52,18 +53,23 @@ def _point_id(tenant_id: str, ticket_id: str) -> int:
def get_existing_ticket_ids(tenant_id: str, collection_id: str) -> set[str]: def get_existing_ticket_ids(tenant_id: str, collection_id: str) -> set[str]:
scroll_filter = Filter(must=[ try:
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)), scroll_filter = Filter(must=[
FieldCondition(key="collection_id", match=MatchValue(value=collection_id)), FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)),
]) FieldCondition(key="collection_id", match=MatchValue(value=collection_id)),
points, _ = client.scroll( ])
collection_name=COLLECTION_NAME, points, _ = client.scroll(
limit=10000, collection_name=COLLECTION_NAME,
scroll_filter=scroll_filter, limit=10000,
with_payload=True, scroll_filter=scroll_filter,
with_vectors=False, with_payload=True,
) with_vectors=False,
return {p.payload["ticket_id"] for p in points if "ticket_id" in (p.payload or {})} )
return {p.payload["ticket_id"] for p in points if "ticket_id" in (p.payload or {})}
except UnexpectedResponse as e:
if b"doesn't exist" in e.content:
return set()
raise
async def upsert_chunks(tenant_id: str, chunks: list[dict], vectors: list[list[float]]): async def upsert_chunks(tenant_id: str, chunks: list[dict], vectors: list[list[float]]):

303
docs/API.md Normal file
View file

@ -0,0 +1,303 @@
# API
Базовый URL: `http://<host>/api/v1`
Авторизация: `Authorization: Bearer <api_key>`
> Получить API-ключ можно при регистрации через UI (отображается в настройках профиля).
---
## 1. Поиск по базе знаний
`POST /api/v1/search`
Поиск релевантных тикетов в коллекции с опциональной генерацией ответа.
### Request body
```json
{
"collection_id": "83f64913-9cb6-495e-984a-cbae31eacf54",
"query": "не могу начать смену в приложении",
"generate_answer": true
}
```
| Поле | Тип | По умолчанию | Описание |
|------------------|---------|--------------|------------------------------------|
| `collection_id` | string | — | ID коллекции |
| `query` | string | — | Поисковый запрос |
| `generate_answer`| boolean | `true` | Сгенерировать ответ LLM или нет |
### Response
```json
{
"answer": "Проверьте подключение к интернету...",
"sources": [
{
"ticket_id": "12345",
"score": 0.5885,
"category": "техподдержка",
"full_text": "Категория: техподдержка\nПроблема: ...\nРешение: ..."
}
]
}
```
| Поле | Тип | Описание |
|----------|----------------------------|----------------------------------------|
| `answer` | string или null | Сгенерированный ответ LLM или null |
| `sources`| array of SourceItem | Найденные тикеты (от 0 до 5) |
### SourceItem
| Поле | Тип | Описание |
|------------|---------|-----------------------------|
| `ticket_id`| string | ID тикета |
| `score` | float | Релевантность (0..1) |
| `category` | string | Категория тикета |
| `full_text`| string | Полный текст решения |
### Пример (curl)
```bash
curl -s -X POST http://sovet.itoservice.ru/api/v1/search \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{
"collection_id": "83f64913-9cb6-495e-984a-cbae31eacf54",
"query": "не могу начать смену в приложении",
"generate_answer": true
}'
```
### Пример (Python)
```python
import requests
resp = requests.post(
"http://sovet.itoservice.ru/api/v1/search",
headers={"Authorization": "Bearer <api-key>"},
json={
"collection_id": "83f64913-9cb6-495e-984a-cbae31eacf54",
"query": "не могу начать смену в приложении",
"generate_answer": True,
},
)
data = resp.json()
print(data["answer"])
```
---
## 2. Загрузка тикетов
`POST /api/v1/tickets?collection_id=<uuid>`
Загрузка массива тикетов в коллекцию. Дубликаты по `ticket_id` автоматически пропускаются.
### Request body
```json
{
"tickets": [
{
"ticket_id": 12345,
"category": "техподдержка",
"description": "Не работает принтер",
"client": "Иван",
"messages": [
{"role": "user", "text": "У меня не печатает принтер"},
{"role": "assistant", "text": "Проверьте подключение кабеля USB к компьютеру..."}
]
}
]
}
```
| Поле | Тип | По умолчанию | Описание |
|---------------|----------------------|--------------|-----------------------------|
| `ticket_id` | int или string | — | Уникальный ID тикета |
| `category` | string | `""` | Категория проблемы |
| `description` | string | `""` | Краткое описание проблемы |
| `client` | string | `""` | Имя клиента |
| `messages` | array of Message | `[]` | Переписка (user + assistant)|
### Message
```json
{"role": "user|assistant", "text": "текст сообщения"}
```
- `role` — `"user"` или `"assistant"`. Сообщения `"assistant"` используются как решение.
- `text` — текст сообщения. Решение должно быть длиннее 100 символов и не заканчиваться вопросительным знаком.
### Response
```json
{
"processed": 1,
"skipped": 0,
"collection_ticket_count": 100
}
```
| Поле | Тип | Описание |
|------------------------|-------|-----------------------------------|
| `processed` | int | Количество обработанных тикетов |
| `skipped` | int | Пропущено (дубликат или нет решения)|
| `collection_ticket_count`| int | Всего тикетов в коллекции |
### Почему тикет может быть пропущен (skipped)
1. **Дубликат** — `ticket_id` уже существует в коллекции
2. **Нет решения** — в `messages` нет сообщения от `assistant` длиннее 100 символов
3. **Решение — вопрос** — последнее сообщение `assistant` заканчивается на `?` и короче 150 символов
4. **Решение — заглушка** — текст содержит фразы вроде «уточните», «приложите скрин», «позвоните нам» и т.п.
### Пример (curl)
```bash
curl -s -X POST 'http://sovet.itoservice.ru/api/v1/tickets?collection_id=<uuid>' \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"tickets": [{"ticket_id": 1, "description": "Проблема", "messages": [{"role": "user", "text": "вопрос"}, {"role": "assistant", "text": "Проверьте соединение и перезагрузите устройство, затем повторите попытку. Если не помогает – обратитесь к администратору системы."}]}]}'
```
---
## 3. Список коллекций
`GET /api/v1/collections`
### Response
```json
[
{
"id": "83f64913-9cb6-495e-984a-cbae31eacf54",
"user_id": "30939310-e3a3-4993-b1ac-10c81b751843",
"name": "OFRS",
"ticket_count": 2898,
"created_at": "2026-06-29T11:45:27.866413Z"
}
]
```
---
## 4. Профиль пользователя
`GET /api/v1/me`
### Response
```json
{
"id": "30939310-e3a3-4993-b1ac-10c81b751843",
"email": "user@example.com",
"api_key": "abc123...",
"plan": "pro",
"created_at": "2026-06-29T11:45:27.866413Z"
}
```
---
## 5. Сброс API-ключа
`PATCH /api/v1/me/api-key`
### Response
```json
{
"api_key": "новый-ключ-32-символа"
}
```
---
## 6. JWT-эндпоинты (для UI)
Эти эндпоинты требуют JWT-токен (получается через `/api/login`), а не API-ключ.
| Метод | Путь | Описание |
|-------|-------------------------------|------------------------------|
| POST | `/api/register` | Регистрация |
| POST | `/api/login` | Вход (возвращает JWT) |
| GET | `/api/me` | Профиль |
| POST | `/api/collections` | Создать коллекцию |
| GET | `/api/collections` | Список коллекций |
| GET | `/api/collections/{id}` | Информация о коллекции |
| DELETE| `/api/collections/{id}` | Удалить коллекцию |
| POST | `/api/collections/{id}/tickets` | Загрузить тикеты (JSON)|
| POST | `/api/collections/{id}/tickets/upload`| Загрузить тикеты (файл)|
| POST | `/api/search` | Поиск |
### Регистрация
```json
POST /api/register
{
"email": "user@example.com",
"password": "secret123",
"plan": "free"
}
→ 201 {"access_token": "jwt...", "token_type": "bearer"}
```
### Логин
```json
POST /api/login
{
"email": "user@example.com",
"password": "secret123"
}
→ {"access_token": "jwt...", "token_type": "bearer"}
```
---
## 7. Ошибки
Все ошибки возвращают стандартный формат FastAPI:
```json
{
"detail": "Collection not found"
}
```
HTTP-статусы:
- `200` — успех
- `201` — создано
- `400` — неверный запрос
- `401` — неавторизован
- `404` — не найдено
- `429` — превышен лимит запросов (30 req/min)
- `500` — внутренняя ошибка сервера
---
## 8. Rate limiting
30 запросов в минуту на один IP-адрес. При превышении — `429 Too Many Requests`.
Лимит действует на все эндпоинты, кроме `/api/health`.
---
## 9. Health check
`GET /api/health`
```json
{"status": "ok"}
```
Не требует авторизации.

115
docs/DEPLOY_DOCKER.md Normal file
View file

@ -0,0 +1,115 @@
# Развёртывание через Docker
## Требования
- Docker Engine 24+
- Docker Compose v2+
- Git
## Состав
Сервис состоит из 5 компонентов:
| Компонент | Образ | Назначение |
|-------------|------------------------------------|----------------------|
| postgres | `postgres:16` | База данных |
| redis | `redis:7-alpine` | Кэш / Celery broker |
| qdrant | `qdrant/qdrant` | Векторная БД |
| backend | сборка из `./backend` | FastAPI приложение |
| frontend | сборка из `./frontend` | React SPA (nginx) |
Опционально — мониторинг (Prometheus + Grafana) из `docker-compose.monitoring.yml`.
## 1. Клонирование и настройка
```bash
git clone <repo-url> support-bot-saas
cd support-bot-saas
```
Создайте файл `.env` в корне проекта:
```ini
OPENROUTER_API_KEY=sk-or-v1-...
OPENROUTER_EMBEDDING_MODEL=nvidia/llama-nemotron-embed-vl-1b-v2:free
OPENROUTER_LLM_MODEL=openai/gpt-oss-120b:free
JWT_SECRET=<случайная строка 32+ символов>
# Опционально: HTTP-прокси для OpenRouter
PROXY_URL=192.168.1.1:3128
PROXY_LOGIN=
PROXY_PASS=
# Эти переменные переопределяются в docker-compose.yml для работы по container name:
# DATABASE_URL, REDIS_URL, QDRANT_URL — можно не указывать
```
## 2. Запуск
```bash
docker compose up -d
```
Проверка:
```bash
docker compose ps
curl http://localhost:8080/api/health
# {"status":"ok"}
```
Фронтенд будет доступен на `http://localhost:8080`.
## 3. Мониторинг (опционально)
```bash
docker compose -f docker-compose.monitoring.yml up -d
```
- Prometheus: `http://localhost:9090`
- Grafana: `http://localhost:3001` (admin / admin)
## 4. Остановка
```bash
docker compose down
# С удалением томов БД:
docker compose down -v
```
## 5. Переменные окружения
| Переменная | По умолчанию | Обязательная |
|-------------------------------|---------------------------------------------------|--------------|
| `OPENROUTER_API_KEY` | `""` | Да |
| `OPENROUTER_EMBEDDING_MODEL` | `nvidia/llama-nemotron-embed-vl-1b-v2:free` | Нет |
| `OPENROUTER_LLM_MODEL` | `openrouter/owl-alpha` | Нет |
| `JWT_SECRET` | `change-me-in-production` | Да (в проде) |
| `DATABASE_URL` | `postgresql+asyncpg://postgres:postgres@localhost:5432/support_bot` | Нет (переопределяется в compose) |
| `REDIS_URL` | `redis://localhost:6379/0` | Нет (переопределяется в compose) |
| `QDRANT_URL` | `http://localhost:6333` | Нет (переопределяется в compose) |
| `PROXY_URL` | `""` | Нет |
| `PROXY_LOGIN` | `""` | Нет |
| `PROXY_PASS` | `""` | Нет |
| `JWT_EXPIRE_MINUTES` | `1440` | Нет |
## 6. Сборка образов вручную
```bash
# Бэкенд
docker build -t support-bot-backend ./backend
# Фронтенд
docker build -t support-bot-frontend ./frontend
```
После сборки замените `build: ./backend` и `build: ./frontend` в `docker-compose.yml` на `image: support-bot-backend` и `image: support-bot-frontend`, либо удалите `build` секции и пересоберите compose.
## 7. Тома (volumes)
| Том | Назначение | Размер по умолчанию |
|-----------------|----------------------|---------------------|
| `pgdata` | Данные PostgreSQL | без ограничения |
| `qdrant_storage`| Данные Qdrant | без ограничения |
Тома создаются автоматически в `docker compose up`.

182
docs/DEPLOY_KUBERNETES.md Normal file
View file

@ -0,0 +1,182 @@
# Развёртывание в Kubernetes
## Требования
- Kubernetes 1.28+ (проверено на k0s 1.36.1)
- kubectl 1.28+
- Доступ к registry с образами backend и frontend
## Состав manifests
Все манифесты в `k8s/`. Порядок применения — по префиксу номера.
| Файл | Ресурсы |
|-------------------------|-------------------------------------|
| `00-namespace.yaml` | Namespace `support-bot` |
| `01-postgres.yaml` | PV, PVC, Deployment, Service (postgres:16-alpine) |
| `02-redis.yaml` | PV, PVC, Deployment, Service (redis:7-alpine) |
| `03-qdrant.yaml` | PV, PVC, Deployment, Service (qdrant/qdrant) |
| `04-backend.yaml` | Deployment, Service (FastAPI) |
| `05-frontend.yaml` | Deployment, Service (nginx SPA) |
| `06-ingress.yaml` | IngressRoute (Traefik CRD) |
| `host-fix-pod.yaml` | Debug pod для отладки узла |
## 1. Подготовка
### 1.1 Создание namespace
```bash
kubectl apply -f k8s/00-namespace.yaml
```
### 1.2 Настройка Traefik (Ingress Controller)
```bash
# CRDs и RBAC (однократно)
kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.3/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.3/docs/content/reference/dynamic-configuration/kubernetes-crd-rbac.yml
# Сам Traefik
kubectl apply -f k8s/traefik/01-core.yaml
```
Traefik запускается с `hostNetwork: true` на портах 80/443.
### 1.3 Настройка Private Registry (если необходимо)
```bash
kubectl create secret docker-registry regcred \
--docker-server=<registry> \
--docker-username=<user> \
--docker-password=<pass> \
-n support-bot
```
Secret `regcred` уже указан в `imagePullSecrets` у backend и frontend.
### 1.4 Secrets
Секрет `app-env` содержит переменные окружения для backend. Создаётся из `.env`:
```bash
kubectl create secret generic app-env \
--from-env-file=.env \
-n support-bot
```
Состав ключей в `app-env`:
| Ключ | Назначение |
|------------------------------|--------------------------------------|
| `OPENROUTER_API_KEY` | API-ключ OpenRouter |
| `OPENROUTER_EMBEDDING_MODEL` | Модель эмбеддингов |
| `OPENROUTER_LLM_MODEL` | Модель LLM |
| `JWT_SECRET` | Секрет для JWT |
| `PROXY_URL` | Адрес HTTP-прокси (опционально) |
| `PROXY_LOGIN` | Логин прокси (опционально) |
| `PROXY_PASS` | Пароль прокси (опционально) |
Переменные `DATABASE_URL`, `REDIS_URL`, `QDRANT_URL` заданы в Deployment статически (через container_name).
## 2. Развёртывание
### 2.1 Базы данных
```bash
kubectl apply -f k8s/01-postgres.yaml
kubectl apply -f k8s/02-redis.yaml
kubectl apply -f k8s/03-qdrant.yaml
```
Дождаться запуска:
```bash
kubectl wait --for=condition=ready pod -l app=postgres -n support-bot --timeout=120s
kubectl wait --for=condition=ready pod -l app=redis -n support-bot --timeout=60s
kubectl wait --for=condition=ready pod -l app=qdrant -n support-bot --timeout=60s
```
### 2.2 Backend
```bash
kubectl apply -f k8s/04-backend.yaml
kubectl rollout status deployment/backend -n support-bot
```
Проверка:
```bash
kubectl exec deploy/backend -n support-bot -- curl -s http://localhost:8000/api/health
# {"status":"ok"}
```
### 2.3 Frontend
```bash
kubectl apply -f k8s/05-frontend.yaml
kubectl rollout status deployment/frontend -n support-bot
```
### 2.4 Ingress
```bash
kubectl apply -f k8s/06-ingress.yaml
```
IngressRoute слушает порт 80 (entrypoint `web`), хост задаётся в манифесте.
## 3. Проверка
```bash
curl http://<node-ip>/api/health
# {"status":"ok"}
curl http://<node-ip>/
# HTML страница
```
## 4. Обновление backend
После изменения кода или `.env`:
```bash
# 1. Собрать образ
docker build -t <registry>/backend:latest ./backend
docker push <registry>/backend:latest
# 2. Обновить секрет (если менялся .env)
kubectl delete secret app-env -n support-bot
kubectl create secret generic app-env --from-env-file=.env -n support-bot
# 3. Перекатить под
kubectl rollout restart deployment/backend -n support-bot
kubectl rollout status deployment/backend -n support-bot
```
## 5. Persistent Volumes
| Компонент | Размер | Путь на ноде |
|-----------|--------|-----------------------------|
| postgres | 10Gi | `/mnt/data/postgres` |
| redis | 5Gi | `/mnt/data/redis` |
| qdrant | 20Gi | `/mnt/data/qdrant` |
PV привязаны к ноде через `nodeAffinity`. При смене воркера — заменить значение `hostnames` в манифестах.
## 6. Удаление
```bash
# Удалить всё, кроме PV
kubectl delete -f k8s/06-ingress.yaml
kubectl delete -f k8s/05-frontend.yaml
kubectl delete -f k8s/04-backend.yaml
kubectl delete -f k8s/03-qdrant.yaml
kubectl delete -f k8s/02-redis.yaml
kubectl delete -f k8s/01-postgres.yaml
# Секреты
kubectl delete secret app-env regcred -n support-bot
# Namespace (удалит всё, включая PV/PVC)
kubectl delete namespace support-bot
```

View file

@ -17,7 +17,7 @@ spec:
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: In operator: In
values: values:
- worker-172.16.0.5 - worker-192.168.123.5
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim

View file

@ -17,7 +17,7 @@ spec:
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: In operator: In
values: values:
- worker-172.16.0.5 - worker-192.168.123.5
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim

View file

@ -17,7 +17,7 @@ spec:
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: In operator: In
values: values:
- worker-172.16.0.5 - worker-192.168.123.5
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim

View file

@ -49,6 +49,21 @@ spec:
secretKeyRef: secretKeyRef:
name: app-env name: app-env
key: JWT_SECRET key: JWT_SECRET
- name: PROXY_URL
valueFrom:
secretKeyRef:
name: app-env
key: PROXY_URL
- name: PROXY_LOGIN
valueFrom:
secretKeyRef:
name: app-env
key: PROXY_LOGIN
- name: PROXY_PASS
valueFrom:
secretKeyRef:
name: app-env
key: PROXY_PASS
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /api/health path: /api/health

View file

@ -15,6 +15,8 @@ spec:
labels: labels:
app: frontend app: frontend
spec: spec:
imagePullSecrets:
- name: regcred
containers: containers:
- name: frontend - name: frontend
image: git.itoservice.ru/dosnav/support-bot-saas/frontend:latest image: git.itoservice.ru/dosnav/support-bot-saas/frontend:latest

23
k8s/host-fix-pod.yaml Normal file
View file

@ -0,0 +1,23 @@
apiVersion: v1
kind: Pod
metadata:
name: host-fix
namespace: support-bot
spec:
hostPID: true
hostNetwork: true
containers:
- name: host-fix
image: cr-internal.twcstorage.ru/k0sproject/calico-node:v3.32.0-0
command: ["sleep", "3600"]
securityContext:
privileged: true
runAsUser: 0
volumeMounts:
- name: host-root
mountPath: /host
volumes:
- name: host-root
hostPath:
path: /
type: Directory

12
scripts/script.py Normal file
View file

@ -0,0 +1,12 @@
import requests, sys
r = requests.post(
"http://sovet.itoservice.ru/api/v1/search",
headers={"Authorization": "Bearer 5fd81e60ba17418fa930cdf70504ebb2"},
json={"collection_id": "83f64913-9cb6-495e-984a-cbae31eacf54", "query": sys.argv[1], "generate_answer": True},
)
data = r.json()
if data["answer"]:
print(data["answer"])
else:
print("Ошибка генерации ответа (LLM недоступен). Найдено источников:", len(data["sources"]))

56
scripts/test_api.sh Normal file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://sovet.itoservice.ru}"
API_KEY="${1:-5fd81e60ba17418fa930cdf70504ebb2}"
COLLECTION_ID="${2:-}"
echo "=== Коллекции ==="
COLLECTIONS=$(curl -s -X GET "$BASE_URL/api/v1/collections" \
-H "Authorization: Bearer $API_KEY")
echo "$COLLECTIONS" | python3 -m json.tool 2>/dev/null || echo "$COLLECTIONS"
if [ -z "$COLLECTION_ID" ]; then
COLLECTION_ID=$(echo "$COLLECTIONS" | python3 -c "
import sys, json
data = json.load(sys.stdin)
if data: print(data[0]['id'])
else: print('')
" 2>/dev/null || echo "")
fi
if [ -z "$COLLECTION_ID" ]; then
echo "Нет коллекций. Создайте через админку, затем: $0 <api_key> <collection_id>"
exit 1
fi
echo "collection_id=$COLLECTION_ID"
echo ""
echo "=== Загрузка тикета ==="
curl -s -X POST "$BASE_URL/api/v1/tickets?collection_id=$COLLECTION_ID" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tickets": [{
"ticket_id": 999,
"category": "мобильное приложение",
"description": "Не могу начать смену в приложении",
"client": "Иван",
"messages": [
{"role": "user", "text": "Не могу начать смену в приложении, кнопка не активна"},
{"role": "assistant", "text": "Проверьте подключение к интернету и перезапустите приложение. Если не помогает, очистите кеш в настройках телефона. Убедитесь, что установлена последняя версия приложения из магазина."}
]
}]
}' | python3 -m json.tool 2>/dev/null || true
echo ""
echo "=== Поиск ==="
curl -s -X POST "$BASE_URL/api/v1/search" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"collection_id\": \"$COLLECTION_ID\",
\"query\": \"не могу начать смену в приложении\",
\"generate_answer\": true
}" | python3 -m json.tool 2>/dev/null || true