Compare commits
2
Commits
572b40dd71
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c07ac3ffd3 | ||
|
|
d0de81d0a7 |
@@ -0,0 +1,22 @@
|
|||||||
|
.deps
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
*.err
|
||||||
|
.isolated/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
agent-transcripts/
|
||||||
|
test_respond.py
|
||||||
|
test_preanalysis.py
|
||||||
|
ok_case.err
|
||||||
|
ok_case2.err
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CURSOR_API_KEY=crsr_your_key_here
|
||||||
|
CURSOR_MODEL=auto
|
||||||
|
|
||||||
|
# Bootstrap admin (created on first start if users list is empty)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
# Optional: unused placeholder for future signed cookies (not read by main.py)
|
||||||
|
SESSION_SECRET=change-me
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Local Python installs / venvs
|
||||||
|
.deps/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Secrets — never push
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Runtime data (chats, uploads, db.json). Keep the empty dir in git.
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
|
||||||
|
# Logs and test leftovers
|
||||||
|
*.log
|
||||||
|
*.log.*
|
||||||
|
*.out
|
||||||
|
*.err
|
||||||
|
|
||||||
|
# OS junk
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor / local scratch
|
||||||
|
.isolated/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
node_modules/
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# OS libs for the cursor-sdk Linux wheel: it vendors Node + cursor-sdk-bridge.js
|
||||||
|
# (see cursor_sdk/_vendor/bridge/). ca-certificates for Cursor API HTTPS.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
libstdc++6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN python -m venv /opt/venv \
|
||||||
|
&& /opt/venv/bin/pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
ENV PATH="/opt/venv/bin:${PATH}" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
COPY main.py log_preanalysis.py ./
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
EXPOSE 8010
|
||||||
|
|
||||||
|
# GET / serves the login page without auth (unlike /api/auth/me, which is 401).
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8010/', timeout=4)"
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# Helpomatica
|
||||||
|
|
||||||
|
Локальная веб-панель в духе Claude Projects: проекты, инструкции, файлы,
|
||||||
|
общие чаты, пользователи с ролями и ответы **Cursor Agent**. Интерфейс
|
||||||
|
на русском. Это не SPA-фреймворк и не облачный продукт — один процесс
|
||||||
|
FastAPI раздаёт статику и JSON API, данные лежат на диске сервера.
|
||||||
|
|
||||||
|
Ответы агента **не полностью офлайн**: нужен `CURSOR_API_KEY` с
|
||||||
|
[Cursor Dashboard → Integrations](https://cursor.com/dashboard/integrations).
|
||||||
|
Веб-панель, логин, файлы и предразбор логов работают и без ключа.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как запустить локально
|
||||||
|
|
||||||
|
Скопируйте `.env.example` в `.env` и вставьте ключ:
|
||||||
|
|
||||||
|
```env
|
||||||
|
CURSOR_API_KEY=crsr_your_key_here
|
||||||
|
CURSOR_MODEL=auto
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\run.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт ставит зависимости в `.deps` (`pip install --target .deps`),
|
||||||
|
выставляет `PYTHONPATH` и поднимает uvicorn с `--reload` на
|
||||||
|
**`0.0.0.0:8010`**.
|
||||||
|
|
||||||
|
- На этой машине: http://127.0.0.1:8010
|
||||||
|
- С других ПК в LAN: `http://<IP-этой-машины>:8010`
|
||||||
|
- Вход по умолчанию: **`admin` / `admin`**
|
||||||
|
|
||||||
|
Если PowerShell блокирует локальные сценарии:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\run.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Если страница не открывается с другого ПК, разрешите входящий TCP 8010
|
||||||
|
в брандмауэре Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
netsh advfirewall firewall add rule name="Helpomatica 8010" dir=in action=allow protocol=TCP localport=8010
|
||||||
|
```
|
||||||
|
|
||||||
|
Загрузки и агент выполняются **на машине сервера**, не в браузере клиента.
|
||||||
|
Файлы копируются во временный workspace из `data/uploads/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Нужен файл `.env` (из `.env.example`). Данные не кладутся в образ:
|
||||||
|
том `./data:/app/data` хранит `db.json` и загрузки.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте http://localhost:8010 и войдите. После пересборки контейнера
|
||||||
|
проекты, чаты и файлы остаются в `./data`.
|
||||||
|
|
||||||
|
Healthcheck бьёт в `GET /` (страница логина, без авторизации).
|
||||||
|
`GET /api/auth/me` для проверки не подходит — без cookie он отдаёт 401.
|
||||||
|
|
||||||
|
Подробности про Cursor Agent в Linux-контейнере — в разделе
|
||||||
|
[Cursor SDK и Docker](#cursor-sdk-и-docker).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что это такое
|
||||||
|
|
||||||
|
Панель для команды на одной машине / в LAN:
|
||||||
|
|
||||||
|
- **Проекты** — название, описание, инструкции, модель, иконка.
|
||||||
|
- **Инструкции** — общий контекст для всех чатов проекта.
|
||||||
|
- **Файлы** — материалы проекта (видны во всех чатах) или только этого чата.
|
||||||
|
- **Чаты** — общие на проект: любой вошедший пользователь читает и пишет
|
||||||
|
в тех же разговорах.
|
||||||
|
- **Пользователи** — роли `admin` и `user`. Админ видит имена авторов
|
||||||
|
сообщений; обычный пользователь видит «Вы» / «Коллега».
|
||||||
|
- **Ответы** — локальный Cursor Agent (`AsyncAgent`) со стримингом,
|
||||||
|
размышлениями, историей инструментов и кнопкой «Стоп».
|
||||||
|
|
||||||
|
Дополнительно в UI: поиск по чатам, экспорт чата в Markdown, чипы
|
||||||
|
контекстных файлов, копирование кода, цитаты строк лога, диаграммы
|
||||||
|
Mermaid, формулы KaTeX, светлая/тёмная тема, индикатор статуса агента.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер (static/index.html + app.js + style.css)
|
||||||
|
│ cookie helpomatica_session
|
||||||
|
▼
|
||||||
|
FastAPI (main.py) ──► data/db.json
|
||||||
|
│ data/uploads/
|
||||||
|
│ %TEMP%/helpomatica-agent/hlp-* (в Docker: /tmp/...)
|
||||||
|
▼
|
||||||
|
cursor-sdk: AsyncClient.launch_bridge → AsyncAgent.send → SSE
|
||||||
|
▼
|
||||||
|
Cursor API (нужен CURSOR_API_KEY)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Бэкенд:** FastAPI, статика смонтирована на `/static`, корень `GET /`
|
||||||
|
отдаёт `static/index.html`. Фронтенд — ванильный JS, без React/Vue.
|
||||||
|
- **Хранение:** JSON-файл `data/db.json` (атомарная запись через `.tmp`)
|
||||||
|
и файловая система `data/uploads/`. Папка `data/` в Git не входит.
|
||||||
|
- **Сессии:** список в `db.json`, cookie HttpOnly `helpomatica_session`
|
||||||
|
(`SameSite=Lax`, без `Secure` — HTTP по IP в LAN), срок 30 дней,
|
||||||
|
не больше 200 сессий. Пароль: PBKDF2-HMAC-SHA256, 200 000 итераций.
|
||||||
|
- **Доступ:** все авторизованные видят все проекты и общие чаты.
|
||||||
|
Отдельных ACL на проект нет. Файлы с `scope=chat` попадают в агент
|
||||||
|
только этого чата; `scope=project` — во все чаты проекта.
|
||||||
|
- **Параллельность:** глобального single-flight нет. У каждого `chat_id`
|
||||||
|
своя очередь: один активный запрос и максимум один в ожидании
|
||||||
|
(второй лишний → HTTP 409). Разные чаты запускают отдельные local bridge.
|
||||||
|
- **Слушает** `0.0.0.0:8010`.
|
||||||
|
|
||||||
|
`SESSION_SECRET` в `.env` — заготовка, **main.py его не читает**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
Версии из текущего `pip install` в `.deps` (в `requirements.txt`
|
||||||
|
пакеты **без пинов**):
|
||||||
|
|
||||||
|
| Слой | Что |
|
||||||
|
|------|-----|
|
||||||
|
| Язык | Python **3.12** (локально 3.12.10). `cursor-sdk` требует **≥ 3.10** |
|
||||||
|
| API | FastAPI **0.141.1**, Starlette, Pydantic v2 |
|
||||||
|
| Сервер | uvicorn[standard] **0.52.1** |
|
||||||
|
| Агент | cursor-sdk **1.0.27** (`AsyncAgent`, `AsyncClient.launch_bridge`, `LocalAgentOptions`) + httpx |
|
||||||
|
| Конфиг | python-dotenv **1.2.2** |
|
||||||
|
| Загрузки | python-multipart **0.0.32** |
|
||||||
|
| Предразбор логов | `log_preanalysis.py` (stdlib) |
|
||||||
|
| Фронтенд | `static/app.js`, `static/style.css` — без сборки |
|
||||||
|
| CDN | Mermaid 11, KaTeX 0.16.22, шрифты Google (Manrope, Playfair Display) |
|
||||||
|
| Данные | JSON + файлы на диске |
|
||||||
|
|
||||||
|
Локальный запуск (`run.ps1`) кладёт пакеты в `.deps` и добавляет каталог
|
||||||
|
в `sys.path`. Docker ставит те же пакеты в venv через `pip install -r requirements.txt`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Форма `data/db.json`
|
||||||
|
|
||||||
|
Корень — объект со списками. При пустом файле / первом старте создаётся
|
||||||
|
админ из `ADMIN_USERNAME` / `ADMIN_PASSWORD`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "hex",
|
||||||
|
"username": "admin",
|
||||||
|
"display_name": "Admin",
|
||||||
|
"password_hash": "salt$pbkdf2hex",
|
||||||
|
"role": "admin",
|
||||||
|
"created_at": "ISO-8601"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sessions": [{ "id": "token", "user_id": "…", "created_at": "…" }],
|
||||||
|
"projects": [{
|
||||||
|
"id": "…", "name": "…", "description": "…", "instructions": "…",
|
||||||
|
"icon": "✦", "model": "auto", "created_by": "…",
|
||||||
|
"created_at": "…", "updated_at": "…"
|
||||||
|
}],
|
||||||
|
"chats": [{
|
||||||
|
"id": "…", "project_id": "…", "title": "…", "sort_order": 0,
|
||||||
|
"created_by": "…", "updated_by": "…",
|
||||||
|
"created_at": "…", "updated_at": "…"
|
||||||
|
}],
|
||||||
|
"messages": [{
|
||||||
|
"id": "…", "chat_id": "…", "role": "user|assistant", "content": "…",
|
||||||
|
"attachments": [{ "id": "…", "name": "…" }],
|
||||||
|
"user_id": "… или null", "author_name": "Admin|Помощник|…",
|
||||||
|
"thinking": "…", "thinking_duration_ms": 17519,
|
||||||
|
"activity": [{ "id": "a1", "type": "thinking|shell|…", "title": "…",
|
||||||
|
"detail": "…", "status": "running|completed" }],
|
||||||
|
"created_at": "…"
|
||||||
|
}],
|
||||||
|
"files": [{
|
||||||
|
"id": "…", "project_id": "…", "chat_id": "null или id чата",
|
||||||
|
"scope": "project|chat", "name": "line_u_codes.log.1",
|
||||||
|
"label": "", "stored_name": "<uuid>_оригинал",
|
||||||
|
"content_type": "…", "size": 123, "created_at": "…"
|
||||||
|
}],
|
||||||
|
"analysis_cache": [{
|
||||||
|
"key": "sha256", "file_sig": "sha256", "project_id": "…",
|
||||||
|
"job_id": "", "question": "нормализованный текст",
|
||||||
|
"answer": "…", "preanalysis": "markdown", "summary": "…",
|
||||||
|
"created_at": "…"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Байты файлов лежат в `data/uploads/<stored_name>`, в UI показывается
|
||||||
|
оригинальное `name`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как проходит запрос к агенту
|
||||||
|
|
||||||
|
1. Пользователь пишет сообщение. Фронт сохраняет его
|
||||||
|
`POST /api/chats/{id}/messages` (роль `user`, вложения, автор).
|
||||||
|
2. Сразу же `POST /api/chats/{id}/respond` — SSE-поток
|
||||||
|
(`text/event-stream`).
|
||||||
|
3. Сервер занимает слот чата (или ставит в очередь на один).
|
||||||
|
Событие `status: queued` / `starting`.
|
||||||
|
4. Собирается workspace: `prepare_agent_workspace` создаёт
|
||||||
|
`%TEMP%/helpomatica-agent/hlp-<project8>-*` (в Linux/Docker —
|
||||||
|
`/tmp/helpomatica-agent/hlp-…`). Туда hardlink/копия загрузок
|
||||||
|
(и в `./`, и в `./files/`), плюс подсказки `README.md`,
|
||||||
|
`PROJECT_FILES.md`, `AGENTS.md`, `.cursorignore`.
|
||||||
|
5. Если вопрос похож на разбор логов (`log_preanalysis.looks_like_log_job`)
|
||||||
|
и в скоупе есть `*.log*`:
|
||||||
|
- ищется `analysis_cache`;
|
||||||
|
- иначе поток-скан логов с SSE `progress` («Читаю лог… N%»);
|
||||||
|
- результат пишется в `./PREANALYSIS.md`.
|
||||||
|
6. Промпт: инструкции проекта + выдержки файлов + последние 30 сообщений
|
||||||
|
+ правило «cwd уже workspace, читай PREANALYSIS.md первым».
|
||||||
|
7. В **отдельном потоке** (на Windows — `WindowsProactorEventLoopPolicy`)
|
||||||
|
вызывается `AsyncClient.launch_bridge(workspace=cwd)` и
|
||||||
|
`AsyncAgent.create` / `agent.send`. События моста читаются в этом
|
||||||
|
потоке; основной event loop забирает их через `asyncio.to_thread(queue.get)`.
|
||||||
|
8. В браузер уходят SSE:
|
||||||
|
- `thinking` — дельты `thinking-delta`;
|
||||||
|
- `activity` — инструменты, shell, статус, шаги «Логи»;
|
||||||
|
- `delta` — токены ответа (`text-delta`);
|
||||||
|
- `thinking_done`, `done` / `cancelled` / `error`.
|
||||||
|
9. Готовый текст сохраняется как сообщение ассистента (`thinking`,
|
||||||
|
`activity`, `thinking_duration_ms`). При успехе пишется кэш разбора
|
||||||
|
(до 80 записей).
|
||||||
|
|
||||||
|
Кнопка «Стоп» — `POST /api/chats/{id}/cancel`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workspace агента и файлы
|
||||||
|
|
||||||
|
- Каталог: `tempfile.gettempdir()/helpomatica-agent/hlp-…`.
|
||||||
|
- Копируются файлы **проекта** + файлы **этого чата** + вложения сообщений.
|
||||||
|
- Вложения, chat-scope и логи копируются всегда; остальные файлы проекта
|
||||||
|
режутся лимитами 40 МБ / файл и 80 МБ суммарно.
|
||||||
|
- Если hardlink недоступен — `shutil.copy2`.
|
||||||
|
- Агент работает только с этим cwd; исходники панели и диск клиента
|
||||||
|
ему не отдаются.
|
||||||
|
|
||||||
|
Цитаты в ответе вида `:15231:line_u_codes.log.1` или
|
||||||
|
`::log:line_u_codes.log.1:15231::` фронт превращает в ссылку на
|
||||||
|
`GET /api/files/{id}/excerpt?start=&end=` (фрагмент ±25 строк).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Авторизация и API (кратко)
|
||||||
|
|
||||||
|
Публично: `GET /`, `/static/*`, `POST /api/auth/login`.
|
||||||
|
Остальные `/api/*` требуют cookie.
|
||||||
|
|
||||||
|
| Метод | Путь | Назначение |
|
||||||
|
|-------|------|------------|
|
||||||
|
| POST | `/api/auth/login` | вход, Set-Cookie |
|
||||||
|
| POST | `/api/auth/logout` | выход |
|
||||||
|
| GET | `/api/auth/me` | текущий пользователь |
|
||||||
|
| GET/POST | `/api/users` | список / создание (только admin) |
|
||||||
|
| GET | `/api/agent/status` | ключ, SDK, busy, очереди чатов |
|
||||||
|
| CRUD | `/api/projects`, `/api/chats` | проекты, чаты, порядок |
|
||||||
|
| GET | `/api/chats/{id}/export` | Markdown |
|
||||||
|
| POST | `/api/chats/{id}/messages` | сохранить сообщение |
|
||||||
|
| POST | `/api/chats/{id}/respond` | SSE-ответ агента |
|
||||||
|
| POST | `/api/chats/{id}/cancel` | остановить генерацию |
|
||||||
|
| GET | `/api/search?q=` | поиск по названиям и тексту (от 2 символов, до 30) |
|
||||||
|
| POST | `/api/projects/{id}/files` | загрузка (`scope` + `chat_id`) |
|
||||||
|
| POST | `/api/chats/{id}/files/bind` | привязать файлы к чату |
|
||||||
|
| GET | `/api/files/{id}` | скачать |
|
||||||
|
| GET | `/api/files/{id}/excerpt` | фрагмент лога по строкам |
|
||||||
|
|
||||||
|
Модель проекта выбирается в UI (`auto`, `composer-2.5`, `composer-2`,
|
||||||
|
`gpt-5.2`, `claude-4.6-sonnet`) и уходит в `AgentOptions.model`.
|
||||||
|
По умолчанию — `CURSOR_MODEL` из `.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cursor SDK и Docker
|
||||||
|
|
||||||
|
На Windows wheel `cursor-sdk` кладёт в
|
||||||
|
`cursor_sdk/_vendor/bridge/bin/`:
|
||||||
|
|
||||||
|
- `cursor-sdk-bridge.cmd` → запускает **`node.exe`** (вендорный, ~89 МБ)
|
||||||
|
- `../dist/bin/cursor-sdk-bridge.js` — сам мост (Node, не отдельный .exe агента)
|
||||||
|
|
||||||
|
`resolve_bridge_path()` ищет: `CURSOR_SDK_BRIDGE_BIN`, затем bundled
|
||||||
|
`bin/cursor-sdk-bridge` (POSIX) / `cursor-sdk-bridge.cmd` (Windows),
|
||||||
|
затем PATH.
|
||||||
|
|
||||||
|
**Не копируйте `.deps` с Windows-хоста в Linux-образ** — там `node.exe`.
|
||||||
|
Dockerfile ставит пакет заново: Linux-wheel cursor-sdk (x64/arm64)
|
||||||
|
должен принести свой `node` + launcher `cursor-sdk-bridge`.
|
||||||
|
|
||||||
|
Если в контейнере мост не стартует (нет bundled node, ошибка glibc,
|
||||||
|
нет `cursor-sdk-bridge`):
|
||||||
|
|
||||||
|
- веб-UI, логин, файлы и **предразбор логов** всё равно работают;
|
||||||
|
- ответы Cursor Agent нужно снимать на Windows-хосте через `.\run.ps1`.
|
||||||
|
|
||||||
|
Образ не содержит `.env`. Ключ передаётся `env_file: .env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Сразу смените пароль админа (`admin`/`admin` только для первого старта).
|
||||||
|
- Не коммитьте `.env` (уже в `.gitignore`). Ключ Cursor — секрет.
|
||||||
|
- Панель слушает весь LAN: ограничьте доступ брандмауэром.
|
||||||
|
- Cookie без `Secure`: нормально для HTTP в LAN, не для публичного HTTPS
|
||||||
|
без доработки.
|
||||||
|
- Это не multi-tenant isolation: любой `user` видит все проекты и чаты.
|
||||||
|
- Генерации картинок в панели нет; агенту это явно запрещено в промпте.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файлы репозитория
|
||||||
|
|
||||||
|
| Путь | Роль |
|
||||||
|
|------|------|
|
||||||
|
| `main.py` | FastAPI-приложение |
|
||||||
|
| `log_preanalysis.py` | потоковый скан логов → `PREANALYSIS.md` |
|
||||||
|
| `static/` | HTML/CSS/JS |
|
||||||
|
| `run.ps1` | локальный запуск на Windows |
|
||||||
|
| `requirements.txt` | зависимости |
|
||||||
|
| `.env.example` | шаблон переменных (без секретов) |
|
||||||
|
| `Dockerfile`, `docker-compose.yml`, `.dockerignore` | контейнер |
|
||||||
|
| `test_preanalysis.py` | короткий тест предразбора логов |
|
||||||
|
| `data/` | БД и загрузки на диске сервера — **не в Git** (только `data/.gitkeep`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Выгрузка в Gitea
|
||||||
|
|
||||||
|
В репозиторий кладите только исходники продукта: `main.py`, `log_preanalysis.py`, `static/`, `requirements.txt`, `run.ps1`, Docker-файлы, `.env.example`, `.gitignore`, `README.md`.
|
||||||
|
|
||||||
|
**Не коммитьте и не копируйте в Gitea:**
|
||||||
|
|
||||||
|
- `.env` — там `CURSOR_API_KEY` и пароль админа
|
||||||
|
- `data/` — чаты, загрузки, `db.json` (том Docker `./data` тоже только локальный)
|
||||||
|
- `.deps/` — локальный `pip install --target` (ставится заново из `requirements.txt`)
|
||||||
|
|
||||||
|
После клона:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
copy .env.example .env
|
||||||
|
# вставьте CURSOR_API_KEY и смените ADMIN_PASSWORD
|
||||||
|
docker compose up -d --build
|
||||||
|
# или локально на Windows:
|
||||||
|
.\run.ps1
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
helpomatica:
|
||||||
|
build: .
|
||||||
|
image: helpomatica:local
|
||||||
|
ports:
|
||||||
|
- "8010:8010"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
CURSOR_MODEL: ${CURSOR_MODEL:-auto}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- agent-tmp:/tmp/helpomatica-agent
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8010/', timeout=4)",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
agent-tmp:
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
"""Stream-scan InMark-style logs without loading the whole file into RAM."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
|
ProgressCb = Callable[[dict[str, Any]], None]
|
||||||
|
|
||||||
|
LOG_JOB_HINTS = re.compile(
|
||||||
|
r"job[_\s]?id|заказ|расхожден|error\s*6[45]|промежутк|частот|"
|
||||||
|
r"plc_errors|line_u|задани[еюя]|маркиров|отбраков|лог[аиов]?\b|"
|
||||||
|
r"histogram|ошибк|gap|код[аыов]?\b|смен[аыеу]",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
JOB_ID_RE = re.compile(
|
||||||
|
r"(?:job(?:[_\s-]?id)?|заказ(?:а|у|ом)?|задани[еюя]|jobid)\s*[:=#№]?\s*(\d{3,8})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
STANDALONE_JOB_RE = re.compile(r"\b(\d{4,6})\b")
|
||||||
|
DATE_ISO_RE = re.compile(r"\b(20\d{2}-\d{2}-\d{2})\b")
|
||||||
|
DATE_RU_RE = re.compile(r"\b(\d{1,2})[./](\d{1,2})[./](20\d{2}|\d{2})\b")
|
||||||
|
TS_ISO_RE = re.compile(r"(20\d{2}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})")
|
||||||
|
TS_RU_RE = re.compile(r"(\d{2}\.\d{2}\.20\d{2})[ T](\d{2}:\d{2}:\d{2})")
|
||||||
|
SOURCE_BRACKET_RE = re.compile(r"\[([A-Za-zА-Яа-яЁё0-9_./-]{2,40})\]")
|
||||||
|
SOURCE_FIELD_RE = re.compile(
|
||||||
|
r"\b(?:source|src|sender|from|channel|канал)\s*[=:]\s*([^\s,;\]=]+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
ERROR_RE = re.compile(r"\b(?:Error|error_?)\s*(\d{1,3})\b")
|
||||||
|
ERROR64_RE = re.compile(r"\b(?:Error|error_?)\s*64\b", re.IGNORECASE)
|
||||||
|
ERROR65_RE = re.compile(r"\b(?:Error|error_?)\s*65\b", re.IGNORECASE)
|
||||||
|
JOB_IN_LINE_RE = re.compile(
|
||||||
|
r"(?:job(?:[_\s-]?id)?|заказ|JobId)\s*[=:#]?\s*(\d{3,8})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
CODE_RE = re.compile(
|
||||||
|
r"\b(?:01\d{14}21[A-Za-z0-9]{1,24}|[A-F0-9]{24,44}|\d{20,32})\b"
|
||||||
|
)
|
||||||
|
LOG_NAME_RE = re.compile(r"\.log(\.\d+)?(\.gz)?$", re.IGNORECASE)
|
||||||
|
|
||||||
|
PREFERRED_LOG_NAMES = (
|
||||||
|
"line_u_codes.log",
|
||||||
|
"plc_errors.log",
|
||||||
|
"line_u.log",
|
||||||
|
"line.log",
|
||||||
|
)
|
||||||
|
|
||||||
|
GAP_SECONDS = 30
|
||||||
|
MAX_UNIQUE_CODES = 20_000
|
||||||
|
MAX_ERROR64_HITS = 80
|
||||||
|
MAX_GAPS = 25
|
||||||
|
MAX_SAMPLE_LINES = 12
|
||||||
|
MAX_PREANALYSIS_CHARS = 70_000
|
||||||
|
READ_CHUNK = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_log_job(query: str, log_file_names: Iterable[str] | None = None) -> bool:
|
||||||
|
text = str(query or "")
|
||||||
|
if LOG_JOB_HINTS.search(text):
|
||||||
|
return True
|
||||||
|
names = [str(name or "") for name in (log_file_names or [])]
|
||||||
|
if names and (JOB_ID_RE.search(text) or DATE_ISO_RE.search(text) or DATE_RU_RE.search(text)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_log_filename(name: str) -> bool:
|
||||||
|
lower = str(name or "").lower()
|
||||||
|
if LOG_NAME_RE.search(lower):
|
||||||
|
return True
|
||||||
|
return ".log." in lower or lower.endswith(".log")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_job_id(query: str) -> str | None:
|
||||||
|
text = str(query or "")
|
||||||
|
match = JOB_ID_RE.search(text)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
hinted = LOG_JOB_HINTS.search(text)
|
||||||
|
if hinted:
|
||||||
|
standalone = STANDALONE_JOB_RE.search(text)
|
||||||
|
if standalone:
|
||||||
|
return standalone.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_shift_date(query: str) -> str | None:
|
||||||
|
text = str(query or "")
|
||||||
|
iso = DATE_ISO_RE.search(text)
|
||||||
|
if iso:
|
||||||
|
return iso.group(1)
|
||||||
|
ru = DATE_RU_RE.search(text)
|
||||||
|
if not ru:
|
||||||
|
return None
|
||||||
|
day, month, year = ru.group(1), ru.group(2), ru.group(3)
|
||||||
|
if len(year) == 2:
|
||||||
|
year = f"20{year}"
|
||||||
|
try:
|
||||||
|
return datetime(int(year), int(month), int(day)).strftime("%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_log_encoding(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
raw = path.open("rb").read(65536)
|
||||||
|
except OSError:
|
||||||
|
return "utf-8"
|
||||||
|
if raw.startswith(b"\xef\xbb\xbf"):
|
||||||
|
return "utf-8-sig"
|
||||||
|
for encoding in ("utf-8", "cp1251"):
|
||||||
|
try:
|
||||||
|
raw.decode(encoding)
|
||||||
|
return encoding
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return "cp1251"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(line: str) -> datetime | None:
|
||||||
|
match = TS_ISO_RE.search(line)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(
|
||||||
|
f"{match.group(1)} {match.group(2)}", "%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
match = TS_RU_RE.search(line)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(
|
||||||
|
f"{match.group(1)} {match.group(2)}", "%d.%m.%Y %H:%M:%S"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _source_of(line: str, fallback: str) -> str:
|
||||||
|
field = SOURCE_FIELD_RE.search(line)
|
||||||
|
if field:
|
||||||
|
return field.group(1)[:40]
|
||||||
|
bracket = SOURCE_BRACKET_RE.search(line)
|
||||||
|
if bracket:
|
||||||
|
token = bracket.group(1)
|
||||||
|
if not re.fullmatch(r"\d{1,2}:\d{2}:\d{2}.*", token):
|
||||||
|
return token[:40]
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _open_log_binary(path: Path):
|
||||||
|
name = path.name.lower()
|
||||||
|
handle = path.open("rb")
|
||||||
|
if name.endswith(".gz"):
|
||||||
|
return gzip.GzipFile(fileobj=handle, mode="rb"), handle
|
||||||
|
return handle, handle
|
||||||
|
|
||||||
|
|
||||||
|
def iter_log_lines(path: Path, encoding: str):
|
||||||
|
"""Yield (line_no, text, bytes_read, total_bytes) without loading the file."""
|
||||||
|
size = 0
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
stream, raw = _open_log_binary(path)
|
||||||
|
try:
|
||||||
|
leftover = b""
|
||||||
|
line_no = 0
|
||||||
|
bytes_read = 0
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(READ_CHUNK)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
bytes_read += len(chunk)
|
||||||
|
data = leftover + chunk
|
||||||
|
parts = data.split(b"\n")
|
||||||
|
leftover = parts.pop()
|
||||||
|
for part in parts:
|
||||||
|
line_no += 1
|
||||||
|
text = part.rstrip(b"\r").decode(encoding, errors="replace")
|
||||||
|
yield line_no, text, min(bytes_read, size) if size else bytes_read, size
|
||||||
|
if leftover:
|
||||||
|
line_no += 1
|
||||||
|
text = leftover.rstrip(b"\r").decode(encoding, errors="replace")
|
||||||
|
yield line_no, text, size or bytes_read, size
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
stream.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
if raw is not stream:
|
||||||
|
try:
|
||||||
|
raw.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def rank_log_files(entries: list[dict]) -> list[dict]:
|
||||||
|
def score(entry: dict) -> tuple:
|
||||||
|
name = str(entry.get("name") or "").lower()
|
||||||
|
preferred = 0
|
||||||
|
for index, prefix in enumerate(PREFERRED_LOG_NAMES):
|
||||||
|
if name.startswith(prefix) or prefix in name:
|
||||||
|
preferred = 10 - index
|
||||||
|
break
|
||||||
|
size = int(entry.get("size") or 0)
|
||||||
|
return (preferred, 1 if is_log_filename(name) else 0, min(size, 10**12))
|
||||||
|
|
||||||
|
logs = [entry for entry in entries if is_log_filename(str(entry.get("name") or ""))]
|
||||||
|
logs.sort(key=score, reverse=True)
|
||||||
|
return logs[:6]
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_progress(
|
||||||
|
on_progress: ProgressCb | None,
|
||||||
|
*,
|
||||||
|
phase: str,
|
||||||
|
message: str,
|
||||||
|
percent: int | None,
|
||||||
|
file: str = "",
|
||||||
|
) -> None:
|
||||||
|
if not on_progress:
|
||||||
|
return
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"phase": phase,
|
||||||
|
"message": message,
|
||||||
|
"percent": percent,
|
||||||
|
}
|
||||||
|
if file:
|
||||||
|
payload["file"] = file
|
||||||
|
on_progress(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_one_file(
|
||||||
|
path: Path,
|
||||||
|
display_name: str,
|
||||||
|
job_id: str | None,
|
||||||
|
shift_date: str | None,
|
||||||
|
*,
|
||||||
|
on_progress: ProgressCb | None = None,
|
||||||
|
overall_offset: int = 0,
|
||||||
|
overall_total: int = 1,
|
||||||
|
cancel: Callable[[], bool] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
encoding = detect_log_encoding(path)
|
||||||
|
sources: Counter[str] = Counter()
|
||||||
|
errors: Counter[str] = Counter()
|
||||||
|
hours: Counter[str] = Counter()
|
||||||
|
unique_codes: set[str] = set()
|
||||||
|
unique_capped = False
|
||||||
|
code_events = 0
|
||||||
|
error64: list[dict[str, Any]] = []
|
||||||
|
error65: list[dict[str, Any]] = []
|
||||||
|
gaps: list[dict[str, Any]] = []
|
||||||
|
samples: list[dict[str, Any]] = []
|
||||||
|
first_ts: datetime | None = None
|
||||||
|
last_ts: datetime | None = None
|
||||||
|
prev_ts: datetime | None = None
|
||||||
|
prev_line = 0
|
||||||
|
line_count = 0
|
||||||
|
job_lines = 0
|
||||||
|
shift_lines = 0
|
||||||
|
last_pct = -1
|
||||||
|
|
||||||
|
def maybe_sample(line_no: int, line: str, reason: str) -> None:
|
||||||
|
if len(samples) >= MAX_SAMPLE_LINES:
|
||||||
|
return
|
||||||
|
samples.append(
|
||||||
|
{
|
||||||
|
"file": display_name,
|
||||||
|
"line": line_no,
|
||||||
|
"text": line[:240],
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for line_no, line, bytes_read, total in iter_log_lines(path, encoding):
|
||||||
|
if cancel and cancel():
|
||||||
|
break
|
||||||
|
line_count = line_no
|
||||||
|
if overall_total:
|
||||||
|
local = (bytes_read / total) if total else 1.0
|
||||||
|
overall = overall_offset + local * (total or 0)
|
||||||
|
pct = min(99, int(100 * overall / overall_total))
|
||||||
|
if pct >= last_pct + 2:
|
||||||
|
last_pct = pct
|
||||||
|
_emit_progress(
|
||||||
|
on_progress,
|
||||||
|
phase="scan",
|
||||||
|
message=f"Читаю лог… {pct}%",
|
||||||
|
percent=pct,
|
||||||
|
file=display_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
ts = _parse_timestamp(line)
|
||||||
|
if ts:
|
||||||
|
if first_ts is None:
|
||||||
|
first_ts = ts
|
||||||
|
maybe_sample(line_no, line, "first")
|
||||||
|
last_ts = ts
|
||||||
|
hours[ts.strftime("%Y-%m-%d %H:00")] += 1
|
||||||
|
if prev_ts is not None:
|
||||||
|
delta = (ts - prev_ts).total_seconds()
|
||||||
|
if delta >= GAP_SECONDS:
|
||||||
|
gaps.append(
|
||||||
|
{
|
||||||
|
"file": display_name,
|
||||||
|
"from_line": prev_line,
|
||||||
|
"to_line": line_no,
|
||||||
|
"from_ts": prev_ts.isoformat(sep=" ", timespec="seconds"),
|
||||||
|
"to_ts": ts.isoformat(sep=" ", timespec="seconds"),
|
||||||
|
"seconds": int(delta),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
prev_ts = ts
|
||||||
|
prev_line = line_no
|
||||||
|
|
||||||
|
in_shift = True
|
||||||
|
if shift_date and ts:
|
||||||
|
in_shift = ts.strftime("%Y-%m-%d") == shift_date
|
||||||
|
elif shift_date and shift_date in line:
|
||||||
|
in_shift = True
|
||||||
|
elif shift_date:
|
||||||
|
in_shift = False
|
||||||
|
if in_shift:
|
||||||
|
shift_lines += 1
|
||||||
|
|
||||||
|
job_hit = JOB_IN_LINE_RE.search(line)
|
||||||
|
if job_id and (job_id in line or (job_hit and job_hit.group(1) == job_id)):
|
||||||
|
job_lines += 1
|
||||||
|
elif job_hit:
|
||||||
|
job_lines += 0
|
||||||
|
|
||||||
|
sources[_source_of(line, display_name)] += 1
|
||||||
|
|
||||||
|
for err in ERROR_RE.findall(line):
|
||||||
|
if in_shift or not shift_date:
|
||||||
|
errors[err] += 1
|
||||||
|
|
||||||
|
if ERROR64_RE.search(line) and len(error64) < MAX_ERROR64_HITS:
|
||||||
|
error64.append(
|
||||||
|
{
|
||||||
|
"file": display_name,
|
||||||
|
"line": line_no,
|
||||||
|
"ts": ts.isoformat(sep=" ", timespec="seconds") if ts else "",
|
||||||
|
"text": line[:220],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
maybe_sample(line_no, line, "Error64")
|
||||||
|
if ERROR65_RE.search(line) and len(error65) < MAX_ERROR64_HITS:
|
||||||
|
error65.append(
|
||||||
|
{
|
||||||
|
"file": display_name,
|
||||||
|
"line": line_no,
|
||||||
|
"ts": ts.isoformat(sep=" ", timespec="seconds") if ts else "",
|
||||||
|
"text": line[:220],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
maybe_sample(line_no, line, "Error65")
|
||||||
|
|
||||||
|
for code in CODE_RE.findall(line):
|
||||||
|
code_events += 1
|
||||||
|
if not unique_capped:
|
||||||
|
unique_codes.add(code)
|
||||||
|
if len(unique_codes) >= MAX_UNIQUE_CODES:
|
||||||
|
unique_capped = True
|
||||||
|
|
||||||
|
if last_ts and line_count:
|
||||||
|
maybe_sample(line_count, "", "last")
|
||||||
|
if samples and samples[-1]["reason"] == "last" and not samples[-1]["text"]:
|
||||||
|
samples.pop()
|
||||||
|
|
||||||
|
gaps.sort(key=lambda item: int(item.get("seconds") or 0), reverse=True)
|
||||||
|
gaps = gaps[:MAX_GAPS]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file": display_name,
|
||||||
|
"path": str(path),
|
||||||
|
"encoding": encoding,
|
||||||
|
"lines": line_count,
|
||||||
|
"job_lines": job_lines,
|
||||||
|
"shift_lines": shift_lines,
|
||||||
|
"first_ts": first_ts.isoformat(sep=" ", timespec="seconds") if first_ts else "",
|
||||||
|
"last_ts": last_ts.isoformat(sep=" ", timespec="seconds") if last_ts else "",
|
||||||
|
"sources": sources.most_common(30),
|
||||||
|
"errors": errors.most_common(25),
|
||||||
|
"hours": hours.most_common(24),
|
||||||
|
"unique_codes": len(unique_codes),
|
||||||
|
"unique_capped": unique_capped,
|
||||||
|
"code_events": code_events,
|
||||||
|
"error64": error64,
|
||||||
|
"error65": error65,
|
||||||
|
"gaps": gaps,
|
||||||
|
"samples": samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration(seconds: int) -> str:
|
||||||
|
value = max(0, int(seconds))
|
||||||
|
if value < 60:
|
||||||
|
return f"{value} с"
|
||||||
|
minutes, rest = divmod(value, 60)
|
||||||
|
if minutes < 60:
|
||||||
|
return f"{minutes} мин {rest} с" if rest else f"{minutes} мин"
|
||||||
|
hours, minutes = divmod(minutes, 60)
|
||||||
|
return f"{hours} ч {minutes} мин"
|
||||||
|
|
||||||
|
|
||||||
|
def render_preanalysis_markdown(report: dict[str, Any]) -> str:
|
||||||
|
job_id = report.get("job_id") or "не найден в вопросе"
|
||||||
|
shift = report.get("shift_date") or "не указана"
|
||||||
|
lines: list[str] = [
|
||||||
|
"# PREANALYSIS",
|
||||||
|
"",
|
||||||
|
"Сервер уже просканировал логи **одним проходом**. "
|
||||||
|
"Используй этот файл первым. Не читай логи целиком заново, "
|
||||||
|
"если цифры выглядят правдоподобно. Для проверки бери фрагмент "
|
||||||
|
"по номеру строки (`:15231:line_u_codes.log.1` или "
|
||||||
|
"`::log:line_u_codes.log.1:15231::`).",
|
||||||
|
"",
|
||||||
|
f"- Job id: `{job_id}`",
|
||||||
|
f"- Дата смены (из вопроса): `{shift}`",
|
||||||
|
f"- Файлов просканировано: {len(report.get('files') or [])}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for item in report.get("files") or []:
|
||||||
|
name = item.get("file") or "log"
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"## {name}",
|
||||||
|
"",
|
||||||
|
f"- Кодировка: `{item.get('encoding')}`",
|
||||||
|
f"- Строк: {item.get('lines')}",
|
||||||
|
f"- Строк с job id: {item.get('job_lines')}",
|
||||||
|
f"- Строк за смену: {item.get('shift_lines')}",
|
||||||
|
f"- Интервал: {item.get('first_ts') or '—'} → {item.get('last_ts') or '—'}",
|
||||||
|
f"- Событий с кодами: {item.get('code_events')}",
|
||||||
|
f"- Уникальных кодов: {item.get('unique_codes')}"
|
||||||
|
+ (" (учёт ограничен) " if item.get("unique_capped") else ""),
|
||||||
|
"",
|
||||||
|
"### Источники",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
sources = item.get("sources") or []
|
||||||
|
if sources:
|
||||||
|
for src, count in sources:
|
||||||
|
lines.append(f"- `{src}`: {count}")
|
||||||
|
else:
|
||||||
|
lines.append("- (нет)")
|
||||||
|
lines.extend(["", "### Ошибки за смену (гистограмма)", ""])
|
||||||
|
errors = item.get("errors") or []
|
||||||
|
if errors:
|
||||||
|
for code, count in errors:
|
||||||
|
lines.append(f"- Error {code}: {count}")
|
||||||
|
else:
|
||||||
|
lines.append("- (нет совпадений Error N)")
|
||||||
|
lines.extend(["", "### Частота по часам (топ)", ""])
|
||||||
|
hours = item.get("hours") or []
|
||||||
|
if hours:
|
||||||
|
for hour, count in hours[:16]:
|
||||||
|
lines.append(f"- {hour}: {count}")
|
||||||
|
else:
|
||||||
|
lines.append("- (нет меток времени)")
|
||||||
|
lines.extend(["", "### Длинные промежутки (≥ 30 с)", ""])
|
||||||
|
gaps = item.get("gaps") or []
|
||||||
|
if gaps:
|
||||||
|
for gap in gaps:
|
||||||
|
lines.append(
|
||||||
|
f"- {_format_duration(int(gap.get('seconds') or 0))}: "
|
||||||
|
f"{gap.get('from_ts')} → {gap.get('to_ts')} "
|
||||||
|
f"(`:{gap.get('from_line')}:{name}` … `:{gap.get('to_line')}:{name}`)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("- нет промежутков ≥ 30 с")
|
||||||
|
lines.extend(["", "### Error64", ""])
|
||||||
|
hits64 = item.get("error64") or []
|
||||||
|
if hits64:
|
||||||
|
for hit in hits64[:40]:
|
||||||
|
ts = hit.get("ts") or "—"
|
||||||
|
lines.append(
|
||||||
|
f"- {ts} `:{hit.get('line')}:{name}` — {hit.get('text')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("- нет")
|
||||||
|
lines.extend(["", "### Error65", ""])
|
||||||
|
hits65 = item.get("error65") or []
|
||||||
|
if hits65:
|
||||||
|
for hit in hits65[:40]:
|
||||||
|
ts = hit.get("ts") or "—"
|
||||||
|
lines.append(
|
||||||
|
f"- {ts} `:{hit.get('line')}:{name}` — {hit.get('text')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("- нет")
|
||||||
|
lines.extend(["", "### Примеры строк", ""])
|
||||||
|
for sample in item.get("samples") or []:
|
||||||
|
text = str(sample.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
lines.append(
|
||||||
|
f"- `:{sample.get('line')}:{name}` ({sample.get('reason')}): `{text}`"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
text = "\n".join(lines).strip() + "\n"
|
||||||
|
if len(text) > MAX_PREANALYSIS_CHARS:
|
||||||
|
text = text[:MAX_PREANALYSIS_CHARS].rstrip() + "\n\n…\n"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def preanalyze_logs(
|
||||||
|
files: list[tuple[Path, str]],
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
on_progress: ProgressCb | None = None,
|
||||||
|
cancel: Callable[[], bool] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
job_id = detect_job_id(query)
|
||||||
|
shift_date = detect_shift_date(query)
|
||||||
|
existing = [(path, name) for path, name in files if path.exists() and path.is_file()]
|
||||||
|
total_bytes = 0
|
||||||
|
for path, _name in existing:
|
||||||
|
try:
|
||||||
|
total_bytes += path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
total_bytes = max(total_bytes, 1)
|
||||||
|
offset = 0
|
||||||
|
scanned: list[dict[str, Any]] = []
|
||||||
|
_emit_progress(
|
||||||
|
on_progress,
|
||||||
|
phase="scan",
|
||||||
|
message="Читаю лог… 0%",
|
||||||
|
percent=0,
|
||||||
|
)
|
||||||
|
for path, name in existing:
|
||||||
|
size = 0
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
item = _scan_one_file(
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
job_id,
|
||||||
|
shift_date,
|
||||||
|
on_progress=on_progress,
|
||||||
|
overall_offset=offset,
|
||||||
|
overall_total=total_bytes,
|
||||||
|
cancel=cancel,
|
||||||
|
)
|
||||||
|
scanned.append(item)
|
||||||
|
offset += size
|
||||||
|
if cancel and cancel():
|
||||||
|
break
|
||||||
|
_emit_progress(
|
||||||
|
on_progress,
|
||||||
|
phase="scan",
|
||||||
|
message="Читаю лог… 100%",
|
||||||
|
percent=100,
|
||||||
|
)
|
||||||
|
report = {
|
||||||
|
"job_id": job_id or "",
|
||||||
|
"shift_date": shift_date or "",
|
||||||
|
"files": scanned,
|
||||||
|
}
|
||||||
|
report["markdown"] = render_preanalysis_markdown(report)
|
||||||
|
report["summary"] = _short_summary(report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _short_summary(report: dict[str, Any]) -> str:
|
||||||
|
bits = []
|
||||||
|
if report.get("job_id"):
|
||||||
|
bits.append(f"job {report['job_id']}")
|
||||||
|
if report.get("shift_date"):
|
||||||
|
bits.append(report["shift_date"])
|
||||||
|
for item in report.get("files") or []:
|
||||||
|
bits.append(
|
||||||
|
f"{item.get('file')}: {item.get('lines')} строк, "
|
||||||
|
f"Error64×{len(item.get('error64') or [])}, "
|
||||||
|
f"промежутков {len(item.get('gaps') or [])}"
|
||||||
|
)
|
||||||
|
return "; ".join(bits) if bits else "логи просканированы"
|
||||||
|
|
||||||
|
|
||||||
|
def excerpt_lines(
|
||||||
|
path: Path,
|
||||||
|
start: int,
|
||||||
|
end: int,
|
||||||
|
encoding: str | None = None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Return (text, encoding) for 1-indexed inclusive line range."""
|
||||||
|
start = max(1, int(start))
|
||||||
|
end = max(start, int(end))
|
||||||
|
encoding = encoding or detect_log_encoding(path)
|
||||||
|
collected: list[str] = []
|
||||||
|
for line_no, text, _read, _total in iter_log_lines(path, encoding):
|
||||||
|
if line_no < start:
|
||||||
|
continue
|
||||||
|
if line_no > end:
|
||||||
|
break
|
||||||
|
collected.append(f"{line_no}\t{text}")
|
||||||
|
return "\n".join(collected) + ("\n" if collected else ""), encoding
|
||||||
|
|
||||||
|
|
||||||
|
def write_preanalysis_md(workspace: Path, markdown: str) -> Path:
|
||||||
|
target = Path(workspace) / "PREANALYSIS.md"
|
||||||
|
target.write_text(markdown, encoding="utf-8")
|
||||||
|
listing = Path(workspace) / "PROJECT_FILES.md"
|
||||||
|
if listing.exists():
|
||||||
|
extra = "\n- ./PREANALYSIS.md — server-side log scan; read this first\n"
|
||||||
|
text = listing.read_text(encoding="utf-8")
|
||||||
|
if "PREANALYSIS.md" not in text:
|
||||||
|
listing.write_text(text.rstrip() + extra, encoding="utf-8")
|
||||||
|
return target
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi
|
||||||
|
cursor-sdk
|
||||||
|
python-dotenv
|
||||||
|
python-multipart
|
||||||
|
uvicorn[standard]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not (Test-Path ".deps\fastapi")) {
|
||||||
|
python -m pip install --target .deps -r requirements.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:PYTHONPATH = Join-Path $PWD ".deps"
|
||||||
|
$env:HELPMATICA_ROOT = $PWD
|
||||||
|
|
||||||
|
if (-not (Test-Path ".env")) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Создайте .env из .env.example и добавьте CURSOR_API_KEY для ответов AI." -ForegroundColor Yellow
|
||||||
|
Write-Host "Ключ: https://cursor.com/dashboard/integrations" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bind 0.0.0.0 so other machines on the LAN can reach this host.
|
||||||
|
# Local: http://127.0.0.1:8010 | LAN: http://<this-machine-ip>:8010
|
||||||
|
# Windows Firewall may need an inbound allow for TCP 8010.
|
||||||
|
python -m uvicorn main:app --reload --reload-dir . --host 0.0.0.0 --port 8010
|
||||||
+2466
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,395 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#171713" />
|
||||||
|
<title>Helpomatica — рабочее пространство</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Playfair+Display:wght@600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/static/style.css?v=20260813e" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-screen" id="loginScreen">
|
||||||
|
<form class="login-card" id="loginForm">
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="brand-mark">H</div>
|
||||||
|
<h1>Helpomatica</h1>
|
||||||
|
<p>Войдите, чтобы открыть общие проекты и чаты.</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Логин
|
||||||
|
<input name="username" autocomplete="username" required placeholder="admin" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Пароль
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="primary-button login-submit">Войти</button>
|
||||||
|
<p class="login-error hidden" id="loginError"></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-shell hidden" id="appShell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark">H</div>
|
||||||
|
<span>Helpomatica</span>
|
||||||
|
<button class="icon-button mobile-close" id="closeSidebar">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="new-chat-button" id="newChatButton">
|
||||||
|
<span class="plus">+</span>
|
||||||
|
Новый чат
|
||||||
|
<span class="shortcut">Ctrl K</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="sidebar-search" id="sidebarSearchWrap">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="sidebarSearch"
|
||||||
|
placeholder="Поиск чатов…"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<div class="search-results hidden" id="searchResults"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="main-nav">
|
||||||
|
<button class="nav-item" data-view="home">
|
||||||
|
<span>⌂</span> Обзор
|
||||||
|
</button>
|
||||||
|
<button class="nav-item active" data-view="projects">
|
||||||
|
<span>◇</span> Проекты
|
||||||
|
</button>
|
||||||
|
<button class="nav-item hidden" data-view="users" id="usersNav">
|
||||||
|
<span>◎</span> Пользователи
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-section projects-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span>Проекты</span>
|
||||||
|
<button class="icon-button" id="sidebarAddProject" title="Новый проект">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="project-nav-list" id="projectNavList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section recents-section">
|
||||||
|
<div class="section-heading"><span>Недавние</span></div>
|
||||||
|
<div class="recent-list" id="recentList">
|
||||||
|
<div class="sidebar-empty">Чатов пока нет</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="profile-button" id="profileButton" type="button">
|
||||||
|
<span class="avatar" id="userAvatar">И</span>
|
||||||
|
<span>
|
||||||
|
<strong id="userDisplayName">Пользователь</strong>
|
||||||
|
<small id="userRoleLabel">user</small>
|
||||||
|
</span>
|
||||||
|
<span class="dots">•••</span>
|
||||||
|
</button>
|
||||||
|
<button class="text-button logout-button" id="logoutButton">Выйти</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="icon-button menu-button" id="openSidebar">☰</button>
|
||||||
|
<div class="breadcrumbs" id="breadcrumbs">Все проекты</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div class="agent-status" id="agentStatus" title="Статус агента">
|
||||||
|
<span class="status-dot" id="agentStatusDot"></span>
|
||||||
|
<span id="agentStatusText">…</span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button" id="themeToggle" title="Сменить тему">◐</button>
|
||||||
|
<button class="outline-button" id="headerAddProject">+ Проект</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="view active" id="projectsView">
|
||||||
|
<div class="content-wide">
|
||||||
|
<div class="page-title-row">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Рабочее пространство</p>
|
||||||
|
<h1>Ваши проекты</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Инструкции, материалы и общие разговоры команды — в одном месте.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="primary-button" id="mainAddProject">Создать проект</button>
|
||||||
|
</div>
|
||||||
|
<div class="project-grid" id="projectGrid"></div>
|
||||||
|
<div class="empty-state hidden" id="projectsEmpty">
|
||||||
|
<div class="empty-icon">◇</div>
|
||||||
|
<h2>Создайте первый проект</h2>
|
||||||
|
<p>Соберите инструкции, файлы и тематические чаты вместе.</p>
|
||||||
|
<button class="primary-button" id="emptyAddProject">Создать проект</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" id="usersView">
|
||||||
|
<div class="content-wide">
|
||||||
|
<div class="page-title-row">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Администрирование</p>
|
||||||
|
<h1>Пользователи</h1>
|
||||||
|
<p class="subtitle">Локальные учётки с доступом ко всем чатам проектов.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="users-form" id="userCreateForm">
|
||||||
|
<label>
|
||||||
|
Логин
|
||||||
|
<input name="username" required maxlength="64" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Имя
|
||||||
|
<input name="display_name" maxlength="80" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Пароль
|
||||||
|
<input name="password" type="password" required minlength="4" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Роль
|
||||||
|
<select name="role">
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="primary-button">Добавить</button>
|
||||||
|
</form>
|
||||||
|
<div class="users-table" id="usersTable"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" id="projectView">
|
||||||
|
<div class="project-layout" id="projectDropZone">
|
||||||
|
<div class="project-main">
|
||||||
|
<div class="project-heading">
|
||||||
|
<div class="project-icon-large" id="projectIcon">✦</div>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Проект</p>
|
||||||
|
<h1 id="projectName">Название проекта</h1>
|
||||||
|
<p class="subtitle" id="projectDescription"></p>
|
||||||
|
</div>
|
||||||
|
<div class="menu-wrap">
|
||||||
|
<button class="icon-button project-menu" id="projectMenuBtn" title="Меню проекта">⋮</button>
|
||||||
|
<div class="menu-dropdown hidden" id="projectMenu">
|
||||||
|
<button type="button" id="renameProject">Переименовать</button>
|
||||||
|
<button type="button" id="deleteProject" class="danger">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-card" id="projectComposer">
|
||||||
|
<div class="composer-attachments hidden" id="projectComposerAttachments"></div>
|
||||||
|
<textarea
|
||||||
|
id="quickPrompt"
|
||||||
|
rows="1"
|
||||||
|
placeholder="С чего начнём?"
|
||||||
|
></textarea>
|
||||||
|
<div class="composer-footer">
|
||||||
|
<label class="attach-button" title="Прикрепить файл к разговору">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||||
|
</svg>
|
||||||
|
<input type="file" id="projectFileInput" multiple />
|
||||||
|
</label>
|
||||||
|
<span class="composer-hint">Новый разговор в этом проекте</span>
|
||||||
|
<button class="send-button" id="quickSend" title="Отправить">↑</button>
|
||||||
|
</div>
|
||||||
|
<p class="drop-hint composer-drop-hint">Перетащите файлы сюда</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-header">
|
||||||
|
<h2>Разговоры</h2>
|
||||||
|
<button class="text-button" id="addChat">+ Новый</button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-list" id="chatList"></div>
|
||||||
|
<div class="empty-inline hidden" id="chatsEmpty">
|
||||||
|
Пока нет разговоров. Начните с вопроса выше.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="details-panel">
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h3>Инструкции</h3>
|
||||||
|
<button class="text-button" id="editInstructions">Изменить</button>
|
||||||
|
</div>
|
||||||
|
<p id="instructionsPreview">
|
||||||
|
Добавьте правила и контекст для всех разговоров проекта.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h3>Модель</h3>
|
||||||
|
</div>
|
||||||
|
<select id="modelSelect" class="model-select">
|
||||||
|
<option value="auto">auto</option>
|
||||||
|
<option value="composer-2.5">composer-2.5</option>
|
||||||
|
<option value="composer-2">composer-2</option>
|
||||||
|
<option value="gpt-5.2">gpt-5.2</option>
|
||||||
|
<option value="claude-4.6-sonnet">claude-4.6-sonnet</option>
|
||||||
|
</select>
|
||||||
|
<p class="panel-hint">Используется для ответов агента в этом проекте.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-section files-panel" id="filesPanel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h3>Файлы</h3>
|
||||||
|
<label class="text-button upload-label">
|
||||||
|
+ Добавить
|
||||||
|
<input type="file" id="fileInput" multiple />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="file-list" id="fileList"></div>
|
||||||
|
<p class="panel-empty" id="filesEmpty">Добавьте документы и материалы.</p>
|
||||||
|
<p class="drop-hint">Перетащите файлы сюда</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" id="chatView">
|
||||||
|
<div class="chat-page" id="chatDropZone">
|
||||||
|
<div class="chat-header">
|
||||||
|
<button class="back-button" id="backToProject">←</button>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow" id="chatProjectName">Проект</p>
|
||||||
|
<h2 id="chatTitle">Новый чат</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chat-header-actions">
|
||||||
|
<a class="text-button export-chat-link" id="exportChat" href="#" download>Экспорт</a>
|
||||||
|
<div class="menu-wrap">
|
||||||
|
<button class="icon-button" id="chatMenuBtn" title="Меню чата">⋮</button>
|
||||||
|
<div class="menu-dropdown hidden" id="chatMenu">
|
||||||
|
<button type="button" id="renameChat">Переименовать</button>
|
||||||
|
<a class="menu-link" id="exportChatMenu" href="#" download>Экспорт</a>
|
||||||
|
<button type="button" id="deleteChat" class="danger">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="messages" id="messages"></div>
|
||||||
|
<div class="gen-progress hidden" id="genProgress">
|
||||||
|
<div class="gen-progress-label" id="genProgressLabel">Читаю лог…</div>
|
||||||
|
<div class="gen-progress-track">
|
||||||
|
<div class="gen-progress-bar" id="genProgressBar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-composer" id="chatComposer">
|
||||||
|
<div class="composer-attachments hidden" id="composerAttachments"></div>
|
||||||
|
<div class="composer-row">
|
||||||
|
<label class="attach-button" title="Прикрепить файл к этому чату">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||||
|
</svg>
|
||||||
|
<input type="file" id="chatFileInput" multiple />
|
||||||
|
</label>
|
||||||
|
<textarea id="messageInput" rows="1" placeholder="Напишите сообщение…"></textarea>
|
||||||
|
<div class="composer-actions">
|
||||||
|
<button class="send-button" id="messageSend" title="Отправить">↑</button>
|
||||||
|
<button class="stop-button hidden" id="messageStop" title="Остановить">■</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop hidden" id="projectModal">
|
||||||
|
<form class="modal" id="projectForm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Новое пространство</p>
|
||||||
|
<h2>Создать проект</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="icon-button modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input name="name" required maxlength="80" placeholder="Например, Новый продукт" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<textarea name="description" rows="3" placeholder="Чем вы занимаетесь в этом проекте?"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Инструкции
|
||||||
|
<textarea name="instructions" rows="5" placeholder="Роль помощника, стиль ответов, важный контекст…"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="outline-button modal-close">Отмена</button>
|
||||||
|
<button type="submit" class="primary-button">Создать</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop hidden" id="instructionsModal">
|
||||||
|
<form class="modal" id="instructionsForm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Контекст проекта</p>
|
||||||
|
<h2>Инструкции</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="icon-button instructions-close">×</button>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Эти инструкции будут доступны во всех чатах проекта
|
||||||
|
<textarea name="instructions" rows="12" placeholder="Опишите роль, задачи и правила работы…"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="outline-button instructions-close">Отмена</button>
|
||||||
|
<button type="submit" class="primary-button">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop hidden" id="renameModal">
|
||||||
|
<form class="modal" id="renameForm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow" id="renameEyebrow">Переименование</p>
|
||||||
|
<h2 id="renameTitle">Новое имя</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="icon-button rename-close">×</button>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input name="name" required maxlength="120" />
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="kind" value="project" />
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="outline-button rename-close">Отмена</button>
|
||||||
|
<button type="submit" class="primary-button">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<div class="diagram-lightbox hidden" id="diagramLightbox" role="dialog" aria-modal="true" aria-label="Просмотр диаграммы">
|
||||||
|
<button type="button" class="diagram-lightbox-close" id="diagramLightboxClose" aria-label="Закрыть">×</button>
|
||||||
|
<div class="diagram-lightbox-inner" id="diagramLightboxInner"></div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script src="/static/app.js?v=20260813e"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+2479
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import log_preanalysis as lp
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_job_and_date() -> None:
|
||||||
|
query = "Разбери заказ 17876 за 2026-08-06: расхождение, Error64, промежутки, частота"
|
||||||
|
assert lp.looks_like_log_job(query, ["line_u_codes.log.1"])
|
||||||
|
assert lp.detect_job_id(query) == "17876"
|
||||||
|
assert lp.detect_shift_date(query) == "2026-08-06"
|
||||||
|
assert lp.detect_shift_date("смена 06.08.2026") == "2026-08-06"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_scan(tmp_path: Path) -> None:
|
||||||
|
log = tmp_path / "line_u_codes.log.1"
|
||||||
|
lines = [
|
||||||
|
"2026-08-06 08:00:00 [Scanner] job=17876 code=010460406000000021ABC1",
|
||||||
|
"2026-08-06 08:00:01 [PLC] job=17876 Error 12",
|
||||||
|
"2026-08-06 08:05:00 [Scanner] job=17876 Error64 timeout",
|
||||||
|
"2026-08-06 08:05:01 [PLC] Error65",
|
||||||
|
"2026-08-06 10:00:00 [Scanner] last line",
|
||||||
|
]
|
||||||
|
log.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
percents: list[int] = []
|
||||||
|
|
||||||
|
def on_progress(info: dict) -> None:
|
||||||
|
if info.get("percent") is not None:
|
||||||
|
percents.append(int(info["percent"]))
|
||||||
|
|
||||||
|
report = lp.preanalyze_logs(
|
||||||
|
[(log, "line_u_codes.log.1")],
|
||||||
|
"заказ 17876 2026-08-06 Error64 промежутки частота",
|
||||||
|
on_progress=on_progress,
|
||||||
|
)
|
||||||
|
assert report["job_id"] == "17876"
|
||||||
|
item = report["files"][0]
|
||||||
|
assert item["lines"] == 5
|
||||||
|
assert item["error64"]
|
||||||
|
assert item["error65"]
|
||||||
|
assert item["gaps"]
|
||||||
|
assert item["gaps"][0]["seconds"] >= 30
|
||||||
|
md = report["markdown"]
|
||||||
|
assert "PREANALYSIS" in md
|
||||||
|
assert "Error64" in md
|
||||||
|
text, encoding = lp.excerpt_lines(log, 2, 3)
|
||||||
|
assert encoding == "utf-8"
|
||||||
|
assert "2\t" in text and "Error 12" in text
|
||||||
|
assert percents
|
||||||
|
assert percents[-1] == 100
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_detect_job_and_date()
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
test_stream_scan(Path(folder))
|
||||||
|
print("ok")
|
||||||
Reference in New Issue
Block a user