feat: add authenticated search frontend

This commit is contained in:
agent committed 2026-10-10 18:34:07 +03:00
1 parent 93e5fc5837
commit 0a30adb85f
20 files changed
+885 -113

No files matched your search

+282
View File
@@ -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 или проверки доступности работающего интерфейса.
- Не считать утверждение дизайна разрешением на публикацию сервиса.
+11 -8
View File
@@ -9,15 +9,17 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.10.4`. Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
`0.11.0` и внутренний frontend лаборатории `0.1.0`. Авторизация хранится в
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
действующий Налоговый кодекс для запросов о налоге на прибыль.
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.10.4` | приоритет кодекса для темы налога на прибыль; тематический поиск ОсОО/ЖЧК |
| Frontend | — | ещё не создан |
| Backend | `0.11.0` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
| Frontend | `0.1.0` | лаборатория поиска, личный кабинет и управление учетными записями |
| Сбор и обработка правовых данных | `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.0 · Frontend v0.1.0
+19 -6
View File
@@ -1,6 +1,6 @@
# Backend Акылдаш
Версия: `0.10.4`
Версия: `0.11.0`
Первая 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.0 · Frontend v0.1.0
+1 -1
View File
@@ -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.0"
)
return int(result.failed > 0)
+1 -1
View File
@@ -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.0"
)
return int(result.failed > 0)
+125 -10
View File
@@ -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": {"responses": {"200": {"description": "Password changed"}, "400": {"description": "Invalid password"}}}},
"/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
+160
View File
@@ -0,0 +1,160 @@
"""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
);
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
);
""")
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.commit()
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 | None, new_password: str) -> None:
self._validate(username, new_password)
salt = secrets.token_bytes(16)
password_hash = self._hash(new_password, salt)
with self._lock:
user = self.connection.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
if not user:
raise AuthError("account not found")
if old_password is not None and not hmac.compare_digest(self._hash(old_password, user["password_salt"]), user["password_hash"]):
raise AuthError("current password is incorrect")
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 WHERE username=?", (password_hash, salt, 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:
return [self._public(row) for row in self.connection.execute("SELECT * FROM users ORDER BY username COLLATE NOCASE")]
+1 -1
View File
@@ -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.0", file=sys.stderr)
return 0
+2 -2
View File
@@ -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.0"
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.0")
return 0
+1 -1
View File
@@ -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.0")
return 0
+211 -51
View File
@@ -5,80 +5,123 @@
<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-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 +134,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 +159,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.0 · Backend v0.11.0 · Внутренняя лаборатория релевантности</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 +189,114 @@
];
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.0 · Backend v0.11.0'; }
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 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);
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 +313,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 +322,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>
+1
View File
@@ -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
+29 -4
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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.0 · Frontend v0.1.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+11 -8
View File
@@ -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.0`
- Frontend: `0.1.0` — поиск, лаборатория релевантности, аккаунты
## Краткий итог
@@ -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.0 · Frontend v0.1.0
@@ -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. Данные и поисковый индекс
+14 -10
View File
@@ -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.0 · Frontend v0.1.0
+1 -1
View File
@@ -44,4 +44,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.0 · Frontend v0.1.0