Создать авторизованный frontend поиска и лаборатории #40
No files matched your search
@@ -2,6 +2,9 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Backend `0.11.2`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе.
|
||||
- Backend `0.11.1` и frontend `0.1.1`: в управлении пользователями показываются даты первого и последнего входа, а также последнего сброса пароля.
|
||||
- Backend `0.11.0` и frontend `0.1.0`: лаборатория переоформлена по дизайн-системе «Түндүк»; добавлены учетные записи, обязательная смена временного пароля, личный кабинет и управление пользователями для `admin`.
|
||||
- Backend обновлён до `0.10.4`: многословный поиск получил низковесный OR fallback с требованием совпадения минимум двух терминов; fallback пропускает распознанные названия нормативных актов.
|
||||
- Backend обновлён до `0.10.3`: запрос о налоге на прибыль поднимает действующий
|
||||
Налоговый кодекс; точное совпадение фразы дополнительно усиливает результат.
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
---
|
||||
name: "Акылдаш · Түндүк"
|
||||
description: "Утверждённая дизайн-система правового портала Кыргызской Республики"
|
||||
colors:
|
||||
bg: "#F7F2E8"
|
||||
surface: "#FFFFFF"
|
||||
surface-muted: "#FBF8F4"
|
||||
text: "#38262A"
|
||||
text-secondary: "#786469"
|
||||
border: "#DED3CE"
|
||||
primary: "#9D2633"
|
||||
primary-hover: "#84202B"
|
||||
primary-active: "#701B24"
|
||||
on-primary: "#FFFFFF"
|
||||
primary-subtle: "#F8EBED"
|
||||
accent: "#A37B32"
|
||||
highlight: "#FFF0C7"
|
||||
focus: "#245C78"
|
||||
success: "#246843"
|
||||
success-bg: "#EAF4EC"
|
||||
warning: "#82520D"
|
||||
warning-bg: "#FFF3DA"
|
||||
error: "#9D2633"
|
||||
error-bg: "#FBEDEF"
|
||||
info: "#245C78"
|
||||
info-bg: "#EAF2F6"
|
||||
typography:
|
||||
document-title:
|
||||
fontFamily: "Noto Serif, serif"
|
||||
fontSize: "32px"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.2
|
||||
section-title:
|
||||
fontFamily: "Noto Serif, serif"
|
||||
fontSize: "24px"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.3
|
||||
document-body:
|
||||
fontFamily: "Noto Sans, sans-serif"
|
||||
fontSize: "18px"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.65
|
||||
control:
|
||||
fontFamily: "Noto Sans, sans-serif"
|
||||
fontSize: "16px"
|
||||
fontWeight: 500
|
||||
lineHeight: 1.5
|
||||
metadata:
|
||||
fontFamily: "Noto Sans, sans-serif"
|
||||
fontSize: "14px"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
rounded:
|
||||
default: "6px"
|
||||
spacing:
|
||||
xs: "4px"
|
||||
sm: "8px"
|
||||
compact: "12px"
|
||||
md: "16px"
|
||||
lg: "24px"
|
||||
xl: "32px"
|
||||
section: "48px"
|
||||
---
|
||||
|
||||
# Design System: Акылдаш · Түндүк
|
||||
|
||||
## Overview
|
||||
|
||||
**Статус: утверждено пользователем 10 октября 2026 года.** Это целевая
|
||||
дизайн-система портала и внутренней лаборатории проверки поисковой выдачи.
|
||||
Она ещё не внедрена в работающий интерфейс. Утверждение дизайна не означает
|
||||
готовность публичного сервиса или разрешение на deployment.
|
||||
|
||||
**Creative North Star: "Түндүк — спокойное пространство для работы с правом".**
|
||||
|
||||
Профессиональный правовой сервис с кыргызской идентичностью: светлый фон
|
||||
цвета войлока, белое поле документа, глубокий красный для действий,
|
||||
тёмный сливовый текст и небольшие золотистые геометрические детали.
|
||||
Главное — читаемость длинных документов и ясная иерархия.
|
||||
|
||||
Это единственная нормативная спецификация дизайна проекта. Требования
|
||||
исходного brief перенесены сюда; отдельный brief и ранние варианты удалены.
|
||||
Визуальный ориентир — утверждённый пользователем
|
||||
[концептуальный экран](docs/design/tunduk-desktop-concept.png).
|
||||
Точные токены и правила этой спецификации важнее случайных деталей
|
||||
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
|
||||
ранних концепций не используется.
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
- Спокойное чтение и компактные, понятные элементы управления.
|
||||
- Национальная идентичность через палитру, знак и несколько точных деталей.
|
||||
- Поиск и документ сохраняют общий контекст на широком экране.
|
||||
- Единая визуальная система публичного сервиса и внутренней лаборатории.
|
||||
- Содержимое и доступные функции определяются реальными данными и API.
|
||||
|
||||
## Colors
|
||||
|
||||
Нормативные значения находятся в YAML выше. При реализации ключи `colors`
|
||||
переносятся в CSS custom properties `--color-<ключ>` либо эквивалентные
|
||||
семантические токены существующей системы тем. HEX не размножаются
|
||||
по компонентам.
|
||||
|
||||
| Роль | Токен | Применение |
|
||||
|---|---|---|
|
||||
| Светлый войлок | `bg` | Общий фон и окружение рабочей области |
|
||||
| Белый лист | `surface` | Документ, поля и основные поверхности |
|
||||
| Мягкая поверхность | `surface-muted` | Вторичные панели |
|
||||
| Сливовые чернила | `text` | Основной текст и заголовки |
|
||||
| Вторичный текст | `text-secondary` | Метаданные и пояснения |
|
||||
| Разделители | `border` | Контуры и границы панелей |
|
||||
| Глубокий красный | `primary` | Главное действие, ссылки и активная навигация |
|
||||
| Состояния красного | `primary-hover`, `primary-active` | Hover и нажатие |
|
||||
| Текст на красном | `on-primary` | Главная кнопка и активный пункт меню |
|
||||
| Мягкое красное выделение | `primary-subtle` | Выбранный результат, вторичная навигация |
|
||||
| Золотистая геометрия | `accent` | Небольшие декоративные линии |
|
||||
| Найденный фрагмент | `highlight` | Подсветка совпадения с исходным тёмным текстом |
|
||||
| Фокус | `focus` | Контрастная обводка клавиатурного фокуса |
|
||||
| Успех | `success`, `success-bg` | Подтверждение действия, подтверждённый статус |
|
||||
| Предупреждение | `warning`, `warning-bg` | Важные ограничения с текстовым объяснением |
|
||||
| Ошибка | `error`, `error-bg` | Ошибка с доступным действием для восстановления |
|
||||
| Информация | `info`, `info-bg` | Информационные сообщения |
|
||||
|
||||
Статус и ошибка всегда имеют текстовую подпись: цвет не передаёт смысл
|
||||
самостоятельно. Не назначать юридический статус по визуальной эвристике;
|
||||
использовать данные backend. Золотистый не используется для мелкого текста.
|
||||
Контраст проверяется на фактических сочетаниях: обычный текст не менее
|
||||
4.5:1, крупный текст и необходимые графические элементы не менее 3:1.
|
||||
|
||||
## Typography
|
||||
|
||||
**Заголовки документа и разделов:** Noto Serif, веса 600 и 700.
|
||||
**Интерфейс и основной текст:** Noto Sans, веса 400, 500 и 600.
|
||||
Шрифты подключаются штатным способом, предпочтительно локальными WOFF2
|
||||
с `font-display: swap`. До поставки проверить лицензии и символы
|
||||
`Ө ө Ү ү Ң ң`. Файлы шрифтов пока не добавлены.
|
||||
|
||||
| Элемент | Размер | Межстрочный интервал |
|
||||
|---|---|---|
|
||||
| Заголовок документа / страницы документа | 30–32 px; на телефоне 24–26 px | 1.2 |
|
||||
| Заголовок раздела | 22–24 px | 1.3 |
|
||||
| Текст документа | 17–18 px | 1.6–1.7 |
|
||||
| Элементы управления | 14–16 px | По токену `control` |
|
||||
| Метаданные | 13–14 px | По токену `metadata` |
|
||||
|
||||
Мелкий текст не ниже 12 px. Ориентир ширины документа — 65–75 символов
|
||||
в строке. Длинные названия документов и кыргызские подписи переносятся,
|
||||
не обрезаются. Даты, суммы и числовые колонки используют `tabular-nums`,
|
||||
обычные данные не оформляются моноширинным шрифтом.
|
||||
|
||||
## Layout
|
||||
|
||||
Шкала отступов определяется `spacing`. Шапка на desktop — ориентировочно
|
||||
72–88 px. Навигация / фильтры — 220–240 px. Панель контекста, когда
|
||||
существуют соответствующие данные и функции, — 300–340 px. Центральная
|
||||
колонка занимает остаток; это адаптивные ориентиры, а не фиксированная
|
||||
ширина всей страницы.
|
||||
|
||||
Для поискового рабочего экрана: шапка с брендом, поиском и языком;
|
||||
фильтры слева, результаты в середине, открытый документ справа. Оглавление
|
||||
можно сворачивать в подписанную кнопку, чтобы не сужать область чтения.
|
||||
Разворачивание документа и поиск внутри него реализуются только вместе
|
||||
с работающим поведением, а не как декоративные кнопки.
|
||||
|
||||
Внутренняя лаборатория сохраняет свой двухпанельный сценарий: фактический
|
||||
порядок результатов и оценивание слева, текст выбранной редакции справа.
|
||||
Она не получает фильтры, аналитику или публичную навигацию исключительно
|
||||
для сходства с концепцией. Панель проверяющего и сохранения не перекрывает
|
||||
результаты и текст документа.
|
||||
|
||||
При недостатке места сначала сворачивается дополнительная панель контекста,
|
||||
затем навигация. На телефоне — одна колонка; связанные материалы и
|
||||
оглавление доступны через подписанные кнопки. В лаборатории сохраняется
|
||||
существующее мобильное открытие документа и возврат фокуса при закрытии.
|
||||
Поиск, действия и метаданные переносятся. Основной текст не требует
|
||||
горизонтальной прокрутки; широкие таблицы имеют отдельную обозначенную
|
||||
область прокрутки.
|
||||
|
||||
Проверочные размеры при реализации: **1440, 1024 и 390 px**. Это размеры
|
||||
проверки, а не заранее навязанные CSS breakpoints.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
Основные поверхности плоские. Иерархия создаётся белым листом на тёплом
|
||||
фоне, отступами, типографикой и тонкими разделителями. Мягкая тень нужна
|
||||
главным образом выпадающим меню и временным панелям. Значения теней
|
||||
уточняются при реализации; декоративное размытие и обилие теней запрещены.
|
||||
|
||||
## Shapes
|
||||
|
||||
Основное скругление — `rounded.default` (6 px), контуры — 1 px.
|
||||
Небольшие статусные метки могут быть округлее. Кнопки не превращаются
|
||||
в капсулы. Границы интерактивных полей обозначаются явным контуром,
|
||||
а не только фоновым цветом или разделителем соседней панели.
|
||||
|
||||
Если утверждённый логотип уже существует, он сохраняется. При отсутствии
|
||||
логотипа допускается небольшой оригинальный геометрический знак,
|
||||
вдохновлённый круглым верхним элементом юрты — түндүком. Знак из
|
||||
генеративного макета является направлением, а не готовым SVG-ассетом.
|
||||
Государственная символика не копируется.
|
||||
|
||||
Тонкий симметричный орнамент по мотивам шырдака допустим на краях шапки
|
||||
и в пустой нижней части навигации. Он выполняется простым SVG,
|
||||
помечается `aria-hidden`, не находится под текстом и не мешает нажатию.
|
||||
На телефоне орнамент сокращается либо убирается.
|
||||
|
||||
## Components
|
||||
|
||||
Это требования к будущей реализации, а не перечень уже обновлённых компонентов.
|
||||
|
||||
### Кнопки и поля
|
||||
|
||||
- Главная кнопка: `primary` и `on-primary`, высота около 40 px.
|
||||
- Вторичная кнопка: светлая поверхность и явный контур.
|
||||
- На touch-экране область нажатия не менее 44×44 px.
|
||||
- Поля белые, с постоянной подписью; placeholder не заменяет label.
|
||||
- Видимый фокус: обводка `focus` около 2 px с отступом.
|
||||
- Hover, active, focus-visible, disabled, loading, empty и error
|
||||
реализуются последовательно, с понятными сообщениями.
|
||||
- Переходы 120–180 мс, без анимации текста документа;
|
||||
учитывается `prefers-reduced-motion`.
|
||||
|
||||
### Навигация, результаты и документ
|
||||
|
||||
- Активный пункт меню — красный фон и белая подпись; вторичная
|
||||
навигация — `primary-subtle` и тёмно-красный текст.
|
||||
- Активная вкладка обозначена подписью и нижней линией. Длинные подписи
|
||||
переносятся либо прокручиваются без обрезки.
|
||||
- Результаты — упорядоченные строки: название, тип, статус, дата при
|
||||
наличии и выдержка. Каждое поле не оборачивается в отдельную карточку.
|
||||
- Выбранный результат обозначается мягким красным выделением и текстом.
|
||||
- Документ — единый белый лист: заголовок, реальные метаданные,
|
||||
доступные действия, оглавление и текст. Абзацы не разбиваются на карточки.
|
||||
- Подсветка совпадения сохраняет исходный текст; число совпадений
|
||||
и переходы между ними показываются только при работающем поиске.
|
||||
- Источник и выбранная редакция доступны рядом с документом.
|
||||
Недоступный язык не подменяется выдуманным переводом.
|
||||
- Язык интерфейса и язык документа — отдельные состояния:
|
||||
смена интерфейса не должна самовольно менять текст документа.
|
||||
- Footer сохраняет версии frontend и backend. Отсутствующий frontend
|
||||
или макет явно обозначаются, версия не придумывается.
|
||||
|
||||
### Внутренняя оценка выдачи
|
||||
|
||||
Сохраняются шкала 0–3 с полными текстовыми подписями, комментарии,
|
||||
черновик, идентификация проверяющего, сохранение подписанного снимка,
|
||||
обучение и существующие ограничения доступа. Оценки не передаются
|
||||
только цветом. Служебные данные не становятся публичными из-за редизайна.
|
||||
|
||||
### Доступность и состояния
|
||||
|
||||
Семантические заголовки, labels, ссылки и кнопки; иконки без текста
|
||||
имеют доступное имя. Проверяются Tab, Enter, Escape, возврат фокуса
|
||||
и отсутствие клавиатурных ловушек. Статусы доступны скринридеру.
|
||||
Пустая выдача предлагает изменить запрос; ошибка предлагает повторное
|
||||
действие. Ошибка сохранения не уничтожает черновик.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do:
|
||||
|
||||
- Сохранять название «Акылдаш», реальные данные, маршруты, API и права доступа.
|
||||
- Использовать общий слой семантических токенов, существующие компоненты
|
||||
и CSS Modules / BEM согласно правилам соответствующей части проекта.
|
||||
- Разделять официальный текст, сведения о происхождении и выводы системы.
|
||||
- Проверять чтение длинных RU/KY-документов и одноязычные редакции.
|
||||
- Использовать этот документ как нормативную спецификацию для новых экранов.
|
||||
|
||||
### Don't:
|
||||
|
||||
- Не переносить демонстрационные даты, судебные материалы, связи
|
||||
и юридические формулировки из изображения в продукт.
|
||||
- Не добавлять аналитику, сравнение редакций, регистрацию и другие функции
|
||||
только ради сходства с концепцией.
|
||||
- Не менять поисковое ранжирование или сохранение разметки ради оформления.
|
||||
- Не использовать ковровые текстуры, фотографии гор, иллюстрации юрт,
|
||||
крупные флаги, золотые градиенты и декоративное размытие.
|
||||
- Не делать золотистыми мелкие подписи и не передавать смысл одним цветом.
|
||||
- Не добавлять отдельную тёмную тему без задачи; существующую сохранять.
|
||||
- Не считать генеративный скриншот доказательством точных HEX,
|
||||
загрузки Noto или проверки доступности работающего интерфейса.
|
||||
- Не считать утверждение дизайна разрешением на публикацию сервиса.
|
||||
@@ -9,15 +9,17 @@ Telegram-бот — только часть рабочего окружения
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||
`0.10.4`. Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
|
||||
`0.11.2` и внутренний frontend лаборатории `0.1.1`. Авторизация хранится в
|
||||
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
|
||||
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
|
||||
действующий Налоговый кодекс для запросов о налоге на прибыль.
|
||||
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.10.4` | приоритет кодекса для темы налога на прибыль; тематический поиск ОсОО/ЖЧК |
|
||||
| Frontend | — | ещё не создан |
|
||||
| Backend | `0.11.2` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
|
||||
| Frontend | `0.1.1` | лаборатория поиска, личный кабинет и управление учетными записями |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
@@ -34,11 +36,12 @@ tools/
|
||||
backend/
|
||||
ingestion/ получение и обновление правовых источников
|
||||
normalization/ воспроизводимая нормализация исходного архива
|
||||
search/ Search API, авторизация и внутренний frontend
|
||||
```
|
||||
|
||||
Каталоги для загрузки и обработки источников, RAG, backend и frontend будут
|
||||
создаваться с первой реальной задачей в соответствующей области. Это позволит
|
||||
выбрать структуру по фактическим требованиям, а не поддерживать пустой каркас.
|
||||
Внутренний frontend отдаётся существующим Search API без отдельного
|
||||
Node-сервиса. Отдельный публичный frontend остаётся за readiness gate в
|
||||
`docs/product/frontend-design-readiness-plan.md`.
|
||||
|
||||
Начать знакомство с проектом: [документация](docs/README.md) и
|
||||
[обзор продукта](docs/product/project-overview.md).
|
||||
@@ -59,4 +62,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.4 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.1
|
||||
+19
-6
@@ -1,6 +1,6 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.10.4`
|
||||
Версия: `0.11.2`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
@@ -218,7 +218,14 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
|
||||
|
||||
## Внутренняя лаборатория релевантности
|
||||
|
||||
Запустите Search API на localhost и откройте `http://127.0.0.1:8080/review`:
|
||||
Создайте первую учетную запись администратора:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python3 -m search.api --users-db data/search-users.sqlite3 --create-admin
|
||||
```
|
||||
|
||||
Команда запросит и подтвердит пароль `admin` в терминале. Затем запустите
|
||||
Search API на localhost и откройте `http://127.0.0.1:8080/review`:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python3 -m search.api \\
|
||||
@@ -227,9 +234,15 @@ PYTHONPATH=backend python3 -m search.api \\
|
||||
|
||||
Лаборатория показывает фактический порядок выдачи OpenSearch, позволяет открыть
|
||||
текст редакции, поставить результату оценку от 0 до 3 и сохранить снимок с
|
||||
комментариями. Оценки сохраняются в SQLite, экспорт доступен через
|
||||
`GET /search-reviews/export`. Интерфейс предназначен только для локальной сети
|
||||
или защищённого reverse proxy; не публикуйте его напрямую в интернет.
|
||||
комментариями. Поиск и API доступны только после входа. Пользователь `admin`
|
||||
управляет учетными записями; новые пользователи должны заменить временный
|
||||
пароль при первом входе. Хранилище пользователей `data/search-users.sqlite3`
|
||||
нужно резервировать отдельно от поискового индекса. Экспорт доступен
|
||||
авторизованным пользователям через `GET /search-reviews/export`.
|
||||
|
||||
При утере пароля `admin` используйте `PYTHONPATH=backend python3 -m search.api
|
||||
--users-db data/search-users.sqlite3 --reset-admin-password`; следующий вход
|
||||
потребует сменить временный пароль.
|
||||
|
||||
Для проверки текущей выдачи без будущего HTTP API используйте CLI:
|
||||
|
||||
@@ -240,4 +253,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.10.4 · Frontend — не создан
|
||||
Акылдаш · Backend v0.11.2 · Frontend v0.1.1
|
||||
@@ -364,7 +364,7 @@ def main() -> int:
|
||||
print(
|
||||
f"discovered={result.discovered} downloaded={result.downloaded} "
|
||||
f"skipped={result.skipped} failed={result.failed}\n"
|
||||
f"Akyldash Backend v{APP_VERSION} · Frontend — not created"
|
||||
f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.1"
|
||||
)
|
||||
return int(result.failed > 0)
|
||||
|
||||
|
||||
@@ -720,7 +720,7 @@ def main() -> int:
|
||||
print(
|
||||
f"discovered={result.discovered} normalized={result.normalized} "
|
||||
f"skipped={result.skipped} failed={result.failed}\n"
|
||||
f"Akyldash Backend v{APP_VERSION} · Frontend — not created"
|
||||
f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.1"
|
||||
)
|
||||
return int(result.failed > 0)
|
||||
|
||||
|
||||
+125
-10
@@ -4,12 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import getpass
|
||||
import http.cookies
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from search.auth import AuthError, AuthStore
|
||||
from search.minjust_opensearch import APP_VERSION, request_json
|
||||
from search.catalog import CATALOGS, labels
|
||||
from search.query import build_search_query
|
||||
@@ -60,11 +64,20 @@ def date(value: str | None, name: str) -> str | None:
|
||||
|
||||
|
||||
def openapi() -> dict:
|
||||
responses = {"200": {"description": "Successful response"}, "400": {"description": "Invalid request"}, "404": {"description": "Not found"}, "502": {"description": "Search backend unavailable"}}
|
||||
responses = {"200": {"description": "Successful response"}, "400": {"description": "Invalid request"}, "401": {"description": "Authentication required"}, "403": {"description": "Additional access required"}, "404": {"description": "Not found"}, "502": {"description": "Search backend unavailable"}}
|
||||
return {
|
||||
"openapi": "3.0.3",
|
||||
"info": {"title": "Akyldash Search API", "version": API_VERSION},
|
||||
"components": {"securitySchemes": {"SessionCookie": {"type": "apiKey", "in": "cookie", "name": "akyldash_session"}}},
|
||||
"security": [{"SessionCookie": []}],
|
||||
"paths": {
|
||||
"/auth/login": {"post": {"security": [], "responses": {"200": {"description": "Signed in and session cookie issued"}, "401": {"description": "Invalid login or password"}}}},
|
||||
"/auth/logout": {"post": {"responses": {"200": {"description": "Signed out"}}}},
|
||||
"/auth/me": {"get": {"responses": {"200": {"description": "Current account"}, "401": {"description": "Authentication required"}}}},
|
||||
"/auth/change-password": {"post": {"description": "Requires the current password and a different new password", "responses": {"200": {"description": "Password changed"}, "400": {"description": "Current password is missing or invalid, or the new password is invalid or unchanged"}}}},
|
||||
"/admin/users": {"get": {"responses": {"200": {"description": "User list"}}}, "post": {"responses": {"201": {"description": "User created with a temporary password"}}}},
|
||||
"/admin/users/{username}": {"delete": {"responses": {"200": {"description": "User deleted"}}}},
|
||||
"/admin/users/{username}/reset-password": {"post": {"responses": {"200": {"description": "Temporary password set"}}}},
|
||||
"/search": {"get": {"responses": responses, "parameters": [
|
||||
{"name": "q", "in": "query", "required": True, "schema": {"type": "string"}},
|
||||
{"name": "language", "in": "query", "schema": {"type": "string", "enum": ["ru", "ky"]}},
|
||||
@@ -90,12 +103,14 @@ def openapi() -> dict:
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, base_url: str, index: str, data_root: Path, reviews_db: Path | str = ":memory:", review_secret: bytes | None = None):
|
||||
def __init__(self, base_url: str, index: str, data_root: Path, reviews_db: Path | str = ":memory:", review_secret: bytes | None = None, users_db: Path | str = ":memory:", cookie_secure: bool = False):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.index = index
|
||||
self.data_root = data_root
|
||||
self.review_store = ReviewStore(reviews_db)
|
||||
self.review_snapshots = ReviewSnapshots(review_secret)
|
||||
self.auth_store = AuthStore(users_db)
|
||||
self.cookie_secure = cookie_secure
|
||||
|
||||
def search_url(self, suffix: str) -> str:
|
||||
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
|
||||
@@ -242,12 +257,12 @@ class Api:
|
||||
raise ApiError(404, "edition language not found")
|
||||
return {"api_version": API_VERSION, "edition": metadata, "content": content}
|
||||
|
||||
def save_review(self, body: dict) -> dict:
|
||||
def save_review(self, body: dict, username: str) -> dict:
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(400, "request body must be an object")
|
||||
try:
|
||||
snapshot = self.review_snapshots.verify(body["review_token"])
|
||||
reviewer = body["reviewer"].strip()
|
||||
reviewer = username
|
||||
overall_comment = body.get("overall_comment", "").strip()
|
||||
submitted = body["results"]
|
||||
except (KeyError, AttributeError, TypeError, ValueError) as error:
|
||||
@@ -288,12 +303,49 @@ class Api:
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise ApiError(500, "review interface is unavailable") from error
|
||||
|
||||
def handle(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
|
||||
def handle(self, method: str, path: str, body: dict | None = None, user: dict | None = None) -> tuple[int, dict]:
|
||||
parsed = urllib.parse.urlsplit(path)
|
||||
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||||
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
|
||||
if method == "POST" and parts == ["auth", "login"]:
|
||||
try:
|
||||
token, account = self.auth_store.login(body.get("username"), body.get("password"))
|
||||
return 200, {"account": account, "session_token": token}
|
||||
except AuthError as error:
|
||||
raise ApiError(401, str(error)) from error
|
||||
if method == "POST" and parts == ["auth", "logout"]:
|
||||
return 200, {"ok": True}
|
||||
if method == "GET" and parts == ["auth", "me"]:
|
||||
return 200, {"account": user}
|
||||
if method == "POST" and parts == ["auth", "change-password"]:
|
||||
try:
|
||||
self.auth_store.change_password(user["username"], body.get("current_password"), body.get("new_password"))
|
||||
token, account = self.auth_store.login(user["username"], body.get("new_password"))
|
||||
return 200, {"account": account, "session_token": token}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "GET" and parts == ["admin", "users"]:
|
||||
return 200, {"users": self.auth_store.list_users()}
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and parts == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.create_user(body.get("username"), body.get("password"))
|
||||
return 201, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.delete_user(parts[2])
|
||||
return 200, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
|
||||
try:
|
||||
self.auth_store.reset_password(parts[2], body.get("password"))
|
||||
return 200, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if method == "POST" and parts == ["search-reviews"]:
|
||||
return 201, self.save_review(body)
|
||||
return 201, self.save_review(body, user["username"] if user else body.get("reviewer", ""))
|
||||
if method == "GET" and parts == ["search-reviews", "export"]:
|
||||
return 200, {"api_version": API_VERSION, "reviews": self.review_store.export()}
|
||||
if method != "GET":
|
||||
@@ -317,28 +369,61 @@ def handler(api: Api):
|
||||
class RequestHandler(BaseHTTPRequestHandler):
|
||||
def respond(self, method: str):
|
||||
try:
|
||||
if method == "GET" and urllib.parse.urlsplit(self.path).path == "/review":
|
||||
route = urllib.parse.urlsplit(self.path).path
|
||||
cookies = http.cookies.SimpleCookie(self.headers.get("Cookie", ""))
|
||||
session = cookies.get("akyldash_session")
|
||||
token = session.value if session else None
|
||||
user = api.auth_store.user_for_token(token)
|
||||
if method == "GET" and route in {"/", "/review"}:
|
||||
body = api.review_page().encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if not (method == "POST" and route in {"/auth/login", "/auth/logout"}):
|
||||
if not user:
|
||||
raise ApiError(401, "authentication required")
|
||||
if user["must_change_password"] and not (method == "POST" and route == "/auth/change-password") and not (method == "GET" and route == "/auth/me"):
|
||||
raise ApiError(403, "change password before continuing")
|
||||
if route.startswith("/admin/users") and user["username"].casefold() != "admin":
|
||||
raise ApiError(403, "admin account required")
|
||||
body = None
|
||||
if method == "POST":
|
||||
if method in {"POST", "DELETE"}:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length > 1_000_000:
|
||||
raise ApiError(413, "request body is too large")
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
status, payload = api.handle(method, self.path, body)
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(400, "request body must be a JSON object")
|
||||
if method == "POST" and route == "/auth/logout":
|
||||
api.auth_store.logout(token)
|
||||
status, payload = api.handle(method, self.path, body, user)
|
||||
except json.JSONDecodeError:
|
||||
status, payload = 400, {"api_version": API_VERSION, "error": "request body must be valid JSON"}
|
||||
except ApiError as error:
|
||||
status, payload = error.status, {"api_version": API_VERSION, "error": error.message}
|
||||
except (ValueError, http.cookies.CookieError):
|
||||
status, payload = 400, {"api_version": API_VERSION, "error": "invalid request"}
|
||||
session_token = payload.pop("session_token", None) if isinstance(payload, dict) else None
|
||||
body = json.dumps(payload, ensure_ascii=False).encode()
|
||||
self.send_response(status)
|
||||
if session_token:
|
||||
secure = "; Secure" if api.cookie_secure else ""
|
||||
self.send_header("Set-Cookie", f"akyldash_session={session_token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200{secure}")
|
||||
elif method == "POST" and urllib.parse.urlsplit(self.path).path == "/auth/logout":
|
||||
secure = "; Secure" if api.cookie_secure else ""
|
||||
self.send_header("Set-Cookie", f"akyldash_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{secure}")
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
@@ -349,6 +434,9 @@ def handler(api: Api):
|
||||
def do_POST(self):
|
||||
self.respond("POST")
|
||||
|
||||
def do_DELETE(self):
|
||||
self.respond("DELETE")
|
||||
|
||||
def log_message(self, format: str, *args):
|
||||
return
|
||||
|
||||
@@ -361,13 +449,40 @@ def main() -> int:
|
||||
parser.add_argument("--index", default="akyldash-fragments-current")
|
||||
parser.add_argument("--data", type=Path, default=Path("data/minjust-normalized"))
|
||||
parser.add_argument("--reviews-db", type=Path, default=Path("data/search-reviews.sqlite3"))
|
||||
parser.add_argument("--users-db", type=Path, default=Path("data/search-users.sqlite3"))
|
||||
parser.add_argument("--review-secret", default=None)
|
||||
parser.add_argument("--cookie-secure", action=argparse.BooleanOptionalAction, default=os.environ.get("COOKIE_SECURE", "false").lower() == "true")
|
||||
parser.add_argument("--create-admin", action="store_true", help="create the initial admin account and exit")
|
||||
parser.add_argument("--reset-admin-password", action="store_true", help="set a temporary password for admin and exit")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
parser.add_argument("--version", action="version", version=APP_VERSION)
|
||||
arguments = parser.parse_args()
|
||||
secret = arguments.review_secret.encode() if arguments.review_secret else None
|
||||
ThreadingHTTPServer((arguments.host, arguments.port), handler(Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret))).serve_forever()
|
||||
api = Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret, arguments.users_db, arguments.cookie_secure)
|
||||
if arguments.create_admin or arguments.reset_admin_password:
|
||||
users = api.auth_store.list_users()
|
||||
admin_exists = any(user["username"].casefold() == "admin" for user in users)
|
||||
if arguments.create_admin and users:
|
||||
parser.error("accounts already exist")
|
||||
if arguments.reset_admin_password and not admin_exists:
|
||||
parser.error("admin account does not exist")
|
||||
if arguments.create_admin and arguments.reset_admin_password:
|
||||
parser.error("choose either --create-admin or --reset-admin-password")
|
||||
password = getpass.getpass("Password for admin (12+ characters): ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if password != confirm:
|
||||
parser.error("passwords do not match")
|
||||
try:
|
||||
if arguments.create_admin:
|
||||
api.auth_store.create_admin(password)
|
||||
else:
|
||||
api.auth_store.reset_password("admin", password)
|
||||
except AuthError as error:
|
||||
parser.error(str(error))
|
||||
print("Initial admin account created." if arguments.create_admin else "Admin password reset. Change it at next sign-in.")
|
||||
return 0
|
||||
ThreadingHTTPServer((arguments.host, arguments.port), handler(api)).serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Local user accounts and cookie sessions for the internal search workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
USERNAME = re.compile(r"^[A-Za-z0-9._-]{3,64}$")
|
||||
PASSWORD_ITERATIONS = 310_000
|
||||
SESSION_TTL = 60 * 60 * 12
|
||||
|
||||
|
||||
class AuthError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class AuthStore:
|
||||
def __init__(self, path: Path | str):
|
||||
if path != ":memory:":
|
||||
database = Path(path)
|
||||
database.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
descriptor = os.open(database, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600)
|
||||
os.close(descriptor)
|
||||
except FileExistsError:
|
||||
pass
|
||||
self.connection = sqlite3.connect(path, check_same_thread=False)
|
||||
self.connection.row_factory = sqlite3.Row
|
||||
self._lock = threading.RLock()
|
||||
self.connection.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT PRIMARY KEY COLLATE NOCASE,
|
||||
password_hash BLOB NOT NULL,
|
||||
password_salt BLOB NOT NULL,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
first_login_at INTEGER,
|
||||
last_login_at INTEGER,
|
||||
password_reset_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash BLOB PRIMARY KEY,
|
||||
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
""")
|
||||
user_columns = {row["name"] for row in self.connection.execute("PRAGMA table_info(users)")}
|
||||
for column in ("first_login_at", "last_login_at", "password_reset_at"):
|
||||
if column not in user_columns:
|
||||
self.connection.execute(f"ALTER TABLE users ADD COLUMN {column} INTEGER")
|
||||
self.connection.execute("PRAGMA foreign_keys = ON")
|
||||
self.connection.commit()
|
||||
|
||||
@staticmethod
|
||||
def _validate(username: str, password: str) -> tuple[str, str]:
|
||||
if not isinstance(username, str) or not USERNAME.fullmatch(username):
|
||||
raise AuthError("login must be 3–64 letters, numbers, dots, underscores or hyphens")
|
||||
if not isinstance(password, str) or not 12 <= len(password) <= 256:
|
||||
raise AuthError("password must be between 12 and 256 characters")
|
||||
return username, password
|
||||
|
||||
@staticmethod
|
||||
def _hash(password: str, salt: bytes) -> bytes:
|
||||
return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PASSWORD_ITERATIONS)
|
||||
|
||||
def create_admin(self, password: str) -> None:
|
||||
self._validate("admin", password)
|
||||
salt = secrets.token_bytes(16)
|
||||
with self._lock:
|
||||
if self.connection.execute("SELECT 1 FROM users LIMIT 1").fetchone():
|
||||
raise AuthError("accounts already exist")
|
||||
self.connection.execute("INSERT INTO users(username,password_hash,password_salt,must_change_password,created_at) VALUES('admin',?,?,0,?)", (self._hash(password, salt), salt, int(time.time())))
|
||||
self.connection.commit()
|
||||
|
||||
# shortcut: login throttling belongs to the HTTPS reverse proxy; add local limits before direct public exposure.
|
||||
def login(self, username: str, password: str) -> tuple[str, dict]:
|
||||
if not isinstance(username, str) or not isinstance(password, str):
|
||||
raise AuthError("invalid login or password")
|
||||
with self._lock:
|
||||
user = self.connection.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,)).fetchone()
|
||||
salt = user["password_salt"] if user else b"\0" * 16
|
||||
expected_hash = user["password_hash"] if user else b"\0" * 32
|
||||
valid = hmac.compare_digest(self._hash(password, salt), expected_hash)
|
||||
if not user or not valid:
|
||||
raise AuthError("invalid login or password")
|
||||
with self._lock:
|
||||
current = self.connection.execute("SELECT * FROM users WHERE username=?", (user["username"],)).fetchone()
|
||||
if not current or not hmac.compare_digest(current["password_hash"], expected_hash):
|
||||
raise AuthError("invalid login or password")
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = int(time.time())
|
||||
self.connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (now,))
|
||||
self.connection.execute("INSERT INTO sessions(token_hash,username,expires_at) VALUES(?,?,?)", (hashlib.sha256(token.encode()).digest(), user["username"], now + SESSION_TTL))
|
||||
self.connection.execute("UPDATE users SET first_login_at=COALESCE(first_login_at,?),last_login_at=? WHERE username=?", (now, now, user["username"]))
|
||||
self.connection.commit()
|
||||
current = self.connection.execute("SELECT * FROM users WHERE username=?", (user["username"],)).fetchone()
|
||||
return token, self._public(current)
|
||||
|
||||
@staticmethod
|
||||
def _public(user: sqlite3.Row) -> dict:
|
||||
return {"username": user["username"], "role": "Администратор", "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
|
||||
|
||||
def user_for_token(self, token: str | None) -> dict | None:
|
||||
if not token:
|
||||
return None
|
||||
with self._lock:
|
||||
user = self.connection.execute("SELECT users.* FROM sessions JOIN users USING(username) WHERE token_hash = ? AND expires_at > ?", (hashlib.sha256(token.encode()).digest(), int(time.time()))).fetchone()
|
||||
return self._public(user) if user else None
|
||||
|
||||
def logout(self, token: str | None) -> None:
|
||||
if token:
|
||||
with self._lock:
|
||||
self.connection.execute("DELETE FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.encode()).digest(),))
|
||||
self.connection.commit()
|
||||
|
||||
def change_password(self, username: str, old_password: str, new_password: str) -> None:
|
||||
if not isinstance(old_password, str):
|
||||
raise AuthError("current password is required")
|
||||
self._validate(username, new_password)
|
||||
with self._lock:
|
||||
user = self.connection.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
||||
if not user:
|
||||
raise AuthError("account not found")
|
||||
if not hmac.compare_digest(self._hash(old_password, user["password_salt"]), user["password_hash"]):
|
||||
raise AuthError("current password is incorrect")
|
||||
if hmac.compare_digest(self._hash(new_password, user["password_salt"]), user["password_hash"]):
|
||||
raise AuthError("new password must be different")
|
||||
salt = secrets.token_bytes(16)
|
||||
password_hash = self._hash(new_password, salt)
|
||||
self.connection.execute("UPDATE users SET password_hash=?,password_salt=?,must_change_password=0 WHERE username=?", (password_hash, salt, username))
|
||||
self.connection.execute("DELETE FROM sessions WHERE username=?", (username,))
|
||||
self.connection.commit()
|
||||
|
||||
def create_user(self, username: str, password: str) -> None:
|
||||
self._validate(username, password)
|
||||
salt = secrets.token_bytes(16)
|
||||
password_hash = self._hash(password, salt)
|
||||
try:
|
||||
with self._lock:
|
||||
self.connection.execute("INSERT INTO users(username,password_hash,password_salt,must_change_password,created_at) VALUES(?,?,?,1,?)", (username, password_hash, salt, int(time.time())))
|
||||
self.connection.commit()
|
||||
except sqlite3.IntegrityError as error:
|
||||
raise AuthError("login already exists") from error
|
||||
|
||||
def delete_user(self, username: str) -> None:
|
||||
if username.casefold() == "admin":
|
||||
raise AuthError("admin account cannot be deleted")
|
||||
with self._lock:
|
||||
self.connection.execute("DELETE FROM users WHERE username=?", (username,))
|
||||
self.connection.commit()
|
||||
|
||||
def reset_password(self, username: str, password: str) -> None:
|
||||
self._validate(username, password)
|
||||
salt = secrets.token_bytes(16)
|
||||
password_hash = self._hash(password, salt)
|
||||
with self._lock:
|
||||
cursor = self.connection.execute("UPDATE users SET password_hash=?,password_salt=?,must_change_password=1,password_reset_at=? WHERE username=?", (password_hash, salt, int(time.time()), username))
|
||||
if not cursor.rowcount:
|
||||
raise AuthError("account not found")
|
||||
self.connection.execute("DELETE FROM sessions WHERE username=?", (username,))
|
||||
self.connection.commit()
|
||||
|
||||
def list_users(self) -> list[dict]:
|
||||
with self._lock:
|
||||
users = []
|
||||
for row in self.connection.execute("SELECT * FROM users ORDER BY username COLLATE NOCASE"):
|
||||
account = self._public(row)
|
||||
account.update(first_login_at=row["first_login_at"], last_login_at=row["last_login_at"], password_reset_at=row["password_reset_at"])
|
||||
users.append(account)
|
||||
return users
|
||||
@@ -82,7 +82,7 @@ def main() -> int:
|
||||
raise SystemExit("--top-k must be greater than zero")
|
||||
result = evaluate(load_queries(arguments.relevance_set), arguments.url, arguments.index, arguments.top_k)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print(f"Akyldash Backend v{APP_VERSION} · Frontend — not created", file=sys.stderr)
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.1", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.10.4"
|
||||
APP_VERSION = "0.11.2"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||
|
||||
@@ -460,7 +460,7 @@ def main() -> int:
|
||||
else:
|
||||
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
|
||||
destination = arguments.output
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash Backend v{APP_VERSION} · Frontend — not created")
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.1.1")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ def main() -> int:
|
||||
if arguments.top_k <= 0:
|
||||
raise SystemExit("--top-k must be greater than zero")
|
||||
print(json.dumps(search_documents(arguments.url, arguments.index, arguments.language, arguments.query, arguments.top_k), ensure_ascii=False))
|
||||
print(f"Akyldash Backend v{APP_VERSION} · Frontend — not created")
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.1")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+214
-51
@@ -5,80 +5,124 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Оценка поисковой выдачи · Акылдаш</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font: 16px/1.5 system-ui, sans-serif; color: #17202a; background: #eef2f5; --ink: #17202a; --muted: #5d6b78; --line: #dbe2e8; --surface: #fff; --surface-muted: #f7f9fb; --brand: #0f5b78; --brand-dark: #0a4258; --accent: #e7b84b; --focus: #1769aa; }
|
||||
:root { color-scheme: light; font: 16px/1.5 "Noto Sans", "Segoe UI", sans-serif; color: #38262A; background: #F7F2E8; --ink: #38262A; --muted: #786469; --line: #DED3CE; --surface: #fff; --surface-muted: #FBF8F4; --brand: #9D2633; --brand-dark: #84202B; --accent: #A37B32; --focus: #245C78; --subtle: #F8EBED; --serif: "Noto Serif", Georgia, serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: var(--ink); background: radial-gradient(circle at 85% -10%, #dcecf0 0, transparent 35rem), #eef2f5; }
|
||||
body { margin: 0; color: var(--ink); background: #F7F2E8; }
|
||||
.review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: var(--surface); }
|
||||
.review__skip:focus { inset-inline-start: 1rem; z-index: 2; }
|
||||
.review__header, .review__main { max-width: 1320px; margin: auto; padding-inline: clamp(1rem, 3vw, 3rem); }
|
||||
.review__header { padding-block: clamp(2rem, 5vw, 4rem) 1.5rem; }
|
||||
.review__header h1 { max-width: 18ch; margin: .35rem 0 .75rem; font-size: clamp(2rem, 4vw, 3.5rem); line-height: 1.05; letter-spacing: -.04em; }
|
||||
.review__header p { max-width: 58ch; margin: 0; color: var(--muted); font-size: 1.05rem; }
|
||||
.review__header { padding-block: 0 1.5rem; background: var(--surface); border-block-end: 1px solid var(--line); }
|
||||
.review__header h1 { max-width: 24ch; margin: 1.5rem 0 .4rem; font: 700 clamp(1.75rem, 3vw, 2rem)/1.2 var(--serif); }
|
||||
.review__header p { max-width: 65ch; margin: 0; color: var(--muted); }
|
||||
.review__header-top { display: flex; justify-content: space-between; gap: 1rem; align-items: start; }
|
||||
.review__eyebrow { display: inline-flex; align-items: center; gap: .5rem; color: var(--brand); font-size: .75rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.review__eyebrow::before { content: ''; width: .55rem; height: .55rem; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px rgb(231 184 75 / .2); }
|
||||
.review__eyebrow { display: inline-flex; align-items: center; gap: .5rem; color: var(--ink); font: 600 1.1rem/1.25 var(--serif); }
|
||||
.review__eyebrow::before { content: ''; width: 1.5rem; height: 1.5rem; border: 2px solid var(--brand); border-radius: 50%; background: linear-gradient(90deg, transparent 45%, var(--brand) 45% 55%, transparent 55%), linear-gradient(0deg, transparent 45%, var(--brand) 45% 55%, transparent 55%); }
|
||||
.review__main { padding-block-end: 2rem; }
|
||||
.review__search { display: grid; grid-template-columns: minmax(18rem, 1fr) 9rem 9rem auto; align-items: end; gap: .75rem; padding: 1.25rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 1.25rem 3rem rgb(24 42 54 / .08); }
|
||||
.review__search { display: grid; grid-template-columns: minmax(18rem, 1fr) 9rem 9rem auto; align-items: end; gap: .75rem; padding: 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
|
||||
.review__field { display: grid; gap: .35rem; min-width: 0; }
|
||||
.review__field span, .review__actions label { color: var(--muted); font-size: .8rem; font-weight: 700; }
|
||||
.review__field span, .review__actions label { color: var(--muted); font-size: .875rem; font-weight: 700; }
|
||||
.review__field--small { min-width: 0; }
|
||||
input, select, textarea, button { font: inherit; }
|
||||
input, select, textarea { width: 100%; border: 1px solid #b8c4ce; border-radius: .6rem; padding: .7rem .8rem; background: var(--surface); color: var(--ink); }
|
||||
input::placeholder, textarea::placeholder { color: #87939e; }
|
||||
input, select, textarea { width: 100%; min-height: 44px; border: 1px solid var(--line); border-radius: 6px; padding: .7rem .8rem; background: var(--surface); color: var(--ink); }
|
||||
input::placeholder, textarea::placeholder { color: var(--muted); }
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
button { cursor: pointer; border: 1px solid #b8c4ce; border-radius: .6rem; padding: .7rem 1rem; background: var(--surface); color: var(--ink); font-weight: 700; transition-property: background-color, border-color, color, scale; transition-duration: 150ms; transition-timing-function: cubic-bezier(.2, 0, 0, 1); }
|
||||
button:hover { background: #edf4f7; border-color: #7d9aaa; }
|
||||
button { min-height: 44px; cursor: pointer; border: 1px solid var(--line); border-radius: 6px; padding: .55rem .9rem; background: var(--surface); color: var(--ink); font-weight: 600; transition-property: background-color, border-color, color, scale; transition-duration: 150ms; transition-timing-function: cubic-bezier(.2, 0, 0, 1); }
|
||||
button:active { scale: .96; }
|
||||
button:disabled { cursor: wait; opacity: .55; }
|
||||
.review__button--primary { background: var(--brand); color: #fff; border-color: var(--brand); }
|
||||
.review__button--primary:hover { background: var(--brand-dark); border-color: var(--brand-dark); }
|
||||
.review__status { min-height: 1.7rem; margin-block: 1rem; color: var(--muted); }
|
||||
.review__status--error { color: #9b1c1c; }
|
||||
.review__workspace { display: grid; grid-template-columns: minmax(24rem, .9fr) minmax(28rem, 1.1fr); gap: 1.25rem; align-items: start; }
|
||||
.review__results, .review__document { min-width: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; padding: clamp(1rem, 2vw, 1.5rem); box-shadow: 0 .75rem 2rem rgb(24 42 54 / .05); }
|
||||
.review__status--error { color: #9D2633; }
|
||||
.review__workspace { display: grid; grid-template-columns: minmax(24rem, .9fr) minmax(28rem, 1.1fr); gap: 1rem; align-items: start; }
|
||||
.review__results, .review__document { min-width: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: clamp(1rem, 2vw, 1.5rem); }
|
||||
.review__panel-heading { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; margin-block-end: 1rem; }
|
||||
.review__panel-heading h2 { margin: 0; font-size: 1.1rem; letter-spacing: -.01em; }
|
||||
.review__panel-hint { color: var(--muted); font-size: .8rem; }
|
||||
.review__panel-heading h2 { margin: 0; font: 600 1.5rem/1.3 var(--serif); }
|
||||
.review__panel-hint { color: var(--muted); font-size: .875rem; }
|
||||
.review__results-list { display: grid; gap: .75rem; margin: 0; padding: 0; list-style: none; }
|
||||
.review__result { padding: 1rem; border: 1px solid var(--line); border-radius: .75rem; background: var(--surface-muted); transition-property: background-color, border-color, box-shadow, scale; transition-duration: 150ms; transition-timing-function: cubic-bezier(.2, 0, 0, 1); }
|
||||
.review__result:hover, .review__result--selected { border-color: #8eb3c1; background: #f1f8fa; box-shadow: 0 .5rem 1rem rgb(15 91 120 / .08); }
|
||||
.review__result { padding: 1rem 0; border: 0; border-block-end: 1px solid var(--line); border-radius: 0; background: var(--surface); transition-property: background-color, border-color; transition-duration: 150ms; transition-timing-function: cubic-bezier(.2, 0, 0, 1); }
|
||||
.review__result--selected { background: var(--subtle); box-shadow: none; }
|
||||
.review__result:first-child { border-block-start: 1px solid var(--line); }
|
||||
.review__result-title { display: flex; gap: .65rem; align-items: baseline; width: 100%; text-align: start; font-weight: 800; border: 0; padding: 0; background: none; color: var(--brand-dark); }
|
||||
.review__result-title:hover { background: none; border-color: transparent; color: var(--brand); }
|
||||
.review__result-title { display: flex; gap: .65rem; align-items: baseline; width: 100%; min-height: 44px; text-align: start; font-weight: 700; border: 0; padding: 0; background: none; color: var(--brand-dark); }
|
||||
.review__rank { flex: 0 0 auto; color: var(--brand); font-variant-numeric: tabular-nums; }
|
||||
.review__meta { margin-block: .5rem; color: var(--muted); font-size: .8rem; }
|
||||
.review__meta { margin-block: .5rem; color: var(--muted); font-size: .875rem; }
|
||||
.review__rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: .4rem; margin-block: 1rem .75rem; padding: 0; border: 0; }
|
||||
.review__rating legend { width: 100%; margin-block-end: .35rem; font-size: .8rem; font-weight: 700; color: var(--muted); grid-column: 1 / -1; }
|
||||
.review__rating label { min-width: 0; padding: .45rem .25rem; border: 1px solid #c5d0d8; border-radius: .5rem; color: var(--muted); text-align: center; font-size: .8rem; cursor: pointer; }
|
||||
.review__rating legend { width: 100%; margin-block-end: .35rem; font-size: .875rem; font-weight: 700; color: var(--muted); grid-column: 1 / -1; }
|
||||
.review__rating label { display: grid; place-items: center; min-width: 0; min-height: 44px; padding: .45rem .25rem; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); text-align: center; font-size: .875rem; cursor: pointer; }
|
||||
.review__rating label:has(input:checked) { border-color: var(--brand); background: var(--brand); color: #fff; }
|
||||
.review__rating label:hover { border-color: #7d9aaa; }
|
||||
.review__rating label:has(input:focus-visible) { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.review__rating input { position: absolute; opacity: 0; inline-size: 1px; block-size: 1px; }
|
||||
.review__rating input { position: absolute; opacity: 0; inline-size: 1px; min-height: 1px; block-size: 1px; }
|
||||
.review__comment { min-height: 4rem; resize: vertical; background: var(--surface); }
|
||||
.review__document { position: sticky; inset-block-start: 1rem; min-height: 24rem; }
|
||||
.review__document-meta { margin-block-end: 1rem; padding-block-end: 1rem; border-block-end: 1px solid var(--line); color: var(--muted); }
|
||||
.review__document-body { max-width: 75ch; overflow-wrap: anywhere; }
|
||||
.review__document-body { max-width: 75ch; overflow-wrap: anywhere; font: 400 1.125rem/1.65 "Noto Sans", "Segoe UI", sans-serif; }
|
||||
.review__document-body img { max-width: 100%; height: auto; }
|
||||
.review__actions { max-width: 1320px; margin: 1.5rem auto 0; padding-inline: clamp(1rem, 3vw, 3rem); }
|
||||
.review__actions-inner { display: flex; align-items: end; gap: .75rem; padding: 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 .75rem 2rem rgb(24 42 54 / .05); }
|
||||
.review__actions label { display: grid; gap: .35rem; flex: 1; color: var(--muted); font-size: .8rem; font-weight: 700; }
|
||||
.review__actions-inner { display: flex; align-items: end; gap: .75rem; padding: 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
|
||||
.review__actions label { display: grid; gap: .35rem; flex: 1; color: var(--muted); font-size: .875rem; font-weight: 700; }
|
||||
.review__actions label:last-of-type { flex: 2; }
|
||||
dialog { max-width: min(56rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid var(--line); border-radius: 1rem; padding: 1.5rem; box-shadow: 0 2rem 5rem rgb(10 20 30 / .2); }
|
||||
dialog { max-width: min(38rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid var(--line); border-radius: 6px; padding: 1.5rem; box-shadow: 0 1rem 2.5rem rgb(10 20 30 / .18); }
|
||||
dialog::backdrop { background: rgb(10 20 30 / .5); }
|
||||
.review__dialog-close { float: inline-end; }
|
||||
.review__empty { padding: 3rem 1rem; color: var(--muted); text-align: center; }
|
||||
.review__empty strong { display: block; margin-block-end: .35rem; color: var(--ink); }
|
||||
.review__tour-spotlight { position: fixed; z-index: 1; pointer-events: none; border: 4px solid var(--accent); border-radius: .75rem; box-shadow: 0 0 0 100vmax rgb(10 30 40 / .58), 0 0 0 8px rgb(231 184 75 / .25); }
|
||||
.review__tour { width: min(34rem, calc(100% - 2rem)); padding: 0; color: var(--ink); }
|
||||
.review__tour-spotlight { position: fixed; z-index: 1; pointer-events: none; border: 4px solid var(--accent); border-radius: 6px; box-shadow: 0 0 0 100vmax rgb(56 38 42 / .58), 0 0 0 8px rgb(163 123 50 / .25); }
|
||||
.review__tour { width: min(34rem, calc(100% - 2rem)); padding: .5rem; color: var(--ink); }
|
||||
.review__tour::backdrop { background: transparent; }
|
||||
.review__tour-content { padding: clamp(1.25rem, 4vw, 2rem); }
|
||||
.review__tour-kicker { margin: 0 0 .5rem; color: var(--brand); font-size: .75rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.review__tour h2 { margin: 0 0 .65rem; font-size: clamp(1.35rem, 3vw, 1.75rem); line-height: 1.1; letter-spacing: -.02em; }
|
||||
.review__tour-content { padding: clamp(1.5rem, 4vw, 2rem); }
|
||||
.review__tour h2 { margin: 0 0 .65rem; font-size: clamp(1.5rem, 3vw, 2rem); line-height: 1.2; letter-spacing: -.02em; }
|
||||
.review__tour p { margin: 0; color: var(--muted); }
|
||||
.review__tour-footer { display: flex; justify-content: space-between; gap: .75rem; align-items: center; margin-block-start: 1.5rem; }
|
||||
.review__tour-progress { color: var(--muted); font-size: .8rem; }
|
||||
.review__tour-progress { color: var(--muted); font-size: .8125rem; }
|
||||
.review__tour-actions { display: flex; gap: .5rem; }
|
||||
.review__nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; max-width: 1440px; margin: auto; padding: .75rem clamp(1rem, 3vw, 3rem); border-block-end: 1px solid var(--line); }
|
||||
.review__nav-links, .review__nav-account { display: flex; align-items: center; gap: .5rem; }
|
||||
.review__nav button, .review__nav a { min-height: 44px; }
|
||||
.review__nav .is-active { color: var(--brand); border-color: var(--brand); background: var(--subtle); }
|
||||
.review__page { max-width: 38rem; }
|
||||
.review__page h2 { font: 600 1.5rem/1.3 var(--serif); }
|
||||
.review__page label { display: grid; gap: .35rem; margin-block: .9rem; color: var(--muted); font-size: .9rem; font-weight: 600; }
|
||||
.review__page-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-block-start: 1rem; }
|
||||
.review__notice { padding: .75rem; background: #FFF3DA; color: #82520D; border-radius: 6px; }
|
||||
.review__user-list { padding: 0; list-style: none; }
|
||||
.review__user-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; padding-block: .75rem; border-block-end: 1px solid var(--line); }
|
||||
.review__user-meta { display: grid; gap: .25rem; color: var(--muted); white-space: pre-line; }
|
||||
.review__user-reset { display: flex; flex-wrap: wrap; gap: .5rem; align-items: end; }
|
||||
.review__user-reset label { display: grid; gap: .25rem; margin: 0; }
|
||||
.review__user-reset input { width: min(15rem, 100%); }
|
||||
.review__header-top { padding-block: .75rem; align-items: center; }
|
||||
.review__header-content { max-width: 1440px; margin: auto; padding-inline: clamp(1rem, 3vw, 3rem); }
|
||||
body.review__locked .review__header, body.review__locked .review__main, body.review__locked .review__actions, body.review__locked footer, body.review__locked .search { visibility: hidden; }
|
||||
.search { max-width: 1600px; margin: auto; }
|
||||
.search__query { padding: 1rem clamp(1rem, 3vw, 3rem); background: var(--surface); border-block-end: 1px solid var(--line); }
|
||||
.search__query > label, .search__filters > label { display: grid; gap: .35rem; color: var(--muted); font-size: .85rem; font-weight: 600; }
|
||||
.search__query-row { display: grid; grid-template-columns: minmax(15rem, 1fr) auto 9rem; gap: .75rem; align-items: end; }
|
||||
.search__language { display: grid; gap: .25rem; color: var(--muted); font-size: .8125rem; }
|
||||
.search__status { margin-inline: clamp(1rem, 3vw, 3rem); }
|
||||
.search__layout { display: grid; grid-template-columns: 230px minmax(320px, .9fr) minmax(420px, 1.2fr); min-height: 65vh; border-block: 1px solid var(--line); background: var(--surface); }
|
||||
.search__filters, .search__results, .search__document { min-width: 0; padding: 1rem; }
|
||||
.search__filters, .search__results { border-inline-end: 1px solid var(--line); }
|
||||
.search__filters h2, .search__results h2, .search__document h2 { margin: 0; font: 600 1.5rem/1.3 var(--serif); }
|
||||
.search__filters .review__panel-heading, .search__results .review__panel-heading { align-items: center; }
|
||||
.search__filter { margin-block: 1.25rem; }
|
||||
.search__filter h3 { margin: 0 0 .4rem; font-size: 1rem; }
|
||||
.search__filter label { display: flex; align-items: center; gap: .5rem; min-height: 44px; }
|
||||
.search__filter input { width: 24px; min-height: 24px; height: 24px; accent-color: var(--brand); }
|
||||
.search__filters > label { margin-block: .75rem; }
|
||||
.search__result-list { margin: 0; padding: 0; list-style: none; }
|
||||
.search__result { padding: 1rem .5rem; border-block-end: 1px solid var(--line); }
|
||||
.search__result--selected { background: var(--subtle); }
|
||||
.search__result-title { min-height: 44px; border: 0; padding: 0; text-align: start; background: none; color: var(--brand); font-weight: 700; }
|
||||
.search__pagination { display: flex; justify-content: space-between; align-items: center; gap: .5rem; padding-block-start: 1rem; }
|
||||
.search__document { min-height: 60vh; }
|
||||
.search__document-controls { display: flex; flex-wrap: wrap; gap: .75rem; }
|
||||
.search__document-controls label { display: grid; gap: .25rem; min-width: 11rem; color: var(--muted); font-size: .875rem; }
|
||||
.search__document-body { max-width: 75ch; margin: auto; font: 400 1.125rem/1.65 "Noto Sans", "Segoe UI", sans-serif; }
|
||||
.search__document-body h1, .search__document-body h2, .search__document-body h3 { font-family: var(--serif); }
|
||||
.search__document-body img { max-width: 100%; height: auto; }
|
||||
.search__sr-only { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (max-width: 800px) {
|
||||
.search__layout { grid-template-columns: 1fr; }
|
||||
.search__filters, .search__results { border-inline-end: 0; border-block-end: 1px solid var(--line); }
|
||||
.search__document { min-height: 24rem; }
|
||||
.review__search { grid-template-columns: 1fr 1fr; }
|
||||
.review__search .review__field:first-child { grid-column: 1 / -1; }
|
||||
.review__workspace { grid-template-columns: 1fr; }
|
||||
@@ -91,15 +135,17 @@
|
||||
.review__actions label { flex: 1 1 14rem !important; }
|
||||
.review__actions button { flex: 1 1 100%; }
|
||||
}
|
||||
@media (max-width: 480px) { .review__search { grid-template-columns: 1fr; } .review__search .review__field:first-child, .review__search button { grid-column: auto; } .review__rating label { font-size: .72rem; } }
|
||||
@media (max-width: 480px) { .review__search { grid-template-columns: 1fr; } .review__search .review__field:first-child, .review__search button { grid-column: auto; } .review__rating { grid-template-columns: repeat(2, minmax(0, 1fr)); } .search__query-row { grid-template-columns: 1fr 1fr; } .search__query-row > input { grid-column: 1 / -1; } .review__nav { align-items: start; flex-wrap: wrap; } .review__nav-links { order: 3; width: 100%; flex-wrap: wrap; } .review__nav-account { margin-inline-start: auto; } }
|
||||
@media (hover: hover) { button:hover { background: var(--surface-muted); border-color: var(--muted); } .review__button--primary:hover { background: var(--brand-dark); border-color: var(--brand-dark); } .review__result:hover { background: var(--subtle); } .review__result-title:hover, .search__result-title:hover { background: none; color: var(--brand); } .review__rating label:hover { border-color: var(--brand); } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="review__skip" href="#results">Перейти к результатам</a>
|
||||
<header class="review__header">
|
||||
<div class="review__header-top"><div class="review__eyebrow">Внутренняя лаборатория</div><button id="tutorial-open" type="button">Как это работает</button></div>
|
||||
<h1>Проверка качества поиска</h1><p>Помогите проверить, насколько первые результаты отвечают на юридический запрос.</p>
|
||||
<div class="review__nav"><div class="review__eyebrow">Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="search-nav" href="/">Поиск</a><a id="review-nav" href="/review">Лаборатория</a><button id="account-open" type="button">Личный кабинет</button><button id="users-open" type="button" hidden>Пользователи</button></nav><div class="review__nav-account"><span id="signed-in-user"></span><button id="logout" type="button">Выйти</button></div></div>
|
||||
<div class="review__header-content"><div class="review__header-top"><button id="tutorial-open" type="button">Как это работает</button></div>
|
||||
<h1>Проверка качества поиска</h1><p>Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div>
|
||||
</header>
|
||||
<main class="review__main" id="review">
|
||||
<form class="review__search" id="search-form">
|
||||
@@ -114,12 +160,27 @@
|
||||
<section class="review__document" id="document-panel" aria-labelledby="document-heading"><div class="review__panel-heading"><h2 id="document-heading">Просмотр документа</h2><span class="review__panel-hint">Откройте результат</span></div><div class="review__document-meta" id="document-meta">Выберите название в списке, чтобы прочитать документ.</div><article class="review__document-body" id="document-body"></article></section>
|
||||
</div>
|
||||
</main>
|
||||
<div class="review__actions"><div class="review__actions-inner"><label>Ваше имя или псевдоним<input id="reviewer" maxlength="120" autocomplete="name" placeholder="Например, Айжан К."></label> <label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label> <button class="review__button--primary" id="save" type="button">Сохранить разметку</button></div></div>
|
||||
<main class="search" id="search-page" hidden>
|
||||
<h1 class="search__sr-only">Поиск по правовой базе</h1>
|
||||
<form class="search__query" id="public-search-form"><label for="public-query">Поиск по правовой базе</label><div class="search__query-row"><input id="public-query" maxlength="500" autocomplete="off" placeholder="Введите правовой вопрос или название документа"><button class="review__button--primary" type="submit">Найти</button><label class="search__language">Язык<select id="public-language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label></div></form>
|
||||
<p class="review__status" id="public-status" role="status" aria-live="polite"></p>
|
||||
<div class="search__layout"><aside class="search__filters" aria-labelledby="filters-heading"><div class="review__panel-heading"><h2 id="filters-heading">Фильтры</h2><button id="clear-filters" type="button">Сбросить</button></div><div id="filter-fields"></div><label>Дата принятия от<input id="date-from" type="date"></label><label>Дата принятия до<input id="date-to" type="date"></label></aside>
|
||||
<section class="search__results" aria-labelledby="search-results-heading"><div class="review__panel-heading"><div><h2 id="search-results-heading">Результаты</h2><span class="review__panel-hint" id="result-count">Введите запрос</span></div><label>Сортировка<select id="sort-order"><option value="relevance">По релевантности</option><option value="date">По дате</option></select></label></div><ol id="public-results" class="search__result-list"><li class="review__empty"><strong>Начните с поиска</strong>Введите запрос, чтобы найти документы.</li></ol><div class="search__pagination"><button id="page-prev" type="button" disabled>Назад</button><span id="page-label">Страница 1</span><button id="page-next" type="button" disabled>Далее</button></div></section>
|
||||
<section class="search__document" aria-labelledby="public-document-heading"><div class="review__panel-heading"><h2 id="public-document-heading">Документ</h2></div><div class="search__document-controls"><label>Редакция<select id="document-edition" disabled></select></label><label>Язык документа<select id="document-language" disabled></select></label></div><p id="public-document-meta" class="review__document-meta">Выберите результат, чтобы прочитать доступную редакцию.</p><article id="public-document-body" class="review__document-body"></article></section>
|
||||
</div>
|
||||
</main>
|
||||
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div></div>
|
||||
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
|
||||
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
|
||||
<dialog id="account-dialog" aria-labelledby="account-heading"><section class="review__page"><h2 id="account-heading">Личный кабинет</h2><dl><dt>Логин</dt><dd id="profile-username"></dd><dt>Роль</dt><dd id="profile-role">Администратор</dd><dt>Дата создания</dt><dd id="profile-created"></dd></dl><form id="profile-password-form"><h3>Изменить пароль</h3><p>Новый пароль должен содержать не менее 12 символов.</p><label>Текущий пароль<input id="profile-current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="profile-new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="profile-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Изменить пароль</button><button type="button" data-close="account-dialog">Закрыть</button></div></form></section></dialog>
|
||||
<dialog id="users-dialog" aria-labelledby="users-heading"><section class="review__page"><h2 id="users-heading">Управление пользователями</h2><form id="create-user-form"><label>Логин<input id="new-username" autocomplete="off" minlength="3" maxlength="64" required></label><label>Временный пароль<input id="temporary-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p>Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.</p><p id="users-error" role="alert" class="review__status review__status--error"></p><button class="review__button--primary" type="submit">Создать пользователя</button></form><ul id="user-list" class="review__user-list"></ul><button type="button" data-close="users-dialog">Закрыть</button></section></dialog>
|
||||
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
||||
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
|
||||
<footer class="review__header">Акылдаш · Backend v0.10.4 · Внутренняя лаборатория релевантности</footer>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.1.1 · Backend v0.11.2 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<script>
|
||||
document.body.classList.add('review__locked');
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
const draftStorageKey = () => `${DRAFT_KEY}:${account?.username || 'anonymous'}`;
|
||||
const TOUR_KEY = 'akyldash-search-review-tour-seen';
|
||||
const TOUR_STEPS = [
|
||||
{ target: 'query', title: 'Начните с юридического вопроса', text: 'Введите практический запрос, например «как открыть ОсОО?», выберите язык и нажмите «Показать результаты».', label: 'Шаг 1 из 4' },
|
||||
@@ -129,14 +190,116 @@
|
||||
];
|
||||
const state = { results: [], token: '', selected: null, query: '' };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let account = null;
|
||||
const reviewRoute = location.pathname === '/review';
|
||||
$('review').hidden = !reviewRoute; $('search-page').hidden = reviewRoute; $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', !reviewRoute);
|
||||
if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.1.1 · Backend v0.11.2'; }
|
||||
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
|
||||
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
|
||||
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = `${user.username} · ${user.role}`; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); $('users-open').hidden = user.username.toLowerCase() !== 'admin'; document.body.classList.remove('review__locked'); if (!reviewRoute) initSearchPage(); else if (!hasSeenTour()) setTimeout(openTutorial, 350); };
|
||||
const filterFields = [['document_types', 'Вид документа', 'document_type'], ['statuses', 'Статус', 'status'], ['authorities', 'Орган принятия', 'authority'], ['legal_forces', 'Юридическая сила', 'legal_force']];
|
||||
let filterCatalogs = {};
|
||||
let publicPage = 1;
|
||||
let selectedPublicDocument = null;
|
||||
const selectedFilters = () => Object.fromEntries(filterFields.map(([key,, parameter]) => [parameter, document.querySelector(`input[name="filter-${parameter}"]:checked`)?.value || '']));
|
||||
function renderFilterCatalogs(payload) { filterCatalogs = payload; const root = $('filter-fields'); root.replaceChildren(); filterFields.forEach(([key, title, parameter]) => { const items = payload[key] || []; if (!items.length) return; const group = document.createElement('section'); group.className = 'search__filter'; const heading = document.createElement('h3'); heading.textContent = title; group.append(heading); items.forEach((item) => { const label = document.createElement('label'); const input = document.createElement('input'); input.type = 'radio'; input.name = `filter-${parameter}`; input.value = item.code; input.addEventListener('change', () => runSearch(1)); const text = document.createElement('span'); text.textContent = `${item.labels.ru || item.labels.ky || item.code} (${item.count})`; label.append(input, text); group.append(label); }); root.append(group); }); }
|
||||
function renderPublicResults(results) { const list = $('public-results'); list.replaceChildren(); if (!results.length) { list.innerHTML = '<li class="review__empty"><strong>Документы не найдены</strong>Измените запрос или сбросьте фильтры.</li>'; return; } results.forEach((result, index) => { const row = document.createElement('li'); row.className = 'search__result'; const title = document.createElement('button'); title.type = 'button'; title.className = 'search__result-title'; title.textContent = result.name || 'Название не указано'; title.addEventListener('click', () => openPublicDocument(result, row)); const meta = document.createElement('p'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number, result.legal_force].filter(Boolean).join(' · '); const snippet = document.createElement('p'); snippet.textContent = result.snippet || 'Для документа нет выдержки из текста.'; row.append(title, meta, snippet); list.append(row); }); }
|
||||
async function runSearch(page = 1) { const query = $('public-query').value.trim(); if (!query) { $('public-status').textContent = 'Введите запрос, чтобы начать поиск.'; return; } publicPage = page; $('public-status').textContent = 'Выполняется поиск…'; const params = new URLSearchParams({q: query, language: $('public-language').value, page: String(page), page_size: '20', sort: $('sort-order').value}); for (const [key, value] of Object.entries(selectedFilters())) if (value) params.set(key, value); if ($('date-from').value) params.set('date_from', $('date-from').value); if ($('date-to').value) params.set('date_to', $('date-to').value); history.replaceState(null, '', `/?${params}`); try { const response = await apiFetch(`/search?${params}`); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || 'Не удалось выполнить поиск.'); renderPublicResults(payload.results); $('result-count').textContent = `${payload.results.length} документов`; $('public-status').textContent = payload.results.length ? `Результаты для запроса «${query}».` : `По запросу «${query}» документов нет. Измените запрос или фильтры.`; $('page-label').textContent = `Страница ${page}`; $('page-prev').disabled = page <= 1; $('page-next').disabled = !payload.has_next; } catch (error) { $('public-status').textContent = error.message || 'Не удалось выполнить поиск. Повторите попытку.'; $('result-count').textContent = 'Поиск недоступен'; } }
|
||||
async function loadPublicEdition() {
|
||||
if (!selectedPublicDocument) return;
|
||||
const {code} = selectedPublicDocument;
|
||||
const edition = $('document-edition').value;
|
||||
const editionInfo = selectedPublicDocument.editions.find((item) => item.source_code === edition);
|
||||
const languages = editionInfo?.available_languages || [];
|
||||
const languageSelect = $('document-language');
|
||||
[...languageSelect.options].forEach((option) => { option.disabled = !languages.includes(option.value); });
|
||||
if (!languages.length) { $('public-document-meta').textContent = 'В этой редакции нет доступного текста.'; $('public-document-body').replaceChildren(); return; }
|
||||
if (!languages.includes(languageSelect.value)) languageSelect.value = languages[0];
|
||||
const language = languageSelect.value;
|
||||
$('public-document-meta').textContent = 'Загрузка редакции…';
|
||||
try {
|
||||
const response = await apiFetch(`/documents/${encodeURIComponent(code)}/editions/${encodeURIComponent(edition)}?language=${encodeURIComponent(language)}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || 'Редакция недоступна.');
|
||||
const content = payload.content[language];
|
||||
const meta = $('public-document-meta'); meta.replaceChildren();
|
||||
const details = document.createElement('span'); details.textContent = [selectedPublicDocument.type, selectedPublicDocument.status, selectedPublicDocument.date_adopted].filter(Boolean).join(' · ');
|
||||
const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(code)}/edition/${encodeURIComponent(edition)}/${encodeURIComponent(language)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = 'Официальный источник';
|
||||
meta.append(details, document.createTextNode(' · '), source);
|
||||
$('public-document-body').innerHTML = content.html;
|
||||
} catch (error) { $('public-document-meta').textContent = error.message || 'Не удалось загрузить документ.'; $('public-document-body').replaceChildren(); }
|
||||
}
|
||||
async function openPublicDocument(result, row) {
|
||||
document.querySelectorAll('.search__result--selected').forEach((item) => item.classList.remove('search__result--selected'));
|
||||
row.classList.add('search__result--selected'); selectedPublicDocument = result;
|
||||
$('public-document-heading').textContent = result.name || 'Документ'; $('public-document-body').replaceChildren();
|
||||
try {
|
||||
const response = await apiFetch(`/documents/${encodeURIComponent(result.code)}/editions`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || 'Не удалось получить список редакций.');
|
||||
selectedPublicDocument.editions = payload.editions || [];
|
||||
const editionSelect = $('document-edition'); editionSelect.replaceChildren();
|
||||
(payload.editions || []).forEach((edition) => { const option = document.createElement('option'); option.value = edition.source_code; const name = edition.name?.[$('public-language').value] || edition.name?.ru || edition.name?.ky; option.textContent = [name, edition.source_code].filter(Boolean).join(' · '); editionSelect.append(option); });
|
||||
const matchedEdition = [...editionSelect.options].find((option) => option.value === String(result.edition));
|
||||
if (matchedEdition) editionSelect.value = matchedEdition.value;
|
||||
editionSelect.disabled = !editionSelect.options.length;
|
||||
const languages = payload.available_languages || [];
|
||||
const languageSelect = $('document-language'); languageSelect.replaceChildren();
|
||||
languages.forEach((language) => { const option = document.createElement('option'); option.value = language; option.textContent = language === 'ru' ? 'Русский' : 'Кыргызский'; languageSelect.append(option); });
|
||||
languageSelect.disabled = !languages.length;
|
||||
if (!languages.length || !editionSelect.options.length) throw new Error('Для документа нет доступных редакций с текстом.');
|
||||
languageSelect.value = languages.includes($('public-language').value) ? $('public-language').value : languages[0];
|
||||
await loadPublicEdition();
|
||||
} catch (error) { $('public-document-meta').textContent = error.message || 'Документ недоступен.'; }
|
||||
}
|
||||
$('document-edition').addEventListener('change', loadPublicEdition); $('document-language').addEventListener('change', loadPublicEdition);
|
||||
async function initSearchPage() { if (reviewRoute || filterCatalogs.length) return; const params = new URLSearchParams(location.search); $('public-query').value = params.get('q') || ''; $('public-language').value = params.get('language') || 'ru'; $('sort-order').value = params.get('sort') || 'relevance'; $('date-from').value = params.get('date_from') || ''; $('date-to').value = params.get('date_to') || ''; const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); for (const [key,, parameter] of filterFields) { const value = params.get(parameter); const input = value && document.querySelector(`input[name="filter-${parameter}"][value="${CSS.escape(value)}"]`); if (input) input.checked = true; } const query = params.get('q'); if (query) runSearch(Number(params.get('page') || 1)); }
|
||||
$('public-search-form').addEventListener('submit', (event) => { event.preventDefault(); runSearch(1); });
|
||||
$('sort-order').addEventListener('change', () => runSearch(1)); $('date-from').addEventListener('change', () => runSearch(1)); $('date-to').addEventListener('change', () => runSearch(1)); $('public-language').addEventListener('change', async () => { const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); if ($('public-query').value.trim()) runSearch(1); });
|
||||
$('page-prev').addEventListener('click', () => runSearch(publicPage - 1)); $('page-next').addEventListener('click', () => runSearch(publicPage + 1));
|
||||
$('clear-filters').addEventListener('click', () => { document.querySelectorAll('#filter-fields input:checked').forEach((input) => { input.checked = false; }); $('date-from').value = ''; $('date-to').value = ''; $('sort-order').value = 'relevance'; runSearch(1); });
|
||||
async function loadUsers() {
|
||||
$('users-error').textContent = '';
|
||||
const response = await apiFetch('/admin/users');
|
||||
if (!response.ok) { $('users-error').textContent = 'Не удалось загрузить список пользователей.'; return; }
|
||||
const {users} = await response.json();
|
||||
const formatUserDate = (timestamp) => timestamp ? new Intl.DateTimeFormat('ru-RU', {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(timestamp * 1000)) : '—';
|
||||
const list = $('user-list'); list.replaceChildren();
|
||||
users.forEach((user) => {
|
||||
const row = document.createElement('li'); row.className = 'review__user-row';
|
||||
const label = document.createElement('span'); label.textContent = `${user.username} · Администратор${user.must_change_password ? ' · требуется сменить пароль' : ''}`; row.append(label);
|
||||
const dates = document.createElement('small'); dates.className = 'review__user-meta'; dates.textContent = `Первый вход: ${formatUserDate(user.first_login_at)}\nПоследний вход: ${formatUserDate(user.last_login_at)}\nСброс пароля: ${formatUserDate(user.password_reset_at)}`; row.append(dates);
|
||||
if (user.username.toLowerCase() !== 'admin') {
|
||||
const resetForm = document.createElement('form'); resetForm.className = 'review__user-reset';
|
||||
const password = document.createElement('input'); password.type = 'password'; password.autocomplete = 'new-password'; password.minLength = 12; password.maxLength = 256; password.required = true;
|
||||
const passwordLabel = document.createElement('label'); passwordLabel.append(document.createTextNode('Новый временный пароль (от 12 символов)'), password);
|
||||
const reset = document.createElement('button'); reset.type = 'submit'; reset.textContent = 'Сбросить пароль'; resetForm.append(passwordLabel, reset);
|
||||
resetForm.addEventListener('submit', async (event) => { event.preventDefault(); const result = await apiFetch(`/admin/users/${encodeURIComponent(user.username)}/reset-password`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password: password.value})}); if (!result.ok) { $('users-error').textContent = (await result.json()).error || 'Не удалось сбросить пароль.'; return; } await loadUsers(); });
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Удалить'; remove.addEventListener('click', async () => { if (!confirm(`Удалить учетную запись ${user.username}? Сохраненная разметка останется в истории.`)) return; const result = await apiFetch(`/admin/users/${encodeURIComponent(user.username)}`, {method: 'DELETE'}); if (!result.ok) { $('users-error').textContent = 'Не удалось удалить учетную запись.'; return; } await loadUsers(); });
|
||||
row.append(resetForm, remove);
|
||||
}
|
||||
list.append(row);
|
||||
});
|
||||
}
|
||||
$('login-form').addEventListener('submit', async (event) => { event.preventDefault(); $('login-error').textContent = ''; const response = await fetch('/auth/login', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('login-username').value, password: $('login-password').value})}); const payload = await response.json(); if (!response.ok) { $('login-error').textContent = payload.error || 'Не удалось войти. Проверьте логин и пароль.'; $('login-password').focus(); return; } $('login-dialog').close(); $('login-password').value = ''; if (payload.account.must_change_password) { $('current-password').value = sessionTemporaryPassword; $('password-dialog').showModal(); $('new-password').focus(); } else { enterWorkspace(payload.account); } });
|
||||
let sessionTemporaryPassword = '';
|
||||
$('login-form').addEventListener('submit', () => { sessionTemporaryPassword = $('login-password').value; }, true);
|
||||
$('password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('password-error').textContent = ''; if ($('new-password').value !== $('confirm-password').value) { $('password-error').textContent = 'Новые пароли не совпадают.'; $('confirm-password').focus(); return; } const response = await fetch('/auth/change-password', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('current-password').value, new_password: $('new-password').value})}); const payload = await response.json(); if (!response.ok) { $('password-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('password-dialog').close(); sessionTemporaryPassword = ''; $('password-form').reset(); enterWorkspace(payload.account); });
|
||||
$('profile-password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('profile-error').textContent = ''; const response = await apiFetch('/auth/change-password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('profile-current-password').value, new_password: $('profile-new-password').value})}); const payload = await response.json(); if (!response.ok) { $('profile-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('account-dialog').close(); $('profile-password-form').reset(); enterWorkspace(payload.account); });
|
||||
$('create-user-form').addEventListener('submit', async (event) => { event.preventDefault(); $('users-error').textContent = ''; const response = await apiFetch('/admin/users', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('new-username').value, password: $('temporary-password').value})}); const payload = await response.json(); if (!response.ok) { $('users-error').textContent = payload.error || 'Не удалось создать учетную запись.'; return; } $('create-user-form').reset(); await loadUsers(); });
|
||||
$('account-open').addEventListener('click', () => $('account-dialog').showModal());
|
||||
$('users-open').addEventListener('click', async () => { await loadUsers(); $('users-dialog').showModal(); });
|
||||
document.querySelectorAll('[data-close]').forEach((button) => button.addEventListener('click', () => $(button.dataset.close).close()));
|
||||
$('logout').addEventListener('click', async () => { await fetch('/auth/logout', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: '{}'}); account = null; document.body.classList.add('review__locked'); showLogin(); });
|
||||
(async () => { const response = await fetch('/auth/me', {credentials: 'same-origin'}); if (!response.ok) { showLogin(); return; } const payload = await response.json(); if (payload.account.must_change_password) { $('password-dialog').showModal(); $('current-password').focus(); return; } enterWorkspace(payload.account); })();
|
||||
const setStatus = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('review__status--error', error); $('status').setAttribute('role', error ? 'alert' : 'status'); $('status').setAttribute('aria-live', error ? 'assertive' : 'polite'); };
|
||||
const setError = (text) => setStatus(text, true);
|
||||
const escapeText = (value) => value == null ? '' : String(value);
|
||||
const readDraft = () => { try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || 'null'); } catch (_) { return null; } };
|
||||
const readDraft = () => { try { return JSON.parse(localStorage.getItem(draftStorageKey()) || 'null'); } catch (_) { return null; } };
|
||||
const hasSeenTour = () => { try { return localStorage.getItem(TOUR_KEY) === '1'; } catch (_) { return false; } };
|
||||
const markTourSeen = () => { try { localStorage.setItem(TOUR_KEY, '1'); } catch (_) {} };
|
||||
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(DRAFT_KEY, JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, reviewer: $('reviewer').value, overallComment: $('overall-comment').value, results})); } catch (_) {} };
|
||||
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } item.querySelector('textarea').value = saved.comment || ''; }); };
|
||||
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(draftStorageKey(), JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, overallComment: $('overall-comment').value, results})); } catch (_) {} };
|
||||
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = account?.username || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } item.querySelector('textarea').value = saved.comment || ''; }); };
|
||||
function renderResults() {
|
||||
$('results').replaceChildren();
|
||||
if (!state.results.length) { $('results').innerHTML = '<li class="review__empty"><strong>Результатов пока нет</strong>Введите другой запрос и попробуйте снова.</li>'; return; }
|
||||
@@ -153,7 +316,7 @@
|
||||
}
|
||||
async function openDocument(index, trigger) {
|
||||
const result = state.results[index]; state.selected = trigger; [...$('results').children].forEach((item) => item.classList.remove('review__result--selected')); trigger.closest('.review__result')?.classList.add('review__result--selected'); setStatus('Загрузка документа…');
|
||||
try { const response = await fetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = content.html; $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = content.html; if (matchMedia('(max-width: 800px)').matches) $('document-dialog').showModal(); setStatus('Документ загружен.'); } catch (error) { setError('Не удалось загрузить документ. Повторите попытку.'); }
|
||||
try { const response = await apiFetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = content.html; $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = content.html; if (matchMedia('(max-width: 800px)').matches) $('document-dialog').showModal(); setStatus('Документ загружен.'); } catch (error) { setError('Не удалось загрузить документ. Повторите попытку.'); }
|
||||
}
|
||||
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
|
||||
let tutorialStep = 0; let tutorialReturnFocus = null;
|
||||
@@ -162,11 +325,11 @@
|
||||
function closeTutorial() { clearTourTarget(); $('tutorial-dialog').close(); markTourSeen(); tutorialReturnFocus?.focus(); }
|
||||
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); $('tutorial-dialog').showModal(); $('tutorial-next').focus(); }
|
||||
$('tutorial-open').addEventListener('click', openTutorial); $('tutorial-next').addEventListener('click', () => { if (tutorialStep === TOUR_STEPS.length - 1) closeTutorial(); else { tutorialStep += 1; renderTutorial(); } }); $('tutorial-skip').addEventListener('click', closeTutorial); $('tutorial-dialog').addEventListener('cancel', (event) => { event.preventDefault(); closeTutorial(); });
|
||||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await fetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } });
|
||||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } });
|
||||
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
|
||||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); if (!reviewer) { $('reviewer').focus(); setError('Укажите проверяющего.'); return; } const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await fetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(DRAFT_KEY); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||||
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; }
|
||||
if (!hasSeenTour()) setTimeout(openTutorial, 350);
|
||||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await apiFetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(draftStorageKey()); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||||
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = account?.username || ''; $('overall-comment').value = draft.overallComment || ''; }
|
||||
if (reviewRoute) $('tutorial-open').hidden = false;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,3 +6,4 @@ OPENSEARCH_MEM_LIMIT=4g
|
||||
OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g
|
||||
# Generate with: openssl rand -hex 32
|
||||
REVIEW_SECRET=replace-with-a-random-secret
|
||||
COOKIE_SECURE=true
|
||||
@@ -5,8 +5,9 @@ binds the API only to `127.0.0.1`; publish it through an authenticated reverse
|
||||
proxy or VPN. OpenSearch is not published outside the compose network.
|
||||
|
||||
The current compose intentionally disables the OpenSearch security plugin to
|
||||
match the existing API client. Keep both services on a private host/network
|
||||
until authenticated OpenSearch support is implemented.
|
||||
match the existing API client. Keep both services on a private host/network.
|
||||
The application provides cookie-based accounts for its search API and review
|
||||
workspace; it does not replace host-level network controls or TLS.
|
||||
|
||||
## First deployment
|
||||
|
||||
@@ -32,6 +33,30 @@ until authenticated OpenSearch support is implemented.
|
||||
validation succeed. Back up `DATA_ROOT` and the `opensearch-data` volume
|
||||
before the first import.
|
||||
|
||||
## Initial administrator and accounts
|
||||
|
||||
After starting the backend for the first time, create the initial `admin`
|
||||
account with an interactive password prompt:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f compose.yaml exec backend python -m search.api --create-admin
|
||||
```
|
||||
|
||||
The account database is stored at `DATA_ROOT/search-users.sqlite3`. Back it up
|
||||
alongside the review database. Sign in at `/review`; `admin` can create accounts
|
||||
with temporary passwords. Each new account must replace its temporary password
|
||||
at first sign-in. Only the `admin` login can create, reset, or delete accounts.
|
||||
|
||||
If the `admin` password is lost, reset it in an interactive terminal with:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f compose.yaml exec backend python -m search.api --reset-admin-password
|
||||
```
|
||||
|
||||
The next sign-in requires another password change. Serve the backend behind
|
||||
HTTPS, rate-limit login requests at the reverse proxy, and keep
|
||||
`COOKIE_SECURE=true` in production.
|
||||
|
||||
This is a deployment baseline, not a public internet exposure recipe. TLS,
|
||||
authentication, backups, monitoring, and a production OpenSearch security
|
||||
configuration must be provided by the host reverse proxy/operations setup.
|
||||
backups, monitoring, and a production OpenSearch security configuration must
|
||||
be provided by the host reverse proxy/operations setup.
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REVIEW_SECRET: ${REVIEW_SECRET:?set REVIEW_SECRET in .env}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
||||
command:
|
||||
- python
|
||||
- -m
|
||||
@@ -50,6 +51,8 @@ services:
|
||||
- /app/data/minjust-normalized
|
||||
- --reviews-db
|
||||
- /app/data/search-reviews.sqlite3
|
||||
- --users-db
|
||||
- /app/data/search-users.sqlite3
|
||||
- --review-secret
|
||||
- ${REVIEW_SECRET}
|
||||
ports:
|
||||
|
||||
+3
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
## Продукт
|
||||
|
||||
- [Дизайн-система «Түндүк»](../DESIGN.md) — утверждённые палитра,
|
||||
типографика, компоненты и правила адаптивности портала и лаборатории.
|
||||
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
|
||||
правила работы с данными.
|
||||
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
|
||||
@@ -39,4 +41,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.3 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -1,12 +1,12 @@
|
||||
# Статус проекта
|
||||
|
||||
Последняя проверка: 2026-09-18
|
||||
Последняя проверка: 2026-10-10
|
||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: `0.10.3`
|
||||
- Frontend: не создан
|
||||
- Backend: `0.11.2`
|
||||
- Frontend: `0.1.1` — поиск, лаборатория релевантности, аккаунты
|
||||
|
||||
## Краткий итог
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
- Не настроены уведомления Gitea.
|
||||
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
|
||||
|
||||
### Готовность поиска к frontend
|
||||
### Готовность поиска к публичному frontend
|
||||
|
||||
- Реализована внутренняя лаборатория для просмотра и оценки фактической
|
||||
выдачи OpenSearch; пилот с юристами ещё не проведён.
|
||||
@@ -140,8 +140,11 @@
|
||||
v1; спорные строки нельзя включать в baseline.
|
||||
- Не зафиксированы неизменяемая копия подтверждённого relevance set и baseline
|
||||
`Recall@10`/`MRR@10`.
|
||||
- Не пройден финальный readiness gate: обновление корпуса, переиндексация,
|
||||
проверка выдачи и API-сценарии для RU, KY и одноязычных актов.
|
||||
- Публичный readiness gate остаётся открытым: нужны обновление корпуса,
|
||||
переиндексация, проверка выдачи и API-сценарии для RU, KY и одноязычных актов.
|
||||
- Создан внутренний авторизованный frontend поиска и лаборатории; первая
|
||||
учетная запись `admin` создаётся CLI-командой, новых пользователей добавляет
|
||||
только `admin`.
|
||||
|
||||
## Важные неизвестные
|
||||
|
||||
@@ -162,7 +165,7 @@
|
||||
3. Разобрать ошибки ранжирования, сохранить baseline и повторить ту же проверку
|
||||
после изменений.
|
||||
4. Затем пройти readiness gate issue #21 для корпуса, индекса и Search API перед
|
||||
проектированием публичного frontend.
|
||||
подготовкой публичного frontend.
|
||||
|
||||
## История изменений статуса
|
||||
|
||||
@@ -278,4 +281,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.3 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.1
|
||||
@@ -1,19 +1,20 @@
|
||||
# Готовность к проектированию frontend
|
||||
# Готовность к публичному frontend
|
||||
|
||||
Этот документ определяет обязательный результат до начала проектирования и
|
||||
разработки пользовательского интерфейса. Он дополняет
|
||||
разработки публичной пользовательской системы. Он дополняет
|
||||
[план frontend поисковой СПС](frontend-search-sps-plan.md): тот описывает MVP и
|
||||
спринты frontend, этот — критерий перехода к ним.
|
||||
|
||||
## Правило перехода
|
||||
|
||||
Проектирование frontend начинается, когда выполнены все обязательные пункты
|
||||
этого плана и пройден финальный readiness gate. До этого не создаются
|
||||
`frontend/`, макеты, UI-компоненты или mock-данные, заменяющие неготовый
|
||||
backend-контракт.
|
||||
Публичный frontend и его выпуск начинаются, когда выполнены все обязательные
|
||||
пункты этого плана и пройден финальный readiness gate. Внутренняя
|
||||
авторизованная поисковая страница и лаборатория могут развиваться отдельно по
|
||||
явной задаче и используют только реальные существующие API.
|
||||
|
||||
Вне этого этапа остаются аккаунты, уведомления, RAG, судебная практика и
|
||||
внешние коммерческие сервисы.
|
||||
Персональные функции вне этого этапа: закладки, заметки, уведомления, RAG,
|
||||
судебная практика и внешние коммерческие сервисы. Внутренние учетные записи
|
||||
для лаборатории не включают публичную регистрацию.
|
||||
|
||||
## 1. Данные и поисковый индекс
|
||||
|
||||
|
||||
@@ -105,6 +105,11 @@ MVP не входят.
|
||||
|
||||
## Рекомендуемая основа frontend
|
||||
|
||||
Для внутреннего frontend лаборатории и авторизованного поиска используется
|
||||
одна HTML-страница, которую отдаёт существующий Search API, и браузерный
|
||||
JavaScript без сборщика и runtime-зависимостей. Это сохраняет текущую схему
|
||||
развёртывания. Публичный frontend после readiness gate можно оценить отдельно.
|
||||
|
||||
- Next.js App Router и TypeScript;
|
||||
- CSS Modules с BEM-именованием;
|
||||
- дизайн-токены для цветов, отступов, типографики и состояний;
|
||||
@@ -131,13 +136,13 @@ TypeScript в стандартной конфигурации. См.
|
||||
|
||||
Правила применения:
|
||||
|
||||
1. До разработки экранов выбрать в Refero не более трёх подходящих направлений
|
||||
и на их основе утвердить одно собственное направление Акылдаша.
|
||||
1. Визуальное направление «Түндүк» утверждено пользователем. Применять
|
||||
корневой [DESIGN.md](../../DESIGN.md); повторный выбор направления не нужен.
|
||||
2. Не копировать чужую дизайн-систему целиком. Цвета, типографика, плотность и
|
||||
компоненты должны учитывать длинные юридические тексты, два языка и
|
||||
доступность.
|
||||
3. Зафиксировать утверждённое направление в `frontend/DESIGN.md` и перенести
|
||||
значения в дизайн-токены проекта.
|
||||
3. Перенести значения из корневого `DESIGN.md` в дизайн-токены проекта.
|
||||
Не создавать отдельный `frontend/DESIGN.md` или конкурирующие спецификации.
|
||||
4. Дизайн-токены и компоненты Акылдаша являются источником истины. Внешние
|
||||
рекомендации не могут отменять BEM, доступность, требования безопасности и
|
||||
продуктовые ограничения проекта.
|
||||
@@ -156,9 +161,7 @@ runtime-зависимостями frontend. Регистрация в стор
|
||||
|
||||
- создать `frontend/`;
|
||||
- настроить Next.js, TypeScript, lint и сборку;
|
||||
- выбрать до трёх референсов в Refero Styles и утвердить одно визуальное
|
||||
направление;
|
||||
- создать `frontend/DESIGN.md` с правилами выбранного направления;
|
||||
- применить утверждённую дизайн-систему «Түндүк» из корневого `DESIGN.md`;
|
||||
- установить полный набор `jakubkrehel/skills` для проектных design review;
|
||||
- определить маршруты и типы API;
|
||||
- создать дизайн-токены;
|
||||
@@ -230,9 +233,10 @@ runtime-зависимостями frontend. Регистрация в стор
|
||||
|
||||
## Спринты после MVP
|
||||
|
||||
### Спринт 5 — персональный кабинет
|
||||
### Спринт 5 — персональные функции
|
||||
|
||||
Авторизация, закладки, заметки, подборки и сохранённые фильтры.
|
||||
Базовый вход и смена пароля для внутреннего frontend уже реализованы. Для
|
||||
публичного продукта остаются закладки, заметки, подборки и сохранённые фильтры.
|
||||
|
||||
### Спринт 6 — контроль изменений
|
||||
|
||||
@@ -258,4 +262,4 @@ runtime-зависимостями frontend. Регистрация в стор
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.1
|
||||
@@ -44,4 +44,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.1
|
||||
Reference in new issue
Block a user