diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..8e727ab --- /dev/null +++ b/DESIGN.md @@ -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 или проверки доступности работающего интерфейса. +- Не считать утверждение дизайна разрешением на публикацию сервиса. diff --git a/README.md b/README.md index 8e418c2..3860fa0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/README.md b/backend/README.md index 63b5bda..86f455b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/ingestion/minjust_cbd.py b/backend/ingestion/minjust_cbd.py index bfbe5bd..5f177c1 100644 --- a/backend/ingestion/minjust_cbd.py +++ b/backend/ingestion/minjust_cbd.py @@ -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) diff --git a/backend/normalization/minjust_cbd.py b/backend/normalization/minjust_cbd.py index 13dec26..84a195c 100644 --- a/backend/normalization/minjust_cbd.py +++ b/backend/normalization/minjust_cbd.py @@ -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) diff --git a/backend/search/api.py b/backend/search/api.py index 70c2e4a..10fac81 100644 --- a/backend/search/api.py +++ b/backend/search/api.py @@ -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 diff --git a/backend/search/auth.py b/backend/search/auth.py new file mode 100644 index 0000000..3b6611f --- /dev/null +++ b/backend/search/auth.py @@ -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")] diff --git a/backend/search/evaluate_relevance.py b/backend/search/evaluate_relevance.py index aa43c38..17b1ecf 100644 --- a/backend/search/evaluate_relevance.py +++ b/backend/search/evaluate_relevance.py @@ -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 diff --git a/backend/search/minjust_opensearch.py b/backend/search/minjust_opensearch.py index 7e3f841..3379ce9 100644 --- a/backend/search/minjust_opensearch.py +++ b/backend/search/minjust_opensearch.py @@ -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 diff --git a/backend/search/query.py b/backend/search/query.py index 9e9873a..96fa150 100644 --- a/backend/search/query.py +++ b/backend/search/query.py @@ -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 diff --git a/backend/search/review.html b/backend/search/review.html index 4ac1cf1..cf02cc4 100644 --- a/backend/search/review.html +++ b/backend/search/review.html @@ -5,80 +5,123 @@ Оценка поисковой выдачи · Акылдаш Перейти к результатам
-
Внутренняя лаборатория
-

Проверка качества поиска

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

+
Акылдаш
+
+

Проверка качества поиска

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

-
+
+

Поиск по правовой базе

+
+

+
+

Результаты

Введите запрос
  1. Начните с поискаВведите запрос, чтобы найти документы.
Страница 1
+

Документ

Выберите результат, чтобы прочитать доступную редакцию.

+
+
+
+

Вход в Акылдаш

Введите учетные данные, выданные администратором.

+

Создайте новый пароль

Для продолжения замените временный пароль. Используйте не менее 12 символов.

+

Личный кабинет

Логин
Роль
Администратор
Дата создания

Изменить пароль

Новый пароль должен содержать не менее 12 символов.

+

Управление пользователями

Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.

Документ

Короткое знакомство

- + diff --git a/deploy/production/.env.example b/deploy/production/.env.example index bf593d3..3d785e5 100644 --- a/deploy/production/.env.example +++ b/deploy/production/.env.example @@ -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 diff --git a/deploy/production/README.md b/deploy/production/README.md index 5f200b1..ea68e44 100644 --- a/deploy/production/README.md +++ b/deploy/production/README.md @@ -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. diff --git a/deploy/production/compose.yaml b/deploy/production/compose.yaml index 116897c..6f07fcd 100644 --- a/deploy/production/compose.yaml +++ b/deploy/production/compose.yaml @@ -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: diff --git a/docs/README.md b/docs/README.md index e3d573e..5b32dc2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/design/tunduk-desktop-concept.png b/docs/design/tunduk-desktop-concept.png new file mode 100644 index 0000000..01d5879 Binary files /dev/null and b/docs/design/tunduk-desktop-concept.png differ diff --git a/docs/operations/project-status.md b/docs/operations/project-status.md index 9d5fd88..8b68368 100644 --- a/docs/operations/project-status.md +++ b/docs/operations/project-status.md @@ -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 diff --git a/docs/product/frontend-design-readiness-plan.md b/docs/product/frontend-design-readiness-plan.md index 3321800..fdda43c 100644 --- a/docs/product/frontend-design-readiness-plan.md +++ b/docs/product/frontend-design-readiness-plan.md @@ -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. Данные и поисковый индекс diff --git a/docs/product/frontend-search-sps-plan.md b/docs/product/frontend-search-sps-plan.md index 0196f18..038ab5f 100644 --- a/docs/product/frontend-search-sps-plan.md +++ b/docs/product/frontend-search-sps-plan.md @@ -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 diff --git a/docs/product/project-overview.md b/docs/product/project-overview.md index 8116d78..74377c1 100644 --- a/docs/product/project-overview.md +++ b/docs/product/project-overview.md @@ -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