diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d61a290..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,109 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -**Ollama Proxy** — Go-сервис, выступающий прокси между Codex CLI и локальной Ollama. Добавляет авторизацию-заглушку и LLM-маршрутизатор: маленькая модель (gemma:1b) классифицирует входящий запрос и перенаправляет его к нужной целевой модели (qwen2.5-coder для кода, gemma для текста). - -Без БД, без Docker на текущем этапе. Чистый Go, stateless. - -## Архитектура - -``` -Codex CLI → Ollama Proxy (:11435) → Ollama (:11434) - │ - Token check (AUTH_TOKEN из .env) - Router LLM: gemma:1b → code / document / general - │ - qwen2.5-coder:1.5b | gemma:1b | gemma:1b -``` - -**Поток запроса:** -1. Codex CLI отправляет запрос с `model: "auto"` на прокси -2. Middleware проверяет `Authorization: Bearer ` -3. Router: если модель не указана явно → gemma:1b классифицирует последнее user-сообщение -4. Прокси подменяет поле `model` в запросе и проксирует в реальную Ollama -5. Стриминг-ответ (NDJSON) возвращается в Codex CLI построчно - -**Маршрутизация срабатывает только один раз** (при первом запросе с `model: "auto"`). Последующие запросы в той же сессии Codex CLI отправляет уже с конкретным именем модели — прокси пропускает роутер и проксирует напрямую. - -## Структура проекта - -``` -cmd/server/main.go # Точка входа — загрузка конфига, запуск HTTP-сервера -internal/ - config/config.go # Конфигурация из env-переменных - model/ - ollama.go # Типы Ollama API (Chat, Generate, Tags, Message) - handler/ - middleware.go # Проверка AUTH_TOKEN (Bearer token) - proxy.go # HTTP-хендлеры: /api/chat, /api/generate, /api/tags - service/ - ollama_client.go # HTTP-клиент к Ollama (streaming + синхронный) - router.go # LLM-маршрутизатор через gemma:1b - router/ - router.go # chi-роутер, регистрация маршрутов -go.mod / go.sum # Зависимости -Makefile # Команды сборки и запуска -.env # Локальные переменные окружения (не в git) -docs/ - architecture.md # Подробная архитектура -``` - -## Ключевые команды - -```bash -make build # go build -o ollama-proxy ./cmd/server -make run # go run ./cmd/server (читает .env) -make test # go test ./... -``` - -## Переменные окружения (.env) - -| Переменная | Описание | По умолчанию | -|-----------|----------|-------------| -| `PROXY_PORT` | Порт прокси | 11435 | -| `AUTH_TOKEN` | Токен авторизации (заглушка) | — (обязательна) | -| `OLLAMA_URL` | URL реальной Ollama | http://localhost:11434 | -| `ROUTER_MODEL` | Модель-классификатор | gemma:1b | -| `CODE_MODEL` | Модель для кода | qwen2.5-coder:1.5b | -| `DOC_MODEL` | Модель для документов | gemma:1b | -| `GENERAL_MODEL` | Общая модель | gemma:1b | - -## Модели Ollama (для тестирования) - -```bash -ollama pull gemma:1b # Router LLM + документы + общее -ollama pull qwen2.5-coder:1.5b # Целевая модель для кода -``` - -## Тестирование через curl - -```bash -# Здоровье сервиса -curl http://localhost:11435/health - -# Без токена → 401 -curl -X POST http://localhost:11435/api/chat -d '{}' - -# С токеном, auto-маршрутизация (code) -curl http://localhost:11435/api/chat \ - -H "Authorization: Bearer " \ - -d '{"model":"auto","messages":[{"role":"user","content":"напиши функцию на Go"}]}' - -# С токеном, явная модель (без маршрутизации) -curl http://localhost:11435/api/chat \ - -H "Authorization: Bearer " \ - -d '{"model":"gemma:1b","messages":[{"role":"user","content":"привет"}]}' - -# Список моделей -curl http://localhost:11435/api/tags \ - -H "Authorization: Bearer " -``` - -## Конвенции - -- Комментарии в коде — на русском -- Переносы строк LF (`.gitattributes`) -- Версионирование: bumpversion, `VERSION` файл diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md deleted file mode 100644 index e1dfe03..0000000 --- a/docs/DEVELOPER.md +++ /dev/null @@ -1,110 +0,0 @@ -# Руководство разработчика - -## Требования - -- Go 1.21+ -- [Ollama](https://ollama.com) — установлен и запущен локально - -## Первый запуск - -### 1. Скачать модели в Ollama - -```bash -ollama pull gemma:1b # Router LLM + документы + общее -ollama pull qwen2.5-coder:1.5b # Целевая модель для кода -``` - -Проверить что модели доступны: -```bash -ollama list -``` - -### 2. Настроить .env - -```env -PROXY_PORT=11435 -AUTH_TOKEN=my-secret-token -OLLAMA_URL=http://localhost:11434 -ROUTER_MODEL=gemma:1b -CODE_MODEL=qwen2.5-coder:1.5b -DOC_MODEL=gemma:1b -GENERAL_MODEL=gemma:1b -``` - -### 3. Запустить прокси - -```bash -make run -# или напрямую: -go run ./cmd/server -``` - ---- - -## Команды - -```bash -make build # Собрать бинарник ./ollama-proxy -make run # go run ./cmd/server (читает .env) -make test # go test ./... -``` - ---- - -## Тестирование через curl - -```bash -# Проверка работоспособности (без токена) -curl http://localhost:11435/health - -# Без токена → 401 -curl -X POST http://localhost:11435/api/chat \ - -d '{"model":"auto","messages":[{"role":"user","content":"привет"}]}' - -# Auto-маршрутизация → Router LLM (gemma:1b) выберет модель -curl http://localhost:11435/api/chat \ - -H "Authorization: Bearer my-secret-token" \ - -d '{"model":"auto","messages":[{"role":"user","content":"напиши функцию на Go"}]}' -# → классифицирует как "code" → запрос идёт в qwen2.5-coder:1.5b - -# Явная модель (без маршрутизации) -curl http://localhost:11435/api/chat \ - -H "Authorization: Bearer my-secret-token" \ - -d '{"model":"gemma:1b","messages":[{"role":"user","content":"расскажи про Go"}]}' - -# Список моделей -curl http://localhost:11435/api/tags \ - -H "Authorization: Bearer my-secret-token" -``` - ---- - -## Настройка Codex CLI - -- **Base URL**: `http://localhost:11435` -- **API Key**: значение из `AUTH_TOKEN` -- **Model**: `auto` (автомаршрутизация) или конкретная модель - ---- - -## Как добавить новую модель - -1. `ollama pull ` -2. Добавить переменную в `.env`: `ANALYSIS_MODEL=llama3.2:3b` -3. Добавить поле в `internal/config/config.go` -4. Расширить логику в `internal/service/router.go` - ---- - -## Структура кода - -| Файл | Ответственность | -|------|----------------| -| `cmd/server/main.go` | Точка входа, инициализация | -| `internal/config/config.go` | Env-переменные | -| `internal/model/ollama.go` | Go-типы Ollama API | -| `internal/handler/middleware.go` | Проверка токена | -| `internal/handler/proxy.go` | HTTP-хендлеры | -| `internal/service/ollama_client.go` | Клиент к Ollama (streaming + sync) | -| `internal/service/router.go` | LLM-маршрутизатор | -| `internal/router/router.go` | chi-роутер, маршруты | diff --git a/docs/architecture.docx b/docs/architecture.docx deleted file mode 100644 index b1b042c..0000000 Binary files a/docs/architecture.docx and /dev/null differ diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 693c8ec..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,277 +0,0 @@ -# Архитектура Ollama Proxy - -## 1. Общее описание - -**Ollama Proxy** — Go-сервис, прозрачно встающий между Codex CLI и локальной Ollama. Для Codex CLI прокси выглядит как обычная Ollama (те же эндпоинты, тот же формат). Внутри прокси: - -1. Проверяет авторизацию (токен из env) -2. Классифицирует запрос через Router LLM (gemma:1b) -3. Подменяет модель в запросе на подходящую -4. Проксирует запрос в реальную Ollama -5. Стримит ответ обратно в Codex CLI - -Сервис **stateless** — нет БД, нет сессий, нет хранения истории. История сообщений хранится на стороне Codex CLI. - ---- - -## 2. Архитектурная схема - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ CODEX CLI │ -│ (отправляет запросы как к обычной Ollama) │ -└────────────────────────────┬────────────────────────────────────────┘ - │ HTTP model: "auto" или "qwen2.5-coder:1.5b" - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ OLLAMA PROXY (:11435) │ -│ │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ Token Auth Middleware │ │ -│ │ Authorization: Bearer → 401 если неверный │ │ -│ └───────────────────────┬──────────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────────▼─────────────────────────────────────┐ │ -│ │ Router LLM │ │ -│ │ │ │ -│ │ Если model == "auto" или пустой: │ │ -│ │ → gemma:1b (синхронный вызов, stream=false) │ │ -│ │ → промпт: "Classify: code / document / general" │ │ -│ │ → ответ: одно слово │ │ -│ │ │ │ -│ │ Если model указана явно → пропустить, использовать как есть │ │ -│ └────────────────────────┬─────────────────────────────────────┘ │ -│ │ model подменён на целевую │ -│ ┌────────────────────────▼─────────────────────────────────────┐ │ -│ │ Proxy │ │ -│ │ POST /api/chat → Ollama /api/chat │ │ -│ │ Стриминг NDJSON построчно (bufio.Scanner + http.Flusher) │ │ -│ └────────────────────────┬─────────────────────────────────────┘ │ -└───────────────────────────┼─────────────────────────────────────────┘ - │ HTTP model: "qwen2.5-coder:1.5b" - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ OLLAMA (:11434) │ -│ │ -│ gemma:1b qwen2.5-coder:1.5b (другие модели) │ -│ (router + docs) (код) │ -└─────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. Структура проекта - -``` -. -├── cmd/server/main.go # Точка входа: загрузка конфига, запуск HTTP-сервера -├── internal/ -│ ├── config/ -│ │ └── config.go # Парсинг env-переменных (caarlos0/env) -│ ├── model/ -│ │ └── ollama.go # Go-типы Ollama API: ChatRequest, ChatResponse, -│ │ # GenerateRequest, GenerateResponse, TagsResponse, Message -│ ├── handler/ -│ │ ├── middleware.go # Проверка Bearer-токена (простое сравнение строк) -│ │ └── proxy.go # HTTP-хендлеры: HandleChat, HandleGenerate, HandleTags -│ ├── service/ -│ │ ├── ollama_client.go # HTTP-клиент к Ollama: -│ │ │ # ProxyChat — стриминг /api/chat -│ │ │ # ProxyGenerate — стриминг /api/generate -│ │ │ # GetTags — список моделей -│ │ │ # Complete — синхронный вызов (для роутера) -│ │ └── router.go # LLM-маршрутизатор: -│ │ # Route(ctx, model, messages) → целевая модель -├── router/ -│ └── router.go # chi-роутер, подключение middleware и хендлеров -├── go.mod / go.sum # Зависимости -├── Makefile # build / run / test -├── .env # Локальные переменные (не в git) -├── .gitignore -├── .gitattributes # LF line endings -├── VERSION # Семантическая версия -└── docs/ - └── architecture.md # Этот файл -``` - ---- - -## 4. Поток обработки запроса - -### Случай 1: `model = "auto"` (первый запрос в сессии) - -``` -1. Codex CLI → POST /api/chat {"model":"auto", "messages":[...]} - -2. Middleware: проверить Authorization: Bearer - → если неверный: 401 Unauthorized - -3. Handler: декодировать ChatRequest из JSON - -4. Router.Route(ctx, "auto", messages): - a. Взять последнее user-сообщение - b. POST /api/chat к Ollama с model=gemma:1b, stream=false - Промпт: "Classify the following user request into exactly one category: - code, document, general. - Reply with ONLY the category name, nothing else. - User request: {текст}" - c. Получить ответ: "code" - d. Вернуть CODE_MODEL = "qwen2.5-coder:1.5b" - -5. Подменить req.Model = "qwen2.5-coder:1.5b" - -6. OllamaClient.ProxyChat(ctx, w, req): - a. POST /api/chat к Ollama с model=qwen2.5-coder:1.5b - b. Читать ответ построчно (bufio.Scanner) - c. Каждую строку NDJSON сразу писать в w + Flush() - -7. Codex CLI получает стриминг, видит model="qwen2.5-coder:1.5b" в ответе - → запоминает модель для последующих запросов -``` - -### Случай 2: `model = "qwen2.5-coder:1.5b"` (последующие запросы) - -``` -1. Codex CLI → POST /api/chat {"model":"qwen2.5-coder:1.5b", "messages":[...]} - -2. Middleware: проверить токен - -3. Router.Route(ctx, "qwen2.5-coder:1.5b", messages): - → модель указана явно, не "auto" → вернуть как есть - -4. OllamaClient.ProxyChat(ctx, w, req): - → прямой прокси к Ollama, Router LLM не вызывается -``` - ---- - -## 5. Ollama API — поддерживаемые эндпоинты - -| Метод | URL | Описание | -|-------|-----|----------| -| GET | `/health` | Проверка работоспособности (без авторизации) | -| POST | `/api/chat` | Чат. Streaming NDJSON | -| POST | `/api/generate` | Генерация текста. Streaming NDJSON | -| GET | `/api/tags` | Список моделей из реальной Ollama | - -### Формат запроса `/api/chat` - -```json -{ - "model": "auto", - "messages": [ - {"role": "user", "content": "напиши функцию на Go"} - ], - "stream": true -} -``` - -### Формат ответа `/api/chat` (NDJSON, одна строка = один чанк) - -```json -{"model":"qwen2.5-coder:1.5b","created_at":"2025-01-01T12:00:00Z","message":{"role":"assistant","content":"func"},"done":false} -{"model":"qwen2.5-coder:1.5b","created_at":"2025-01-01T12:00:01Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":1234567890,"eval_count":42} -``` - ---- - -## 6. Авторизация - -Простая проверка Bearer-токена: - -``` -Authorization: Bearer -``` - -- Токен задаётся в `AUTH_TOKEN` (.env) -- Сравнение строк — никакого JWT, bcrypt, БД -- Неверный или отсутствующий токен → `401 Unauthorized` -- `/health` — без авторизации - -**Это временная заглушка.** В следующем этапе будет полноценная авторизация с JWT и БД. - ---- - -## 7. Router LLM — детали реализации - -**Промпт классификатора:** -``` -Classify the following user request into exactly one category: code, document, general. -Reply with ONLY the category name, nothing else. - -User request: {последнее user-сообщение} -``` - -**Вызов:** синхронный (`stream: false`), короткий timeout (5–10 сек). - -**Парсинг ответа:** -- Взять ответ, привести к нижнему регистру, убрать пробелы -- Если содержит "code" → CODE_MODEL -- Если содержит "document" → DOC_MODEL -- Иначе → GENERAL_MODEL (fallback) - -**Маппинг категорий → модели:** - -| Категория | Тестовая модель | Production модель | -|-----------|----------------|------------------| -| code | qwen2.5-coder:1.5b | qwen2.5-coder:32b | -| document | gemma:1b | deepseek-r1:70b | -| general | gemma:1b | llama3.3:70b | - ---- - -## 8. Streaming-прокси — детали реализации - -Ключевые требования: -- `http.Flusher` — сбрасывать буфер после каждой строки -- `bufio.Scanner` с буфером 1MB (для длинных строк с кодом) -- `http.Client` без глобального таймаута (используется контекст запроса) -- Отмена через `r.Context()` — если Codex CLI отключился, upstream-запрос к Ollama отменяется - -```go -// Псевдокод -scanner := bufio.NewScanner(resp.Body) -scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - -flusher := w.(http.Flusher) -for scanner.Scan() { - w.Write(scanner.Bytes()) - w.Write([]byte("\n")) - flusher.Flush() -} -``` - ---- - -## 9. Переменные окружения - -| Переменная | Описание | По умолчанию | -|-----------|----------|-------------| -| `PROXY_PORT` | Порт прокси | 11435 | -| `AUTH_TOKEN` | Токен авторизации | — (обязательна) | -| `OLLAMA_URL` | URL реальной Ollama | http://localhost:11434 | -| `ROUTER_MODEL` | Модель-классификатор | gemma:1b | -| `CODE_MODEL` | Модель для кода | qwen2.5-coder:1.5b | -| `DOC_MODEL` | Модель для документов | gemma:1b | -| `GENERAL_MODEL` | Общая модель | gemma:1b | - ---- - -## 10. Технологический стек - -| Компонент | Технология | -|-----------|-----------| -| Язык | Go | -| HTTP-фреймворк | chi/v5 | -| Конфигурация | caarlos0/env/v11 | -| Streaming | bufio.Scanner + http.Flusher | -| LLM-инфраструктура | Ollama (локально) | - ---- - -## 11. Следующие этапы (после MVP) - -- **Авторизация**: полноценный JWT + PostgreSQL + регистрация/логин -- **История чатов**: хранение сообщений в БД (User → Chat → Message) -- **Production-модели**: qwen2.5-coder:32b, deepseek-r1:70b -- **Codex CLI**: тестирование сквозного потока с реальным клиентом diff --git a/docs/diagrams/mermaid.esm.min.mjs b/docs/diagrams/mermaid.esm.min.mjs deleted file mode 100644 index 7bd0f72..0000000 --- a/docs/diagrams/mermaid.esm.min.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import{a as ht}from"./chunks/mermaid.esm.min/chunk-HQLFZTFY.mjs";import{a as Yt}from"./chunks/mermaid.esm.min/chunk-MEBTFSOL.mjs";import{a as Ut,b as qt}from"./chunks/mermaid.esm.min/chunk-7LIB5WBN.mjs";import{a as Bt}from"./chunks/mermaid.esm.min/chunk-L736DJ4U.mjs";import"./chunks/mermaid.esm.min/chunk-QTJCGBHB.mjs";import"./chunks/mermaid.esm.min/chunk-USR3SDWQ.mjs";import{b as St}from"./chunks/mermaid.esm.min/chunk-2VPXETT4.mjs";import"./chunks/mermaid.esm.min/chunk-S67DUUA5.mjs";import"./chunks/mermaid.esm.min/chunk-LM6QDVU5.mjs";import{a as Mt}from"./chunks/mermaid.esm.min/chunk-HESFG3RP.mjs";import{b as Vt,j as yt,l as $t,m as V,n as Nt,o as Ht}from"./chunks/mermaid.esm.min/chunk-YM3XIQPS.mjs";import"./chunks/mermaid.esm.min/chunk-TI4EEUUG.mjs";import{A as G,B as It,C as Y,D as Ft,G as _t,M as Gt,O as zt,aa as z,b as g,ba as X,c as gt,d as At,f as Tt,g as lt,ga as k,h as J,i as Z,j as Ct,k as Rt,r as tt,u as ut,v as kt,w as Ot,x as Pt,y as Dt,z as jt}from"./chunks/mermaid.esm.min/chunk-ZKYS2E5M.mjs";import{d as xt}from"./chunks/mermaid.esm.min/chunk-YPUTD6PB.mjs";import"./chunks/mermaid.esm.min/chunk-6BY5RJGC.mjs";import{a as r}from"./chunks/mermaid.esm.min/chunk-GTKDMUJJ.mjs";var Xt="c4",Ie=r(t=>/^\s*C4Context|C4Container|C4Component|C4Dynamic|C4Deployment/.test(t),"detector"),Fe=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/c4Diagram-GNV6VVOW.mjs");return{id:Xt,diagram:t}},"loader"),_e={id:Xt,detector:Ie,loader:Fe},Wt=_e;var Kt="flowchart",Ge=r((t,e)=>e?.flowchart?.defaultRenderer==="dagre-wrapper"||e?.flowchart?.defaultRenderer==="elk"?!1:/^\s*graph/.test(t),"detector"),ze=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-RXJ4TZVH.mjs");return{id:Kt,diagram:t}},"loader"),Ve={id:Kt,detector:Ge,loader:ze},Qt=Ve;var Jt="flowchart-v2",$e=r((t,e)=>e?.flowchart?.defaultRenderer==="dagre-d3"?!1:(e?.flowchart?.defaultRenderer==="elk"&&(e.layout="elk"),/^\s*graph/.test(t)&&e?.flowchart?.defaultRenderer==="dagre-wrapper"?!0:/^\s*flowchart/.test(t)),"detector"),Ne=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-RXJ4TZVH.mjs");return{id:Jt,diagram:t}},"loader"),He={id:Jt,detector:$e,loader:Ne},Zt=He;var tr="er",Ue=r(t=>/^\s*erDiagram/.test(t),"detector"),qe=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/erDiagram-K5RJBHCA.mjs");return{id:tr,diagram:t}},"loader"),Be={id:tr,detector:Ue,loader:qe},rr=Be;var er="gitGraph",Ye=r(t=>/^\s*gitGraph/.test(t),"detector"),Xe=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/gitGraphDiagram-WO7WVN2C.mjs");return{id:er,diagram:t}},"loader"),We={id:er,detector:Ye,loader:Xe},ar=We;var ir="gantt",Ke=r(t=>/^\s*gantt/.test(t),"detector"),Qe=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/ganttDiagram-5MLOKHXO.mjs");return{id:ir,diagram:t}},"loader"),Je={id:ir,detector:Ke,loader:Qe},or=Je;var nr="info",Ze=r(t=>/^\s*info/.test(t),"detector"),ta=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/infoDiagram-E3C2IIUA.mjs");return{id:nr,diagram:t}},"loader"),sr={id:nr,detector:Ze,loader:ta};var cr="pie",ra=r(t=>/^\s*pie/.test(t),"detector"),ea=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/pieDiagram-Q56JBFDI.mjs");return{id:cr,diagram:t}},"loader"),mr={id:cr,detector:ra,loader:ea};var pr="quadrantChart",aa=r(t=>/^\s*quadrantChart/.test(t),"detector"),ia=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/quadrantDiagram-KBTC774P.mjs");return{id:pr,diagram:t}},"loader"),oa={id:pr,detector:aa,loader:ia},dr=oa;var fr="xychart",na=r(t=>/^\s*xychart-beta/.test(t),"detector"),sa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/xychartDiagram-4C6ER3FX.mjs");return{id:fr,diagram:t}},"loader"),ca={id:fr,detector:na,loader:sa},gr=ca;var lr="requirement",ma=r(t=>/^\s*requirement(Diagram)?/.test(t),"detector"),pa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/requirementDiagram-OPD2HUS5.mjs");return{id:lr,diagram:t}},"loader"),da={id:lr,detector:ma,loader:pa},ur=da;var Dr="sequence",fa=r(t=>/^\s*sequenceDiagram/.test(t),"detector"),ga=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/sequenceDiagram-ODO66PDE.mjs");return{id:Dr,diagram:t}},"loader"),la={id:Dr,detector:fa,loader:ga},yr=la;var xr="class",ua=r((t,e)=>e?.class?.defaultRenderer==="dagre-wrapper"?!1:/^\s*classDiagram/.test(t),"detector"),Da=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/classDiagram-MKYM2BOE.mjs");return{id:xr,diagram:t}},"loader"),ya={id:xr,detector:ua,loader:Da},hr=ya;var Er="classDiagram",xa=r((t,e)=>/^\s*classDiagram/.test(t)&&e?.class?.defaultRenderer==="dagre-wrapper"?!0:/^\s*classDiagram-v2/.test(t),"detector"),ha=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/classDiagram-v2-PRA2ZCF7.mjs");return{id:Er,diagram:t}},"loader"),Ea={id:Er,detector:xa,loader:ha},wr=Ea;var br="state",wa=r((t,e)=>e?.state?.defaultRenderer==="dagre-wrapper"?!1:/^\s*stateDiagram/.test(t),"detector"),ba=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/stateDiagram-76M766UR.mjs");return{id:br,diagram:t}},"loader"),La={id:br,detector:wa,loader:ba},Lr=La;var vr="stateDiagram",va=r((t,e)=>!!(/^\s*stateDiagram-v2/.test(t)||/^\s*stateDiagram/.test(t)&&e?.state?.defaultRenderer==="dagre-wrapper"),"detector"),Sa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/stateDiagram-v2-NOSC7VFN.mjs");return{id:vr,diagram:t}},"loader"),Ma={id:vr,detector:va,loader:Sa},Sr=Ma;var Mr="journey",Aa=r(t=>/^\s*journey/.test(t),"detector"),Ta=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/journeyDiagram-UZIDTGLP.mjs");return{id:Mr,diagram:t}},"loader"),Ca={id:Mr,detector:Aa,loader:Ta},Ar=Ca;var Ra=r((t,e,a)=>{g.debug(`rendering svg for syntax error -`);let i=Yt(e),o=i.append("g");i.attr("viewBox","0 0 2412 512"),Gt(i,100,512,!0),o.append("path").attr("class","error-icon").attr("d","m411.313,123.313c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32-9.375,9.375-20.688-20.688c-12.484-12.5-32.766-12.5-45.25,0l-16,16c-1.261,1.261-2.304,2.648-3.31,4.051-21.739-8.561-45.324-13.426-70.065-13.426-105.867,0-192,86.133-192,192s86.133,192 192,192 192-86.133 192-192c0-24.741-4.864-48.327-13.426-70.065 1.402-1.007 2.79-2.049 4.051-3.31l16-16c12.5-12.492 12.5-32.758 0-45.25l-20.688-20.688 9.375-9.375 32.001-31.999zm-219.313,100.687c-52.938,0-96,43.063-96,96 0,8.836-7.164,16-16,16s-16-7.164-16-16c0-70.578 57.422-128 128-128 8.836,0 16,7.164 16,16s-7.164,16-16,16z"),o.append("path").attr("class","error-icon").attr("d","m459.02,148.98c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l16,16c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16.001-16z"),o.append("path").attr("class","error-icon").attr("d","m340.395,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16-16c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l15.999,16z"),o.append("path").attr("class","error-icon").attr("d","m400,64c8.844,0 16-7.164 16-16v-32c0-8.836-7.156-16-16-16-8.844,0-16,7.164-16,16v32c0,8.836 7.156,16 16,16z"),o.append("path").attr("class","error-icon").attr("d","m496,96.586h-32c-8.844,0-16,7.164-16,16 0,8.836 7.156,16 16,16h32c8.844,0 16-7.164 16-16 0-8.836-7.156-16-16-16z"),o.append("path").attr("class","error-icon").attr("d","m436.98,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688l32-32c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32c-6.251,6.25-6.251,16.375-0.001,22.625z"),o.append("text").attr("class","error-text").attr("x",1440).attr("y",250).attr("font-size","150px").style("text-anchor","middle").text("Syntax error in text"),o.append("text").attr("class","error-text").attr("x",1250).attr("y",400).attr("font-size","100px").style("text-anchor","middle").text(`mermaid version ${a}`)},"draw"),Et={draw:Ra},Tr=Et;var ka={db:{},renderer:Et,parser:{parse:r(()=>{},"parse")}},Cr=ka;var Rr="flowchart-elk",Oa=r((t,e={})=>/^\s*flowchart-elk/.test(t)||/^\s*flowchart|graph/.test(t)&&e?.flowchart?.defaultRenderer==="elk"?(e.layout="elk",!0):!1,"detector"),Pa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/flowDiagram-RXJ4TZVH.mjs");return{id:Rr,diagram:t}},"loader"),ja={id:Rr,detector:Oa,loader:Pa},kr=ja;var Or="timeline",Ia=r(t=>/^\s*timeline/.test(t),"detector"),Fa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/timeline-definition-VFFECQCT.mjs");return{id:Or,diagram:t}},"loader"),_a={id:Or,detector:Ia,loader:Fa},Pr=_a;var jr="mindmap",Ga=r(t=>/^\s*mindmap/.test(t),"detector"),za=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/mindmap-definition-RP2J3NYQ.mjs");return{id:jr,diagram:t}},"loader"),Va={id:jr,detector:Ga,loader:za},Ir=Va;var Fr="kanban",$a=r(t=>/^\s*kanban/.test(t),"detector"),Na=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/kanban-definition-4PSEFK7X.mjs");return{id:Fr,diagram:t}},"loader"),Ha={id:Fr,detector:$a,loader:Na},_r=Ha;var Gr="sankey",Ua=r(t=>/^\s*sankey-beta/.test(t),"detector"),qa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/sankeyDiagram-2NKXCTV4.mjs");return{id:Gr,diagram:t}},"loader"),Ba={id:Gr,detector:Ua,loader:qa},zr=Ba;var Vr="packet",Ya=r(t=>/^\s*packet-beta/.test(t),"detector"),Xa=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-7BLTIMBB.mjs");return{id:Vr,diagram:t}},"loader"),$r={id:Vr,detector:Ya,loader:Xa};var Nr="radar",Wa=r(t=>/^\s*radar-beta/.test(t),"detector"),Ka=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/diagram-R7SGKMCD.mjs");return{id:Nr,diagram:t}},"loader"),Hr={id:Nr,detector:Wa,loader:Ka};var Ur="block",Qa=r(t=>/^\s*block-beta/.test(t),"detector"),Ja=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/blockDiagram-GQNB4GIR.mjs");return{id:Ur,diagram:t}},"loader"),Za={id:Ur,detector:Qa,loader:Ja},qr=Za;var Br="architecture",ti=r(t=>/^\s*architecture/.test(t),"detector"),ri=r(async()=>{let{diagram:t}=await import("./chunks/mermaid.esm.min/architectureDiagram-YZ6UH2CF.mjs");return{id:Br,diagram:t}},"loader"),ei={id:Br,detector:ti,loader:ri},Yr=ei;var Xr=!1,$=r(()=>{Xr||(Xr=!0,z("error",Cr,t=>t.toLowerCase().trim()==="error"),z("---",{db:{clear:r(()=>{},"clear")},styles:{},renderer:{draw:r(()=>{},"draw")},parser:{parse:r(()=>{throw new Error("Diagrams beginning with --- are not valid. If you were trying to use a YAML front-matter, please ensure that you've correctly opened and closed the YAML front-matter with un-indented `---` blocks")},"parse")},init:r(()=>null,"init")},t=>t.toLowerCase().trimStart().startsWith("---")),Z(Wt,_r,wr,hr,rr,or,sr,mr,ur,yr,kr,Zt,Qt,Ir,Pr,ar,Sr,Lr,Ar,dr,zr,$r,gr,qr,Yr,Hr))},"addDiagrams");var Wr=r(async()=>{g.debug("Loading registered diagrams");let e=(await Promise.allSettled(Object.entries(lt).map(async([a,{detector:i,loader:o}])=>{if(o)try{X(a)}catch{try{let{diagram:n,id:m}=await o();z(m,n,i)}catch(n){throw g.error(`Failed to load external diagram with key ${a}. Removing from detectors.`),delete lt[a],n}}}))).filter(a=>a.status==="rejected");if(e.length>0){g.error(`Failed to load ${e.length} external diagrams`);for(let a of e)g.error(a);throw new Error(`Failed to load ${e.length} external diagrams`)}},"loadRegisteredDiagrams");var rt="comm",et="rule",at="decl";var Kr="@import";var Qr="@namespace",Jr="@keyframes";var Zr="@layer";var wt=Math.abs,W=String.fromCharCode;function it(t){return t.trim()}r(it,"trim");function K(t,e,a){return t.replace(e,a)}r(K,"replace");function te(t,e,a){return t.indexOf(e,a)}r(te,"indexof");function j(t,e){return t.charCodeAt(e)|0}r(j,"charat");function I(t,e,a){return t.slice(e,a)}r(I,"substr");function h(t){return t.length}r(h,"strlen");function re(t){return t.length}r(re,"sizeof");function N(t,e){return e.push(t),t}r(N,"append");var ot=1,H=1,ee=0,w=0,D=0,q="";function nt(t,e,a,i,o,n,m,s){return{value:t,root:e,parent:a,type:i,props:o,children:n,line:ot,column:H,length:m,return:"",siblings:s}}r(nt,"node");function ae(){return D}r(ae,"char");function ie(){return D=w>0?j(q,--w):0,H--,D===10&&(H=1,ot--),D}r(ie,"prev");function b(){return D=w2||U(D)>3?"":" "}r(se,"whitespace");function ce(t,e){for(;--e&&b()&&!(D<48||D>102||D>57&&D<65||D>70&&D<97););return st(t,Q()+(e<6&&O()==32&&b()==32))}r(ce,"escaping");function bt(t){for(;b();)switch(D){case t:return w;case 34:case 39:t!==34&&t!==39&&bt(D);break;case 40:t===41&&bt(t);break;case 92:b();break}return w}r(bt,"delimiter");function me(t,e){for(;b()&&t+D!==57;)if(t+D===84&&O()===47)break;return"/*"+st(e,w-1)+"*"+W(t===47?t:b())}r(me,"commenter");function pe(t){for(;!U(O());)b();return st(t,w)}r(pe,"identifier");function ge(t){return ne(mt("",null,null,null,[""],t=oe(t),0,[0],t))}r(ge,"compile");function mt(t,e,a,i,o,n,m,s,c){for(var l=0,y=0,p=m,x=0,A=0,L=0,f=1,C=1,v=1,u=0,S="",R=o,T=n,E=i,d=S;C;)switch(L=u,u=b()){case 40:if(L!=108&&j(d,p-1)==58){te(d+=K(ct(u),"&","&\f"),"&\f",wt(l?s[l-1]:0))!=-1&&(v=-1);break}case 34:case 39:case 91:d+=ct(u);break;case 9:case 10:case 13:case 32:d+=se(L);break;case 92:d+=ce(Q()-1,7);continue;case 47:switch(O()){case 42:case 47:N(ai(me(b(),Q()),e,a,c),c),(U(L||1)==5||U(O()||1)==5)&&h(d)&&I(d,-1,void 0)!==" "&&(d+=" ");break;default:d+="/"}break;case 123*f:s[l++]=h(d)*v;case 125*f:case 59:case 0:switch(u){case 0:case 125:C=0;case 59+y:v==-1&&(d=K(d,/\f/g,"")),A>0&&(h(d)-p||f===0&&L===47)&&N(A>32?fe(d+";",i,a,p-1,c):fe(K(d," ","")+";",i,a,p-2,c),c);break;case 59:d+=";";default:if(N(E=de(d,e,a,l,y,o,s,S,R=[],T=[],p,n),n),u===123)if(y===0)mt(d,e,E,E,R,n,p,s,T);else{switch(x){case 99:if(j(d,3)===110)break;case 108:if(j(d,2)===97)break;default:y=0;case 100:case 109:case 115:}y?mt(t,E,E,i&&N(de(t,E,E,0,0,o,s,S,o,R=[],p,T),T),o,T,p,s,i?R:T):mt(d,E,E,E,[""],T,0,s,T)}}l=y=A=0,f=v=1,S=d="",p=m;break;case 58:p=1+h(d),A=L;default:if(f<1){if(u==123)--f;else if(u==125&&f++==0&&ie()==125)continue}switch(d+=W(u),u*f){case 38:v=y>0?1:(d+="\f",-1);break;case 44:s[l++]=(h(d)-1)*v,v=1;break;case 64:O()===45&&(d+=ct(b())),x=O(),y=p=h(S=d+=pe(Q())),u++;break;case 45:L===45&&h(d)==2&&(f=0)}}return n}r(mt,"parse");function de(t,e,a,i,o,n,m,s,c,l,y,p){for(var x=o-1,A=o===0?n:[""],L=re(A),f=0,C=0,v=0;f0?A[u]+" "+S:K(S,/&\f/g,A[u])))&&(c[v++]=R);return nt(t,e,a,o===0?et:s,c,l,y,p)}r(de,"ruleset");function ai(t,e,a,i){return nt(t,e,a,rt,W(ae()),I(t,2,-2),0,i)}r(ai,"comment");function fe(t,e,a,i,o){return nt(t,e,a,at,I(t,0,i),I(t,i+1,-1),i,o)}r(fe,"declaration");function pt(t,e){for(var a="",i=0;i{ye.forEach(t=>{t()}),ye=[]},"attachFunctions");var he=r(t=>t.replace(/^\s*%%(?!{)[^\n]+\n?/gm,"").trimStart(),"cleanupComments");function Ee(t){let e=t.match(At);if(!e)return{text:t,metadata:{}};let a=qt(e[1],{schema:Ut})??{};a=typeof a=="object"&&!Array.isArray(a)?a:{};let i={};return a.displayMode&&(i.displayMode=a.displayMode.toString()),a.title&&(i.title=a.title.toString()),a.config&&(i.config=a.config),{text:t.slice(e[0].length),metadata:i}}r(Ee,"extractFrontMatter");var ni=r(t=>t.replace(/\r\n?/g,` -`).replace(/<(\w+)([^>]*)>/g,(e,a,i)=>"<"+a+i.replace(/="([^"]*)"/g,"='$1'")+">"),"cleanupText"),si=r(t=>{let{text:e,metadata:a}=Ee(t),{displayMode:i,title:o,config:n={}}=a;return i&&(n.gantt||(n.gantt={}),n.gantt.displayMode=i),{title:o,config:n,text:e}},"processFrontmatter"),ci=r(t=>{let e=V.detectInit(t)??{},a=V.detectDirective(t,"wrap");return Array.isArray(a)?e.wrap=a.some(({type:i})=>i==="wrap"):a?.type==="wrap"&&(e.wrap=!0),{text:Vt(t),directive:e}},"processDirectives");function Lt(t){let e=ni(t),a=si(e),i=ci(a.text),o=$t(a.config,i.directive);return t=he(i.text),{code:t,title:a.title,config:o}}r(Lt,"preprocessDiagram");function we(t){let e=new TextEncoder().encode(t),a=Array.from(e,i=>String.fromCodePoint(i)).join("");return btoa(a)}r(we,"toBase64");var mi=5e4,pi="graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa",di="sandbox",fi="loose",gi="http://www.w3.org/2000/svg",li="http://www.w3.org/1999/xlink",ui="http://www.w3.org/1999/xhtml",Di="100%",yi="100%",xi="border:0;margin:0;",hi="margin:0",Ei="allow-top-navigation-by-user-activation allow-popups",wi='The "iframe" tag is not supported by your browser.',bi=["foreignobject"],Li=["dominant-baseline"];function Se(t){let e=Lt(t);return Y(),It(e.config??{}),e}r(Se,"processAndSetConfigs");async function vi(t,e){$();try{let{code:a,config:i}=Se(t);return{diagramType:(await Me(a)).type,config:i}}catch(a){if(e?.suppressErrors)return!1;throw a}}r(vi,"parse");var be=r((t,e,a=[])=>` -.${t} ${e} { ${a.join(" !important; ")} !important; }`,"cssImportantStyles"),Si=r((t,e=new Map)=>{let a="";if(t.themeCSS!==void 0&&(a+=` -${t.themeCSS}`),t.fontFamily!==void 0&&(a+=` -:root { --mermaid-font-family: ${t.fontFamily}}`),t.altFontFamily!==void 0&&(a+=` -:root { --mermaid-alt-font-family: ${t.altFontFamily}}`),e instanceof Map){let m=t.htmlLabels??t.flowchart?.htmlLabels?["> *","span"]:["rect","polygon","ellipse","circle","path"];e.forEach(s=>{xt(s.styles)||m.forEach(c=>{a+=be(s.id,c,s.styles)}),xt(s.textStyles)||(a+=be(s.id,"tspan",(s?.textStyles||[]).map(c=>c.replace("color","fill"))))})}return a},"createCssStyles"),Mi=r((t,e,a,i)=>{let o=Si(t,a),n=zt(e,o,t.themeVariables);return pt(ge(`${i}{${n}}`),le)},"createUserStyles"),Ai=r((t="",e,a)=>{let i=t;return!a&&!e&&(i=i.replace(/marker-end="url\([\d+./:=?A-Za-z-]*?#/g,'marker-end="url(#')),i=Ht(i),i=i.replace(/
/g,"
"),i},"cleanUpSvgCode"),Ti=r((t="",e)=>{let a=e?.viewBox?.baseVal?.height?e.viewBox.baseVal.height+"px":yi,i=we(`${t}`);return``},"putIntoIFrame"),Le=r((t,e,a,i,o)=>{let n=t.append("div");n.attr("id",a),i&&n.attr("style",i);let m=n.append("svg").attr("id",e).attr("width","100%").attr("xmlns",gi);return o&&m.attr("xmlns:xlink",o),m.append("g"),t},"appendDivSvgG");function ve(t,e){return t.append("iframe").attr("id",e).attr("style","width: 100%; height: 100%;").attr("sandbox","")}r(ve,"sandboxedIframe");var Ci=r((t,e,a,i)=>{t.getElementById(e)?.remove(),t.getElementById(a)?.remove(),t.getElementById(i)?.remove()},"removeExistingElements"),Ri=r(async function(t,e,a){$();let i=Se(e);e=i.code;let o=G();g.debug(o),e.length>(o?.maxTextSize??mi)&&(e=pi);let n="#"+t,m="i"+t,s="#"+m,c="d"+t,l="#"+c,y=r(()=>{let ft=k(x?s:l).node();ft&&"remove"in ft&&ft.remove()},"removeTempElements"),p=k("body"),x=o.securityLevel===di,A=o.securityLevel===fi,L=o.fontFamily;if(a!==void 0){if(a&&(a.innerHTML=""),x){let M=ve(k(a),m);p=k(M.nodes()[0].contentDocument.body),p.node().style.margin=0}else p=k(a);Le(p,t,c,`font-family: ${L}`,li)}else{if(Ci(document,t,c,m),x){let M=ve(k("body"),m);p=k(M.nodes()[0].contentDocument.body),p.node().style.margin=0}else p=k("body");Le(p,t,c)}let f,C;try{f=await B.fromText(e,{title:i.title})}catch(M){if(o.suppressErrorRendering)throw y(),M;f=await B.fromText("error"),C=M}let v=p.select(l).node(),u=f.type,S=v.firstChild,R=S.firstChild,T=f.renderer.getClasses?.(e,f),E=Mi(o,u,T,n),d=document.createElement("style");d.innerHTML=E,S.insertBefore(d,R);try{await f.renderer.draw(e,t,ht.version,f)}catch(M){throw o.suppressErrorRendering?y():Tr.draw(e,t,ht.version),M}let Oe=p.select(`${l} svg`),Pe=f.db.getAccTitle?.(),je=f.db.getAccDescription?.();Oi(u,Oe,Pe,je),p.select(`[id="${t}"]`).selectAll("foreignobject > *").attr("xmlns",ui);let _=p.select(l).node().innerHTML;if(g.debug("config.arrowMarkerAbsolute",o.arrowMarkerAbsolute),_=Ai(_,x,_t(o.arrowMarkerAbsolute)),x){let M=p.select(l+" svg").node();_=Ti(_,M)}else A||(_=Ft.sanitize(_,{ADD_TAGS:bi,ADD_ATTR:Li,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(xe(),C)throw C;return y(),{diagramType:u,svg:_,bindFunctions:f.db.bindFunctions}},"render");function ki(t={}){let e=Rt({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),Ot(e),e?.theme&&e.theme in tt?e.themeVariables=tt[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=tt.default.getThemeVariables(e.themeVariables));let a=typeof e=="object"?kt(e):Dt();gt(a.logLevel),$()}r(ki,"initialize");var Me=r((t,e={})=>{let{code:a}=Lt(t);return B.fromText(a,e)},"getDiagramFromText");function Oi(t,e,a,i){ue(e,t),De(e,a,i,e.attr("id"))}r(Oi,"addA11yInfo");var F=Object.freeze({render:Ri,parse:vi,getDiagramFromText:Me,initialize:ki,getConfig:G,setConfig:jt,getSiteConfig:Dt,updateSiteConfig:Pt,reset:r(()=>{Y()},"reset"),globalReset:r(()=>{Y(ut)},"globalReset"),defaultConfig:ut});gt(G().logLevel);Y(G());var Pi=r((t,e,a)=>{g.warn(t),yt(t)?(a&&a(t.str,t.hash),e.push({...t,message:t.str,error:t})):(a&&a(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},"handleError"),Ae=r(async function(t={querySelector:".mermaid"}){try{await ji(t)}catch(e){if(yt(e)&&g.error(e.str),P.parseError&&P.parseError(e),!t.suppressErrors)throw g.error("Use the suppressErrors option to suppress these errors"),e}},"run"),ji=r(async function({postRenderCallback:t,querySelector:e,nodes:a}={querySelector:".mermaid"}){let i=F.getConfig();g.debug(`${t?"":"No "}Callback function found`);let o;if(a)o=a;else if(e)o=document.querySelectorAll(e);else throw new Error("Nodes and querySelector are both undefined");g.debug(`Found ${o.length} diagrams`),i?.startOnLoad!==void 0&&(g.debug("Start On Load: "+i?.startOnLoad),F.updateSiteConfig({startOnLoad:i?.startOnLoad}));let n=new V.InitIDGenerator(i.deterministicIds,i.deterministicIDSeed),m,s=[];for(let c of Array.from(o)){g.info("Rendering diagram: "+c.id);if(c.getAttribute("data-processed"))continue;c.setAttribute("data-processed","true");let l=`mermaid-${n.next()}`;m=c.innerHTML,m=Mt(V.entityDecode(m)).trim().replace(//gi,"
");let y=V.detectInit(m);y&&g.debug("Detected early reinit: ",y);try{let{svg:p,bindFunctions:x}=await ke(l,m,c);c.innerHTML=p,t&&await t(l),x&&x(c)}catch(p){Pi(p,s,P.parseError)}}if(s.length>0)throw s[0]},"runThrowsErrors"),Te=r(function(t){F.initialize(t)},"initialize"),Ii=r(async function(t,e,a){g.warn("mermaid.init is deprecated. Please use run instead."),t&&Te(t);let i={postRenderCallback:a,querySelector:".mermaid"};typeof e=="string"?i.querySelector=e:e&&(e instanceof HTMLElement?i.nodes=[e]:i.nodes=e),await Ae(i)},"init"),Fi=r(async(t,{lazyLoad:e=!0}={})=>{$(),Z(...t),e===!1&&await Wr()},"registerExternalDiagrams"),Ce=r(function(){if(P.startOnLoad){let{startOnLoad:t}=F.getConfig();t&&P.run().catch(e=>g.error("Mermaid failed to initialize",e))}},"contentLoaded");if(typeof document<"u"){window.addEventListener("load",Ce,!1)}var _i=r(function(t){P.parseError=t},"setParseErrorHandler"),dt=[],vt=!1,Re=r(async()=>{if(!vt){for(vt=!0;dt.length>0;){let t=dt.shift();if(t)try{await t()}catch(e){g.error("Error executing queue",e)}}vt=!1}},"executeQueue"),Gi=r(async(t,e)=>new Promise((a,i)=>{let o=r(()=>new Promise((n,m)=>{F.parse(t,e).then(s=>{n(s),a(s)},s=>{g.error("Error parsing",s),P.parseError?.(s),m(s),i(s)})}),"performCall");dt.push(o),Re().catch(i)}),"parse"),ke=r((t,e,a)=>new Promise((i,o)=>{let n=r(()=>new Promise((m,s)=>{F.render(t,e,a).then(c=>{m(c),i(c)},c=>{g.error("Error parsing",c),P.parseError?.(c),s(c),o(c)})}),"performCall");dt.push(n),Re().catch(o)}),"render"),P={startOnLoad:!0,mermaidAPI:F,parse:Gi,render:ke,init:Ii,run:Ae,registerExternalDiagrams:Fi,registerLayoutLoaders:Bt,initialize:Te,parseError:void 0,contentLoaded:Ce,setParseErrorHandler:_i,detectType:J,registerIconPacks:St},Hs=P;export{Hs as default}; -/*! Check if previously processed */ -/*! - * Wait for document loaded before starting the execution - */ diff --git a/docs/diagrams/preview.html b/docs/diagrams/preview.html deleted file mode 100644 index 67a00f8..0000000 --- a/docs/diagrams/preview.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - ORM Template — User ERD - - - -

ORM Template — ER-диаграмма

-
Единственная модель: User и ее атрибуты.
- -
-erDiagram
-    USER {
-        uuid id PK "первичный ключ пользователя"
-        text login "уникальный логин"
-        text password "хэш пароля"
-        text role "роль пользователя"
-        bool is_active "активность"
-        bool is_temporal "пароль временный, хранится в открытом виде"
-        timestamptz created_at "дата создания"
-    }
-
- - - - diff --git a/docs/generate_docx.py b/docs/generate_docx.py deleted file mode 100644 index 5d9e674..0000000 --- a/docs/generate_docx.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Generate architecture.docx from architecture content.""" -from docx import Document -from docx.shared import Pt, Inches, RGBColor -from docx.enum.text import WD_ALIGN_PARAGRAPH -from docx.enum.table import WD_TABLE_ALIGNMENT -import os - -doc = Document() - -style = doc.styles['Normal'] -font = style.font -font.name = 'Calibri' -font.size = Pt(11) - -def add_heading(text, level=1): - h = doc.add_heading(text, level=level) - for run in h.runs: - run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E) - -def add_para(text, bold=False): - p = doc.add_paragraph() - run = p.add_run(text) - run.bold = bold - return p - -def add_bullet(text, bold_prefix=None): - p = doc.add_paragraph(style='List Bullet') - if bold_prefix: - run = p.add_run(bold_prefix) - run.bold = True - p.add_run(text) - else: - p.add_run(text) - -def add_table(headers, rows): - table = doc.add_table(rows=1, cols=len(headers)) - table.style = 'Light Grid Accent 1' - table.alignment = WD_TABLE_ALIGNMENT.LEFT - hdr = table.rows[0] - for i, h in enumerate(headers): - hdr.cells[i].text = h - for p in hdr.cells[i].paragraphs: - for run in p.runs: - run.bold = True - for row_data in rows: - row = table.add_row() - for i, val in enumerate(row_data): - row.cells[i].text = val - doc.add_paragraph() - -def add_code(text): - p = doc.add_paragraph() - run = p.add_run(text) - run.font.name = 'Consolas' - run.font.size = Pt(9) - run.font.color.rgb = RGBColor(0x33, 0x33, 0x33) - pf = p.paragraph_format - pf.space_before = Pt(4) - pf.space_after = Pt(4) - -# === TITLE === -title = doc.add_heading('Архитектура AI-платформы', level=0) -title.alignment = WD_ALIGN_PARAGRAPH.CENTER -doc.add_paragraph() - -# === 1. ОБЩЕЕ ОПИСАНИЕ === -add_heading('1. Общее описание') -add_para('AI-платформа для интеллектуальной маршрутизации запросов пользователей по различным LLM-моделям. Система состоит из двух основных компонентов:') -add_bullet(' — авторизация, управление чатами и сообщениями, REST API', 'Go Backend') -add_bullet(' — классификация запросов и маршрутизация к нужной модели', 'Python LLM Orchestrator') - -# === 2. АРХИТЕКТУРНАЯ СХЕМА === -add_heading('2. Архитектурная схема') -add_para('Общий поток данных:', bold=True) -add_code( - 'Пользователь → Go Backend (:8080) → Python Orchestrator (:8000) → LLM (Groq API)\n' - ' ↕ ↕\n' - ' PostgreSQL Router LLM → Target LLM' -) -doc.add_paragraph() - -add_para('Компоненты Go Backend:', bold=True) -add_bullet(' — /api/auth/register, /api/auth/login', 'Auth Handler') -add_bullet(' — CRUD /api/chats', 'Chat Handler') -add_bullet(' — POST/GET /api/chats/{id}/messages', 'Message Handler') -add_bullet(' — bcrypt + JWT генерация/валидация', 'Auth Service') -add_bullet(' — CRUD операции с чатами', 'Chat Service') -add_bullet(' — sliding window контекст + вызов оркестратора', 'Message Service') -add_bullet(' — HTTP-клиент к Python FastAPI', 'Orchestrator Client') - -doc.add_paragraph() -add_para('Компоненты Python Orchestrator:', bold=True) -add_bullet(' — маленькая быстрая модель (llama-3.1-8b-instant) классифицирует запрос', 'Router LLM') -add_bullet(' — Qwen 2.5 Coder 32B для программирования', 'Code Model') -add_bullet(' — DeepSeek R1 70B для анализа документов', 'Document Model') -add_bullet(' — Llama 3.3 70B для общих вопросов', 'General Model') - -# === 3. СТРУКТУРА ПРОЕКТА === -add_heading('3. Структура проекта') -add_code( - '.\n' - '├── cmd/server/main.go # Точка входа Go backend\n' - '├── internal/\n' - '│ ├── config/config.go # Конфигурация (env-переменные)\n' - '│ ├── handler/\n' - '│ │ ├── auth.go # Хендлеры регистрации и логина\n' - '│ │ ├── chat.go # CRUD чатов\n' - '│ │ ├── message.go # Отправка/получение сообщений\n' - '│ │ └── middleware.go # JWT middleware\n' - '│ ├── service/\n' - '│ │ ├── auth.go # bcrypt, JWT генерация\n' - '│ │ ├── chat.go # Бизнес-логика чатов\n' - '│ │ ├── message.go # Контекст + вызов оркестратора\n' - '│ │ └── orchestrator.go # HTTP-клиент к Python\n' - '│ └── router/router.go # chi router, маршруты\n' - '├── ent/schema/\n' - '│ ├── common.go # Миксины (PkMixin, RegisteredMixin)\n' - '│ ├── user.go # Модель User\n' - '│ ├── chat.go # Модель Chat\n' - '│ └── message.go # Модель Message\n' - '├── python/\n' - '│ ├── pyproject.toml\n' - '│ ├── app/\n' - '│ │ ├── main.py # FastAPI приложение\n' - '│ │ ├── config.py # Настройки\n' - '│ │ ├── routers/completions.py # Эндпоинт чат-комплишенов\n' - '│ │ └── services/\n' - '│ │ ├── router_llm.py # Классификатор запросов\n' - '│ │ ├── groq_client.py # Клиент Groq API\n' - '│ │ └── vllm_client.py # Клиент vLLM (заглушка)\n' - '│ └── tests/\n' - '├── atlas/migrations/ # SQL-миграции\n' - '├── Makefile\n' - '└── go.mod / go.sum' -) - -# === 4. МОДЕЛЬ ДАННЫХ === -add_heading('4. Модель данных (PostgreSQL)') - -add_para('Таблица USERS:', bold=True) -add_table( - ['Поле', 'Тип', 'Ограничения'], - [ - ['id', 'UUID', 'PRIMARY KEY, DEFAULT uuid_generate_v4()'], - ['login', 'VARCHAR(128)', 'UNIQUE, NOT NULL'], - ['password_hash', 'VARCHAR(256)', 'NOT NULL (bcrypt)'], - ['role', 'VARCHAR(64)', "DEFAULT 'user'"], - ['is_active', 'BOOLEAN', 'DEFAULT true'], - ['created_at', 'TIMESTAMP', 'IMMUTABLE, DEFAULT now()'], - ] -) - -add_para('Таблица CHATS:', bold=True) -add_table( - ['Поле', 'Тип', 'Ограничения'], - [ - ['id', 'UUID', 'PRIMARY KEY'], - ['title', 'VARCHAR(256)', 'OPTIONAL'], - ['active_model', 'VARCHAR(128)', 'OPTIONAL'], - ['created_at', 'TIMESTAMP', 'IMMUTABLE, DEFAULT now()'], - ['user_chats', 'UUID', 'FK → users.id, NOT NULL'], - ] -) - -add_para('Таблица MESSAGES:', bold=True) -add_table( - ['Поле', 'Тип', 'Ограничения'], - [ - ['id', 'UUID', 'PRIMARY KEY'], - ['role', 'ENUM', 'user | assistant | system'], - ['content', 'TEXT', 'NOT NULL'], - ['model_used', 'VARCHAR(128)', 'OPTIONAL'], - ['token_count', 'INTEGER', 'OPTIONAL'], - ['created_at', 'TIMESTAMP', 'IMMUTABLE, DEFAULT now()'], - ['chat_messages', 'UUID', 'FK → chats.id, NOT NULL'], - ] -) - -add_para('Правило изоляции: каждый чат изолирован. История одного чата не передаётся в другой. Новый чат = чистый контекст.') - -# === 5. API ЭНДПОИНТЫ === -add_heading('5. API эндпоинты') - -add_para('Публичные (без авторизации):', bold=True) -add_table( - ['Метод', 'URL', 'Описание'], - [ - ['GET', '/health', 'Проверка работоспособности'], - ['POST', '/api/auth/register', 'Регистрация {login, password} → {token}'], - ['POST', '/api/auth/login', 'Вход {login, password} → {token}'], - ] -) - -add_para('Защищённые (Bearer JWT):', bold=True) -add_table( - ['Метод', 'URL', 'Описание'], - [ - ['POST', '/api/chats', 'Создать чат {title?, active_model?}'], - ['GET', '/api/chats', 'Список чатов пользователя'], - ['GET', '/api/chats/{id}', 'Получить чат'], - ['DELETE', '/api/chats/{id}', 'Удалить чат (каскадно с сообщениями)'], - ['POST', '/api/chats/{id}/messages', 'Отправить сообщение → получить ответ LLM'], - ['GET', '/api/chats/{id}/messages', 'История сообщений (пагинация)'], - ] -) - -add_para('Python Orchestrator (внутренний):', bold=True) -add_table( - ['Метод', 'URL', 'Описание'], - [ - ['POST', '/api/v1/chat/completions', 'Получить ответ от LLM'], - ] -) - -# === 6. ПОТОК ОБРАБОТКИ === -add_heading('6. Поток обработки сообщения') -add_para('1. Пользователь отправляет POST /api/chats/{id}/messages с содержимым сообщения.') -add_para('2. Go Backend:') -add_bullet('JWT middleware — проверка токена, извлечение user_id') -add_bullet('Проверка: чат принадлежит пользователю') -add_bullet('Сохранить user-сообщение в БД') -add_bullet('Собрать контекст: последние N сообщений из чата (sliding window)') -add_bullet('HTTP POST → Python Orchestrator') -add_para('3. Python Orchestrator:') -add_bullet('Router LLM классифицирует запрос (code / document / general)') -add_bullet('Выбирает целевую модель') -add_bullet('Отправляет контекст + запрос в модель (через Groq API)') -add_bullet('Возвращает: {content, model_used, tokens}') -add_para('4. Go Backend:') -add_bullet('Сохранить assistant-сообщение в БД (с model_used и token_count)') -add_bullet('Вернуть ответ пользователю') - -# === 7. МАРШРУТИЗАЦИЯ === -add_heading('7. Маршрутизация запросов (Router LLM)') -add_para('Router LLM — маленькая быстрая модель (llama-3.1-8b-instant), которая анализирует запрос и определяет категорию:') -add_table( - ['Категория', 'Целевая модель (Groq)', 'Когда'], - [ - ['code', 'qwen-2.5-coder-32b', 'Программирование, код, отладка'], - ['document', 'deepseek-r1-distill-llama-70b', 'Анализ документов, резюме, тексты'], - ['general', 'llama-3.3-70b-versatile', 'Всё остальное'], - ] -) -add_para('Если пользователь явно выбрал модель в чате (active_model), маршрутизация пропускается.') - -# === 8. КОНТЕКСТ === -add_heading('8. Управление контекстом') -add_bullet('Sliding Window: передаются последние N сообщений (по умолчанию 20)', '') -add_bullet('Лимит токенов: максимум MAX_TOKENS (по умолчанию 4096)', '') -add_bullet('Приблизительный подсчёт: len(content) / 4 ≈ 1 токен', '') -add_bullet('Новый чат: пользователь создаёт новый чат для сброса контекста', '') - -# === 9. АУТЕНТИФИКАЦИЯ === -add_heading('9. Аутентификация') -add_bullet('Пароли хешируются bcrypt (golang.org/x/crypto)') -add_bullet('JWT токен (HS256): sub (user_id), role, exp, iat') -add_bullet('Срок действия: 24 часа (настраивается через JWT_EXPIRE_HOURS)') -add_bullet('Заголовок: Authorization: Bearer ') - -# === 10. СТЕК === -add_heading('10. Технологический стек') - -add_para('Go Backend:', bold=True) -add_table( - ['Компонент', 'Технология'], - [ - ['HTTP-фреймворк', 'chi/v5'], - ['ORM', 'Ent (entgo.io)'], - ['Миграции', 'Atlas'], - ['JWT', 'golang-jwt/jwt/v5'], - ['Хеширование', 'golang.org/x/crypto (bcrypt)'], - ['БД-драйвер', 'pgx/v5'], - ['Конфигурация', 'caarlos0/env/v11'], - ] -) - -add_para('Python Orchestrator:', bold=True) -add_table( - ['Компонент', 'Технология'], - [ - ['Web-фреймворк', 'FastAPI'], - ['ASGI-сервер', 'Uvicorn'], - ['LLM API', 'Groq SDK'], - ['HTTP-клиент', 'httpx'], - ['Конфигурация', 'pydantic-settings'], - ] -) - -# === 11. ENV === -add_heading('11. Переменные окружения') - -add_para('Go Backend:', bold=True) -add_table( - ['Переменная', 'Описание', 'По умолчанию'], - [ - ['DB_URL', 'PostgreSQL connection string', '— (обязательна)'], - ['JWT_SECRET', 'Секрет для подписи JWT', '— (обязательна)'], - ['PORT', 'Порт сервера', '8080'], - ['JWT_EXPIRE_HOURS', 'Время жизни токена (часы)', '24'], - ['ORCHESTRATOR_URL', 'URL Python-оркестратора', 'http://localhost:8000'], - ['CONTEXT_WINDOW', 'Кол-во сообщений в контексте', '20'], - ['MAX_TOKENS', 'Максимум токенов контекста', '4096'], - ] -) - -add_para('Python Orchestrator:', bold=True) -add_table( - ['Переменная', 'Описание', 'По умолчанию'], - [ - ['GROQ_API_KEY', 'Ключ Groq API', '— (обязательна)'], - ['ROUTER_MODEL', 'Модель-классификатор', 'llama-3.1-8b-instant'], - ['PORT', 'Порт оркестратора', '8000'], - ] -) - -# === 12. JSON КОНТРАКТ === -add_heading('12. JSON-контракт Go ↔ Python') - -add_para('Запрос (Go → Python):', bold=True) -add_code( - '{\n' - ' "messages": [\n' - ' {"role": "user", "content": "Напиши функцию..."},\n' - ' {"role": "assistant", "content": "Вот функция..."},\n' - ' {"role": "user", "content": "А теперь добавь тесты"}\n' - ' ],\n' - ' "active_model": null\n' - '}' -) - -add_para('Ответ (Python → Go):', bold=True) -add_code( - '{\n' - ' "content": "Вот тесты для функции...",\n' - ' "model_used": "qwen-2.5-coder-32b",\n' - ' "tokens": 523\n' - '}' -) - -# === 13. ЭТАПЫ === -add_heading('13. Этапы разработки') - -add_para('Спринт 1: Go-скелет ✓', bold=True) -add_bullet('go.mod, зависимости, Ent-схемы (User, Chat, Message), main.go + /health') - -add_para('Спринт 2: Аутентификация', bold=True) -add_bullet('Сервис регистрации/логина (bcrypt + JWT), middleware, хендлеры') - -add_para('Спринт 3: CRUD чатов', bold=True) -add_bullet('Сервис и REST-хендлеры создания/получения/удаления чатов') - -add_para('Спринт 4: Python-оркестратор', bold=True) -add_bullet('FastAPI, Router LLM, Groq API клиент, эндпоинт /api/v1/chat/completions') - -add_para('Спринт 5: Сквозной поток сообщений', bold=True) -add_bullet('Go HTTP-клиент к Python, sliding window, хендлеры сообщений, полный цикл') - -# === 14. PRODUCTION === -add_heading('14. Production-сценарий (будущее)') -add_para('После проверки маршрутизации через Groq API система переводится на локальные модели:') -add_bullet('Каждая модель запускается через vLLM на отдельной GPU') -add_bullet('Каждая модель — отдельный сервис (отдельный порт)') -add_bullet('Python Orchestrator переключается с Groq API на локальные vLLM-эндпоинты') -add_bullet('Внешние API не используются') - -# Save -output_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'architecture.docx') -doc.save(output_path) -print(f"Saved to {output_path}")