diff --git a/.env.template b/.env.template index 7b6f5bd..7ca4542 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,9 @@ # Ключ API Polza.ai. Впиши свой ключ после знака = (без кавычек и пробелов). # Ключ автора агента сюда не попадает и не должен. HERMES_CUSTOM_POLZA_AI_API_KEY= + +# Ключ ГОСТ-ассистента (поиск и чтение ГОСТ, СП, СНиП). НЕОБЯЗАТЕЛЬНЫЙ: +# без него агент работает полностью, но инструменты mcp__gostassistent__* +# отвечать не станут — сервер просто не авторизуется. Ключ свой у каждого +# получателя, запрашивается у владельца агента. +MCP_GOSTASSISTENT_API_KEY= diff --git a/INSTALL.md b/INSTALL.md index 07d0655..0504994 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -345,6 +345,15 @@ Если чипа нет — плагины отключены в настройках приложения (`Настройки → Плагины`). +### Девиз рядом с номером сборки + +Справа от номера идёт девиз сборки: **«Работа агента ограничена только вашей +фантазией»**. + +Девиз живёт плагином (`desktop-plugins/slogan`). На узком окне он скрывается сам, +чтобы не теснить кнопку «Новый сеанс», и возвращается при растягивании — это +нормальное поведение, а не пропавший плагин. + ### Время текущей сессии в композере В строке управления композером, **слева от чипа расхода Polza.ai**, виден счётчик времени @@ -357,6 +366,37 @@ Отключить — `Настройки → Плагины`, плагин `session-time`. +### Каталог моделей по цене + +В той же строке управления композером, **сразу за чипом расхода**, стоит чип +**`Модели от 1,63`**. Наведение открывает список чат-моделей Polza.ai от дешёвых +к дорогим: цена входа, выхода и чтения из кэша в рублях за миллион токенов. + +Разброс цен в одном каталоге — три порядка (1,63 ₽ и 17 713 ₽ за миллион токенов +входа), поэтому выбирать модель по названию нельзя: для черновой работы и для +проверки нужны разные. Показаны 40 самых дешёвых, сортировка по цене входа; клик — +обновить, дальше список обновляется сам раз в 30 минут. + +Чип живёт плагином (`desktop-plugins/polza-models`), данные берёт локальный скрипт +(`%LOCALAPPDATA%\hermes\.polza-models\polza-models.cmd`). Ключ остаётся в `.env` +и в окно не попадает. Отключить — `Настройки → Плагины`, плагин `polza-models`. + +### ГОСТ-ассистент (MCP-сервер) + +В конфиг добавлен MCP-сервер **`gostassistent`** — поиск и чтение ГОСТ, СП, СНиП. +Он включён, но **без ключа не авторизуется**: ключ свой у каждого получателя. + +Впишите его в `.env` строкой `MCP_GOSTASSISTENT_API_KEY=ваш_ключ`. Без ключа агент +работает полностью — просто инструменты `mcp__gostassistent__*` отвечать не будут. +Это состояние «нет доступа», а не поломка агента. + +### Сообщения об обновлениях самого Hermes + +Фоновые проверки обновлений Hermes **выключены** (`updates.check: false`): получателю +не нужны сообщения про версии кода, который он не ставил и сам не обновляет. +Обновления **нашего** агента приходят ночной задачей `AILabAgentUpdate` и ставятся +без участия человека. Ручная команда `hermes update` при этом работает как раньше. + ### Боковая навигация Строка **«Сообщения»** из боковой панели убрана: мессенджеры не подключены, а вкладка ведёт diff --git a/README.md b/README.md index 7784213..50bd625 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ | `distribution.yaml` | манифест: имя, версия, автор, требуемые переменные окружения | | `SOUL.md` | персона агента: AILab SCRUME Agent, личный помощник | | `config.yaml` | модель, провайдер (Polza.ai), инструменты, язык — **без ключей** | -| `skills/` | 58 штатных скиллов Hermes (документы, таблицы, PDF, поиск, почта, заметки, код) | +| `skills/` | 63 скилла: 58 штатных Hermes + 5 наших (`skills/ailab/`, раздел ниже) | | `INSTALL.md` | пошаговая инструкция для получателя | | `update-check.ps1` | ночная проверка обновлений; ставится задачей Планировщика `AILabAgentUpdate` | -| `desktop-plugins/` | четыре плагина окна: `version-badge`, `hide-messaging`, `session-time`, `polza-limit` | +| `desktop-plugins/` | шесть плагинов окна: `version-badge`, `slogan`, `hide-messaging`, `session-time`, `polza-limit`, `polza-models` | | `.env.template` | шаблон `.env`, устанавливается как `.env.EXAMPLE` | Ключ API живёт только в `.env` получателя: в `config.yaml` стоит @@ -83,8 +83,11 @@ | Веб-поиск | через MCP-серверы, подключаются отдельно | | Голос | **включён**: распознавание речи (локальный Whisper, без ключей) и синтез русским голосом (`edge`) | | Номер сборки | виден в заголовке окна чипом `AILab v<версия>` (плагин `version-badge`) | +| Девиз | справа от номера сборки, в заголовке окна (плагин `slogan`); на узком окне скрывается сам | | Время сессии | счётчик в композере слева от чипа Polza.ai (плагин `session-time`) | -| Обновления | проверяются сами раз в сутки ночью, ставятся без человека (задача `AILabAgentUpdate`) | +| Цены моделей | чип `Модели` рядом с расходом: модели Polza.ai от дешёвых к дорогим, ₽ за 1 млн токенов (плагин `polza-models`) | +| ГОСТ-ассистент | MCP-сервер `gostassistent` в конфиге; включается своим ключом в `.env` | +| Обновления | наши ставятся сами раз в сутки ночью (задача `AILabAgentUpdate`); сообщения об обновлениях самого Hermes выключены (`updates.check: false`) | | Навигация | строка «Сообщения» убрана из боковой панели (плагин `hide-messaging`) | | Настройки | экспорт, импорт и сброс — рабочие; после «Сброса» запустите установку заново | | Управление рабочим столом | **выключено** (`computer_use`) | diff --git a/config.yaml b/config.yaml index 8dbf333..b7e302e 100644 --- a/config.yaml +++ b/config.yaml @@ -86,7 +86,16 @@ enabled: false send: false updates: - check: true + # ФОНОВЫЕ ПРОВЕРКИ ОБНОВЛЕНИЙ HERMES ВЫКЛЮЧЕНЫ. + # Получателю не нужны сообщения «вышла новая версия Hermes» про тот код, + # который он не ставил и сам не обновляет: у агента свой механизм — ночная + # задача Планировщика AILabAgentUpdate зовёт update-check.ps1 и ставит сборку + # из нашего репозитория (см. README.md). + # + # Гасится именно ПАССИВНАЯ проверка (source_check.py: при check=False и + # passive=True возвращается reason=disabled, наружу ничего не уходит). + # Явный `hermes update` руками продолжает работать — это осознанно. + check: false custom_providers: [] providers: polza-ai: @@ -102,6 +111,18 @@ base_url: https://polza.ai/api/v1 key_env: HERMES_CUSTOM_POLZA_AI_API_KEY timeout: 300 +mcp_servers: + # ГОСТ-ассистент: поиск и чтение нормативных документов (ГОСТ, СП, СНиП). + # Ключ доступа — СВОЙ у получателя, в конфиге только имя переменной + # (headers.Authorization ссылается на .env), как и у ключа Polza.ai. + # Без ключа сервер не авторизуется: инструменты mcp__gostassistent__* будут + # видны, но отвечать не станут — это состояние «нет доступа», а не поломка + # агента. Ключ запрашивается у владельца агента. + gostassistent: + url: https://api.gostassistent.com/api/mcp + headers: + Authorization: 'Bearer ${MCP_GOSTASSISTENT_API_KEY}' + enabled: true plugins: enabled: [] disabled: [] diff --git a/desktop-plugins/polza-limit/plugin.js b/desktop-plugins/polza-limit/plugin.js index 6cbece1..8b19f09 100644 --- a/desktop-plugins/polza-limit/plugin.js +++ b/desktop-plugins/polza-limit/plugin.js @@ -12,8 +12,8 @@ * без -c/-e, не dangerous). * * Наведение открывает подсказку с карточкой: полоса лимита, - * разбитая по моделям, легенда с суммами и долями, цифры по лимиту и кошельку. - * Клик — обновление. + * разбитая по моделям, легенда с суммами, долями и токенами (вход/выход), + * итог по токенам за сутки, цифры по лимиту и кошельку. Клик — обновление. * * ОГРАНИЧЕНИЯ shell.exec: только хвост stdout (4000 символов) и 30 с. Скрипт * печатает компактный JSON (~800 символов, ~3 с), опрос идёт раз в 5 минут. @@ -88,7 +88,9 @@ } function money(v) { - return Number(v).toFixed(2) + // (Number(v) || 0) — иначе отсутствующее поле печатается как «NaN ₽»: + // payload обязан переживать и старую версию скрипта, и неполный ответ. + return (Number(v) || 0).toFixed(2) } /** Цвет всей полосы по заполнению: <30% зелёный, 30–75% акцент, ≥75% красный. */ @@ -108,20 +110,52 @@ return 'var(--ui-green)' } -/** «Модель \x1f рубли \x1f запросы» → объект с цветом. */ +/** «Модель \x1f рубли \x1f запросы \x1f вход \x1f выход \x1f кэш» → объект с цветом. + * + * Хвост с токенами добавлен позже. Скрипт и плагин едут в одном архиве, но при + * ручной подмене одного файла из двух развёртывание может разойтись, поэтому + * отсутствующие поля обязаны давать нули, а не NaN. + */ function parseModels(rows) { return (rows || []).map((row, i) => { - const [name, cost, count] = String(row).split('\u001f') + const [name, cost, count, tin, tout, cache] = String(row).split('\u001f') return { name, cost: Number(cost) || 0, count: Number(count) || 0, + tin: Number(tin) || 0, + tout: Number(tout) || 0, + cache: Number(cache) || 0, color: colorFor(i, name) } }) } +/** Токены компактно для колонки: 4105847 → «4.1M», 1127 → «1.1k», 42 → «42». */ +function tokens(v) { + const n = Number(v) || 0 + + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1)}M` + } + + if (n >= 10_000) { + return `${Math.round(n / 1000)}k` + } + + if (n >= 1_000) { + return `${(n / 1000).toFixed(1)}k` + } + + return `${n}` +} + +/** Токены с разрядами — для итоговой строки под легендой. */ +function exact(v) { + return (Number(v) || 0).toLocaleString('ru-RU') +} + /** Процент суточного лимита ключа; null — лимит не задан (или нет данных). */ function limitPct(k) { const lim = Number(k.limit) || 0 @@ -147,7 +181,7 @@ const modelsSum = models.reduce((acc, m) => acc + m.cost, 0) || 1 return jsxs('div', { - style: { width: '300px', display: 'flex', flexDirection: 'column', gap: '8px' }, + style: { width: '340px', display: 'flex', flexDirection: 'column', gap: '8px' }, children: [ // 1. Полоса лимита, разбитая по моделям: доля каждой — от ЛИМИТА, // поэтому незакрашенный хвост и есть свободный лимит. @@ -191,55 +225,106 @@ }) ] }), - // 2. Легенда: цвет, модель, доля от расхода, сумма, число генераций. + // 2. Легенда: цвет, модель, доля от расхода, сумма, запросы, вход, выход. + // Шапка обязательна: без подписей колонки цифр не читаются. jsxs('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px', fontSize: '11px' }, - children: models.map((m, i) => + children: [ jsxs( 'div', { - style: { display: 'flex', alignItems: 'center', gap: '6px' }, + style: { + display: 'flex', + alignItems: 'center', + gap: '6px', + color: 'var(--ui-text-quaternary)' + }, children: [ - jsx('span', { - style: { - width: '8px', - height: '8px', - borderRadius: '2px', - background: m.color, - flex: '0 0 auto' - } - }), - jsx('span', { - style: { - flex: '1 1 auto', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' - }, - children: m.name - }), - jsx('span', { - style: { color: 'var(--ui-text-tertiary)', fontVariantNumeric: 'tabular-nums' }, - children: `${((m.cost / modelsSum) * 100).toFixed(1)}%` - }), - jsx('span', { - style: { fontVariantNumeric: 'tabular-nums', minWidth: '58px', textAlign: 'right' }, - children: `${money(m.cost)} ₽` - }), - jsx('span', { - style: { - color: 'var(--ui-text-quaternary)', - fontVariantNumeric: 'tabular-nums', - minWidth: '32px', - textAlign: 'right' - }, - children: `${m.count}` - }) + jsx('span', { style: { width: '8px', flex: '0 0 auto' } }), + jsx('span', { style: { flex: '1 1 auto' }, children: 'модель' }), + jsx('span', { style: { minWidth: '32px', textAlign: 'right' }, children: '%' }), + jsx('span', { style: { minWidth: '56px', textAlign: 'right' }, children: '₽' }), + jsx('span', { style: { minWidth: '26px', textAlign: 'right' }, children: 'запр' }), + jsx('span', { style: { minWidth: '40px', textAlign: 'right' }, children: 'вх' }), + jsx('span', { style: { minWidth: '34px', textAlign: 'right' }, children: 'исх' }) ] }, - `row-${i}-${m.name}` + 'legend-head' + ), + ...models.map((m, i) => + jsxs( + 'div', + { + style: { display: 'flex', alignItems: 'center', gap: '6px' }, + children: [ + jsx('span', { + style: { + width: '8px', + height: '8px', + borderRadius: '2px', + background: m.color, + flex: '0 0 auto' + } + }), + jsx('span', { + style: { + flex: '1 1 auto', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }, + children: m.name + }), + jsx('span', { + style: { + color: 'var(--ui-text-tertiary)', + fontVariantNumeric: 'tabular-nums', + minWidth: '32px', + textAlign: 'right' + }, + children: `${((m.cost / modelsSum) * 100).toFixed(1)}%` + }), + jsx('span', { + style: { + fontVariantNumeric: 'tabular-nums', + minWidth: '56px', + textAlign: 'right' + }, + children: `${money(m.cost)} ₽` + }), + jsx('span', { + style: { + color: 'var(--ui-text-quaternary)', + fontVariantNumeric: 'tabular-nums', + minWidth: '26px', + textAlign: 'right' + }, + children: `${m.count}` + }), + jsx('span', { + style: { + color: 'var(--ui-text-tertiary)', + fontVariantNumeric: 'tabular-nums', + minWidth: '40px', + textAlign: 'right' + }, + children: tokens(m.tin) + }), + jsx('span', { + style: { + color: 'var(--ui-text-tertiary)', + fontVariantNumeric: 'tabular-nums', + minWidth: '34px', + textAlign: 'right' + }, + children: tokens(m.tout) + }) + ] + }, + `row-${i}-${m.name}` + ) ) - ) + ] }), // 3. Прежние цифры по ключу. jsxs('div', { @@ -254,6 +339,9 @@ }, children: [ jsx('div', { + children: `токены за сутки: вход ${exact(k.tin)} · из кэша ${exact(k.tcache)} · выход ${exact(k.tout)}` + }), + jsx('div', { children: `${k.gen || 0} генераций за сутки · сброс лимита: ${k.reset || 'daily'}` }), jsx('div', { children: `остаток лимита ${money(k.left)} ₽` }), @@ -272,7 +360,7 @@ const keys = Array.isArray(data.k) ? data.k : [] return jsxs('div', { - style: { width: '300px', display: 'flex', flexDirection: 'column', gap: '12px' }, + style: { width: '340px', display: 'flex', flexDirection: 'column', gap: '12px' }, children: [ jsxs('div', { style: { display: 'flex', justifyContent: 'space-between', fontSize: '11px' }, diff --git a/desktop-plugins/polza-models/plugin.js b/desktop-plugins/polza-models/plugin.js new file mode 100644 index 0000000..8497737 --- /dev/null +++ b/desktop-plugins/polza-models/plugin.js @@ -0,0 +1,296 @@ +/** + * polza-models — ранжирование моделей Polza.ai по цене в строке управления + * композера (composer.actions), сразу за чипом расхода (order 15 против 10). + * + * Зачем: разброс цен в каталоге — три порядка. В одном и том же каталоге + * 1,63 ₽ и 17 713 ₽ за миллион токенов входа. При таком разбросе выбирать + * модель по названию нельзя: для черновой работы и для аудита нужны разные, + * и «дороже» не значит «лучше для этой задачи». + * + * Цены — рублёвые и уже посчитанные: каталог отдаёт их в top_provider.pricing + * с полем currency = RUB, пересчитывать нечего. + * + * Данные достаёт локальный скрипт (scripts/polza-models/polza-models.cmd → + * polza_models.py): читает /v1/models, фильтрует чат-модели с ценой и печатает + * их от дешёвых к дорогим. Ключ остаётся в .env, в рендерер не попадает: + * плагин вызывает готовый .cmd через gateway RPC shell.exec. + * + * ОГРАНИЧЕНИЯ shell.exec: только ХВОСТ stdout (4000 символов) и 30 с. Скрипт + * печатает от самого дешёвого и сам ужимает список под бюджет — иначе обрезка + * съела бы именно дешёвые модели, самое ценное в этом списке. Опрос — раз в + * 30 минут: каталог меняется редко, а запрос тянет 600 КБ. + */ + +import { COMPOSER_AREAS, cn, haptic, host, queryClient, Tip, useQuery, useValue } from '@hermes/plugin-sdk' +import { jsx, jsxs } from 'react/jsx-runtime' + +const ID = 'polza-models' + +// Путь через %LOCALAPPDATA%: shell.exec выполняет команду через cmd.exe, а он +// раскрывает переменную у каждого пользователя. Хардкод C:\\Users\\Admin на чужой +// машине не нашёлся бы. Пробелов в пути нет — кавычки не нужны. +const CMD = + '%LOCALAPPDATA%\\hermes\\.polza-models\\polza-models.cmd' + +const QUERY_KEY = [ID, 'catalog'] +const REFRESH_MS = 1_800_000 + +/** Разбор stdout скрипта: JSON от первой «{» в последней непустой строке. */ +function parsePayload(raw) { + const text = String(raw || '').trim() + const line = text.split('\n').map(s => s.trim()).filter(Boolean).pop() || '' + const cut = line.indexOf('{') + + if (cut < 0) { + throw new Error(line.slice(0, 120) || 'пустой вывод скрипта') + } + + let data + + try { + data = JSON.parse(line.slice(cut)) + } catch { + throw new Error('ответ обрезан (лимит shell.exec — 4000 символов)') + } + + if (data.ok === false) { + throw new Error(data.err || 'скрипт вернул ошибку') + } + + if (!Array.isArray(data.m) || data.m.length === 0) { + throw new Error('в ответе нет массива моделей m') + } + + return data +} + +async function fetchCatalog() { + const res = await host.request('shell.exec', { command: CMD }, 45_000) + + if (res && res.blocked) { + throw new Error(res.hint || 'shell.exec заблокирован') + } + + if (!res || res.code !== 0) { + const err = String((res && res.stderr) || '').trim() + throw new Error(err.slice(0, 160) || `скрипт вернул код ${res ? res.code : '?'}`) + } + + return parsePayload(res.stdout) +} + +/** «id \x1f имя \x1f вход \x1f выход \x1f кэш» → объект. + * + * Хвост добавлен позже, чем первые поля: при ручной подмене одного файла из + * двух развёртывание может разойтись, поэтому отсутствующее обязано давать + * нули, а не NaN. + */ +function parseRows(rows) { + return (rows || []).map(row => { + const [id, name, pin, pout, pcache] = String(row).split('\u001f') + + return { + id: id || '?', + name: name || id || '?', + pin: Number(pin) || 0, + pout: Number(pout) || 0, + pcache: Number(pcache) || 0 + } + }) +} + +/** «1.63» → «1,63»: каталог отдаёт точку, читается это как опечатка. */ +function rub(v, digits = 2) { + return (Number(v) || 0).toFixed(digits).replace('.', ',') +} + +/** Три порядка разброса: 17713.2 → «17,7 тыс.», 1771.32 → «1 771». */ +function rubShort(v) { + const n = Number(v) || 0 + + if (n >= 10_000) { + return `${(n / 1000).toFixed(1).replace('.', ',')} тыс.` + } + + if (n >= 1_000) { + return `${Math.round(n).toLocaleString('ru-RU')}` + } + + return rub(n) +} + +function LegendHead() { + return jsxs('div', { + style: { + display: 'flex', + alignItems: 'center', + gap: '6px', + color: 'var(--ui-text-quaternary)' + }, + children: [ + jsx('span', { style: { flex: '1 1 auto' }, children: 'модель' }), + jsx('span', { style: { minWidth: '52px', textAlign: 'right' }, children: 'вход' }), + jsx('span', { style: { minWidth: '52px', textAlign: 'right' }, children: 'выход' }), + jsx('span', { style: { minWidth: '44px', textAlign: 'right' }, children: 'кэш' }) + ] + }) +} + +function ModelRow({ m, index }) { + // Зебра вместо цвета модели: список читается сверху вниз, и полоски + // помогают не потерять строку. Палитра тут была бы шумом. + const bg = index % 2 ? 'transparent' : 'var(--ui-bg-secondary, transparent)' + + return jsxs('div', { + style: { + display: 'flex', + alignItems: 'center', + gap: '6px', + background: bg, + borderRadius: '3px', + padding: '1px 2px' + }, + children: [ + jsxs('span', { + style: { + flex: '1 1 auto', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }, + children: [ + jsx('span', { children: m.name }), + jsx('span', { + style: { color: 'var(--ui-text-quaternary)', marginLeft: '6px', fontSize: '10px' }, + children: m.id + }) + ] + }), + jsx('span', { + style: { fontVariantNumeric: 'tabular-nums', minWidth: '52px', textAlign: 'right' }, + children: rub(m.pin) + }), + jsx('span', { + style: { + color: 'var(--ui-text-tertiary)', + fontVariantNumeric: 'tabular-nums', + minWidth: '52px', + textAlign: 'right' + }, + children: rub(m.pout) + }), + jsx('span', { + style: { + color: 'var(--ui-text-quaternary)', + fontVariantNumeric: 'tabular-nums', + minWidth: '44px', + textAlign: 'right' + }, + children: m.pcache > 0 ? rub(m.pcache) : '—' + }) + ] + }, `row-${index}-${m.id}`) +} + +function CatalogCard({ data }) { + const models = parseRows(data.m) + + return jsxs('div', { + style: { width: '380px', display: 'flex', flexDirection: 'column', gap: '8px' }, + children: [ + jsxs('div', { + style: { display: 'flex', justifyContent: 'space-between', fontSize: '11px' }, + children: [ + jsx('span', { style: { color: 'var(--ui-text-tertiary)' }, children: 'Polza.ai · модели по цене' }), + jsx('span', { style: { color: 'var(--ui-text-quaternary)' }, children: data.cur || 'RUB' }) + ] + }), + jsx('div', { + style: { color: 'var(--ui-text-quaternary)', fontSize: '11px' }, + children: `${data.n} чат-моделей с ценой · ₽ за 1 млн токенов · от ${rub(data.min)} до ${rubShort(data.max)}` + }), + jsxs('div', { + style: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px' }, + children: [jsx(LegendHead, {}), ...models.map((m, i) => jsx(ModelRow, { m, index: i }, `m-${i}-${m.id}`))] + }), + jsx('div', { + style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' }, + children: `показаны ${models.length} самых дешёвых из ${data.n} · сортировка по входу · клик — обновить` + }) + ] + }) +} + +function ModelsChip() { + const gateway = useValue(host.state.gateway) + const connected = Boolean(gateway) + + const { data, error, isError } = useQuery({ + queryKey: [...QUERY_KEY, connected], + enabled: connected, + queryFn: fetchCatalog, + refetchInterval: REFRESH_MS, + staleTime: 300_000, + retry: false + }) + + const tip = data + ? jsx(CatalogCard, { data }) + : isError + ? `Polza.ai: каталог недоступен — ${String(error && error.message ? error.message : error).slice(0, 240)}` + : connected + ? 'Polza.ai: загрузка каталога…' + : 'Polza.ai: шлюз не подключён' + + return jsx(Tip, { + label: tip, + placement: 'control', + // Тема приложения вместо инверсного фона по умолчанию: таблице цен нужен + // обычный surface. + className: + 'border border-(--ui-stroke-secondary) bg-(--ui-bg-elevated) text-foreground backdrop-blur-md', + children: jsxs('button', { + type: 'button', + className: cn( + 'inline-flex h-(--composer-control-size) shrink-0 items-center gap-1.5 rounded-md px-1.5 text-xs transition-colors', + 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' + ), + onClick: () => { + haptic('tap') + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + }, + children: [ + jsx('span', { + style: { color: 'var(--ui-text-quaternary)', fontSize: '10px', lineHeight: '1' }, + children: 'Модели' + }), + data + ? jsx('span', { + style: { fontVariantNumeric: 'tabular-nums' }, + children: `от ${rub(data.min)}` + }) + : jsx('span', { + style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' }, + children: isError ? '!' : '…' + }) + ] + }) + }) +} + +export default { + id: ID, + name: 'Polza models', + defaultEnabled: true, + register(ctx) { + // composer.actions рендерится внутри строки контролов, до ModelPill — + // то есть визуально левее выбора модели. Order 15 ставит чип сразу за + // расходом (10), чтобы цены и трата читались рядом. + ctx.register({ + id: 'chip', + area: COMPOSER_AREAS.actions, + order: 15, + render: () => jsx(ModelsChip, {}) + }) + } +} diff --git a/desktop-plugins/slogan/plugin.js b/desktop-plugins/slogan/plugin.js new file mode 100644 index 0000000..637d3eb --- /dev/null +++ b/desktop-plugins/slogan/plugin.js @@ -0,0 +1,76 @@ +/** + * slogan — девиз рядом с номером сборки в заголовке окна. + * + * Зачем: строка «Работа агента ограничена только вашей фантазией» — часть + * фирменного вида сборки, а не служебная информация. Ставится в `titleBar.left` + * сразу ПОСЛЕ чипа сборки: реестр сортирует по возрастанию `order`, поэтому + * меньший `order` уезжает левее, а чип сборки занимает 90. + * + * ПОЧЕМУ НЕ СЛЕТИТ ПРИ ОБНОВЛЕНИЯХ. Плагины грузятся из корня дома агента — + * `/desktop-plugins//plugin.js` (один корень на всё приложение, + * не на профиль). Официальное обновление работает с git-копией приложения + * (`/hermes-agent/`) и файлов в `desktop-plugins/` не касается; + * единственный шаг обновления, упоминающий плагины, сбрасывает кэш каталога + * (`post_update.step_drop_live_plugin_catalog`), а не файлы. Поэтому правки + * внутри копии приложения обновление затирает, а плагин — нет. + * + * НА УЗКОМ ОКНЕ СКРЫВАЕТСЯ. Заголовок делят кнопки окна, вкладка «Новый сеанс» + * и чип сборки; длинный девиз на ширине ноутбука выдавит их. Поэтому текст + * показывается только когда окно шире порога, и возвращается при растягивании. + */ + +import { TITLEBAR_AREAS, cn, Tip } from '@hermes/plugin-sdk' +import { useEffect, useState } from 'react' +import { jsx } from 'react/jsx-runtime' + +const ID = 'slogan' +const TEXT = 'Работа агента ограничена только вашей фантазией' + +// Порог подобран по составу заголовка: ниже него девиз начинает теснить +// «Новый сеанс». Значение — ширина окна в пикселях. +const NARROW_PX = 1150 + +function Slogan() { + const [wide, setWide] = useState( + () => typeof window === 'undefined' || window.innerWidth >= NARROW_PX + ) + + useEffect(() => { + const onResize = () => setWide(window.innerWidth >= NARROW_PX) + onResize() + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + if (!wide) { + return null + } + + return jsx(Tip, { + label: TEXT, + placement: 'control', + children: jsx('span', { + // Тот же размер и цвет, что у прочих readout'ов заголовка: девиз — + // справка, а не кнопка, и клики ловить не должен. + className: cn( + 'inline-flex h-full select-none items-center px-1.5 text-[0.6875rem]', + 'text-(--ui-text-quaternary)' + ), + children: TEXT + }) + }) +} + +export default { + id: ID, + name: 'Слоган сборки', + defaultEnabled: true, + register(ctx) { + ctx.register({ + id: 'slogan', + area: TITLEBAR_AREAS.left, + order: 95, + render: () => jsx(Slogan, {}) + }) + } +} diff --git a/desktop-plugins/version-badge/plugin.js b/desktop-plugins/version-badge/plugin.js index 1ce6d9b..0525637 100644 --- a/desktop-plugins/version-badge/plugin.js +++ b/desktop-plugins/version-badge/plugin.js @@ -88,7 +88,7 @@ const tip = version ? [ - `Сборка AILab SCRUME Agent v${version}`, + `Сборка AILab SCRUM Agent v${version}`, shortDate(data.installed) ? `Установлена: ${shortDate(data.installed)}` : '', data.source ? `Источник: ${data.source}` : '' ] diff --git a/distribution.yaml b/distribution.yaml index 50bfaa4..24025d3 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,5 +1,5 @@ name: ailab_scrume_agent -version: 1.5.2 +version: 1.6.0 description: "Личный помощник: документы, письма, договоры, расчёты, поиск, задачи." hermes_requires: ">=0.21.6" author: "AI laboratory SCRUM company" @@ -9,3 +9,6 @@ - name: HERMES_CUSTOM_POLZA_AI_API_KEY description: "Ключ API Polza.ai (свой, отдельный от ключа автора)" required: true + - name: MCP_GOSTASSISTENT_API_KEY + description: "Ключ ГОСТ-ассистента: поиск и чтение ГОСТ, СП, СНиП. Без него агент работает полностью, но инструменты mcp__gostassistent__* не авторизуются" + required: false diff --git a/scripts/polza-limits/polza_limits.py b/scripts/polza-limits/polza_limits.py index 092c61f..ff38036 100644 --- a/scripts/polza-limits/polza_limits.py +++ b/scripts/polza-limits/polza_limits.py @@ -1,24 +1,31 @@ #!/usr/bin/env python3 -"""Суточные лимиты Polza.ai по ДВУМ ключам профилей — компактный JSON для чипа. +"""Суточный лимит Polza.ai по ключу владельца — компактный JSON для чипа. -Ключи (значения в вывод не попадают): +Ключ (значение в вывод не попадает): - * default + local → CUSTOM_PROVIDER_POLZA_AI_KEY из %LOCALAPPDATA%\\hermes\\.env - * ai-si → HERMES_CUSTOM_POLZA_AI_API_KEY из %LOCALAPPDATA%\\hermes\\profiles\\ai-si\\.env + * HERMES_CUSTOM_POLZA_AI_API_KEY из %LOCALAPPDATA%\\hermes\\.env -Порядок работы: сначала /key и /balance на каждый ключ (полосы готовы даже если +Порядок работы: сначала /key и /balance (полоса и кошелёк готовы, даже если история не досчитается), затем генерации за местные сутки с жёстким дедлайном — плагин получает от shell.exec только 30 с и хвост stdout в 4000 символов. Формат: одна строка JSON {"ok":true,"day":"2026-10-14","k":[ - {"p":"default + local","limit":500.0,"used":174.63,"left":325.37,"reset":"daily", + {"p":"мой ключ","limit":500.0,"used":174.63,"left":325.37,"reset":"daily", "amount":4829.57,"avail":325.37, - "m":["DeepSeek: DeepSeek V4.1 Flash\\x1f169.80\\x1f518", ...]}, - {"p":"ai-si","limit":1500.0,...,"m":[...]}]} + "gen":45,"tin":4105847,"tout":52856,"tcache":3038816, + "m":["DeepSeek: DeepSeek V4.1 Flash\\x1f30.25\\x1f45\\x1f4105847\\x1f52856\\x1f3038816", ...]}]} -Модели — плоские строки через \\x1f (имя, рубли, число запросов): объекты с -длинными ключами не влезают в бюджет вывода. +Модели — плоские строки через \\x1f (имя, рубли, число запросов, вход, выход, +кэш): объекты с длинными ключами не влезают в бюджет вывода. + +Токены берутся из usage каждой генерации. Важно, что prompt_tokens ВКЛЮЧАЕТ +cached_tokens — во входе доля кэша доходит до 90%, а кэш-чтение дешевле +примерно вчетверо. Поэтому вход отдаётся разбитым (tin — весь вход, tcache — +его кэшовая часть), иначе цифра входа не объясняет счёт. + +Совместимость: поля tin/tout/tcache и хвост строки модели добавлены позже. +Плагин обязан переживать их отсутствие. """ import argparse import collections @@ -94,19 +101,22 @@ return items -def compact_models(rows: list[tuple[str, float, int]], total: float, max_models: int) -> list[str]: +def compact_models(rows: list[tuple], total: float, max_models: int) -> list[str]: + """Строки моделей в бюджет вывода: имя␟рубли␟запросы␟вход␟выход␟кэш.""" head, dropped = [], [] - for name, cost, count in rows: + for row in rows: + cost = row[1] if (cost >= GROUP_RUB and (total <= 0 or cost / total >= GROUP_SHARE) and len(head) < max_models): - head.append((name, cost, count)) + head.append(row) else: - dropped.append((cost, count)) - out = [f"{n.replace(chr(31), ' ')}\x1f{c:.2f}\x1f{k}" for n, c, k in head] - tail_cost = sum(c for c, _ in dropped) - tail_count = sum(k for _, k in dropped) - if tail_cost > 0.0 or tail_count: - out.append(f"Другое\x1f{tail_cost:.2f}\x1f{tail_count}") + dropped.append(row) + out = [f"{r[0].replace(chr(31), ' ')}\x1f{r[1]:.2f}\x1f{r[2]}\x1f{r[3]}\x1f{r[4]}\x1f{r[5]}" + for r in head] + if dropped: + tail = [sum(r[i] for r in dropped) for i in (1, 2, 3, 4, 5)] + if tail[0] > 0.0 or tail[1]: + out.append(f"Другое\x1f{tail[0]:.2f}\x1f{tail[1]}\x1f{tail[2]}\x1f{tail[3]}\x1f{tail[4]}") return out @@ -140,14 +150,23 @@ pass items = fetch_generations(key, start, end) - per: dict[str, list] = collections.defaultdict(lambda: [0.0, 0]) + # на модель: рубли, запросы, вход, выход, кэш + per: dict[str, list] = collections.defaultdict(lambda: [0.0, 0, 0, 0, 0]) for it in items: - cost = float(it.get("cost") or 0) name = it.get("modelDisplayName") or it.get("model") or "—" - per[name][0] += cost + usage = it.get("usage") or {} + pdet = usage.get("prompt_tokens_details") or {} + per[name][0] += float(it.get("cost") or 0) per[name][1] += 1 - rows = sorted(((n, v[0], v[1]) for n, v in per.items()), key=lambda r: -r[1]) + per[name][2] += int(usage.get("prompt_tokens") or 0) + per[name][3] += int(usage.get("completion_tokens") or 0) + per[name][4] += int(pdet.get("cached_tokens") or 0) + rows = sorted(((n, v[0], v[1], v[2], v[3], v[4]) for n, v in per.items()), + key=lambda r: -r[2]) row["gen"] = sum(r[2] for r in rows) + row["tin"] = sum(r[3] for r in rows) + row["tout"] = sum(r[4] for r in rows) + row["tcache"] = sum(r[5] for r in rows) row["_rows"] = rows return row @@ -167,7 +186,7 @@ payload: dict = {} for cap in (MAX_MODELS, 4, 2, 0): for r, src in keys_models: - total = sum(c for _, c, _ in src) + total = sum(row[1] for row in src) r["m"] = compact_models(src, total, cap) if cap else [] payload = {"ok": True, "day": start.strftime("%Y-%m-%d"), "k": rows} flat = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) @@ -182,9 +201,12 @@ pct = (r["used"] / r["limit"] * 100) if r["limit"] else 0.0 print(f"{r['p']}: {r['used']:.2f} из {r['limit']:.2f} ₽ ({pct:.1f} %), " f"остаток {r['left']:.2f} ₽, генераций {r.get('gen', 0)}, сброс {r['reset']}") + print(f" токены за сутки: вход {r.get('tin', 0):,} " + f"(из них из кэша {r.get('tcache', 0):,}) · выход {r.get('tout', 0):,}") + print(f" {'модель':<34}{'₽':>9}{'запр':>7}{'вход':>12}{'выход':>9}{'кэш':>12}") for m in r["m"]: - n, c, k = m.split("\x1f") - print(f" {float(c):>8.2f} ₽ {k:>5} {n}") + n, c, k, tin, tout, cache = m.split("\x1f") + print(f" {n[:34]:<34}{float(c):>9.2f}{k:>7}{int(tin):>12,}{int(tout):>9,}{int(cache):>12,}") return 0 json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":")) diff --git a/scripts/polza-models/polza-models.cmd b/scripts/polza-models/polza-models.cmd new file mode 100644 index 0000000..e3310ca --- /dev/null +++ b/scripts/polza-models/polza-models.cmd @@ -0,0 +1,16 @@ +@echo off +rem Каталог моделей Polza.ai по возрастанию цены — компактный JSON в stdout. +rem Вызывается плагином polza-models через gateway RPC shell.exec. +rem Версия python НЕ зашита: имя каталога содержит дату сборки и меняется при +rem обновлении, поэтому берём единственный подходящий каталог по маске. +rem ВАЖНО: переводы строк только CRLF. С LF-файлом cmd.exe разбирает кириллицу +rem в rem-строках как команды и падает. +setlocal +set "PY=" +for /d %%D in ("%LOCALAPPDATA%\hermes\tools\python-*") do set "PY=%%D\python.exe" +if not exist "%PY%" ( + echo {"ok":false,"err":"python не найден в %LOCALAPPDATA%\hermes\tools"} + exit /b 1 +) +"%PY%" "%LOCALAPPDATA%\hermes\.polza-models\polza_models.py" %* +endlocal diff --git a/scripts/polza-models/polza_models.py b/scripts/polza-models/polza_models.py new file mode 100644 index 0000000..7da943b --- /dev/null +++ b/scripts/polza-models/polza_models.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Каталог моделей Polza.ai, отсортированный от дешёвых к дорогим. + +Зачем: выбирать модель по цене, а не по названию. Один и тот же класс задач +различается в цене на три порядка (1,63 ₽ против 17 713 ₽ за миллион токенов), +поэтому «дорогая модель» — не синоним «лучшая»: для черновой работы и для +аудита нужны разные. + +Источник: GET /api/v1/models, поле top_provider.pricing. + prompt_per_million — вход, ₽ за 1 млн токенов + completion_per_million — выход, ₽ за 1 млн токенов + input_cache_read_per_million — чтение из кэша, ₽ за 1 млн (в 10–15 раз дешевле входа) + currency — в каталоге указан RUB; значения уже в рублях + +ВАЖНО про бюджет вывода: плагин получает от shell.exec только ХВОСТ stdout +(4000 символов). Поэтому список печатается от самого дешёвого, длина ограничена, +а в конце идёт строка-сводка. Иначе обрезка съела бы именно дешёвые модели — +самое ценное в этом списке. + +Формат: одна строка JSON + {"ok":true,"n":308,"min":1.63,"max":17713.20,"cur":"RUB","m":[ + "qwen/qwen3.5-9b\\x1fQwen: Qwen3.5 9B\\x1f1.63\\x1f4.89\\x1f0.33", ...]} +""" +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request + +API = "https://polza.ai/api/v1" +ENV_PATHS = [r"%LOCALAPPDATA%\hermes\.env"] +ENV_KEY = "HERMES_CUSTOM_POLZA_AI_API_KEY" + +# Хвост stdout у плагина — 4000 символов. Оставляем запас на служебные поля. +MAX_ROWS = 40 + + +def read_key() -> str: + key = (os.environ.get("POLZA_AI_API_KEY") or os.environ.get(ENV_KEY) or "").strip() + if key: + return key + for raw in ENV_PATHS: + path = raw.replace("%LOCALAPPDATA%", os.environ.get("LOCALAPPDATA", "")) + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + m = re.search(rf"^{ENV_KEY}=(.+)$", fh.read(), re.M) + if m: + return m.group(1).strip().strip('"').strip("'") + except OSError: + continue + sys.exit("Не найден ключ Polza.ai в .env") + + +def fetch(key: str, timeout: int = 30) -> list: + req = urllib.request.Request(API + "/models", headers={"Authorization": f"Bearer {key}"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")).get("data") or [] + + +def num(p: dict, field: str) -> float: + try: + return float(p.get(field) or 0) + except (TypeError, ValueError): + return 0.0 + + +def collect(items: list) -> tuple[list, str]: + """Только чат-модели с ценой входа. Сортировка по входу, затем по выходу.""" + rows, currency = [], "RUB" + for x in items: + if x.get("type") != "chat": + continue + p = ((x.get("top_provider") or {}).get("pricing") or {}) + pin = num(p, "prompt_per_million") + if pin <= 0: + continue + currency = p.get("currency") or currency + rows.append(( + pin, + num(p, "completion_per_million"), + num(p, "input_cache_read_per_million"), + x.get("id") or "?", + x.get("name") or x.get("id") or "?", + )) + rows.sort(key=lambda r: (r[0], r[1], r[3])) + return rows, currency + + +def fmt(rows: list, currency: str, limit: int) -> str: + head = rows[:limit] + out = [ + f"{r[3]}\x1f{r[4]}\x1f{r[0]:.2f}\x1f{r[1]:.2f}\x1f{r[2]:.2f}" + for r in head + ] + payload = { + "ok": True, + "n": len(rows), + "shown": len(head), + "min": round(rows[0][0], 2) if rows else 0.0, + "max": round(rows[-1][0], 2) if rows else 0.0, + "cur": currency, + "m": out, + } + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Модели Polza.ai по возрастанию цены") + ap.add_argument("--limit", type=int, default=MAX_ROWS, help="сколько моделей показать") + ap.add_argument("--pretty", action="store_true", help="таблица для чтения глазами") + ap.add_argument("--budget", type=int, default=3900, help="предел длины вывода") + args = ap.parse_args() + + rows, currency = collect(fetch(read_key())) + if not rows: + print(json.dumps({"ok": False, "err": "каталог пуст"}, ensure_ascii=False)) + return 1 + + if not args.pretty: + limit = args.limit + line = fmt(rows, currency, limit) + # ужимаем, пока не влезет: плагин видит только хвост stdout + while len(line) > args.budget and limit > 5: + limit -= 5 + line = fmt(rows, currency, limit) + print(line) + return 0 + + print(f"чат-моделей с ценой: {len(rows)} · валюта {currency} · " + f"от {rows[0][0]:.2f} до {rows[-1][0]:.2f} ₽ за 1 млн токенов\n") + print(f"{'модель':<44}{'вход':>10}{'выход':>10}{'кэш':>9} id") + for r in rows[:args.limit]: + print(f"{r[4][:44]:<44}{r[0]:>10.2f}{r[1]:>10.2f}{r[2]:>9.2f} {r[3]}") + if len(rows) > args.limit: + print(f"... ещё {len(rows) - args.limit} моделей дороже") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ailab/ailab-docx/SKILL.md b/skills/ailab/ailab-docx/SKILL.md new file mode 100644 index 0000000..2708fed --- /dev/null +++ b/skills/ailab/ailab-docx/SKILL.md @@ -0,0 +1,194 @@ +--- +name: ailab-docx +description: "Use when создаёшь документ Word в фирменном стиле. Типографика, цвета и структура корпоративного документа AI_Lab_SCRUM Co." +version: 1.0.0 +author: AI_Lab_SCRUM Co +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [word, docx, documents, style, ailab] + category: ailab + related_skills: [docx, ailab-pdf] +--- + +Правила фирменного стиля **AI_Lab_SCRUM Co** для документов Word. Механика работы +с `.docx` (создание, правка, шаблоны, ревизии) — в штатном скилле `docx`. + +# AILAB DOCX — корпоративные документы + +Создание документов Word в фирменном стиле проектной организации AI_Lab_SCRUM Co. + +**Когда не применять:** правка присланного документа (оформление берётся из +него) и документы по форме заказчика — там бланк задан не нами. + +## Инструменты + +**python-docx** — основная библиотека. На рабочей машине её может не быть: + +```bash +python -c "import docx" || python -m pip install python-docx +python your_script.py +``` + +## Типовой шаблон документа + +```python +from docx import Document +from docx.shared import Inches, Pt, RGBColor, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.section import WD_ORIENT +from docx.oxml.ns import qn + +doc = Document() + +# ── Page setup ── +section = doc.sections[0] +section.top_margin = Inches(PAGE_MARGIN_TOP) +section.bottom_margin = Inches(PAGE_MARGIN_BOTTOM) +section.left_margin = Inches(PAGE_MARGIN_LEFT) +section.right_margin = Inches(PAGE_MARGIN_RIGHT) + +# ── Default style ── +style = doc.styles['Normal'] +font = style.font +font.name = FONT +font.size = Pt(BODY_SIZE) +font.color.rgb = RGBColor(*INK) +style.paragraph_format.space_after = Pt(SP_2) +style.paragraph_format.line_spacing = LH_RELAXED + +# ── Helper: add AILAB-styled heading ── +def ailab_heading(doc, text, level=1): + """Heading in AILAB style: UPPERCASE, Ink, Medium weight.""" + h = doc.add_heading(text, level=level) + for run in h.runs: + run.font.name = FONT + run.font.color.rgb = RGBColor(*INK) + if level == 1: + run.font.size = Pt(H2_SIZE) + run.font.bold = False # Medium, not Bold + elif level == 2: + run.font.size = Pt(H3_SIZE) + run.font.bold = False + return h + +def ailab_body(doc, text): + """Body paragraph — Regular 13pt, relaxed line-height.""" + p = doc.add_paragraph(text) + for run in p.runs: + run.font.name = FONT + run.font.size = Pt(BODY_SIZE) + run.font.color.rgb = RGBColor(*INK) + p.paragraph_format.line_spacing = LH_RELAXED + return p + +def ailab_caption(doc, text): + """Caption / source — small, grey.""" + p = doc.add_paragraph(text) + for run in p.runs: + run.font.name = FONT + run.font.size = Pt(CAPTION_SIZE) + run.font.color.rgb = RGBColor(*GREY_500) + return p + +def ailab_tag(doc, text): + """Silver tag — inline rectangular label.""" + p = doc.add_paragraph() + run = p.add_run(f" {text} ") + run.font.name = FONT + run.font.size = Pt(TAG_SIZE) + run.font.color.rgb = RGBColor(*INK) + run.font.bold = True + # Silver background via shading + from docx.oxml import OxmlElement + shd = OxmlElement('w:shd') + shd.set(qn('w:fill'), 'C1C5D0') + shd.set(qn('w:val'), 'clear') + run._r.get_or_add_rPr().append(shd) + return p + +def ailab_hairline(doc): + """Horizontal hairline rule.""" + p = doc.add_paragraph() + p.paragraph_format.space_before = Pt(SP_3) + p.paragraph_format.space_after = Pt(SP_3) + # Bottom border on paragraph + from docx.oxml import OxmlElement + pPr = p._p.get_or_add_pPr() + pBdr = OxmlElement('w:pBdr') + bottom = OxmlElement('w:bottom') + bottom.set(qn('w:val'), 'single') + bottom.set(qn('w:sz'), '2') # ~0.5pt = hairline + bottom.set(qn('w:space'), '1') + bottom.set(qn('w:color'), 'D8DBE1') + pBdr.append(bottom) + pPr.append(pBdr) + return p + +# ── Document structure example ── + +# Title page block +p = doc.add_paragraph() +run = p.add_run("НАЗВАНИЕ ДОКУМЕНТА") +run.font.name = FONT; run.font.size = Pt(H1_SIZE) +run.font.color.rgb = RGBColor(*INK) +p.alignment = WD_ALIGN_PARAGRAPH.LEFT + +# Subtitle +p = doc.add_paragraph() +run = p.add_run("Тип документа · Проект · Дата") +run.font.name = FONT; run.font.size = Pt(BODY_SIZE) +run.font.color.rgb = RGBColor(*FG_SECONDARY) + +ailab_hairline(doc) + +# Section +ailab_heading(doc, "1. ОБЩИЕ ПОЛОЖЕНИЯ", level=1) +ailab_body(doc, "Настоящий документ определяет порядок...") + +ailab_heading(doc, "2. ТЕХНИЧЕСКИЕ РЕШЕНИЯ", level=1) +ailab_heading(doc, "2.1. Конструктивные решения", level=2) +ailab_body(doc, "Основные несущие конструкции запроектированы...") + +# Source / reference +ailab_hairline(doc) +ailab_caption(doc, "Источник: ГОСТ 34758-2021, СП 118.13330.2022") + +doc.save("document.docx") +``` + +## Правила оформления документов + +### Типографика +- Заголовки: UPPERCASE, Medium (не Bold), Ink Navy +- Тело: Regular 13pt, Ink, межстрочный 1.35–1.5 +- Подписи/сноски: 10pt, Grey 500 +- Выравнивание: ВСЕГДА по левому краю (не по ширине) +- Абзацный отступ: отсутствует (отбивка пустой строкой) + +### Цвета +- Текст: ТОЛЬКО Ink (`#131A2A`) или Grey 500 (`#888888`) +- Акцент: Champagne ТОЛЬКО для ключевых цифр и декоративных заголовков +- Никаких цветных заголовков (синих, красных и т.д.) + +### Структура +- Разделители между секциями — hairline (0.5pt) +- Нумерация разделов: `1. ЗАГОЛОВОК` (цифра + два пробела + UPPERCASE) +- Между разделами — двойной отступ + +### Стиль текста +- Русский язык, деловой стиль +- От третьего лица или безличные конструкции +- Без восклицательных знаков +- Без маркетинговых эпитетов +- Числа с неразрывными пробелами: `6 183 640 792,00 ₽` + +## Антипаттерны + +- 🚫 Times New Roman (только Arial) +- 🚫 Выравнивание по ширине +- 🚫 Цветной текст (кроме ink, grey-500, champagne) +- 🚫 Подчёркивания (кроме ссылок) +- 🚫 Курсив для русского текста +- 🚫 Более 3 уровней заголовков diff --git a/skills/ailab/ailab-pdf/SKILL.md b/skills/ailab/ailab-pdf/SKILL.md new file mode 100644 index 0000000..81d2d3f --- /dev/null +++ b/skills/ailab/ailab-pdf/SKILL.md @@ -0,0 +1,285 @@ +--- +name: ailab-pdf +description: "Use when создаёшь PDF в фирменном стиле. reportlab и стиль AI_Lab_SCRUM Co; главная ловушка — кириллица превращается в квадраты." +version: 1.0.0 +author: AI_Lab_SCRUM Co +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [pdf, reportlab, style, cyrillic, ailab] + category: ailab + related_skills: [pdf, ailab-docx] +--- + +Правила фирменного стиля **AI_Lab_SCRUM Co** для PDF. Общая механика PDF (создание, +чтение, формы, объединение) — в штатном скилле `pdf`. + +# AILAB PDF — корпоративные PDF + +Создание PDF-документов в фирменном стиле проектной организации AI_Lab_SCRUM Co. + +**Когда не применять:** когда нужен редактируемый документ — тогда `ailab-docx`, +а PDF делается экспортом из него. + +## Кириллица в PDF — главная ловушка + +Встроенные шрифты reportlab (Helvetica, Times, Courier) кириллицы **не +содержат**. Документ при этом собирается без единой ошибки, а весь русский текст +превращается в квадраты — замерено: `drawString` с русской строкой даёт в готовом +файле `■■■■■ ■■■■■■■■■`. Поэтому первым делом регистрируется TrueType-шрифт с +кириллицей, и только потом пишется текст. + +Брендовый TT Norms Pro для этого не годится: это OpenType с постскриптовыми +контурами, `TTFont` отвечает «postscript outlines are not supported». Рабочий +шрифт — системный Arial. + +## Инструменты + +**reportlab** — основная библиотека для создания PDF с нуля. +**pypdf / pdfplumber** — для чтения и модификации существующих PDF. + +```bash +python -c "import reportlab" || python -m pip install reportlab pypdf pdfplumber +``` + +## Типовой шаблон: reportlab + +```python +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import mm, cm +from reportlab.lib.colors import HexColor +from reportlab.pdfgen import canvas +from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, + PageBreak, Table, TableStyle, HRFlowable) +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + +# ── Helper: RGB tuple → HexColor ── +def gc(rgb_tuple): + """Convert (R,G,B) to HexColor for reportlab.""" + return HexColor('#%02X%02X%02X' % rgb_tuple) + +# ── Register font — обязательный шаг, без него кириллица станет квадратами ── +pdfmetrics.registerFont(TTFont('ArialTT', r'C:\Windows\Fonts\arial.ttf')) +pdfmetrics.registerFont(TTFont('ArialTT-Bold', r'C:\Windows\Fonts\arialbd.ttf')) + +FONT_PDF = 'ArialTT' +FONT_PDF_BOLD = 'ArialTT-Bold' + +# ── Styles ── +styles = getSampleStyleSheet() + +style_body = ParagraphStyle( + 'AILAB_Body', + fontName=FONT_PDF, + fontSize=BODY_SIZE, + leading=BODY_SIZE * LH_RELAXED, + textColor=gc(INK), + alignment=TA_LEFT, + spaceAfter=SP_2, +) + +style_h1 = ParagraphStyle( + 'AILAB_H1', + fontName=FONT_PDF, + fontSize=H2_SIZE, + leading=H2_SIZE * LH_SNUG, + textColor=gc(INK), + alignment=TA_LEFT, + spaceAfter=SP_4, + spaceBefore=SP_6, +) + +style_h2 = ParagraphStyle( + 'AILAB_H2', + fontName=FONT_PDF, + fontSize=H3_SIZE, + leading=H3_SIZE * LH_SNUG, + textColor=gc(INK), + alignment=TA_LEFT, + spaceAfter=SP_3, + spaceBefore=SP_4, +) + +style_caption = ParagraphStyle( + 'AILAB_Caption', + fontName=FONT_PDF, + fontSize=CAPTION_SIZE, + leading=CAPTION_SIZE * LH_NORMAL, + textColor=gc(GREY_500), + alignment=TA_LEFT, +) + +style_hero = ParagraphStyle( + 'AILAB_Hero', + fontName=FONT_PDF, + fontSize=HERO_SIZE, + leading=HERO_SIZE * LH_TIGHT, + textColor=gc(CHAMPAGNE), + alignment=TA_LEFT, +) + +# ── Document ── +output_path = "document.pdf" +doc = SimpleDocTemplate( + output_path, + pagesize=A4, + leftMargin=PAGE_MARGIN_LEFT * cm * 2.54, # inches → cm + rightMargin=PAGE_MARGIN_RIGHT * cm * 2.54, + topMargin=PAGE_MARGIN_TOP * cm * 2.54, + bottomMargin=PAGE_MARGIN_BOTTOM * cm * 2.54, +) + +story = [] + +# Title page (имитация тёмного cover) +# reportlab не умеет тёмный фон через Platypus — для сложной вёрстки +# используй canvas (см. ниже «Тёмный титульный лист») + +# Content +story.append(Paragraph("1. ОБЩИЕ ПОЛОЖЕНИЯ", style_h1)) +story.append(Paragraph( + "Настоящий документ определяет порядок проектирования и оформления " + "конструктивных решений в составе проектной документации.", + style_body)) +story.append(Spacer(1, SP_5)) + +story.append(Paragraph("2. НОРМАТИВНЫЕ ССЫЛКИ", style_h1)) +story.append(Paragraph( + "ГОСТ 34758-2021 «Лифты. Правила и методы расчёта...»; " + "СП 118.13330.2022 «Общественные здания и сооружения».", + style_body)) +story.append(Spacer(1, SP_5)) + +# Hairline +story.append(HRFlowable( + width="100%", thickness=0.5, + color=gc(HAIRLINE), spaceBefore=SP_4, spaceAfter=SP_4)) + +story.append(Paragraph("AILAB · 2026", style_caption)) + +doc.build(story) +print(f"Saved: {output_path}") +``` + +## Тёмный титульный лист (через canvas) + +```python +from reportlab.lib.pagesizes import A4 + +W, H = A4 # 595.27 x 841.89 pt + +c = canvas.Canvas("cover.pdf", pagesize=A4) + +# Ink background +c.setFillColor(gc(INK)) +c.rect(0, 0, W, H, fill=1, stroke=0) + +# Hero title +c.setFillColor(gc(CHAMPAGNE)) +c.setFont(FONT_PDF, HERO_SIZE) +c.drawString(50, H - 180, "ЗАГОЛОВОК") +c.drawString(50, H - 220, "ПРЕЗЕНТАЦИИ") + +# Hairline +c.setStrokeColor(gc(HAIRLINE)) +c.setLineWidth(0.5) +c.line(50, H - 260, 250, H - 260) + +# Subtitle +c.setFillColor(gc(SILVER)) +c.setFont(FONT_PDF, SMALL_SIZE + 4) +c.drawString(50, H - 285, "Подзаголовок · Дата") + +# Logo / brand mark +c.setFillColor(gc(CHAMPAGNE_DIM)) +c.setFont(FONT_PDF, 14) +c.drawRightString(W - 50, 50, "AILAB") + +c.save() +print("Saved: cover.pdf") +``` + +## Типовой шаблон: генерация из Markdown + +Для быстрой конвертации Markdown → PDF с AILAB-стилем: + +```bash +# Через pandoc с пользовательским шаблоном +pandoc input.md \ + --pdf-engine=weasyprint \ + --css=print.css \ + -o output.pdf +``` + +Файл `print.css`: + +```css +@page { + size: A4; + margin: 2.5cm 2.8cm; + @bottom-center { + content: "AILAB · " counter(page); + font-family: Arial, sans-serif; + font-size: 9pt; + color: #888888; + } +} +body { + font-family: Arial, Helvetica, sans-serif; + font-size: 13pt; + line-height: 1.45; + color: #131A2A; +} +h1 { + font-size: 22pt; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #131A2A; + border-bottom: 1px solid rgba(19,26,42,0.12); + padding-bottom: 8pt; + margin-top: 32pt; +} +h2 { + font-size: 16pt; + font-weight: 500; + color: #131A2A; + margin-top: 24pt; +} +p { + text-align: left; + orphans: 3; + widows: 3; +} +.caption { + font-size: 10pt; + color: #888888; +} +``` + +## Правила PDF + +1. **Всегда** A4, portrait (кроме спец. буклетов) +2. Поля: 2.5–2.8 см со всех сторон +3. Шрифт: Arial (Windows) или Helvetica (cross-platform) +4. Колонтитулы: снизу — «AILAB · № страницы», сверху — пусто +5. Никаких фоновых изображений в теле документа +6. Тёмный ink-фон — ТОЛЬКО на титульной странице +7. Ссылки: Champagne или Ink с underline, НЕ синие +8. **Готовый файл проверяется чтением, а не глазами по коду:** + `python -c "from pypdf import PdfReader; print(PdfReader('document.pdf').pages[0].extract_text()[:200])"` + — в выводе должен быть русский текст. Квадраты `■` означают, что шрифт не + зарегистрирован и файл надо пересобрать + +## Антипаттерны + +- 🚫 Встроенные Helvetica / Times для русского текста — молча дают квадраты +- 🚫 Синие ссылки (стандарт web) — используй ink + underline +- 🚫 Цветные заголовки +- 🚫 Georgia / Times / serif шрифты +- 🚫 Яркие акцентные цвета в таблицах (только champagne, silver, grey-100) +- 🚫 Watermark'и (кроме DRAFT — и то диагонально, champagne, 10% opacity) diff --git a/skills/ailab/anonymization-check/SKILL.md b/skills/ailab/anonymization-check/SKILL.md new file mode 100644 index 0000000..89ef141 --- /dev/null +++ b/skills/ailab/anonymization-check/SKILL.md @@ -0,0 +1,141 @@ +--- +name: anonymization-check +description: "Use when документ заказчика уходит во внешнюю модель. Проверка полноты обезличивания: что закрыто, а что осталось открытым." +version: 1.0.0 +author: AI_Lab_SCRUM Co +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [anonymization, privacy, documents, pii, docx] + category: ailab + related_skills: [docx] +--- + +**Правило: ни один документ заказчика не уходит в модель, пока не обезличен.** +Обезличивание делает человек; этот скилл — контрольный шаг перед загрузкой, +он отвечает на вопрос, **что осталось открытым**. + +# Skill: Проверка полноты обезличивания + +## 1. Назначение + +**Правило: ни один документ заказчика не уходит в модель, пока не обезличен.** +Обезличивание делает человек по своей таблице соответствий. Скилл — контрольный +шаг перед загрузкой: он отвечает на вопрос, **что осталось открытым**. + +Обезличивание делают люди, и оно бывает неполным. В разобранном ТЗ закрыты +адрес, кадастровый номер и топонимы — а наименование заказчика, управляющей +организации, фамилия подписанта и номер ГПЗУ остались открытыми. Никто этого не +скрывал: просто закрыли то, о чём подумали. + +**Результат читает тот, кто будет работать с документом дальше** — и ему нужно +знать не «обезличено/не обезличено», а конкретно: что закрыто токенами, что +осталось открытым и откуда нельзя брать цитаты. Эта строка идёт в шапку любого +отчёта по документу. + +## 2. Когда применять и когда не применять + +**Применять:** документ заказчика (ТЗ, договор, форма КП, переписка) перед +загрузкой во внешнюю модель или перед отчётом, который увидят за пределами отдела. +Проверка идёт **до** предметного разбора — она стоит минуты и не требует +чтения документа целиком. + +**Не применять** для самого обезличивания: скилл проверяет, а не закрывает. +Заменять реальные значения токенами — работа человека, у него есть таблица +соответствий, которую агент не читает. + +## 3. Входные данные и их грабли + +| Что | Формат | Роль | +|---|---|---| +| Обезличенная копия документа | DOCX, TXT | проверяемый | + +**В ТЗ содержимое лежит в ячейках таблиц, а не в абзацах.** Проверка только по +абзацам даёт пустой результат и читается как «всё чисто». Скрипт разбирает и +абзацы, и таблицы; если делаешь проверку иначе — делай так же. + +**`grep -i` в этом окружении не сворачивает регистр кириллицы.** Поиск +`grep -i "ооо"` не найдёт «ООО», и счётчик молча выйдет нулевым. Поэтому шаблоны +написаны явно под нужный регистр, а регистронезависимый поиск — только питоном +по `text.lower()`. + +**Отсутствие находок — не доказательство.** Шаблоны находят то, что в них +описано: организационные формы, инициалы, кадастровые номера, номера ГПЗУ, +адреса. Заказчик, названный описательно («Инвестор проекта в районе станции»), +под шаблон не попадёт. Ноль находок означает «шаблоны не сработали». + +## 4. Источник правды + +Эталона у этой работы нет: проверяется сам документ. Источник правды — **текст +обезличенной копии**, и только он. + +Таблица соответствия токенов и реальных значений **не читается никогда**: +ни для проверки, ни для «уточнить, что за объект». Само наличие такой таблицы +рядом не даёт права её открыть. + + +## 5. Процедура + +1. **Убедись, что работаешь с копией, а не с оригиналом.** Если токенов + обезличивания в документе нет вообще — это, скорее всего, оригинал. Останови + работу и скажи об этом. +2. **Прогони шаблоны:** + `python <скилл>/scripts/scan_open_pii.py "<файл.docx>"`. Скрипт печатает, что + закрыто токенами (сколько и каких видов) и что осталось открытым, по + категориям, с самими находками. +3. **Просмотри найденное глазами.** Не всякая организация — утечка: «ГБУ + «Мосгоргеотрест»» и «АО «Мосводоканал»» это ресурсоснабжающие организации, они + в любом ТЗ Москвы и объект не идентифицируют. А «ООО «Мангазея»» и «ООО «МД + Проект III»» — заказчик и управляющая организация, по ним объект находится + поиском за минуту. Разделять эти два случая — работа человека или агента, а не + шаблона. +4. **Пройдись по тому, что шаблоны не ловят:** описательные упоминания объекта, + узнаваемые характеристики («единственный в районе 50-этажный»), названия + смежных проектов, ссылки на переписку. +5. **Сформулируй строку для шапки отчёта:** что закрыто токенами, что осталось + открытым и откуда не берутся цитаты. + +Самое чувствительное в этих документах — **название объекта и его адрес** +(заказчик в ТЗ обычно прямо не назван). Если что-то идентифицирует объект или +заказчика и токеном не закрыто — не переноси это в отчёт дословно и скажи +пользователю. + +## 6. Выходные данные + +Строка (или короткий блок) для шапки отчёта по документу: + +``` +Обезличивание: закрыто токенами — адрес, кадастровый номер, топонимы (26 замен). +Открыто: наименование заказчика и управляющей организации, фамилия подписанта, +номер ГПЗУ. Цитаты не берутся из подписного блока и пункта «Заказчик». +``` + +Отдельного файла-отчёта скилл не производит: его результат живёт в шапке того +отчёта, ради которого документ и открывали. Если проверка делается впрок — +положи вывод скрипта рядом с документом в рабочей папке. + +## 7. Запреты + +- Не читать таблицу соответствий токенов и не пересылать её. +- Не угадывать, что стоит за токеном. `[АДРЕС-003]` — обычный текст, переноси + как есть. +- Не цитировать подписной блок и пункт «Заказчик». +- Не выносить открытые находки в документы, которые уйдут наружу: назвать факт в + шапке — да, перечислять реальные наименования в отчёте заказчику — нет. +- Не докладывать «документ обезличен» по результату одного прогона шаблонов. + Правильная формулировка: «шаблоны нашли столько-то, вот что осталось открытым». + +## 8. Приёмка + +Эталона пока нет: скилл переносится из другого набора, а свой приёмочный случай +нужно завести — обезличенная копия реального документа с известным людям ответом: +что закрыто токенами и что осталось открытым. + +Семь критериев на этой работе: бизнес-результат — есть готовая строка для шапки, +а не «проверено»; метод — разобраны и абзацы, и таблицы; полномочия — таблица +соответствий не читалась; остановка — сомнительные находки отданы человеку, +а не расклассифицированы самостоятельно; ровно один раз — один документ, один +вывод; доказательство — вывод скрипта с самими находками, а не утверждение; +исключения — если токенов нет вовсе, работа остановлена, а не продолжена на +оригинале. diff --git a/skills/ailab/anonymization-check/scripts/scan_open_pii.py b/skills/ailab/anonymization-check/scripts/scan_open_pii.py new file mode 100644 index 0000000..e4f968d --- /dev/null +++ b/skills/ailab/anonymization-check/scripts/scan_open_pii.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Проверка полноты обезличивания: что закрыто токенами, а что осталось открытым. + +Смысл: обезличивание делают люди, и оно бывает неполным — закрывают то, о чём +подумали. Скрипт ничего не закрывает, он ПОКАЗЫВАЕТ, что осталось открытым, +чтобы это увидел человек до отправки документа во внешнюю модель. + +Использование: + python scan_open_pii.py "<файл.docx>" полный отчёт + python scan_open_pii.py "<файл.docx>" --summary только счётчики, без значений + python scan_open_pii.py "<файл.docx>" --header строка для шапки отчёта + python scan_open_pii.py "<файл.docx>" --json машинный вывод + +Поддерживается .docx (весь пакет: текст, таблицы, колонтитулы, метаданные) и .txt. + +--- Два правила, заложенные в код --- + +1. Разбираются И абзацы, И таблицы. В ТЗ содержимое лежит в ячейках таблиц, а не + в абзацах; проверка только по абзацам даёт пустой результат и читается как + «всё чисто». + +2. Регистр сворачивается питоном (`lower()`), а не средствами ОС: `grep -i` в + Windows-окружении не сворачивает кириллицу, и счётчик молча выходит нулевым. + +--- Чего скрипт НЕ делает --- + +Ноль находок означает «шаблоны не сработали», а НЕ «документ чист». Заказчик, +названный описательно («Инвестор проекта в районе станции»), под шаблон не +попадёт. Поэтому код возврата 0 при пустом результате — это «проверка прошла», +а не «утечек нет». Ненулевой код (1) отдаётся только по флагу --fail-on-open. +""" +import argparse +import json +import re +import sys +import zipfile +from collections import Counter, defaultdict +from pathlib import Path +from xml.etree import ElementTree as ET + +W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + +# ---------------------------------------------------------------- шаблоны +# Пары (категория, регулярное выражение). Регистр НЕ указываем: текст заранее +# приводится к нижнему — так надёжнее, чем флаги в самом шаблоне. +PATTERNS = [ + # Название в кавычках захватывается вместе с формой: без него в отчёте стоит + # голое «ООО», и человек не может отличить заказчика от «АО «Мосводоканал»», + # которое в любом ТЗ Москвы и объект не идентифицирует. + ("орг. форма", r"\b(ооо|оао|зао|пао|ао|ип|гбу|гку|фгбу|фгбоу|фгуп|муп|гуп|нко|сро|тсж|ано)\b" + r"\s*(?:«[^»\n]{1,120}»|\"[^\"\n]{1,120}\")?"), + ("инициалы", r"\b[а-яё]\.\s?[а-яё]\.\s?[а-яё][а-яё-]{2,}"), + ("фамилия с инициалами", r"\b[а-яё][а-яё-]{2,}\s[а-яё]\.\s?[а-яё]\."), + ("кадастровый номер", r"\b\d{2}:\d{2}:\d{6,7}:\d+"), + ("ГПЗУ", r"\bгпзу\b[^\n]{0,60}"), + # \d{1,4} и полный номер телефона: с одиночной \d находка обрывалась до + # «д. 1» и «+7 (4» — в отчёте это бесполезно. + ("адрес", r"\b(?:г\.|гор\.|город|ул\.|улица|пер\.|переулок|проезд|шоссе|проспект|д\.|дом|корп\.|корпус|стр\.|строение|владение|помещение)\s*\.?\s*\d{1,4}"), + # Границы по [\w-] обязательны: без них индекс «123456» находился внутри + # номера ГПЗУ «RU77-123456» — это ложная находка. + ("почтовый индекс", r"(? [(где, что, контекст)] + for part, kind, num, text in blocks: + low = text.lower() + for m in TOKEN_RE.finditer(low): + closed[f"токен {m.group(1).strip().upper()}"] += 1 + n_under = len(UNDERSCORE_RE.findall(text)) + if n_under: + closed["подчёркивания"] += n_under + for category, pattern in PATTERNS: + for m in re.finditer(pattern, low): + found = text[m.start():m.end()].strip() + ctx = text[max(0, m.start() - 40):m.end() + 40].strip() + open_hits[category].append((f"{kind} {part}:{num}", found, ctx)) + return closed, open_hits + + +def is_benign(found: str) -> bool: + return any(h in found.lower() for h in BENIGN_HINT) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Проверка полноты обезличивания документа") + ap.add_argument("file", help=".docx или .txt — обезличенная копия") + ap.add_argument("--summary", action="store_true", help="только счётчики, без значений") + ap.add_argument("--header", action="store_true", help="строка для шапки отчёта") + ap.add_argument("--json", action="store_true", help="машинный вывод") + ap.add_argument("--fail-on-open", action="store_true", help="код 1, если есть открытые находки") + args = ap.parse_args() + + path = Path(args.file) + if not path.exists(): + print(f"файл не найден: {path}", file=sys.stderr) + return 2 + + if path.suffix.lower() == ".docx": + blocks = read_docx(path) + elif path.suffix.lower() in (".txt", ".md"): + blocks = read_txt(path) + else: + print(f"формат не поддержан: {path.suffix} (нужен .docx или .txt)", file=sys.stderr) + return 2 + + closed, open_hits = scan(blocks) + total_closed = sum(closed.values()) + total_open = sum(len(v) for v in open_hits.values()) + benign = sum(1 for cat, items in open_hits.items() for item in items if is_benign(item[1])) + + if not total_closed: + note = ("Признаков обезличивания в документе НЕТ (ни подчёркиваний, ни " + "токенов). Если это должна быть обезличенная копия — скорее всего " + "перед вами оригинал. Работа остановлена, файл дальше не проверялся.") + if args.json: + print(json.dumps({"file": str(path), "error": "no_anonymization_markers", + "note": note}, ensure_ascii=False, indent=2)) + else: + print(note) + return 1 + + cats = {c: len(v) for c, v in sorted(open_hits.items(), key=lambda x: -len(x[1]))} + kinds = ", ".join(f"{k} — {v}" for k, v in closed.most_common()) + + if args.json: + print(json.dumps({ + "file": str(path), + "blocks": len(blocks), + "closed": {"total": total_closed, "kinds": dict(closed)}, + "open": {"total": total_open, "by_category": cats, "likely_benign": benign}, + "findings": {c: [{"where": w, "found": f} for w, f, _ in v] + for c, v in open_hits.items()}, + }, ensure_ascii=False, indent=2)) + elif args.header: + if total_open: + opened = ", ".join(f"{c} ({n})" for c, n in cats.items()) + print(f"Обезличивание: закрыто — {total_closed} замен ({kinds}). " + f"ОТКРЫТО: {opened}. Цитаты из мест с открытыми данными не берутся.") + else: + print(f"Обезличивание: закрыто — {total_closed} замен ({kinds}). Открытых " + f"значений шаблоны не нашли — это НЕ доказательство полноты, " + f"описательные упоминания проверяются глазами.") + elif args.summary: + print(f"документ: {path.name} | блоков текста: {len(blocks)}") + print(f"закрыто: {total_closed} ({kinds})") + print(f"осталось открытым: {total_open} совпадений по {len(cats)} категориям") + for c, n in cats.items(): + print(f" {c}: {n}") + if benign: + print(f" из них похожи на ресурсоснабжающие (обычно не утечка): {benign}") + else: + print(f"ФАЙЛ: {path}") + print(f"блоков текста: {len(blocks)}\n") + print(f"ЗАКРЫТО: {total_closed}") + for k, v in closed.most_common(): + print(f" {k}: {v}") + print(f"\nОСТАЛОСЬ ОТКРЫТЫМ: {total_open} совпадений\n") + for category, items in sorted(open_hits.items(), key=lambda x: -len(x[1])): + print(f"--- {category}: {len(items)} ---") + for where, found, ctx in items[:12]: + flag = " (похоже на ресурсоснабжающую)" if is_benign(found) else "" + print(f" [{where}] {found}{flag}") + print(f" …{ctx}…") + if len(items) > 12: + print(f" … ещё {len(items) - 12}") + print() + print("Ноль находок означает «шаблоны не сработали», а не «документ чист».") + print("Описательные упоминания объекта и заказчика проверяются глазами.") + + if args.fail_on_open and total_open: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ailab/caveman/SKILL.md b/skills/ailab/caveman/SKILL.md new file mode 100644 index 0000000..37763be --- /dev/null +++ b/skills/ailab/caveman/SKILL.md @@ -0,0 +1,106 @@ +--- +name: caveman +description: "Use when нужен предельно краткий ответ. Коротко и по делу: режутся слова, факты не режутся." +version: 1.0.0 +author: AI_Lab_SCRUM Co +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [style, brevity, tokens, communication] + category: ailab + related_skills: [] +--- + +Стиль ответов: коротко, прямо, без воды. Зачем это в токенах — в +`references/token-savings-guide.md`. + +# Режим «пещерный» (caveman) + +Коротко. Прямо. Без воды. Грамматика — когда помогает понять; обрывки — когда не мешают. + +## Правила + +1. **Без вводных и вежливых оборотов.** Не начинай с «Конечно!», «С удовольствием», «Отличный вопрос», «Давай разберём». Ни приветствий, ни благодарностей. +2. **Сначала дело, потом слова.** Сделай задачу. Скажи результат. Остановись. +3. **Короткие фразы.** Одна мысль — одна фраза. Не режь грамматику, если станет непонятно. +4. **Без пересказа вопроса.** Не повторяй за человеком то, что он только что сказал. +5. **Без рассказа о своих действиях.** Не объявляй инструменты: «сейчас прочитаю файл» — просто читай. +6. **Без хвостов.** Не подводи итог сделанному, не спрашивай «что-нибудь ещё?», не предлагай следующие шаги, если не просили. +7. **Объясняй по делу.** Объяснение уместно, если результат неожиданный или объяснение попросили. Иначе молчи. +8. **Код — это ответ.** Если ответ — код, покажи код, без английской обёртки вокруг. +9. **Ошибка — чини.** Упало — почини и скажи. Без извинений и пересказа ошибки. +10. **Термины как есть.** Названия инструментов, команд и файлов не переводи и не переиначивай. + +## Что не сокращать + +Краткость — про текст, а не про содержание. Никогда не сокращай: + +- код — целиком, без пересказа; +- текст ошибок — дословно; +- пути к файлам — точно; +- вывод команд — нужные строки как есть; +- числа, версии и идентификаторы — точно. + +Режь слова. Факты не режь. + +## Когда правила гнутся + +Краткость уступает, когда без слов будет хуже. + +**Объясни, если:** + +- результат неочевиден или странен; +- человек прямо просит объяснить; +- разбор сложной ошибки без контекста приведёт к повторной ошибке; +- предстоит опасное или необратимое действие — сначала предупреди. + +**Дай план заранее, если:** + +- в работе несколько рискованных шагов — перечисли их и выполняй; +- есть двусмысленность, которая съест время. + +**Полные предложения, если:** + +- обрывок двусмыслен; +- без короткого определения термина не понять. + +Проверка: старший инженер запутается или пропустит важное? Да — добавь слов. Нет — режь. + +## Примеры + +Плохо: + +«Конечно! Сейчас посмотрю конфигурацию и всё поправлю.» + +Хорошо: + +«`src/config.ts:14` — timeout: 5000 → 10000» + +Плохо: + +«Я вижу ошибку типов: функция ждёт строку, а приходит число. Я поправил — теперь должно работать. Запустить тесты?» + +Хорошо: + +«`userId` был числом, параметр ждёт строку. Приведение добавлено. Тесты проходят.» + +Плохо: + +«Готово! Я нашёл три ТЗ. Если нужно, могу разобрать каждое подробнее.» + +Хорошо: + +«ТЗ: 3. Папка `docs/tz`.» + +Плохо (молча стирает): + +«[удаляют записи из базы]» + +Хорошо: + +«Удалю все строки `sessions`, отката нет. Продолжать?» + +## Экономия + +Зачем и что именно экономится — в references/token-savings-guide.md (загрузи через skill_view и file_path). diff --git a/skills/ailab/caveman/references/token-savings-guide.md b/skills/ailab/caveman/references/token-savings-guide.md new file mode 100644 index 0000000..ed02cce --- /dev/null +++ b/skills/ailab/caveman/references/token-savings-guide.md @@ -0,0 +1,68 @@ +# Экономия токенов: зачем резать слова + +Справочник к скиллу «пещерный». Отвечает на вопрос, который у скилла остался +без ответа: **что именно экономится**, когда ответ становится короче. + +## Главное: платит не ответ, а контекст + +Короткий ответ экономит не только себя. Каждый следующий ход заново отправляет +модели весь диалог целиком — system prompt, инструкции, историю, выводы +инструментов. Поэтому длинный ответ стоит дороже один раз, а короткий экономит +на **каждом** последующем ходу, пока живёт сессия. + +Замер на живом счёте (одна генерация, 23.10.2026): + +``` +total_tokens: 173163 + prompt_tokens: 172036 ← сколько ушло модели на вход + из них cached: 151898 ← 88 % входа — это переотправка того же контекста + completion_tokens: 1127 ← а вот столько модель написала в ответ +``` + +Полезный вывод из этих чисел: **вход в 150 раз больше выхода**. Один лишний +абзац, оставшийся в истории, переотправляется десятки раз. Один лишний ход +(«сейчас посмотрю файл» → посмотрел → «вот что я нашёл») стоит ещё один полный +проход со всем контекстом. + +## Что именно экономится + +| Что | Как экономится | Где выгода | +|---|---|---| +| Выходные токены | ответ короче | один раз | +| Входные токены | короткая история меньше переотправляется | на каждом следующем ходу | +| Число ходов | нет пустых объявлений о действиях | целый проход контекста за ход | +| Вывод инструментов | читаешь нужные строки, а не всё подряд | попадает в историю навсегда | + +Самая большая экономия — не в формулировках, а в **числе ходов и объёме +прочитанного**. Ответ в три слова, за которым последовали два лишних вызова +инструментов, дороже ответа в тридцать слов без них. + +## Где выгода почти нулевая + +- **Короткий диалог.** На первых ходах контекст маленький — резать нечего. +- **Ответ-код.** Код и так идёт целиком; обёртка вокруг него на экономию не влияет. +- **Числа, пути, ошибки.** Их нельзя сокращать, и это не потеря. + +Экономия накапливается по мере роста сессии. Чем длиннее разговор, тем дороже +каждое слово в истории. + +## Что не сокращать никогда + +Прямое продолжение правил скилла: краткость — про текст, а не про содержание. + +- код — целиком; +- текст ошибок — дословно; +- пути к файлам — точно; +- вывод команд — нужные строки как есть; +- числа, версии и идентификаторы — точно. + +## Практическая проверка перед отправкой + +1. В ответе есть фраза о том, что ты собираешься сделать, вместо самого действия? — Убери, сделай. +2. Есть пересказ вопроса? — Убери. +3. Есть итог, который уже виден из самого ответа? — Убери. +4. Есть предложение следующих шагов, которого не просили? — Убери. +5. Оставшееся можно прочитать без потери смысла? — Отправляй. + +Если после этих пяти шагов ответ стал непонятен — значит резал не то: убирай +слова, а не факты. diff --git a/skills/ailab/om-documentation-review/SKILL.md b/skills/ailab/om-documentation-review/SKILL.md new file mode 100644 index 0000000..9c2f0fd --- /dev/null +++ b/skills/ailab/om-documentation-review/SKILL.md @@ -0,0 +1,177 @@ +--- +name: om-documentation-review +description: "Use when проверяя ЭОМ/ИОС1.1-документацию на ошибки и нормы." +version: 1.1.0 +author: AI_Lab_SCRUM Co +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [engineering, electrical, eom, documentation, review, lighting, automation, pue, ntd] + category: engineering + related_skills: [excel-data-reconciliation, xlsx] +--- + +# Проверка ЭОМ-документации + +Методика проверки проектной/рабочей документации по электрооборудованию (ЭОМ), +электроосвещению и автоматизации зданий на ошибки, опечатки и несоответствия нормам. + +## Когда использовать + +- Проверка однолинейных расчётных схем щитов (ЩС, ЩО, ЩАО, ЩР). +- Сверка номиналов автоматов с нагрузкой и сечением кабеля. +- Поиск опечаток, расхождений в обозначениях, ошибок в цепях отходящих и вводных. +- Проверка соответствия действующим нормам (33 НТД — см. `references/ntd-list.md`). +- Сверка ссылок на НД в комплекте с фактическим статусом документов (действует / изменён / + заменён / отменён) и доступ к источнику статусов — `references/ntd-source-kodeks-techexpert.md`. +- Проверка ТЕКСТОВОЙ ЧАСТИ тома ИОС1.1 (ПЗ): балансы мощности и годовой расход, + категорийность, учёт, заземление/молниезащита, освещение, ОЗДС. Для объёмных томов + использовать трёхэтапный протокол — `references/multi-model-review.md`. + +## Порядок работы + +1. **Извлечь текст** из PDF (`pdf_read.py --text` или встроенная экстракция read_file). + При объёмном томе — постранично в JSON с числом символов на страницу: страницы с + `chars < 50` — сканы без текстового слоя, их надо назвать в ограничениях, а не молчать. + Сразу построить карту тома (что на какой странице: ПЗ / ГЧ / таблицы / схемы). +2. **Пересчитать всё, что считается, кодом** — `Decimal` и регулярки, до чтения выводов: + суммы нагрузок, `Sр = √(Pр² + Qр²)`, `tgφ` до и после КРМ, годовой расход, количество × + удельная мощность. Не глазами и не в уме. Подробно — `references/multi-model-review.md`. +3. **Проверить опечатки** — искать слитные слова, пропущенные буквы, «мм2» вместо «мм²», + двойные буквы. +4. **Сверить обозначения щитов** — одна и та же позиция должна называться одинаково + по всему комплекту (ЩО vs ШО, индексы этажей N). +5. **Проверить вводные цепи** — соответствие: вводной автомат ↔ расчётный ток ↔ сечение кабеля. +6. **Проверить отходящие цепи** — номинал автомата ↔ нагрузка группы ↔ сечение группового кабеля. +7. **Сверить с нормами** по списку НТД (ПУЭ 1.3, СП 256, ГОСТ Р 50571.5.52 и др.) и + **проверить статус ссылочных НД**: каждый упомянутый в тексте и на схемах нормативный + документ — на действующую редакцию и замену. Отменённый или заменённый ссылочный НД — + замечание, даже если сам текст проекта корректен. +8. **Проверить согласованность внутри тома** — категорийность, перечни, нумерация, + дефекты обозначений НТД. См. питфоллы ниже. +9. **Оформить отчёт** — по структуре из раздела «Отчёт». +10. **Прогнать независимого аудитора** (другое семейство моделей, по выдержкам + первоисточника) и свести его находки со своими. Пропускать только для тома до ~20 стр. + +## Критерии соответствия (ключевые цифры) + +### Допустимые токи медных жил в воздухе +(ПУЭ табл. 1.3.4 / ГОСТ Р 50571.5.52-2011 табл. B.52.2) + +| Сечение, мм² | Допустимый ток, А (медь, в воздухе) | +|---|---| +| 1,5 | 19 | +| 2,5 | 27 | +| 4 | 38 | +| 6 | 46 | +| 10 | 70 | +| 16 | 85 | +| 25 | 110 | +| 35 | 135 | +| 50 | 165 | +| 70 | 210 | +| 95 | 255 | + +> При прокладке нескольких жил/кабелей в пучке применять понижающий коэффициент ~0,87. + +### Типовые пары «автомат ↔ кабель» (розеточные/осветительные группы) + +- 16 А → ППГнг(А)-HF 3×2,5 мм² (розетки) +- 10 А → 3×1,5 мм² (освещение) +- 20 А → 3×2,5 мм² +- 25 А → 3×4 мм² +- 32 А → 5×6 мм² +- 63 А → 5×16 мм² + +### Правило выбора аппарата защиты + +`Iном.авт ≤ 1,25 × Iр` (по СП 256.1325800.2016 п.6.1.5 / ПУЭ 3.1.4). +Завышенный автомат = несрабатывание защиты при КЗ. + +## Чек-лист частых ошибок + +- [ ] Автомат завышен/занижен относительно расчётного тока группы. +- [ ] Сечение кабеля меньше требуемого для номинала автомата. +- [ ] Опечатки в марках кабеля или обозначениях щитов. +- [ ] Расхождение одного и того же щита (например «ШО» vs «ЩО»). +- [ ] Несоответствие номеров проекта (PR-XXX) в титуле и в тексте. +- [ ] УЗО на розеточные группы отсутствует или установка ≠ 30 мА. +- [ ] Система заземления указана некорректно (TN-S vs TN-C-S). +- [ ] Электрощитовая/ВРУ/ГРЩ посажены под мусорокамерой ТБО, санузлом, кухней пищеблока, + ИТП, насосной или иным помещением с мокрыми процессами (и наоборот — над ними + жилые комнаты): `references/placement-rules.md`. +- [ ] Ссылочный НД отменён/заменён либо приведён без действующей редакции («с изм. N»). +- [ ] Дефект в САМОМ обозначении НД: опечатка номера, несуществующая редакция, ссылка без года. +- [ ] Строка таблицы нагрузок выпала из ОБОИХ сезонных режимов (лето — «зима», зима — «пожар»). +- [ ] Количество единиц × удельная мощность не даёт колонку Ру. +- [ ] Расчётная мощность к ТУ с запасом менее 1 % — проверить выпавшие строки на пересчёт. +- [ ] Категорийность надёжности разнится между п.4, п.5 и итоговыми сведениями о потребителе. +- [ ] **Сводные мощности в тексте ПЗ не сходятся с таблицами того же тома** (Ру/Рр/Рр сезонные). +- [ ] Ру по составу (типы квартир × кВт/кв) не даёт цифру в подытоге. +- [ ] Марка кабеля противоречит описанной конструкции (HF ≠ изоляция из ПВХ). +- [ ] Один НТД в томе в двух редакциях: в перечне новая, в тексте отменённая. +- [ ] Пропуски и повторы номеров таблиц (ссылки «в таблицах 2 и 3» становятся неоднозначными). +- [ ] Будущая/нереальная дата в штампе документа. + +## Отчёт + +Формат отчёта: сначала критические замечания (безопасность — перегрев, +несрабатывание защиты), затем административные (опечатки, оформление). Каждое +замечание — со ссылкой на пункт НТД и конкретной рекомендацией (заменить X на Y). + +Рабочая четырёхуровневая структура, которая себя оправдала на томах ИОС1.1: + +1. **Критические** — то, что влияет на безопасность или на соответствие ТУ (выпавшие из + баланса нагрузки, противоречия категорийности, неподтверждённые источники). +2. **Существенные** — арифметика, расхождения между документами тома, дефекты НТД. +3. **Административные** — округления, нумерация, неполнота перечней. +4. **«Проверено и соответствует»** — таблица того, что сходится, с результатом проверки. + Обязательный раздел: он показывает объём работы и не даёт читателю думать, что + непроверенное — плохое. +5. **«Ограничения проверки»** — что НЕ проверено и почему (нет графической части, + нет текстового слоя, нет доступа к ИСС). Прямо и без смягчений. + +Разделение томов: ПЗ (текстовая часть) и ГЧ (графическая) проверяются по-разному. По тексту +проверяются балансы, формулы, ссылки, решения; сверка схем со спецификациями, селективность +защит и номиналы по отходящим линиям — ТОЛЬКО по однолинейкам. Если графическая часть не +разобрана, это пишется в ограничениях, а не замалчивается. + +Список НТД — в `references/ntd-list.md`. +Протокол проверки в три этапа (извлечение → сверка кодом → независимый аудитор другого +семейства моделей) и разбор, какой этап что реально ловит — `references/multi-model-review.md`. +Ограничения по размещению электрощитовых (мокрые процессы, мусорокамера ТБО, жилые +комнаты, подвалы) — в `references/placement-rules.md`. + +## Питфоллы, харнесс и схема работы — в references + +Полные списки вынесены, чтобы тело скилла оставалось коротким: + +- **`references/check-pitfalls.md`** — все проверенные ловушки: номера пунктов, + метки режимов, количество×мощность, дефекты обозначений НТД, класс точности, + категорийность, пустые таблицы, шифры штампов. **Читать перед проверкой.** +- **`references/harness-eom-check.md`** — скрипт `scripts/eom_check.py`: три вёрстки + таблиц нагрузок, что он ловит, и все питфоллы парсинга (рвущиеся заголовки, + склейка номера НТД, «Лифт пожарных» и т.п.). +- **`references/review-workflow.md`** — порядок шагов: код → база НТД → аудитор + другого семейства, что даёт каждый и почему не заменяет остальные. +- **`references/review-verdict-docx.md`** — итоговая ведомость заказчику на 4 колонки + («ВОПРОС | МАСТЕРМАЙНД | AI_Lab_SCRUM Co | РАСХОЖДЕНИЯ»), правила красного, + скрипт сборки `scripts/build_review_docx.py` и его питфоллы. +- **`references/multi-model-review.md`** — вызов аудитора через API (питфолл + `reasoning_tokens`, выбор модели). + +### Три правила, которые держать в голове всегда + +1. **Статусы НТД — только база.** Модель их выдумывает. Поиск по номеру → чтение + текста пункта по номеру строки. +2. **Арифметику считать кодом**, не глазами и не моделью: `Decimal`, суммы, `√(P²+Q²)`, + `tgφ`, количество×мощность. +3. **Итог меньше суммы строк — ошибка.** Сумма нагрузок по вводам не может быть меньше + каждого слагаемого. Встретилось — искать, из чего собран итог. + +## Связанные заметки + +Живой источник правды — этот скилл. Методику можно вести и заметками в своём +vault (например, «Проверка ЭОМ-документации», «НТД для ЭОМ», «Размещение +электрощитовых»); при расхождении править скилл, а не заметку. \ No newline at end of file diff --git a/skills/ailab/om-documentation-review/references/check-pitfalls.md b/skills/ailab/om-documentation-review/references/check-pitfalls.md new file mode 100644 index 0000000..de10bd4 --- /dev/null +++ b/skills/ailab/om-documentation-review/references/check-pitfalls.md @@ -0,0 +1,118 @@ +# Питфоллы проверки ЭОМ — полный список + + +- **Проверять номер пункта, а не только наличие НТД.** Типовая ошибка ПЗ — ссылка на + существующий пункт, который в норме говорит о другом. Сверять через + `document_search_in_text_tool`/`document_read_text_tool` (ГОСТ Ассистент) и различать три + исхода: «пункт есть и по делу», «пункт есть, но о другом», «пункта нет». +- **СП 256.1325800.2016: п.8.23 — про НОМИНАЛЬНЫЕ НАПРЯЖЕНИЯ (220/380 В), НЕ про потери.** + Текст про 7,5 % / 3 % / 4 % идёт **следующим, НЕ ПРОНУМЕРОВАННЫМ абзацем** (сверено + по тексту документа 19.10.2026: строка 2816 — п.8.23 про напряжения, строка 2827 — + потери без номера). Ссылка вида «согласно п.8.23 … 7,5 %» — **замечание**, ссылаться + надо на раздел 8 целиком. + + Здесь я дважды ошибался в разные стороны: сначала объявил саму цифру неверной (ложное + критическое замечание), потом — что «п.8.23 про потери, всё верно» (пропустил дефект + ссылки). Правило: цитата может быть точной, а НОМЕР ПУНКТА — не тем. Проверять два + вопроса отдельно: «есть ли такой текст в СП» и «тот ли это пункт». + + Ловушка: поиск в интернете выдаёт цитату про 7,5 % рядом с «8.23», потому что страница + цитирует раздел. Сверять только по номеру строки в базе, а не по выдаче поиска. + + Проверенные соседние случаи той же породы: п.10.13 — про УЗО 30 мА, а не про этажные + щиты (требования к ним — п.10.6–10.11 и раздел 12). +- **Пересчитывать таблицы нагрузок кодом, не глазами.** Считать `Decimal`, а не в уме: суммы + Ру/Рр/Qр, `Sр = √(Pр² + Qр²)`, `tgφ = Qр/Pр` до и после КРМ. Сверять две независимые точки: + «Итого» строки и «Итого ВРУ с КРМ». Обычная картина — «Итого» исключает нагрузку квартир, + а полная сумма равна Руст: проверять оба числа, иначе вывод «итог не сходится» ложный. +- **СВЕРЯТЬ СВОДНЫЕ ЦИФРЫ В ТЕКСТЕ ПЗ С ТАБЛИЦАМИ ТОГО ЖЕ ТОМА — отдельная обязательная проверка.** + Таблицы могут быть посчитаны идеально (итоги сходятся до 0,1 кВт), а в тексте ПЗ стоять + совершенно другие Рр — и ни код, ни база НТД этого не видят, потому что каждый источник + внутренне согласован. Найти в ПЗ фразу вида «ГРЩ: Ру=…; Рр(лето)=…; Рр(зима)=…» и + сопоставить с суммой по вкладкам. + Как отличить дефект от разных величин: если Ру сходится в пределах пары процентов, + а Рр расходится В РАЗЫ — дело не в составе нагрузок, а в коэффициентах или в том, что + часть строк не попала в расчётные колонки. Дополнительно считать загрузку ТП при обоих + значениях: она покажет, какое из двух правдоподобно (76 % против 20 % — разница между + обоснованной ТП и завышенной вчетверо). +- **Метки режимов в сезонных таблицах — сверять МЕЖДУ таблицами, а не внутри строки.** + Строка, помеченная «Режим — Зима» в летней таблице и «Режим Пожар» в зимней, выпадает из + ОБОИХ режимов: лето исключило её как зимнюю, зима — как пожарную. Это самая дорогая + ошибка баланса мощности и находится только построчным сопоставлением двух таблиц по + номеру строки. Тот же тест для «Не участв.» / «Не учит.»: строка, исключённая в одном + режиме, должна быть учтена в другом; исключённая в обоих — проверить, законно ли. +- **Количество × удельная мощность = Ру, построчно.** Расхождение количества между сезонными + таблицами при одинаковом Ру — опечатка (20 шт × 22 кВт ≠ 506 кВт). Ошибка может не влиять + на итог (строка исключена), но это расхождение в документе и оно подлежит замечанию. +- **Считать запас до разрешённой мощности ТУ, а не только факт «уложились».** Если расчётная + мощность отличается от ТУ на проценты (5 кВт из 900 — 0,56 %), то любая выпавшая из баланса + строка ломает соответствие. Пересчитывать сценарий «а если учесть» при нескольких Кс и + показывать превышение в кВт — так замечание становится доказуемым, а не оценочным. +- **Чужие штампы искать программно, а не глазами.** В томе может лежать лист другого + объекта: извлекать из каждой страницы шифр основной надписи регуляркой и сравнивать с + шифром комплекта. Проверено: 3 листа подряд имели шифр `ВЕРН-8А-П-ИОС1.1` и адрес + другого объекта при 78-страничном томе — визуально это самый заметный дефект комплекта, + и именно его легко пропустить при чтении текста. +- **Нумерация таблиц и разделов.** Проверять сквозную нумерацию: пропуски (1, 2, 3, 6) и + повторы номеров («Таблица 2» у расчёта и у учёта) делают ссылки вида «в таблицах 2 и 3» + неоднозначными. Пропуск номеров — не косметика: это признак выпавшего раздела. +- **Категорийность надёжности — сверять по ВСЕМ упоминаниям.** Один и тот же объект в разных + пунктах может быть описан как «2; 1; 1 особая», «I и II» и просто «2». Проверять п.4 + (количество ЭП и мощности), п.5 (требования к надёжности), перечень мероприятий по + резервированию и итоговые сведения о потребителе — расхождение между ними критично, + потому что от категории зависит комплектация АВР. +- **Подтверждение источников для I категории.** Формулировка «питание от двух взаимно + резервируемых источников (данным проектом не разрабатывается)» при заявленной + I категории — замечание: ПУЭ 1.2.18–1.2.20 требует двух независимых источников, и том + должен подтверждать их наличие, а не отсылать к другому проекту. +- **Дефекты обозначений НТД искать строкой, а не по смыслу.** Типовые: опечатка в номере + регламента (ТР ЕАЭС 043/217 вместо 043/2017), несуществующая редакция (ГОСТ Р 50571.5.52-2013 — + есть только -2011, заменён на ГОСТ Р 50571.52-2021), ссылка без года, оборванный переносом + строки номер (ГОСТ 28249-). Проверять каждое обозначение регуляркой по всему тексту тома. +- **Класс точности: разделять «счётчик» и «ТТ».** В тексте и в таблице учёта одна и та же + запись может читаться как 0,5S/1 и 0,5S/0,5 — это разные величины, смешанные в одной графе. + Сверять текст п.«приборы учёта» против таблицы учёта построчно по типу прибора. + +- **Сверять таблицы ПОДЫТОГОВ с составом строк.** «ИТ О ГО: 2219 180 …» в блоке квартир: + количество 180 = 73+47+60 (сходится), а Ру — нет: 73·11 + 47·12 + 60·12 = 2087 ≠ 2219. + Считать каждую составляющую отдельно, не только итог. Завышенное Ру тянет за собой Рр + через Кс, и потом невозможно понять, откуда взялась мощность. +- **Одна и та же точка присоединения в разных пунктах описана по-разному.** В одном томе + встретились «выводы низкого напряжения», «выходные клеммы трансформаторов тока» и + «шины 0,4 кВ ВРУ». Для расчёта потерь и для границы балансовой принадлежности это разные + точки: норма 7,5 % считается от ТП, а не от ВРУ — сверить и с формулировкой нормы, и + между собой. +- **Марка кабеля vs описанная конструкция.** «ППГнг(А)-HF» расшифровывается как halogen free; + если рядом написано «изоляция и оболочка из ПВХ» — это противоречие: ПВХ содержит хлор. + Проверять не только номер ГОСТ, но и совместность марки с материалами по ГОСТ 31565. +- **Один НТД в двух редакциях внутри тома.** В перечне НТД стоит действующая редакция, + а в тексте ссылка — на отменённую (и наоборот). Это отдельный класс дефекта: каждый + фрагмент по отдельности выглядит нормально, противоречие видно только при сквозной + сверке ссылок с перечнем. Делать её регулярно: собрать все НТД из текста и все из перечня. +- **Нормируемая величина отнесена не к той точке.** «Потери от шин 0,4 кВ ВРУ не более + 7,5 %» — цифра верная, точка неверная (норма от ТП). Проверять не только число, но и то, + от какого элемента цепи оно отсчитывается. + +Проектные замечания — не текст, а сверка: одна и та же величина, снятая из двух +источников, должна совпасть. Проверенные типы расхождений: + +- **Спецификация vs схема** — количество приборов/аппаратов в спецификации не + сходится с однолинейкой того же тома. Считать двумя независимыми способами: + по спецификации с раскрытием иерархии позиций и подсчётом по схемам. +- **Таблица параметрирования vs спецификация** — расхождение локализуется по + этажам, а не «в целом»; проверять построчно по каждому этажу. +- **Артикул vs наименование** — наименование прибора может противоречить + приведённому рядом артикулу (однофазный назван трёхфазным и наоборот). +- **Артикул vs перечень согласованных модификаций** — сверять целиком, не по + отдельным полям: правка одного поля меняет прибор как изделие. +- **Обозначение комплекта** — одна позиция в нескольких формах (`.СО` vs `.С`, + `-ТУ1.` vs `-ТУ1`, дефис vs точка); возможен шифр чужого комплекта в основной + надписи. +- **Обозначения ТТ** — дублирование номеров комплектов на одном листе; комплект + трёхфазного учёта показан двумя трансформаторами вместо трёх. +- **Номинал ТТ vs расчётный ток** — проверить отношение `1,2·Iтт/Iр`; занижённый + номинал и вторичный ток сверх 10 А — критично. +- **Направление потока** — определяется по положению источника на листе, а не по + порядку слов в текстовом слое PDF. +- **Растровые PDF** — том без текстового слоя проверить автоматически нельзя; + это указывается в заключении, а не замалчивается. \ No newline at end of file diff --git a/skills/ailab/om-documentation-review/references/harness-eom-check.md b/skills/ailab/om-documentation-review/references/harness-eom-check.md new file mode 100644 index 0000000..6dc93d2 --- /dev/null +++ b/skills/ailab/om-documentation-review/references/harness-eom-check.md @@ -0,0 +1,107 @@ +# Харнесс eom_check.py: вёрстки и питфоллы + + +Механическую часть (шаг 1 и подготовка шага 2) делает скрипт, а не руками: + +```bash +python scripts/eom_check.py "том.pdf" -o "<папка>" --cipher "МП-18/1604-ИОС1.1" +``` + +Даёт `report.md` (протокол), `ntd_task.md` (задание для базы), `rows.csv`, `pages.json`. +Ловит: чужие штампы, пропуски/дубли таблиц, арифметику нагрузок, выпавшие из обоих +режимов строки, `Sр=√(Pр²+Qр²)`, cos/tg, перечень НТД с дефектами обозначений. + +Обкатан на **трёх** комплектах, три разные вёрстки: + +| Комплект | Стр. | Вёрстка | Результат | +|---|---|---|---| +| МП-18/1604 | 78 | A: Кол-во→Ру, сезон, «Итого ВРУ» | 17/17 | +| ШРКП | 50 | B: Ру→Кол-во, ввод×сезон×пожар | 7/7 | +| RGZ 2026.05-01 | 89 | C: **таблица нагрузок пустая**, данные в таблице УКРМ, секции ГРЩ | частично — см. питфоллы | + +### Уроки третьего комплекта (вёрстка C) + +- **Таблица нагрузок может быть ПУСТЫМ БЛАНКОМ.** Найдены заголовок, 15 пронумерованных + строк, «Итого» — и ни одного числа. Текст при этом ссылается: «расчёт представлен + в таблице 1». Скрипт обязан сообщать «таблица найдена и НЕ ЗАПОЛНЕНА» как критическое + замечание, а не сваливаться в «таблицы не распознаны». Это разные вещи: первое — дефект + тома, второе — дефект парсера. +- **Данные могут лежать не в таблице нагрузок.** В RGZ числа по вводам нашлись в таблице + «Расчёт мощности УКРМ». Искать числовые строки нагрузок по всему тому, а не только + после заголовка «нагрузки». +- **ИТОГ МОЖЕТ БЫТЬ МЕНЬШЕ СУММЫ СТРОК — это признак ошибки, а не нормы.** В RGZ + ΣРр по строкам 1793,1 против итога 976,5 (на 817 кВт меньше). Сумма нагрузок по вводам + не может быть меньше каждого слагаемого. При этом √(P²+Q²) итоговой строки сходится + идеально — значит итог посчитан верно, но собран не из показанных строк. +- **Вёрсток минимум три, универсального парсера не будет.** Нужен режим + «не распознал — покажи что нашёл», чтобы инженер доразобрал вручную: карта страниц, + кандидаты таблиц, первая строка каждой. Не притворяться, что разобрал. +- **Повторяющиеся дефекты между комплектами одного проектировщика — проверять первыми.** + У ГЕНПРОЕКТ в трёх томах подряд: ГОСТ Р 21.101-2020 (отменён 01.04.2026), + п.8.23 СП 256 как «потери напряжения» (в действительности — про номинальные 220/380 В), + СП 6.13130.2021 (отменён 29.06.2026). Это шаблон ПЗ, а не случайность: увидел автора — + смотри эти три места в первую очередь. + +### Две вёрстки таблиц нагрузок — обе поддержаны + +| | Вёрстка A (МП-18/1604) | Вёрстка B (ШРКП) | +|---|---|---| +| Колонки | № · Наименование · **Кол-во · Ру** · Кс · … | № · Нагрузка · **Ру · Кол-во** · Кс · Кс(авар) · Кснм · … | +| Итоги | «Итого» / «Итого ВРУ с КРМ» | «Итого по вводу N» / «…с КРМ» | +| Структура | одна таблица на режим | режим × ВВОД × (норма/пожар) | +| Iр | нет | есть | + +Порядок колонок и наличие Iр определяются по строке-шапке, а НЕ по всему тексту. + +### Питфоллы харнесса (все проверены болью) + +- **Заголовок таблицы рвётся на две строки**: «Таблица 2 Итоговый расчёт… Режим» + + «“Зима”». Сезон искать в следующей строке (`pending_season`), иначе таблица получает + метку `?` и сравнение сезонов не работает. +- **Таблица нагрузок не кончается сама.** После итогов идут таблицы учёта с теми же + номерами — без явного сброса `current` строки «2 ГРЩ...» прилипают к зимней таблице + и суммы врут. Сбрасывать на любой другой заголовок «Таблица N». +- **«Режим Пожар» в ОБОИХ таблицах — это норма, а не потеря.** Клапаны ДУ и + противодымная вентиляция считаются отдельным пожарным режимом. Отделять их от + настоящих потерь (свой сезон в одной таблице + пожар в другой), иначе в отчёт уйдут + ложные 171 кВт. +- **Шифр сравнивать по СКЕЛЕТУ (только буквы и цифры).** `МП-18/1604-ИОС1.1` и + `МП-181604-ИОС1.1` — один объект с потерянным разделителем, а не два разных. + Сравнение строкой дало 26 ложных «чужих листов» вместо 3 настоящих. + Дефект «181604» — отдельное замечание по оформлению ГОСТ Р 21.101. +- **Номера НТД рвутся пробелом в любом месте**: `ГОСТ 3214 4-2013`, `ГОСТ 28249- 93`, + `СНиП 23 -05-95`. Искать по плоскому тексту (переносы → пробел) шаблоном с допуском + пробелов ВНУТРИ номера, склеивать, и только потом классифицировать. Разбор сразу + шаблоном «цифры-дефис-цифры» отрезает хвост и рождает ложные «без года». + При склейке удалять ТОЛЬКО пробелы: снести дефисы/точки — превратить + `ГОСТ 10434-82` в `ГОСТ 1043482`. +- **Один документ в разных написаниях** (`СП 256.1325800.2016` / `СП256.1325800.2016`) + объединять по нормализованному ключу, иначе задание в базу пухнет дублями. +- **Рр/Qр/Sр брать С КОНЦА строки, а не по индексу.** Хвост всегда заканчивается + одинаково: `…, Рр, Qр, Sр, [Iр]`. Фиксированный индекс уезжает на единицу, как только + в шапке появляется лишняя колонка (Кснм). Сдвиг: с Iр — 4/3/2, без Iр — 3/2/1. +- **Флаг «есть Iр» — только из шапки самой таблицы нагрузок.** Поиск по всему тому + даёт ложное срабатывание (Iр встречается в другом тексте) и сдвигает всю арифметику. +- **Статус строки в вёрстке B задаётся БЛОКОМ, а не словом в названии.** + «Лифт пожарных подразделений» содержит «пожар», но это нагрузка нормы — 55 кВт + выпадали из суммы, давая ложное расхождение ровно 54,9 кВт. +- **Строки «ИТ О ГО :» и подвиды («1-комнатные 73 11») — не нагрузки.** + Первая — промежуточный итог (квартиры, гигантский Ру ломает сверку), вторые — + раскладка состава. Обе отсекать по регулярке. +- **Слово в шифре ломается пробелом: «ВВО Д 1» вместо «ВВОД 1».** Регулярка + `^В\s*В\s*О?\s*Д?\s*(\d)` с якорем конца строки (иначе ловит цифры из таблиц учёта). +- **К шифру в плотном штампе A3 приклеивается имя щита** (`ГРЩШРКП-ГП-ПИР-4-П-ИОС1.1`). + Сравнивать по ВХОЖДЕНИЮ скелета (свой ⊂ шифр), а не равенством; наивная обрезка + «до первого дефиса» срезает половину настоящего шифра и даёт 50 ложных чужих листов. + Настоящий чужой объект — тот, где скелет НЕ содержит наш. +- **Пустая таблица важнее нераспознанной.** Если в таблице есть заголовок и нумерованные + строки, но нет чисел — это заполненный бланк без данных, критическое замечание. + Отличать от «текстового слоя нет» (тогда нужен OCR) и от «формат незнакомый». +- **Сумма строк ≠ «Итого», и это нормально.** В вёрстке B «Итого по вводу» + включает подытог «ИТ О ГО» (квартиры) — его надо ДОБАВИТЬ к сумме строк. + В вёрстке A квартиры сидят отдельной строкой и уже в Σ — их надо ВЫЧЕСТЬ. + Коррекцию применять только если она реально уменьшает расхождение, иначе + пожарные блоки дают ложный остаток. + +**Балансовые проверки на двух реальных томах дали 24/24 контрольных совпадений +(17 на вёрстке A + 7 на вёрстке B).** \ No newline at end of file diff --git a/skills/ailab/om-documentation-review/references/multi-model-review.md b/skills/ailab/om-documentation-review/references/multi-model-review.md new file mode 100644 index 0000000..c2579fb --- /dev/null +++ b/skills/ailab/om-documentation-review/references/multi-model-review.md @@ -0,0 +1,83 @@ +# Проверка тома в несколько этапов и несколько моделей + +Что реально даёт выигрыш, а что является декорацией. Проверено на полном томе ИОС1.1 +(78 стр., 11,8 МБ, текстовый слой есть). + +## Итоговая схема + +| Этап | Чем делать | Что даёт | +|---|---|---| +| 1. Извлечение | `pypdf` постранично в JSON (`{page, chars, text}`) | полный корпус; проверить, что нет страниц с `chars < 50` (скан без слоя) | +| 2. Сверка кодом | `Decimal` + `re`, на своей машине | вся арифметика, балансы, нумерация, обозначения НТД, повторы | +| 3. Независимый аудитор | модель ДРУГОГО семейства по первоисточнику | находки, которые пропустил из-за невнимательности к тому же тексту | + +## Когда использовать + +Том объёмнее ~20 страниц, или когда отчёт будет разбираться с подрядчиком, или когда одну и +ту же методику надо применить без пропусков и с проверяемым следом. Для быстрой прикидки по +одному листу достаточно этапов 1 и 2. + +## Что НЕ работает + +- **Стадия «сформируй алгоритм» отдельной моделью.** Алгоритм уже есть — чек-лист этого + скилла. Модель, изобретающая план заново, добавляет галлюцинаций, а не пользы. +- **Аудитор, который видит только текст чужого заключения.** Он не может проверить числа, + не имея исходника: начинает соглашаться или придираться к формулировкам, а номера + пунктов НТД выдумывает. Аудитору давать ВЫДЕРЖКИ ИЗ ТОМА, а не пересказ. +- **Mixture-of-Agents в роли проверяющего.** В Hermes reference-модели MoA инструментов не + имеют («you cannot call tools, run commands, browse, or access files») и работают + параллельно ДО действия. Это веер советчиков, а не последовательная проверка работы. +- **Доверять модели арифметику.** Суммы, `√(P²+Q²)`, `tgφ`, годовой расход — только кодом. + +## Как готовить аудитора + +- Дать: выдержки первоисточника (20–30 страниц по делу, ~46 тыс. символов работают) плюс + список ПРОВЕРЯЕМЫХ УТВЕРЖДЕНИЙ с ожидаемым вердиктом `СОВПАДАЕТ / НЕ СОВПАДАЕТ / + НЕ ПРОВЕРИТЬ`. +- Прямо запретить: выдумывать номера пунктов и значения; при отсутствии основания в выдержке + писать «не проверить по выдержке». +- Просить отдельный блок «ЧТО ПРОПУЩЕНО» — там и появляются главные находки. +- Температура 0, `max_tokens` с большим запасом. +- **ЛОВУШКА: reasoning-модель съедает весь бюджет на рассуждения и возвращает пустой + `content`.** Ответ приходит `completion_tokens == max_tokens`, `reasoning_tokens == max_tokens`, + а `content` пустой. Признак: нулевая длина ответа при списанной стоимости. Лечение — поднять + `max_tokens` до 16000 и требовать «отвечай сразу, без длинных рассуждений». +- **Проверено на polza.ai с `anthropic/claude-sonnet-5.5`:** при промпте ~87 тыс. символов + модель ушла в `reasoning`, вернула `finish_reason=length` и **пустой `content`**. Рабочая + схема: `max_tokens` ≥ 24000 (лучше 32000) и **дробление ПЗ на части по ~25 тыс. символов** + (том 78 стр. → 4 части, 45–97 с на часть). Обязательно проверять оба поля — `content` + И `finish_reason`; пустой `content` записывать как ошибку прогона, а не как + «нового не найдено». Эндпоинт: `POST https://api.polza.ai/v1/chat/completions`, + заголовок `Authorization: Bearer `. +- **В промпт аудитора обязательно включать список уже известных замечаний** с припиской + «не повторять» — иначе аудитор отдаёт 50+ строк, из которых половина дублирует шаги 1–2, + и сводить их приходится вручную. С ним — 4 из 4 частей дали реально новые находки. +- Стоимость такого прогона невелика (порядка 25 тыс. токенов промпта у аудитора) — это не + довод против, это довод за постоянное использование. + +## Как сводить два заключения + +Не складывать списки, а разделить находки по источнику: + +- совпало у обоих → в отчёт; +- только у аудитора → **проверить кодом самому**, прежде чем включать: он мог ошибиться в + разборе строки (частая ошибка — принять количество за Ру, потерять перенос строки); +- только у меня → перепроверить по первоисточнику; если аудитор не подтвердил, это аргумент + против, а не за. + +Отдельно фиксировать в отчёте, что дал третий этап — это честная оценка метода и +единственный способ понять, когда его стоит повторять. + +## Разбор таблиц нагрузок из PDF + +Текстовый слой таблицы — плоский текст, колонки разделены пробелами, поэтому разбор идёт +регулярками по префиксу номера строки, а не по позициям колонок: + +```python +m = re.match(rf"^(\d{{1,2}})\s+(\S.*)$", line) # номер строки + остаток +rest = re.sub(r"\d+\s*кВт", "", rest) # убрать «22кВт» из названия +# строки-продолжения (перенос номера с прошлой страницы) отбрасывать по «Изм. Кол.уч.» +``` + +Признак правильного разбора: сумма Ру по строкам сходится с «Итого ВРУ с КРМ» с точностью до +округления. Если не сходится на десятки кВт — разбор врёт, а не документ. diff --git a/skills/ailab/om-documentation-review/references/ntd-list.md b/skills/ailab/om-documentation-review/references/ntd-list.md new file mode 100644 index 0000000..1568b6e --- /dev/null +++ b/skills/ailab/om-documentation-review/references/ntd-list.md @@ -0,0 +1,65 @@ +# Перечень 34 нормативно-технических документов (НТД) + +Сгруппирован по иерархии юридической силы. Использовать для проверки ЭОМ-документации. + +## 1. Федеральные законы (ФЗ) + +1. Федеральный закон от 22.07.2008 № 123-ФЗ — «Технический регламент о требованиях пожарной безопасности» +2. Федеральный закон от 23.11.2009 № 261-ФЗ — «Об энергосбережении и о повышении энергетической эффективности...» +3. Федеральный закон от 30.12.2009 № 384-ФЗ — «Технический регламент о безопасности зданий и сооружений» + +## 2. Постановления Правительства РФ + +4. Постановление Правительства РФ от 16.02.2008 № 87 — «О составе разделов проектной документации и требованиях к их содержанию» +5. Постановление Правительства РФ от 04.05.2012 № 442 — «О функционировании розничных рынков электрической энергии...» +6. Постановление Правительства РФ от 16.09.2020 № 1479 — «Об утверждении Правил противопожарного режима в РФ» + +## 3. Своды правил (СП) + +7. СП 6.13130.2025 — «Системы противопожарной защиты. Электроустановки низковольтные. Требования пожарной безопасности» +8. СП 52.13330.2016 — «Естественное и искусственное освещение» (актуал. СНиП 23-05-95*) +9. СП 76.13330.2016 — «Электротехнические устройства» (актуал. СНиП 3.05.06-85) +10. СП 113.13330.2023 — «Стоянки автомобилей» (актуал. СНиП 21-02-99*) +11. СП 253.1325800.2016 — «Инженерные системы высотных зданий» +12. СП 256.1325800.2016 — «Электроустановки жилых и общественных зданий. Правила проектирования и монтажа» (изм. 1–8) + +## 4. Государственные и национальные стандарты (ГОСТ / ГОСТ Р) + +13. ГОСТ 10434-82 — «Соединения контактные электрические. Классификация. ОТТ» +14. ГОСТ 21.613-2014 — «СПДС. Силовое электрооборудование. Рабочие чертежи» +15. ГОСТ 28249-93 — «Короткие замыкания в электроустановках. Методы расчёта... до 1 кВ» +16. ГОСТ 31565-2012 — «Кабельные изделия. Требования пожарной безопасности» +17. ГОСТ 32144-2013 — «Электрическая энергия... Нормы качества электрической энергии» +18. ГОСТ IEC 60598-2-22-2025 — «Светильники. Часть 2-22. Светильники для аварийного освещения» +19. ГОСТ Р 21.101-2026 — «СПДС. Основные требования к проектной и рабочей документации» +20. ГОСТ Р 21.622-2023 — «СПДС. Правила выполнения рабочей документации систем электроснабжения...» +21. ГОСТ Р 50571.5.52-2011 (МЭК 60364-5-52:2009) — «Электроустановки низковольтные. Ч.5-52. Выбор и монтаж... Электропроводки» +22. ГОСТ Р 50571.5.54-2024 (МЭК 60364-5-54:2021) — «...Ч.5-54. Заземляющие устройства и защитные проводники» +23. ГОСТ Р 53310-2009 — «Проходки кабельные, вводы герметичные... Требования пожарной безопасности» +24. ГОСТ Р 59789-2021 (МЭК 62305-3:2010) — «Молниезащита. Часть 3...» + +## 5. Правила, инструкции и нормативные акты ведомств (ПУЭ, РД, Приказы) + +25. Правила устройства электроустановок (ПУЭ) — 6-е издание +26. Правила устройства электроустановок (ПУЭ) — 7-е издание +27. РД 34.21.122-87 — «Инструкция по устройству молниезащиты зданий и сооружений» +28. Приказ МЧС России от 12.03.2020 № 151 (ред. от 06.11.2025) +29. Приказ МЧС России от 19.03.2020 № 194 (ред. от 16.06.2025) — «Об утверждении СП 1.13130 "Системы противопожарной защиты. Эвакуационные пути и выходы"» +30. Приказ Минстроя России от 17.11.2017 № 1550/пр — «Требования энергетической эффективности зданий...» +31. Приказ Минстроя России от 09.01.2024 № 5/пр — «Об утверждении СП 246.1325800.2023 "Положение об авторском надзоре при строительстве, реконструкции и капитальном ремонте объектов капитального строительства"» +32. Приказ Минэнерго России от 06.05.2014 № 250 — «Обязательные требования к надёжности электроэнергетических систем...» +33. Приказ Минэнерго России от 12.08.2022 № 811 — «Правила технической эксплуатации электроустановок потребителей» +34. Технический циркуляр — «О выполнении основной системы уравнивания потенциалов на входе в здание» + +> Примечание: пункт 34 — циркуляр; суммарно перечень охватывает 34 документа (часть позиций содержит два документа — ФЗ, ПУЭ 6/7 изд.). + +## Ключевые пункты для быстрой сверки + +- **Выбор проводников по нагреву**: ПУЭ 7 изд. гл. 1.3 (табл. 1.3.4 — медь в воздухе). +- **Аппараты защиты**: СП 256.1325800.2016 п.6.1.5; ПУЭ 3.1.4. +- **УЗО**: СП 256.1325800.2016 п.10.13 (ВДТ до 30 мА на конечные цепи розеток); ПУЭ 7.1.71–7.1.83. Пункта 6.4.3 в СП 256 НЕТ — не ссылаться. +- **Заземление**: ПУЭ гл.1.7; ГОСТ Р 50571.5.54-2024. +- **Электропроводки**: ГОСТ Р 50571.5.52-2011 (табл. B.52.2). +- **Оформление документации**: ГОСТ Р 21.622-2023, ГОСТ Р 21.101-2026, СПДС. +- **КЗ-расчёты**: ГОСТ 28249-93. +- **Качество э/э**: ГОСТ 32144-2013. \ No newline at end of file diff --git a/skills/ailab/om-documentation-review/references/ntd-source-kodeks-techexpert.md b/skills/ailab/om-documentation-review/references/ntd-source-kodeks-techexpert.md new file mode 100644 index 0000000..04e08a2 --- /dev/null +++ b/skills/ailab/om-documentation-review/references/ntd-source-kodeks-techexpert.md @@ -0,0 +1,138 @@ +# Источник НТД: «Техэксперт»/«Кодекс» — доступ и сверка актуальности + +Зачем: сверка ссылок в проектной документации требует не только текстов норм, но и статуса +документа (действует / изменён / заменён / отменён) и действующей редакции. Тексты и статусы +живут в ИСС «Техэксперт»/«Кодекс»; открытый фонд статусов и реестров требований не даёт. + +## Топология (чтобы не терять время на поиск) + +- `docs.cntd.ru` — открытый электронный фонд: чтение текстов документов, бесплатно. +- `cntd.ru` — сайт вендора: продукты, контакты, заявки, техописания. +- Интранет-контур заказчика (у нас `ascrew.cntd.ru`) — «ИС Техэксперт: Интранет», документы + под `/kodeks04/`, вход по логину/паролю рабочего места. Веб-доступ закрыт: корень отдаёт + только «Техэксперт-Клиент», модуль кАссист и Кодекс-сервер; внутренних API-путей нет + (`/api/*`, `/webservice/*` → 404). Работа только через клиент + модуль кАссист. +- Веб-версии новых продуктов (SMART Проектирование, Реестр требований) — отдельный контур + (6 поколение), на интранет-сервере их обычно нет. Не искать их в интранете, запрашивать + доступ отдельно. +- Ошибки на странице входа «Не найден пользователь с такой комбинацией логина и пароля» и + «Исчерпан лимит лицензий для пользователей группы» — это лицензирование и группы на + стороне вендора, а не пароль. Обращение к сервисному специалисту, а не повторный вход. + +## Что реально автоматизирует сверку + +| Инструмент | Что делает | Как поставляется | +|---|---|---| +| кАссист (kAssist) | Проверка проектной, рабочей, конструкторской, технологической и внутренней документации: выявляет отменённые/заменённые НД, расставляет и проверяет гиперссылки, пакетный режим по документам и каталогам, формирует отчёт | Модуль, встраивается в MS Office, LibreOffice, Adobe Acrobat Pro; интеграции с AutoCAD, Kompas-3D, ЛОЦМАН PLM | +| Kodeks API | Программный интерфейс для сторонних систем, «инструмент самостоятельной разработки» | По договору, документация публично не публикуется | +| «Реестр требований: Строительство» (входит в SMART: Проектирование) | База экспертно выделенных требований (связь с классификаторами КСИ, приказ Минстроя № 928, ПП № 1417), чек-листы, аудит проекта, сохранение перечня требований в файл | Отдельная подписка | +| СУ НТД | Внутренний фонд (СТО, ТУ, локальные акты), контроль актуальности ссылочных документов | Отдельное решение/внедрение | + +## Поддерживаемые приложения кАссист (методический материал 3.11.8, Приложения 1–2) + +Вендор присылает эти приложения по запросу. Список сразу сверять с софтом заказчика: версия вне +списка = интеграции нет; для ARCHICAD и для самой настройки нужны права администратора. + +| Платформа | Приложение | Версии | +|---|---|---| +| Windows | Microsoft Office | 2003–2024 | +| Windows | LibreOffice | 5.0 и выше (32/64) | +| Windows | МойОфис «Текст»/«Таблицы», десктоп «Стандартная» | 2022.01 (в «Стандартная. Домашняя» интеграции нет) | +| Windows | Adobe Acrobat Pro | 9, 10, 11, DC (2015–2021), 2017, 2020 | +| Windows | AutoCAD, Mechanical, Architecture, Electrical, Utility Design, MEP, Plant 3D, Civil 3D | 2007–2024 (32/64) | +| Windows | Inventor | 2018–2021 | +| Windows | Revit | 2018–2024 | +| Windows | nanoCAD, PLUS, СПДС, Конструкции, ВК, ОПС, СКС, Отопление, Электро, Геоника, Механика, СПДС Железобетон, СПДС Стройплощадка | по продуктам, от 4.0 до 24.1 | +| Windows | Компас-3D | 13–23 | +| Windows | Siemens NX | 8.0, 8.5, 9–12, 1847–1980 | +| Windows | T-FLEX CAD | 14–17 | +| Windows | SOLIDWORKS 3D CAD | 2013–2018, 2020 | +| Windows | ARCHICAD | 25 | +| Windows | PDM STEP Suite | 5.641 и выше | +| Linux (Astra, ALT, RED OS, Ubuntu) | LibreOffice Writer/Calc | 5.1 и выше (64) | +| Linux (Astra, ALT, RED OS, Ubuntu) | Р7-Офис | 2024.1.2.401 и выше (только редактор текста, таблиц нет) | + +## кАссист: подтверждённые факты о режимах и форматах + +Источник — руководства пользователя кАссист 3.10 и 4.0.2, выложенные в открытом доступе +дилерами вендора (дилерские сайты ищутся по запросу «руководство пользователя кАссист pdf»). +Проверять факты под свою установленную версию, но рассчитывать на них можно. + +| Что | Факт | +|---|---| +| Форматы в безинтерфейсном пакетном режиме | doc, docx, rtf, xls, xlsx, pdf — офисное приложение при этом не запускается | +| DWG | только интерактивно, через панель в AutoCAD (текстовое поле, ячейка таблицы); в безинтерфейсный пакетный список DWG не входит | +| Сканы PDF | «В отсканированных, а также отсканированных и распознанных документах работа кАссист **невозможна**» — только PDF, подготовленные редактором PDF | +| Отчёт | RTF, сохраняется вручную или по заданному пути; машиночитаемого формата (XML/JSON/CSV) нет | +| Расписание | есть внутри кАссист: вкладка «Пакетная обработка» → дата, время, периодичность (ежедневно / через интервал), опции сохранения файлов и отчётов | +| Вызов извне | командной строки, службы и API по каталогу в руководстве нет — внешний запуск не подтверждён | +| Логика пакетного прохода | одна подходящая ссылка — ставится автоматически; ничего не найдено или найдено несколько — файл уходит в список «требует внимания» и дорабатывается человеком | +| «Бессылочные» фрагменты | Inventor, Компас-3D (спецификация), Revit, SOLIDWORKS — часть объектов; в nanoCAD таблицы и таблица DWG | + +**Куда девается работа агента.** Извлечение текста, OCR сканов, разбор DWG — делает агент своими +средствами; кАссист этого не умеет (сканы для него закрыты прямо). Что агент не может сам — +статус и действующую редакцию НД. Это и есть настоящий незакрытый вопрос сверки. + +## Чек-лист запроса вендору + +1. Документация Kodeks API: руководство программиста, спецификация методов, форматы обмена, + точка входа, авторизация (логин/пароль, ключ, сертификат, привязка к IP). +2. Перечень методов: поиск документа, карточка и атрибуты, редакции и статус, ссылочные + документы, изменения. +3. Позиции прайса: API, кАссист, Реестр требований, СУ НТД — что входит в действующий договор, + что оформляется допсоглашением. +4. кАссист: запускается ли пакетная проверка **без ручного открытия файлов** (CLI, сервис, + планировщик, API по каталогу) — по руководству ответ «нет, только окно модуля и его + внутреннее расписание», нужно подтверждение для последней версии. Отдельно: подтвердить + список форматов и формат отчёта (по руководству — doc/docx/rtf/xls/xlsx/pdf и RTF). +5. Экспорт перечней требований из «Реестра требований» и машинный API к нему + (XML/JSON/XLSX/CSV). +6. Тестовый стенд или тестовая лицензия API на период отладки. +7. Сервисная (робот) учётная запись и правила её лицензирования — не только рабочее место + пользователя. +8. Регламент сопровождения API и совместимость версий при обновлении системы. +9. Контакт технического специалиста по интеграциям. + +Контакты: служба поддержки 8-800-505-78-25 (8:00–18:00 МСК), `spp@kodeks.ru` / `spp@cntd.ru`. +Демо-доступ 7 дней оформляется по ИНН компании. + +## Питфоллы + +- **Вендор отвечает «раскройте запрос» и «дать всё не можем».** В этом случае не пухнуть запрос, а + срезать его до 5 пунктов, где каждый ответ — «да/нет»: программный доступ (адрес, авторизация, + методы карточки документа, служебная учётка), внешний запуск кАссист и формат отчёта, лицензии + и цена под эти два пункта, форматы документов и поведение на сканах, тестовый стенд + контакт + техспециалиста. В письме писать прямо: «если пункт не закрывается — сообщите, скорректируем + задачу». Остальное срезать самим. +- **Не ждать вендора там, где ответ лежит в открытом доступе.** Режимы, форматы и отчёты кАссист + описаны в руководстве пользователя у дилеров; топология, факты и ограничения берутся из + живых проверок. Прежде чем просить у вендора справку по возможностям — поискать её самому, + а у вендора просить только то, что закрыто договором (API, лицензии, служебная учётка, + тестовый стенд). +- **Вендорский .docx разбирать как OOXML, а не глазами.** `read_file` по .docx склеивает ячейки + таблиц и теряет строки — для таблиц версий и режимов распаковывать пакет (`unzip` + разбор + `word/document.xml` по ``/`` с учётом `gridSpan`/`vMerge`), иначе перечень + приложений и версий читается неверно. +- **Не обещать автоматическую сверку, пока нет подтверждённого программного доступа.** + Сначала проверить фактические точки входа (страницы, эндпоинты) и только потом говорить, + что возможно. +- **Браузерная сессия агента изолирована от браузера пользователя** — авторизация пользователя туда + не переносится. Пароли и коды не вводить и не запрашивать; доступ оформляется как отдельная + учётная запись (лучше сервисная), проверяется живым вызовом, а не «должно работать». +- **Актуальность норм и текст проекта — разные проверки.** Корректно спроектированный узел + со ссылкой на отменённый НД всё равно уходит в замечания. +- **Ссылок на пункты вендорских продуктов не выдумывать**: вендор публикует состав функций, но + не публичные номера методов API — без полученной документации ссылаться не на что. +- **Перечень приложений ≠ перечень форматов.** Присланный вендором список «форматов» оказался + приложениями и версиями, куда встраивается панель кАссист. Он не отвечает ни на вопрос о + форматах файлов документации, которые модуль разбирает (включая сканы PDF), ни о формате + отчёта, ни о запуске без GUI. Не выдавать его за подтверждение автоматизации — говорить прямо, + что прислали не то, и добивать теми же вопросами. +- **Список поддерживаемых приложений описывает интерактивный режим.** Пока вендор не подтвердил + CLI/сервис/API по каталогу, пакетная ночная проверка не подтверждена, сколько бы приложений ни + было в списке. +- **Запрашивать полный методический материал, а не только приложения.** В нём режимы работы, + форматы, отчёты и способ вызова; по Приложениям 1–2 судить о возможностях нельзя. +- **Сверять версии софта заказчика с таблицей.** AutoCAD/Revit/nanoCAD новее списка, ARCHICAD + не 25, Inventor после 2021 — интеграции нет; это конкретное замечание до покупки, а не + «уточнить у вендора». diff --git a/skills/ailab/om-documentation-review/references/placement-rules.md b/skills/ailab/om-documentation-review/references/placement-rules.md new file mode 100644 index 0000000..e5ecbef --- /dev/null +++ b/skills/ailab/om-documentation-review/references/placement-rules.md @@ -0,0 +1,86 @@ +# Ограничения размещения электрощитовых, ВРУ и ГРЩ + +Сверено по действующим текстам (выборка из документов, а не по памяти): +ПУЭ-7 гл. 7.1 (7.1.28–7.1.30); СП 256.1325800.2016 п. 6.6, 14.1–14.5, 18.1.4–18.1.6 +(ред. с Изм. 1–6); СП 31-110-2003 п. 13.1–13.3 (заменён СП 256, оставлен для объектов +по старым нормам); СП 31-108-2002 п. 5.1.14–5.1.15; СП 54.13330.2022 п. 7.26, 7.31; +СанПиН 2.1.3684-21 п. 137; СП 486.1311500.2020 п. 3.2. + +## Главное правило-триггер + +**Мусоросборная (мусороприёмная) камера ТБО = помещение с мокрыми технологическими +процессами → электрощитовую/ВРУ/ГРЩ под ней или над ней располагать не допускается.** + +Механика запрета (для обоснования в замечании): + +- камера по СП 31-108-2002 п. 5.1.14 обязательно имеет подводку горячей и холодной воды, + трап в полу, присоединённый к фекальной канализации, а п. 5.1.15 — водонепроницаемый + пол с уклоном; контейнеры и ствол промываются и дезинфицируются; +- значит это помещение с постоянно действующим мокрым процессом и технологической + канализацией, т.е. «другое помещение, связанное с мокрыми технологическими + процессами» из ПУЭ-7 п. 7.1.29 и СП 256 п. 14.2; +- дополнительно: по СП 54.13330.2022 п. 7.32 (ред. 2016 — 7.3.11) камера защищается + спринклерным орошением по всей площади с питанием от хозяйственно-питьевого + водопровода, т.е. над ней штатно возможен сброс воды; +- оговорка «за исключением случаев, когда приняты специальные меры по надёжной + гидроизоляции» здесь НЕ применима: она про аварийную протечку, а не про штатный + мокрый процесс этажом выше. + +Прямой строки «не допускается располагать под мусоросборной камерой» в ПУЭ-7 и СП 256 +НЕТ — запрет выводится через «мокрые технологические процессы». Так и писать в +замечании: сначала пункт с формулировкой, затем физика процесса. + +## Куда запрещено сажать электрощитовую / ВУ / ВРУ / ГРЩ + +| Что сверху/снизу | Норма | Характер | +|---|---|---| +| Санузлы, ванные, душевые, мойки, моечные, парильные, кухни пищеблоков, **мусорокамера ТБО**, любые помещения с мокрыми технологическими процессами | ПУЭ-7 п. 7.1.29; СП 256 п. 14.2; СП 31-110 п. 13.2 | Не допускается. Исключение — надёжная гидроизоляция, исключающая попадание влаги в помещение с РУ | +| Кухни квартир | ПУЭ-7 п. 7.1.29; СП 256 п. 14.2 | **Разрешено** — прямо исключены из запрета | +| Жилые комнаты (над, под, смежно) | СП 256 п. 14.2; СП 54.13330.2022 п. 7.26; СанПиН 2.1.3684-21 п. 137 | Не допускается безусловно, без варианта с гидроизоляцией | +| Незадымляемые лестничные клетки | ПУЭ-7 п. 7.1.28; СП 256 п. 14.1; СП 31-110 п. 13.1 | Не разрешается | +| Под мокрыми помещениями — для встроенных ТП, КТП, ЗРУ до 10 кВ | СП 256 п. 6.6 | Не размещать (душевые, ванные, уборные) + надёжная гидроизоляция на аварию водопровода/канализации | +| Прокладка канализации, водостоков, газопроводов, трубопроводов с горючими жидкостями через электрощитовую | ПУЭ-7 п. 7.1.29; СП 256 п. 14.3; СП 31-110 п. 13.3 | Не допускается | +| Водопровод/отопление и венткороба через электрощитовую | ПУЭ-7 п. 7.1.29; СП 256 п. 14.3 | Как исключение, если нет ответвлений, люков, задвижек, фланцев, ревизий, вентилей; холодная вода — защита от конденсата, горячая — теплоизоляция | +| Трубопроводы/газосчётчики ближе 1 м от места установки ВРУ вне щитовой | ПУЭ-7 п. 7.1.28 | Расстояние не менее 1 м | +| АСКУЭ/АСУД | СП 256 п. 18.1.5 | Не следует выбирать под санузлами, ванными, душевыми и др. с мокрыми процессами | + +## Где разрешено + +- Сухие подвалы — СП 256 п. 14.1 / ПУЭ-7 п. 7.1.28: только если помещение отделено + противопожарными перегородками с пределом огнестойкости не менее 0,75 ч и доступно + только обслуживающему персоналу. +- Вне специальных помещений (тамбуры, вестибюли, коридоры, лестничные клетки кроме + незадымляемых) — СП 256 п. 14.1: шкафы со степенью защиты не ниже IP31, токоведущие + части закрыты или не ниже IP3X, аппараты и щиты в удобных и доступных для обслуживания + местах. +- В районах, подверженных затоплению, — выше возможного уровня затопления. +- Требования к помещению: естественная вентиляция, электрическое освещение, температура + не ниже +5 °С, аварийное освещение (ПУЭ-7 п. 7.1.30; СП 256 п. 14.4). +- Двери электрощитовых — открываться наружу (ПУЭ-7 п. 7.1.29; СП 256 п. 14.1). + +## Определение «мокрые процессы» + +В ПУЭ-7 и СП 256 определения нет. Единственное нормативное — СП 486.1311500.2020 п. 3.2: +помещение с влажностью внутреннего воздуха свыше 75 % при температуре 12–24 °С, +а также свыше 60 % при температуре свыше 24 °С. + +Следствие: по расчётной влажности мусорокамера может формально не дотягивать до этих +цифр, поэтому запрет по ней обосновывать не влажностью, а составом процессов (вода, трап, +канализация, регулярная промывка) и спринклерной защитой камеры. + +## Как формулировать замечание + +1. Констатация: электрощитовая (поз. …, лист …) расположена под мусоросборной камерой + (поз. …) — не допускается. +2. Норма: ПУЭ-7 п. 7.1.29; СП 256 п. 14.2 — помещения, связанные с мокрыми + технологическими процессами; плюс СП 31-108-2002 п. 5.1.14–5.1.15 (вода, трап, + канализация) и СП 54.13330.2022 п. 7.32 (спринклерное орошение камеры). +3. Проверить вертикаль в обе стороны: что над щитовой (жилая комната = безусловный + запрет по СП 256 п. 14.2 / СП 54 п. 7.26 / СанПиН 2.1.3684-21 п. 137) и что под ней. +4. Решение: разнести по вертикали или смежно. Если помещение под камерой оставить + нельзя иначе — это уже не электрощитовая по существу, и трактовать как исключение + можно только с комплексом: надёжная гидроизоляция на аварию, отвод протечек за + пределы помещения, отсутствие транзита канализации/водостоков, люков и арматуры, + двери наружу — и всё это прописать в проекте явно. +5. В отчёт — в критические замечания (влаговоздействие на РУ = электробезопасность), + а не в административные. diff --git a/skills/ailab/om-documentation-review/references/review-verdict-docx.md b/skills/ailab/om-documentation-review/references/review-verdict-docx.md new file mode 100644 index 0000000..31e7504 --- /dev/null +++ b/skills/ailab/om-documentation-review/references/review-verdict-docx.md @@ -0,0 +1,95 @@ +# Итоговая ведомость заказчику: docx-сверка на 4 колонки + +## When to Use + +Когда просят «проверку тома ИОС1.1 / ЭОМ», «сверку проверок» или просит внести +замечания в существующую docx-таблицу сверки (шаблон вида «ВОПРОС | МАСТЕРМАЙНД | …»). +Это финальный формат результата — не заменяется markdown-отчётом. + +Формат, который принимается как результат проверки тома — не markdown-отчёт, +а **docx-таблица сверки**: «ВОПРОС | МАСТЕРМАЙНД | AI_Lab_SCRUM Co | РАСХОЖДЕНИЯ». + +## Правила оформления + +- 4 колонки; первые три — вопрос и два независимых заключения, четвёртая — расхождения. +- **Несоответствия — красным** (`RGBColor(0xC0, 0, 0)` в python-docx; в исходных файлах + встречается `FF0000`). +- В колонке «Расхождения» — **готовая отредактированная формулировка**. + Схему «было → отредактировано» не применять — колонку «было» не оставлять. +- Новые находки (шаги 1–3) добавляются **дополнительными строками целиком красным**, + в первой колонке — суть со пометкой источника: «(шаг 1, код)» или «(шаг 3, аудитор)». + Так видно, какой этап методики что дал — это и есть оценка метода. +- В конце — одно примечание: что проверено, чем, и что графическая часть не проверялась. + +## Техника сборки (python-docx) + +Скрипт: `scripts/build_review_docx.py`. + +1. Читать **существующий** docx-шаблон и дописывать его, а не создавать с нуля: + у шаблона уже есть стили, заливка шапки и прежние пометки. +2. Новую колонку добавлять копированием последней ячейки (`copy.deepcopy(cell._tc)` + + `cell._tc.addnext(...)`) — в шапке и в каждой строке. +3. Новые строки — копированием строки-образца (`copy.deepcopy(t.rows[1]._tr)` + + `t._tbl.append(...)`), затем заполнять ячейки 0..3. +4. **Обязательно дописать `w:gridCol`** в `w:tblGrid` под новую колонку. Без этого + `table.columns` показывает 4, ячейки читаются, но Word рисует таблицу с ломаными + ширинами — внешний дефект, который видно сразу. После — задать `cell.width`. +5. **Удалять устаревшее примечание** из шаблона: при дописывании остаются два + противоречащих друг другу. Проверять `len(d.paragraphs)` до и после. +6. Тексты ячеек задавать через `cell.text = ""` + `p.add_run(text)` с `font.size = Pt(9)`; + просто `cell.text = ...` теряет формат и даёт огромный шрифт. + +## Логотип и колонтитулы AI_Lab_SCRUM_Co + +Требование: на итоговой ведомости — **логотип и колонтитул AI_Lab_SCRUM_Co +сверху и снизу**. Логотип — `logo AI Lab SCRUM ENGINEERING`, присылается +пользователем как PNG **с прозрачным фоном и БЕЛЫМ текстом** (проверка: +`РГБА` + `getchannel("A")`, у белой части нейтральные каналы). + +### Перекраска логотипа под светлый фон + +На белой бумаге белый текст исчезает — нейтральные (белые/серые) пиксели нужно +перекрасить в тёмный, **не трогая оранжевый градиент**: + +```python +if a == 0: continue +if abs(r-g) < 28 and abs(g-b) < 28 and abs(r-b) < 28: # нейтральный (белый текст) + po[x,y] = (0x1F, 0x24, 0x2B, a) # тёмный, альфу сохранить +else: + po[x,y] = (r, g, b, a) # оранжевый — как есть +``` + +Затем `crop(alpha.getbbox())` и ресайз до ~900 px по ширине. Результат +проверять на белом фоне (`Image.alpha_composite` с белым) — белых ореолов быть +не должно, оранжевый градиент сохраняется. + +### Вставка колонтитулов (python-docx) + +- Логотип — **в верхний колонтитул**, а не в тело: `sec.header`, `is_linked_to_previous = False`, + `run.add_picture(path, width=Cm(3.6))`. В теле при этом картинок оставаться не должно. +- **ЛОВУШКА: фиксированный таб-стоп.** Том бывает форматом A3 landscape + (`page_width` 42 см), поэтому таб на 17 см не работает — текст встаёт по центру. + Стоп считать от фактической области: `Emu(sec.page_width - sec.left_margin - sec.right_margin)`. + Проверять по PDF не по блокам, а **по спанам** (`get_text("dict")` → `lines[].spans[]`), + блоки дают всю ширину и создают ложное впечатление смещения. +- `paragraph_format.tab_stops.clear_all()` перед добавлением своего стопа — в шаблоне. +- Разделительная линия — `w:pBdr` (`bottom` у хедера, `top` у футера), цветом `E06000`. +- Ширины колонок таблицы тоже зависят от формата: для A3 брать ~9,5 / 10 / 11,5 / 11,5 см. +- Проверка результата — рендер в PDF через Word COM (`doc.SaveAs(path, FileFormat=17)`) и + просмотр страниц; полезно проверить и первую, и последнюю страницу. + +## Проверка после сборки + +Пройтись по всем строкам и собрать множество колонок, где есть красные run'ы: + +```python +red_cols = set() +for ci, cell in enumerate(row.cells): + for para in cell.paragraphs: + for r in para.runs: + if r.font.color and r.font.color.rgb and str(r.font.color.rgb) == "C00000" and r.text.strip(): + red_cols.add(ci) +``` + +Ожидание: в старых строках красные — колонки 2 и/или 3; в новых — 0, 2, 3. +Если в старой строке нет ни одного красного — там просто нет расхождений («—»). diff --git a/skills/ailab/om-documentation-review/references/review-workflow.md b/skills/ailab/om-documentation-review/references/review-workflow.md new file mode 100644 index 0000000..49afc52 --- /dev/null +++ b/skills/ailab/om-documentation-review/references/review-workflow.md @@ -0,0 +1,34 @@ +# Схема «код + аудитор» + + +Одна модель текст проверяет плохо. Работает связка из трёх шагов: + +1. **Извлечение** — pypdf по страницам, текст в JSON. Проверить длину текста по каждой + странице: 0 символов = нет текстового слоя, дальше нужен OCR. +2. **Сверка кодом** — всё арифметическое считать Python/Decimal, НЕ моделью. Плюс + механические проверки: нумерация таблиц, полный перечень обозначений НТД регуляркой, + шифры штампов по страницам, количество×мощность. +3. **Независимый аудитор другого семейства** — прямой вызов API (не субагент), модель + ДРУГОГО производителя, в промпте выдержки первоисточника и список своих утверждений + на перепроверку. Даёт то, что пропустил именно потому, что смотрит иначе: у меня так + нашлись метки режимов. + +**Питфолл API-аудитора:** у reasoning-моделей весь `max_tokens` уходит в `reasoning_tokens`, +`content` возвращается ПУСТОЙ при `completion_tokens == max_tokens`. Ставить `max_tokens` +≥ 16000 и проверять `len(content) > 200`, иначе платишь впустую. + +**Лечение — сменить МОДЕЛЬ, а не только поднять лимит.** Подъём до 16 тыс. не помогает, если +модель всё равно рассуждает весь бюджет: ответ снова пустой при `reasoning_tokens == max_tokens`. +Проверенный рабочий порядок: сначала прогнать неразмышляющую модель того же класса +(`openai/gpt-6.1-sol` отработал 28 с и 3 тыс. символов там, где sonnet вернул ноль дважды), +и держать вторую как запасную. В цикле перебирать модели и брать первый ответ с `len(content) > 300`. +Перед прогоном урезать материал до 20–25 страниц по делу: 48 тыс. символов достаточно, +а лишние страницы только увеличивают время рассуждений. + +**Статусы НТД модели не отдавать вообще** — она их выдумывает. Только база +(ИСС «Техэксперт»/«Кодекс»/ГОСТ Ассистент через MCP): поиск по номеру → чтение текста +конкретного пункта по номеру строки. + +**При сведении двух проверок** разбирать расхождения ПО ПЕРВОИСТОЧНИКУ и прямо писать, +кто был прав, а не усреднять формулировки. Разные проверки дополняют друг друга: кодовый +прогон силён в арифметике и внутренней согласованности, но слеп в статусах НТД и в чужих штампах. \ No newline at end of file diff --git a/skills/ailab/om-documentation-review/scripts/brand_review_docx.py b/skills/ailab/om-documentation-review/scripts/brand_review_docx.py new file mode 100644 index 0000000..bf9ef2f --- /dev/null +++ b/skills/ailab/om-documentation-review/scripts/brand_review_docx.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +brand_review_docx.py — логотип и колонтитулы AI_Lab_SCRUM_Co для ведомости сверки. + +Что делает: + 1) перекрашивает приложенный логотип под светлый фон (белый текст → тёмный, + оранжевый градиент сохраняется), обрезает и сохраняет PNG; + 2) ставит логотип в верхний колонтитул слева, AI_Lab_SCRUM_Co справа; + в нижний — наименование слева, описание с датой справа; + 3) добавляет оранжевые разделительные линии (bottom хедера, top футера); + 4) подгоняет ширины колонок под формат страницы. + +Запуск: + python brand_review_docx.py <документ.docx> <логотип.png> [текст колонтитула] + +Питфоллы (проверено): + * логотип приходит БЕЛЫМ на прозрачном фоне — на белой бумаге исчезнет; + * фиксированный таб-стоп на 17 см не работает для A3 landscape (42 см) — + стоп считать от page_width - margins; + * логотип ставить в колонтитул, а не в тело документа. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import docx +from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Cm, Emu, Pt, RGBColor +from PIL import Image + +DARK = RGBColor(0x1F, 0x24, 0x2B) +ORANGE = RGBColor(0xE0, 0x60, 0x00) +DARK_RGB = (0x1F, 0x24, 0x2B) +BRAND = "AI_Lab_SCRUM_Co" + + +def make_light_logo(src: Path, dst: Path, max_width: int = 900) -> Path: + """Белый текст → тёмный, оранжевая графика без изменений.""" + im = Image.open(src).convert("RGBA") + w, h = im.size + px = im.load() + out = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + po = out.load() + for y in range(h): + for x in range(w): + r, g, b, a = px[x, y] + if a == 0: + continue + neutral = abs(r - g) < 28 and abs(g - b) < 28 and abs(r - b) < 28 + po[x, y] = (DARK_RGB[0], DARK_RGB[1], DARK_RGB[2], a) if neutral else (r, g, b, a) + box = out.getchannel("A").getbbox() + if box: + out = out.crop(box) + if out.size[0] > max_width: + out = out.resize((max_width, int(out.size[1] * max_width / out.size[0])), Image.LANCZOS) + out.save(dst) + return dst + + +def _clear(par) -> None: + for r in list(par.runs): + r._element.getparent().remove(r._element) + par.text = "" + + +def _border(par, edge: str, color: str = "E06000") -> None: + pPr = par._p.get_or_add_pPr() + bdr = pPr.find(qn("w:pBdr")) + if bdr is None: + bdr = OxmlElement("w:pBdr") + pPr.append(bdr) + e = OxmlElement("w:" + edge) + e.set(qn("w:val"), "single") + e.set(qn("w:sz"), "6") + e.set(qn("w:space"), "4") + e.set(qn("w:color"), color) + bdr.append(e) + + +def brand(doc_path: Path, logo_src: Path, footer_right: str) -> None: + work = doc_path.parent / "_logo_light.png" + make_light_logo(logo_src, work) + + d = docx.Document(str(doc_path)) + sec = d.sections[0] + text_width = Emu(sec.page_width - sec.left_margin - sec.right_margin) + wide = text_width.cm > 30 + print("формат: %.0f x %.0f см | область %.1f см -> %s" % ( + sec.page_width.cm, sec.page_height.cm, text_width.cm, + "A3 landscape" if wide else "A4")) + + # верх: логотип слева, бренд справа + sec.header.is_linked_to_previous = False + hp = sec.header.paragraphs[0] if sec.header.paragraphs else sec.header.add_paragraph() + _clear(hp) + hp.alignment = WD_ALIGN_PARAGRAPH.LEFT + hp.paragraph_format.tab_stops.clear_all() + hp.paragraph_format.tab_stops.add_tab_stop(text_width, WD_TAB_ALIGNMENT.RIGHT) + hp.add_run().add_picture(str(work), width=Cm(3.6)) + r = hp.add_run("\t" + BRAND) + r.font.size = Pt(9) + r.font.color.rgb = ORANGE + r.bold = True + _border(hp, "bottom") + + # низ: бренд слева, описание справа + sec.footer.is_linked_to_previous = False + fp = sec.footer.paragraphs[0] if sec.footer.paragraphs else sec.footer.add_paragraph() + _clear(fp) + fp.alignment = WD_ALIGN_PARAGRAPH.LEFT + fp.paragraph_format.tab_stops.clear_all() + fp.paragraph_format.tab_stops.add_tab_stop(text_width, WD_TAB_ALIGNMENT.RIGHT) + fl = fp.add_run(BRAND) + fl.font.size = Pt(9) + fl.font.color.rgb = DARK + fl.bold = True + fr = fp.add_run("\t" + footer_right) + fr.font.size = Pt(8) + fr.font.color.rgb = DARK + _border(fp, "top") + + # логотип не должен остаться в теле + for par in list(d.paragraphs): + if par._p.findall(".//" + qn("w:drawing")): + par._element.getparent().remove(par._element) + + colw = [Cm(9.5), Cm(10.0), Cm(11.5), Cm(11.5)] if wide else \ + [Cm(4.4), Cm(5.4), Cm(8.6), Cm(8.6)] + t = d.tables[0] + for row in t.rows: + for ci, cell in enumerate(row.cells): + if ci < len(colw): + cell.width = colw[ci] + + d.save(str(doc_path)) + print("готово:", doc_path) + print("header:", repr(sec.header.paragraphs[0].text)) + print("footer:", repr(sec.footer.paragraphs[0].text)) + + +if __name__ == "__main__": + if len(sys.argv) < 3: + sys.exit(__doc__) + brand(Path(sys.argv[1]), Path(sys.argv[2]), + sys.argv[3] if len(sys.argv) > 3 else "Сверка результатов проверки тома ИОС1.1") diff --git a/skills/ailab/om-documentation-review/scripts/build_review_docx.py b/skills/ailab/om-documentation-review/scripts/build_review_docx.py new file mode 100644 index 0000000..e2e430f --- /dev/null +++ b/skills/ailab/om-documentation-review/scripts/build_review_docx.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +build_review_docx.py — сборка итоговой ведомости сверки тома ЭОМ на 4 колонки. + +Формат: «ВОПРОС | МАСТЕРМАЙНД | AI_Lab_SCRUM Co | РАСХОЖДЕНИЯ». +Несоответствия и новые находки — красным (C00000). Новые пункты — целыми +красными строками с пометкой источника («шаг 1, код» / «шаг 3, аудитор»). + +Запуск: + python build_review_docx.py <шаблон.docx> <выход.docx> + +rash.json: +{ + "by_row": {"2": "текст расхождения для строки 2 шаблона", "3": "..."}, + "new_rows": [["Вопрос", "МастерМайнд", "AI_Lab_SCRUM Co", "Расхождение"], ...], + "note": "итоговое примечание" +} + +Питфоллы (проверено): + * без дописывания w:gridCol Word ломает ширины новой колонки; + * cell.text = ... теряет формат — задавать через add_run + font.size; + * старое примечание шаблона удалять, иначе их остаётся два противоречащих. +""" +from __future__ import annotations + +import copy +import json +import sys +from pathlib import Path + +import docx +from docx.shared import Pt, RGBColor, Cm +from docx.oxml.ns import qn + +RED = RGBColor(0xC0, 0x00, 0x00) +WIDTHS = [Cm(4.4), Cm(5.4), Cm(8.6), Cm(8.6)] +OLD_NOTE_MARKER = "Примечание: красным показаны расхождения, уточнения и дополнения" + + +def set_cell(cell, text: str, red: bool = False, bold: bool = False, size: int = 9) -> None: + cell.text = "" + p = cell.paragraphs[0] + p.paragraph_format.space_after = Pt(2) + r = p.add_run(text) + r.font.size = Pt(size) + if red: + r.font.color.rgb = RED + if bold: + r.bold = True + + +def build(src: Path, dst: Path, payload: dict) -> None: + d = docx.Document(str(src)) + t = d.tables[0] + + # 1) четвёртая колонка в шапке + new_hdr = copy.deepcopy(t.rows[0].cells[-1]._tc) + t.rows[0].cells[-1]._tc.addnext(new_hdr) + set_cell(t.rows[0].cells[3], "РАСХОЖДЕНИЯ", red=True, bold=True) + + # 2) расхождения по существующим строкам шаблона + by_row = payload.get("by_row", {}) + for ri in range(1, len(t.rows)): + row = t.rows[ri] + new_tc = copy.deepcopy(row.cells[-1]._tc) + row.cells[-1]._tc.addnext(new_tc) + txt = by_row.get(str(ri), "") + set_cell(row.cells[3], txt or "—", red=bool(txt)) + + # 3) новые строки — целиком красным + for cells in payload.get("new_rows", []): + t._tbl.append(copy.deepcopy(t.rows[1]._tr)) + row = t.rows[-1] + for ci, txt in enumerate(cells[:4]): + set_cell(row.cells[ci], txt, red=(ci != 1), bold=(ci == 0)) + + # 4) примечание: убрать устаревшее, дописать своё + for para in list(d.paragraphs): + if para.text.strip().startswith(OLD_NOTE_MARKER): + para._element.getparent().remove(para._element) + note = payload.get("note") + if note: + p = d.add_paragraph() + p.paragraph_format.space_before = Pt(8) + r = p.add_run(note) + r.font.size = Pt(9) + r.bold = True + + # 5) ЛОВУШКА: без этого Word ломает ширины новой колонки + grid = t._tbl.find(qn("w:tblGrid")) + cols = grid.findall(qn("w:gridCol")) + while len(cols) < 4: + cols[-1].addnext(copy.deepcopy(cols[-1])) + cols = grid.findall(qn("w:gridCol")) + + for row in t.rows: + for ci, cell in enumerate(row.cells): + if ci < len(WIDTHS): + cell.width = WIDTHS[ci] + + d.save(str(dst)) + print(f"сохранено: {dst}") + print(f"строк: {len(d.tables[0].rows)} | колонок: {len(d.tables[0].columns)}") + + +def verify(path: Path) -> None: + d = docx.Document(str(path)) + t = d.tables[0] + print("шапка:", [c.text.strip() for c in t.rows[0].cells]) + for ri, row in enumerate(t.rows): + red_cols = set() + for ci, cell in enumerate(row.cells): + for para in cell.paragraphs: + for r in para.runs: + if r.font.color and r.font.color.rgb and str(r.font.color.rgb) == "C00000" \ + and r.text.strip(): + red_cols.add(ci) + cells = [c.text.strip() for c in row.cells] + print(f"[{ri:2d}] красн={sorted(red_cols)} | {cells[0][:40]:40s} | " + f"{cells[3][:60] if len(cells) > 3 else ''}") + + +if __name__ == "__main__": + if len(sys.argv) < 4: + sys.exit(__doc__) + src, dst, payload_path = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3]) + build(src, dst, json.loads(payload_path.read_text(encoding="utf-8"))) + verify(dst) diff --git a/skills/ailab/om-documentation-review/scripts/eom_check.py b/skills/ailab/om-documentation-review/scripts/eom_check.py new file mode 100644 index 0000000..0c670ae --- /dev/null +++ b/skills/ailab/om-documentation-review/scripts/eom_check.py @@ -0,0 +1,1025 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +eom_check.py — механическая проверка тома ЭОМ / ИОС1.1 (шаги 1 и 2 из методики). + +Что делает БЕЗ модели, только кодом: + 1. Карта тома: страницы, наличие текстового слоя, где ПЗ/ГЧ. + 2. Шифры основных надписей по каждой странице + расхождения с шифром комплекта + (ищет вклеенные листы чужого объекта). + 3. Перечень всех обозначений НТД с номерами страниц и контекстом -> задание для базы. + 4. Нумерация таблиц: пропуски и дубли номеров. + 5. Арифметика таблиц нагрузок: суммы Ру/Рр/Qр/Sр, сверка с "Итого" и "Итого ВРУ". + 6. Строки, исключённые из ОБОИХ сезонных режимов (выпавшие нагрузки). + 7. Внутренние проверки: Sр = sqrt(Pр^2+Qр^2), cos(phi), tg(phi). + +Запуск: + python eom_check.py <файл.pdf> [-o <папка вывода>] [--cipher МП-18/1604-ИОС1.1] + +Выход в папке _check/: + report.md — итоговый протокол механической проверки + ntd_task.md — задание для базы НТД (шаг 2, проверить вручную/через MCP) + rows.csv — все разобранные строки таблиц нагрузок (для перепроверки) + pages.json — извлечённый текст по страницам + +Важно: скрипт НЕ проверяет статусы НТД (это база, не текст) и НЕ читает графику. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import re +import sys +import unicodedata +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from decimal import Decimal +from pathlib import Path + +try: + from pypdf import PdfReader +except ImportError: # pragma: no cover + sys.exit("нужен pypdf: uv pip install pypdf") + +NUM = r"\d+(?:[.,]\d+)?" + + +# ─────────────────────────────────────────────────────────── извлечение + +def extract_pages(pdf: Path) -> list[dict]: + reader = PdfReader(str(pdf)) + out = [] + for i, page in enumerate(reader.pages, 1): + try: + text = page.extract_text() or "" + except Exception: + text = "" + out.append({"page": i, "chars": len(text.strip()), "text": text}) + return out + + +def norm_space(s: str) -> str: + return re.sub(r"[ \t\u00a0]+", " ", s).strip() + + +# ─────────────────────────────────────────────────────────── 1. карта тома + +def volume_map(pages: list[dict]) -> dict: + total = len(pages) + empty = [p["page"] for p in pages if p["chars"] < 50] + thin = [p["page"] for p in pages if 50 <= p["chars"] < 300] + chars = sum(p["chars"] for p in pages) + + # где текстовая часть, где графика — по плотности текста + heavy = [p["page"] for p in pages if p["chars"] >= 800] + graphic_from = None + if heavy: + # графика начинается там, где после плотного блока пошёл разреженный + for i in range(1, len(heavy)): + if heavy[i] - heavy[i - 1] > 3: + graphic_from = heavy[i] + break + + return { + "pages": total, + "chars": chars, + "empty": empty, + "thin": thin, + "graphic_from": graphic_from, + "no_text_layer": len(empty) > total * 0.5, + } + + +# ─────────────────────────────────────────────────────────── 2. шифры штампов + +CIPHER_RE = re.compile(r"([А-ЯЁA-Z][А-ЯЁA-Z0-9\s\-/]{1,28}?)\s*-\s*ИОС\s*(\d+(?:[.,]\d+)?)") + + +def norm_cipher(raw: str, part: str) -> str: + """Приводит шифр к канонической форме: убирает пробелы вокруг дефисов.""" + s = re.sub(r"\s+", "", raw.upper().replace("Ё", "Е")) + s = re.sub(r"-{2,}", "-", s) + return f"{s}-ИОС{part.replace(',', '.')}" + + +def scan_ciphers(pages: list[dict]) -> dict: + per_page: dict[int, list[str]] = {} + counter: Counter = Counter() + for p in pages: + found = [] + for m in CIPHER_RE.finditer(p["text"]): + # В плотных штампах (А3) к шифру приклеивается имя щита: + # «ГРЩШРКП-ГП-ПИР-4-П-ИОС1.1». Обрезать «на глаз» нельзя — можно + # срезать половину настоящего шифра (ШРКП-ГП-ПИР-4 -> ПИР-4). + # Поэтому сохраняем как есть, а лишний префикс отсекаем при + # сравнении: свой объект опознаётся по ВХОЖДЕНИЮ, не по равенству. + c = norm_cipher(m.group(1), m.group(2)) + if len(c.split("-ИОС")[0]) < 2: + continue + found.append(c) + counter[c] += 1 + if found: + per_page[p["page"]] = sorted(set(found)) + return {"per_page": per_page, "counts": counter} + + +def skeleton(cipher: str) -> str: + """Скелет шифра: только буквы и цифры, без дефисов и слэшей. + + Нужен, чтобы «МП-18/1604-ИОС1.1» и «МП-181604-ИОС1.1» считались одним + шифром (потерянный разделитель), а не разными объектами. + """ + return re.sub(r"[^А-ЯЁA-Z0-9]", "", cipher.upper().replace("Ё", "Е")) + + +def object_part(cipher: str) -> str: + """Часть шифра ДО «-ИОС» — идентифицирует объект, а не подраздел.""" + return skeleton(cipher.split("-ИОС")[0]) + + +def cipher_report(scan: dict, expected: str | None) -> dict: + """Ищет страницы, где шифр относится к ДРУГОМУ объекту. + + Различать три случая: + • чужой объект — скелет префикса не совпадает -> КРИТИЧНО, вклеенный лист + • другой подраздел того же объекта (-ИОС1.2) -> нормально, это ссылка + • иное написание своего шифра (потерян «/») -> замечание по оформлению + """ + if not scan["counts"]: + return {"dominant": None, "aliens": [], "variants": [], "pages_with_cipher": 0} + + dominant = scan["counts"].most_common(1)[0][0] + if expected: + m = re.search(r"ИОС\s*([\d.,]+)", expected) + part = m.group(1) if m else "1.1" + base = expected.split("-ИОС")[0] if "-ИОС" in expected else expected + dominant = norm_cipher(base, part) + + obj = object_part(dominant) + + def same_object(c: str) -> bool: + """Свой объект: скелет совпадает ИЛИ содержит наш как хвост + (перед шифром могло приклеиться имя щита: «ГРЩ» + наш шифр).""" + sk = object_part(c) + return sk == obj or sk.endswith(obj) or obj.endswith(sk) + + aliens: list[dict] = [] + for page, ciphers in sorted(scan["per_page"].items()): + foreign = [c for c in ciphers if not same_object(c)] + if foreign: + aliens.append({"page": page, "ciphers": sorted(set(foreign))}) + + variants = sorted({c for c in scan["counts"] + if object_part(c) == obj and c != dominant}) + + return { + "dominant": dominant, + "aliens": aliens, + "variants": variants, + "pages_with_cipher": len(scan["per_page"]), + } + + +# ─────────────────────────────────────────────────────────── 3. НТД + +# Номера нормативов в PDF рвутся пробелом или переносом в любом месте: +# «ГОСТ 3214 4-2013», «ГОСТ 28249- 93», «СНиП 23 -05-95», «СП 6.13130.202 5». +# Поэтому сначала ЛОВИМ обозначение с внутренними пробелами (склеиваем, +# относящиеся к цифрам), и только потом классифицируем. Разбор по готовому +# шаблону «цифры-дефис-цифры» отрезает хвост и даёт ложные «без года». +def flatten(text: str) -> str: + """Плоский текст: переносы строк -> пробел (иначе номера рвутся).""" + return re.sub(r"\s+", " ", text) + + +NTD_LOOSE = re.compile( + r"(?P" + r"СП\s*\d[\d.\s\-]{0,24}" + r"|ГОСТ(?:\s+Р)?(?:\s+(?:IEC|МЭК|ИСО|ISO))?\s*\d[\d.\s\-]{0,24}" + r"|СНиП\s*\d[\d.\s\-]{0,20}" + r"|СО\s*\d[\d.\s\-]{0,20}" + r"|ТР\s*ЕАЭС\s*\d{3}\s*/\s*\d{3,4}" + r"|№\s*\d{2,4}\s*-\s*Ф[З3]" + r")" +) + + +def tidy_ntd(raw: str) -> str: + """Склеивает обозначение, разорванное ПРОБЕЛАМИ. + + Трогаем ТОЛЬКО пробелы. Дефисы и точки — часть номера, их удалять нельзя + (иначе «ГОСТ 10434-82» превращается в «ГОСТ 1043482» и номер теряет смысл). + «ГОСТ 3214 4-2013» -> «ГОСТ 32144-2013» + «ГОСТ 28249- 93» -> «ГОСТ 28249-93» + «СНиП 23 -05-95» -> «СНиП 23-05-95» + «СО 153 -34.21.122-2003» -> «СО 153-34.21.122-2003» + """ + s = norm_space(raw) + # пробел после типа документа оставляем одним + m = re.match(r"(ГОСТ(?:\s+Р)?(?:\s+(?:IEC|МЭК|ИСО|ISO))?|СНиП|СО|СП|ТР\s*ЕАЭС)(.*)$", s) + head, tail = (m.group(1), m.group(2)) if m else ("", s) + # в хвосте убираем ВСЕ пробелы (включая вокруг «-» и «.»), но сами знаки сохраняем + tail = re.sub(r"\s+", "", tail) + # точка в конце — это знак конца предложения, а не часть номера + tail = tail.rstrip(".,;:") + return (head + " " + tail).strip() if head else tail.strip() + + +# Один документ может встречаться в разных написаниях («СП 256.1325800.2016» и +# «СП256.1325800.2016»). Для отчёта считаем их одним — иначе список пухнет +# дублями и в базу уходит два задания на один документ. +def ntd_identity(key: str) -> str: + return re.sub(r"[^А-ЯЁA-Z0-9]", "", key.upper().replace("Ё", "Е")) + + +def classify_ntd(key: str) -> str: + if key.startswith("СП"): + return "СП" + if key.startswith("ГОСТ"): + return "ГОСТ" + if key.startswith("СНиП"): + return "СНиП" + if key.startswith("СО"): + return "СО" + if key.startswith("ТР"): + return "ТР ЕАЭС" + if "ФЗ" in key or "Ф3" in key: + return "ФЗ" + return "прочее" + + +def ntd_completeness(key: str) -> str | None: + """Дефект обозначения, видимый механически.""" + if re.fullmatch(r"ГОСТ(?:\s+Р)?(?:\s+(?:IEC|МЭК|ИСО|ISO))?\s*\d[\d.\-]*", key): + if not re.search(r"-\d{2,4}$", key): + return "без года" + if re.fullmatch(r"СНиП\s*\d[\d.\-]*", key): + if not re.search(r"-\d{2,4}$", key): + return "обрезан номер" + if re.fullmatch(r"СП\s*\d+(\.\d+){1,3}", key): + # СП без года в конце (СП 256.1325800.2016 — год есть) + if not re.search(r"\.\d{4}$", key): + return "без года" + return None + + +def scan_ntd(pages: list[dict]) -> dict: + """Собирает обозначения НТД; ПУЭ разбирается отдельным проходом.""" + items: dict[str, dict] = {} + + for p in pages: + text = flatten(p["text"]) + + for m in NTD_LOOSE.finditer(text): + key = tidy_ntd(m.group("html")) + if len(key) < 4: + continue + ident = ntd_identity(key) + rec = items.setdefault(ident, { + "kind": classify_ntd(key), "raw": key, "pages": set(), "ctx": [], + "spellings": set()}) + rec["spellings"].add(key) + # канонизируем: чем больше в написании пробелов, тем привычнее для чтения + if key.count(" ") > rec["raw"].count(" "): + rec["raw"] = key + rec["pages"].add(p["page"]) + if len(rec["ctx"]) < 3: + a = max(0, m.start() - 70) + b = min(len(text), m.end() + 70) + rec["ctx"].append(norm_space(text[a:b])) + + # ПУЭ упоминается без номера — собираем отдельно, чтобы не плодить мусор + if re.search(r"\bПУЭ\b", text): + rec = items.setdefault("ПУЭ", { + "kind": "ПУЭ", "raw": "ПУЭ", "pages": set(), "ctx": [], + "spellings": {"ПУЭ"}}) + rec["pages"].add(p["page"]) + if len(rec["ctx"]) < 3: + mm = re.search(r"\bПУЭ[^.;]{0,80}", text) + if mm: + rec["ctx"].append(norm_space(mm.group(0))) + + for v in items.values(): + v["pages"] = sorted(v["pages"]) + return items + + +# ─────────────────────────────────────────────────────────── 4. нумерация таблиц + +def scan_tables(pages: list[dict]) -> dict: + seen: list[tuple[int, int]] = [] + for p in pages: + for m in re.finditer(r"Таблица\s+(\d{1,2})", p["text"]): + seen.append((int(m.group(1)), p["page"])) + + numbers = [n for n, _ in seen] + uniq = sorted(set(numbers)) + gaps = [n for n in range(1, max(uniq) + 1) if n not in uniq] if uniq else [] + dupes = {n: c for n, c in Counter(numbers).items() if c > 1} + + return {"all": seen, "numbers": uniq, "gaps": gaps, "dupes": dupes} + + +# ─────────────────────────────────────────────────────────── 5-6. таблицы нагрузок + +@dataclass +class Row: + """Одна строка таблицы нагрузок. + + ``vals`` — числа ПОСЛЕ «Ру» и «Кол-во», в порядке документа. Хвост всегда + оканчивается одинаково: …, Рр, Qр, Sр, (Iр). Поэтому надёжнее брать С КОНЦА, + а не по абсолютному индексу: в вёрстке B между Кснм и cosφ есть лишние + колонки, и фиксированный индекс уезжает. + """ + table: str + n: int + name: str + qty: Decimal | None + ru: Decimal | None + vals: list[Decimal] = field(default_factory=list) + status: str = "calc" + raw: str = "" + has_current: bool = False # есть ли колонка Iр в конце + + def _tail(self, back: int) -> Decimal | None: + """Рр/Qр/Sр считаем С КОНЦА строки — там колонки всегда стоят подряд. + + Хвост строки: …, Рр, Qр, Sр, [Iр]. + back=3 -> Рр, back=2 -> Qр, back=1 -> Sр (когда Iр есть) + back=2 -> Рр, back=1 -> Qр, back=0 -> Sр (когда Iр нет) + Считать по абсолютному индексу нельзя: в вёрстке B между «Кснм» и + «cosφ» появляется лишняя колонка, и все индексы уезжают на единицу. + """ + if not self.vals: + return None + idx = len(self.vals) - back + return self.vals[idx] if 0 <= idx < len(self.vals) else None + + @property + def participates(self) -> bool: + """Строка учтена в итоге. + + В вёрстке B явных пометок «Не участ.» нет — строка участвует, если у неё + заполнены Рр/Qр/Sр. Нулевую строку (зарядные с Кс=0) тоже считаем + участвующей: она входит в итог нулём, и исключение искажает сумму. + """ + # В вёрстке B пожарная таблица — отдельный блок со своим итогом, + # поэтому её строки УЧАСТВУЮТ в сумме своей таблицы (ключ содержит «пожар»). + if self.status == "excluded" or self.status == "other_season": + return False + return len(self.vals) >= 5 + + @property + def rr(self) -> Decimal | None: + return self._tail(4 if self.has_current else 3) # Рр + + @property + def qr(self) -> Decimal | None: + return self._tail(3 if self.has_current else 2) # Qр + + @property + def sr(self) -> Decimal | None: + return self._tail(2 if self.has_current else 1) # Sр + + + +# ── Два стандарта вёрстки таблиц нагрузок ──────────────────────────────────── +# Стандарт A (МП-18/1604): колонки «№ | Наименование | Кол-во | Ру | Кс | ...», +# таблица одна на режим, итоги «Итого» / «Итого ВРУ с КРМ». +# Стандарт B (ШРКП): колонки «№ | Нагрузка | Ру | Кол-во | ...», строки разбиты +# на ВВОДА, итоги «Итого по вводу N» / «Итого по вводу N с КРМ», плюс отдельный +# блок «(Пожар. реж)» на каждый ввод. +# +# Определяем порядок колонок по заголовку таблицы, а не угадываем. + +LAYOUT_A = "qty_ru" # сначала Кол-во, потом Ру +LAYOUT_B = "ru_qty" # сначала Ру, потом Кол-во + + +def detect_layout(text: str) -> str: + """По заголовку колонок определяет, что идёт первым — Ру или Кол-во.""" + for line in text.splitlines(): + flat = re.sub(r"\s+", " ", line) + if "Нагрузка" in flat and ("Ру" in flat or "Ру," in flat) and "Кол" in flat: + i_ru = flat.find("Ру") + i_kol = flat.find("Кол") + return LAYOUT_B if i_ru < i_kol else LAYOUT_A + return LAYOUT_A # по умолчанию — как в МП-18/1604 + + +MODE_HEAD_RE = re.compile(r"Расчет нагрузок в режиме\s+(\w+)", re.I) +# «ВВОД 1», «ВВО Д 1», «ВВОД1» — пробел рвёт и само слово +INPUT_HEAD_RE = re.compile(r"^В\s*В\s*О\s*Д?\s*(\d)\s*:?\s*$", re.I) +FIRE_MARK_RE = re.compile(r"\(Пожар\.?\s*реж", re.I) +RE_INPUT_TOTAL = re.compile(r"Итого по вводу\s*\d", re.I) +INPUT_TOTAL_KRМ = re.compile(r"Итого по вводу\s*(\d)\s*с КРМ", re.I) +KRM_RE = re.compile(r"Компенсация РМ", re.I) + +def is_load_table_head(line: str) -> bool: + """Заголовок сезонной таблицы нагрузок. + + В PDF заголовок часто РВЁТСЯ на две строки: «Таблица 2 Итоговый расчет … + Режим» + ««Зима»». Поэтому ищем по признаку «расчёт/расчет», а сезон берём + из этой же строки ИЛИ из regex по слову Зима/Лето. + """ + low = line.lower() + if "таблица" not in low: + return False + return "расчет" in low or "расчёт" in low + + +def head_season(line: str) -> str: + low = line.lower() + if "лето" in low: + return "лето" + if "зима" in low: + return "зима" + return "?" + + +def detect_status(line: str) -> str: + low = line.lower() + if "пожар" in low: + return "fire" + if "не участ" in low or "не учит" in low: + return "excluded" + if "зима" in low or "лето" in low: + return "other_season" + return "calc" + + +def parse_load_tables(pages: list[dict]) -> tuple[list[Row], dict[str, dict]]: + """Разбор таблиц нагрузок в ДВУХ вёрстках (см. LAYOUT_A / LAYOUT_B). + + Ключ таблицы: «<режим>|<ввод>|<пожар>», например «лето|1|норма». + """ + rows: list[Row] = [] + totals: dict[str, dict] = {} + all_text = "\n".join(p["text"] for p in pages[:40]) + layout = detect_layout(all_text) + + # Есть ли колонка Iр — смотреть ТОЛЬКО в строке-шапке таблицы нагрузок. + # Поиск по всему тому даёт ложные срабатывания (Iр встречается в тексте + # и в других таблицах) и сдвигает Рр/Qр/Sр. + has_current = False + for line in all_text.splitlines(): + flat = re.sub(r"\s+", " ", line) + if "Нагрузка" in flat and ("Ру" in flat) and "Кол" in flat: + has_current = bool(re.search(r"Iр|Iр,|Ip", flat)) + break + + current: str | None = None # ключ таблицы + pending_season = False + pending_input: str | None = None + in_fire = False + mode: str | None = None + + def make_key(season: str, inp: str, fire: bool) -> str: + return f"{season}|{inp}|{'пожар' if fire else 'норма'}" + + for p in pages: + for raw_line in p["text"].splitlines(): + line = norm_space(raw_line) + if not line: + continue + + # сезон уехал на следующую строку после заголовка + if pending_season: + seas = head_season(line) + if seas != "?": + mode = seas + current = make_key(seas, pending_input or "—", in_fire) + totals.setdefault(current, {}) + pending_season = False + continue + + m_mode = MODE_HEAD_RE.search(line) + if m_mode: + mode = m_mode.group(1).lower() + in_fire = False + pending_input = None + current = make_key(mode, pending_input or "—", False) + totals.setdefault(current, {}) + continue + + if is_load_table_head(line): + if head_season(line) != "?": + mode = head_season(line) + current = make_key(mode, pending_input or "—", False) + totals.setdefault(current, {}) + else: + pending_season = True + continue + + # блок «(Пожар. реж)» — свой набор строк на том же вводе + if FIRE_MARK_RE.search(line): + in_fire = True + if mode and pending_input: + current = make_key(mode, pending_input, True) + totals.setdefault(current, {}) + continue + + m_inp = INPUT_HEAD_RE.match(line) + if m_inp: + pending_input = m_inp.group(1) + in_fire = False + if mode: + current = make_key(mode, pending_input, False) + totals.setdefault(current, {}) + continue + + # «- Итого по вводу 1: ...» и «... с КРМ» + if RE_INPUT_TOTAL.search(line) or KRM_RE.search(line): + if current: + # «Итого по вводу 1: 0,93 0,39 446,9 174,2 479,6 728,7» + # = [№ввода] cosφ tgφ Рр Qр Sр Iр + # Отбрасываем номер ввода; далее ТРИ подряд: Рр, Qр, Sр. + # Брать хвост нельзя — Iр сдвигает всё на один. + tail = line.split(":", 1)[1] if ":" in line else line + nums = [Decimal(x.replace(",", ".")) for x in re.findall(NUM, tail)] + slot = "vru" if "КРМ" in line else "itogo" + if len(nums) >= 5: + totals[current][slot] = { + "rr": nums[2], "qr": nums[3], "sr": nums[4], + "page": p["page"], + } + continue + + # сводка по авар. режиму — не итог одного ввода + if re.match(r"^Итого по авар", line, re.I): + continue + + # старая вёрстка: «Итого ...» / «Итого ВРУ c КРМ» + m_vru = re.search(r"Итого\s+ВРУ[^\n]*", line, re.I) + if m_vru and current: + nums = [Decimal(x.replace(",", ".")) for x in re.findall(NUM, m_vru.group(0))] + if len(nums) >= 5: + totals[current]["vru"] = { + "ru": nums[0], "rr": nums[-3], "qr": nums[-2], "sr": nums[-1], + "page": p["page"]} + continue + m_itog = re.match(r"^Итого\s+(.+)$", line) + if m_itog and current: + nums = [Decimal(x.replace(",", ".")) for x in re.findall(NUM, m_itog.group(1))] + if len(nums) >= 3: + totals[current]["itogo"] = { + "ru": nums[0], "rr": nums[-3], "qr": nums[-2], "sr": nums[-1], + "page": p["page"]} + continue + + # «ИТ О ГО :» / «ИТОГО:» — это промежуточный итог внутри таблицы + # (например по квартирам), а не строка нагрузки. Иначе он попадает + # в разбор как нагрузка с Ру=2219 кВт и ломает сверку. + if re.match(r"^И\s*Т\s*О\s*Г\s*О", line, re.I): + if current: + tail = line.split(":", 1)[1] if ":" in line else line + nums = [Decimal(x.replace(",", ".")) for x in re.findall(NUM, tail)] + if len(nums) >= 6: + # «ИТ О ГО : 2219 180 0,144 0,144 1 0,95 0,329 319,5 105 336,4 509,6» + # [Ру, Кол-во, ... , Рр, Qр, Sр, Iр] — берём хвост + sub = totals[current].setdefault("subtotals", []) + sub.append({ + "ru": nums[0], "rr": nums[-4], "qr": nums[-3], + "sr": nums[-2], "page": p["page"]}) + continue + + # вложенные строки подвидов («1-комнатные 73 11») — не отдельные нагрузки + if re.match(r"^\d+-\s*комнатн", line, re.I): + continue + + m_row = re.match(rf"^(\d{{1,2}})\s+(\S.*)$", line) + if not m_row or current is None: + continue + if "Изм." in line or "Кол.уч" in line: + continue + + n = int(m_row.group(1)) + rest = m_row.group(2) + name = re.split(rf"\s{NUM}\s", " " + rest, maxsplit=1)[0].strip() + + cleaned = re.sub(r"\d+\s*кВт", "", rest) + nums = [Decimal(x.replace(",", ".")) for x in re.findall(NUM, cleaned)] + if len(nums) < 2: + continue + + if layout == LAYOUT_B: + ru, qty = nums[0], nums[1] # Ру, Кол-во + else: + qty, ru = nums[0], nums[1] # Кол-во, Ру + if not (Decimal(0) < ru < Decimal(20000)): + continue + + if layout == LAYOUT_B: + # статус задаётся блоком (норма / «(Пожар. реж)»), а НЕ словом + # в названии: «Лифт пожарных подразделений» — это нагрузка + # нормального режима, хотя слово «пожар» в нём есть. + status = "fire" if in_fire else "calc" + else: + status = detect_status(rest) + + row = Row(table=current, n=n, name=name[:60], qty=qty, ru=ru, + vals=nums[2:], status=status, raw=line[:200], + has_current=has_current) + rows.append(row) + + return rows, totals + + +def rows_by_table(rows: list[Row]) -> dict[str, list[Row]]: + out: dict[str, list[Row]] = defaultdict(list) + for r in rows: + out[r.table].append(r) + return dict(out) + + +def season_pair(tables: dict[str, list[Row]]) -> tuple[str, str] | None: + summer = [t for t in tables if "лето" in t.lower()] + winter = [t for t in tables if "зима" in t.lower()] + if summer and winter: + return summer[0], winter[0] + return None + + +def dropped_rows(tables: dict[str, list[Row]]) -> tuple[list[dict], list[dict]]: + """Строки, выпавшие из баланса. Возвращает (настоящие потери, пожарный резерв). + + Различать два случая, иначе вывод будет ложным: + • «пожар» в ОБОИХ режимах — это НОРМА. Нагрузки СПЗ (клапаны ДУ, + противодымная вентиляция) считаются отдельным пожарным режимом и в + нормальном балансе не участвуют. В замечания не идёт. + • «свой сезон» в одной таблице и «пожар»/«исключено» в другой — нагрузка + не учтена НИГДЕ. Это и есть выпавшая нагрузка. + """ + pair = season_pair(tables) + if not pair: + return [], [] + a, b = pair + by_a = {r.n: r for r in tables[a]} + by_b = {r.n: r for r in tables[b]} + + lost: list[dict] = [] + fire_ok: list[dict] = [] + for n in sorted(set(by_a) | set(by_b)): + ra, rb = by_a.get(n), by_b.get(n) + if not ra or not rb: + continue + if ra.participates or rb.participates: + continue + + item = { + "n": n, + "name": (ra.name or rb.name), + "ru": ra.ru if ra.ru is not None else rb.ru, + "status_a": ra.status, "status_b": rb.status, + "table_a": a, "table_b": b, + } + if ra.status == "fire" and rb.status == "fire": + fire_ok.append(item) + else: + lost.append(item) + return lost, fire_ok + + +def arithmetic(tables: dict[str, list[Row]], totals: dict[str, dict]) -> list[dict]: + findings = [] + for tag, rows in tables.items(): + if not rows: + continue + act = [r for r in rows if r.participates] + sum_ru_all = sum((r.ru for r in rows if r.ru is not None), Decimal(0)) + sum_ru_act = sum((r.ru for r in act if r.ru is not None), Decimal(0)) + sum_rr = sum((r.rr for r in act if r.rr is not None), Decimal(0)) + sum_qr = sum((r.qr for r in act if r.qr is not None), Decimal(0)) + sum_sr = sum((r.sr for r in act if r.sr is not None), Decimal(0)) + + t = totals.get(tag, {}) + itogo = t.get("itogo") + vru = t.get("vru") + + findings.append({ + "table": tag, + "rows": len(rows), + "participating": len(act), + "sum_ru_all": sum_ru_all, + "sum_ru_act": sum_ru_act, + "sum_rr": sum_rr, + "sum_qr": sum_qr, + "sum_sr": sum_sr, + "doc_itogo": itogo, + "doc_vru": vru, + }) + return findings + + +def sqrt_checks(totals: dict[str, dict]) -> list[dict]: + out = [] + for tag, t in totals.items(): + v = t.get("vru") + if not v: + continue + p, q, s = float(v["rr"]), float(v["qr"]), float(v["sr"]) + calc = math.sqrt(p * p + q * q) + diff = abs(calc - s) + cos = p / s if s else None + tg = q / p if p else None + out.append({ + "table": tag, "p": p, "q": q, "s_doc": s, "s_calc": round(calc, 2), + "diff": round(diff, 2), "ok": diff < 0.6, + "cos": round(cos, 4) if cos else None, + "tg": round(tg, 4) if tg else None, + }) + return out + + +# ─────────────────────────────────────────────────────────── отчёт + +def fmt(d) -> str: + if d is None: + return "—" + if isinstance(d, Decimal): + s = f"{d:.2f}".rstrip("0").rstrip(".") + return s.replace(".", ",") + return str(d) + + +def build_report(pdf: Path, vmap: dict, ciph: dict, ntd: dict, tabs: dict, + rows: list[Row], totals: dict, tables: dict[str, list[Row]], + dropped: list[dict], fire_ok: list[dict], + arith: list[dict], sqrts: list[dict], outdir: Path) -> str: + L: list[str] = [] + A = L.append + + A(f"# Механическая проверка тома: {pdf.name}\n") + A("Выполнено кодом (без модели). Статусы НТД и графика — НЕ проверялись.\n") + + A("## 1. Карта тома\n") + A(f"- Страниц: **{vmap['pages']}**, извлечено символов: **{vmap['chars']}**") + if vmap["no_text_layer"]: + A("- ⚠ **текстового слоя нет больше чем на половине страниц — нужен OCR, дальше проверка невозможна**") + if vmap["empty"]: + A(f"- страниц без текста ({len(vmap['empty'])}): {', '.join(map(str, vmap['empty'][:30]))}") + if vmap["thin"]: + A(f"- страниц с разреженным текстом ({len(vmap['thin'])}): {', '.join(map(str, vmap['thin'][:30]))}") + if vmap["graphic_from"]: + A(f"- предположительно графика начинается со стр. **{vmap['graphic_from']}** " + f"(дальше текстовый слой разрежен — схемы, а не текст)") + A("") + + A("## 2. Шифры основных надписей\n") + if ciph["dominant"]: + n_pages = ciph.get("pages_with_cipher") or 0 + A(f"Доминирующий шифр: **{ciph['dominant']}** (найден на {n_pages} стр.)\n") + if ciph["aliens"]: + A("### ⚠ Страницы с ЧУЖИМ шифром — вклеенные листы другого объекта\n") + A("| стр. | шифр на листе |") + A("|---|---|") + for a in ciph["aliens"]: + A(f"| {a['page']} | {', '.join(a['ciphers'])} |") + A("") + A("Проверить визуально: совпадает ли объект, адрес и ГИП в штампе.\n") + else: + A("Чужих шифров не найдено.\n") + if ciph["variants"]: + A(f"Варианты написания своего шифра (нормализуются в один): {', '.join(ciph['variants'])}\n") + glued = [v for v in ciph["variants"] if not any(ch.isdigit() for ch in v.split("-ИОС")[0][-4:]) is False] + prefixed = [v for v in ciph["variants"] + if len(object_part(v)) > len(object_part(ciph["dominant"] or ""))] + if prefixed: + A(f"**К шифру приклеено имя щита** ({len(prefixed)} вариантов) — в плотных штампах А3 " + f"обозначение идёт вплотную к названию панели, например `{prefixed[0]}`. " + f"Это не чужой объект, но для ГЧ стоит проверить, что шифр в штампе читается однозначно.\n") + + A("## 3. Нумерация таблиц\n") + if tabs["numbers"]: + A(f"Найденные номера: {tabs['numbers']}") + if tabs["gaps"]: + A(f"- ⚠ **пропущены номера: {tabs['gaps']}** — проверить, не выпал ли раздел") + if tabs["dupes"]: + det = ", ".join(f"№{n} — {c} раза" for n, c in sorted(tabs["dupes"].items())) + A(f"- ⚠ **номера повторяются: {det}** — ссылки вида «в таблицах N и M» станут неоднозначными") + else: + A("Таблиц не найдено.") + A("") + + A("## 4. Таблицы нагрузок: арифметика\n") + if arith: + A("| таблица | строк | участвуют | ΣРу всех | ΣРу участн. | ΣРр | ΣQр | ΣSр |") + A("|---|---|---|---|---|---|---|---|") + for a in arith: + A(f"| {a['table']} | {a['rows']} | {a['participating']} | {fmt(a['sum_ru_all'])} | " + f"{fmt(a['sum_ru_act'])} | {fmt(a['sum_rr'])} | {fmt(a['sum_qr'])} | {fmt(a['sum_sr'])} |") + A("") + A("**Сверка с «Итого» в документе:**\n") + for a in arith: + it, vr = a["doc_itogo"], a["doc_vru"] + if it: + A(f"- {a['table']} «Итого»: Ру={fmt(it.get('ru'))} Рр={fmt(it['rr'])} " + f"Qр={fmt(it['qr'])} Sр={fmt(it['sr'])} (стр. {it['page']})") + if vr: + A(f"- {a['table']} «Итого с КРМ»: Ру={fmt(vr.get('ru'))} Рр={fmt(vr['rr'])} " + f"Qр={fmt(vr['qr'])} Sр={fmt(vr['sr'])} (стр. {vr['page']})") + A("") + A("### Разбор расхождений: сумма строк против «Итого»\n") + A("Сумма строк в таблице почти никогда не равна итогу: в итог входят строки,") + A("вынесенные отдельным подытогом (квартиры), и не входят исключённые. Ниже —") + A("разложение остатка, чтобы было видно, объясним он или это ошибка.\n") + for a in arith: + tag = a["table"] + it = a["doc_itogo"] or a["doc_vru"] + if not it: + continue + delta = a["sum_rr"] - it["rr"] + subs = totals.get(tag, {}).get("subtotals") or [] + sub_sum = sum((s["rr"] for s in subs), Decimal(0)) + + # Квартиры обычно выносятся отдельной строкой и в «Итого» не входят. + # Их Рр вычитаем отдельно, иначе разница выглядит ошибкой. + apt = [r for r in tables.get(tag, []) + if re.search(r"квартир", r.name, re.I) and r.rr] + apt_sum = sum((r.rr for r in apt), Decimal(0)) + + # Подытог («ИТ О ГО») добавляет вынесенное в сумму -> знак «+». + # Квартиры в вёрстке A сидят ОТДЕЛЬНОЙ строкой и уже входят в Σ, + # а в итог нет -> их надо вычесть, знак «-». + # Коррекцию применяем ТОЛЬКО если она реально объясняет разницу: + # иначе «квартиры» из другой части таблицы дают ложный остаток. + if subs and abs(delta + sub_sum) < abs(delta): + rest = delta + sub_sum + used = "подытог" + elif apt_sum and abs(delta - apt_sum) < abs(delta) - 1: + rest = delta - apt_sum + used = "квартиры" + else: + rest = delta + used = None + line = f"- **{tag}**: Σ строк = {fmt(a['sum_rr'])}; итог = {fmt(it['rr'])}; разница = {fmt(delta)}" + if used == "подытог": + line += f"; подытоги = {fmt(sub_sum)}" + if used == "квартиры": + line += f"; квартиры (отдельной строкой) = {fmt(apt_sum)}" + if used: + line += f"; остаток = **{fmt(rest)}**" + if abs(rest) < 2: + line += " — сходится" + A(line) + A("") + A("> Остаток, близкий к нулю, — баланс верный. Остаток в десятки кВт — либо") + A("> пропущенная строка, либо расхождение в коэффициентах: смотреть построчно.") + A("> В вёрстке с вводами («Итого по вводу N») итог считается по вводу целиком,\n" + "> поэтому сверять нужно суммы строк, накопленные ДО строки итога.\n") + else: + A("Таблицы нагрузок не распознаны (проверить формат извлечения).") + A("") + + A("## 5. Строки, выпавшие из ОБОИХ сезонных режимов\n") + if dropped: + A("| № | нагрузка | Ру, кВт | статус в одной таблице | статус в другой |") + A("|---|---|---|---|---|") + for d in dropped: + A(f"| {d['n']} | {d['name'][:45]} | {fmt(d['ru'])} | {d['status_a']} | {d['status_b']} |") + A("") + total = sum((d["ru"] for d in dropped if d["ru"] is not None), Decimal(0)) + A(f"Суммарная Ру выпавших строк: **{fmt(total)} кВт**.") + A("") + A("Это критическая проверка: строка, помеченная «зима» в летней таблице и «пожар» " + "в зимней, не учтена НИГДЕ. Сравнить с запасом до разрешённой мощности по ТУ.\n") + else: + A("Выпавших строк не найдено (или таблицы не распознаны).\n") + + if fire_ok: + A("### Пожарный режим — это не потеря\n") + A("Эти строки исключены в обоих режимах как «Режим Пожар». Для нагрузок СПЗ " + "это **норма**: они считаются отдельным пожарным режимом.\n") + A("| № | нагрузка | Ру, кВт |") + A("|---|---|---|") + for d in fire_ok: + A(f"| {d['n']} | {d['name'][:45]} | {fmt(d['ru'])} |") + A("") + + A("## 6. Внутренние проверки (Sр, cos φ, tg φ)\n") + if sqrts: + A("| таблица | Рр | Qр | Sр док. | Sр расч. | Δ | статус | cos φ | tg φ |") + A("|---|---|---|---|---|---|---|---|---|") + for s in sqrts: + A(f"| {s['table']} | {s['p']} | {s['q']} | {s['s_doc']} | {s['s_calc']} | " + f"{s['diff']} | {'OK' if s['ok'] else 'РАСХОЖДЕНИЕ'} | {s['cos']} | {s['tg']} |") + A("") + A("> tg φ сравнивать с требованием ТУ (обычно не выше 0,36) и cos φ с 0,94.") + else: + A("Итогов ВРУ с КРМ не найдено.") + A("") + + A("## 7. Что НЕ проверено этим прогоном\n") + A("- **Статусы и замены НТД** — требуют базы (ГОСТ Ассистент / Техэксперт), см. `ntd_task.md`") + A("- **Графическая часть** — однолинейки, спецификации, селективность защит") + A("- **Привязка номеров пунктов к тексту норм** — только чтение первоисточника по номеру строки") + A("- **Полнота текстовой части** по п.87 ПП РФ №87 — смысловая проверка, не механическая") + A("") + + return "\n".join(L) + + +def build_ntd_task(ntd: dict) -> str: + L: list[str] = [] + A = L.append + A("# Задание для проверки НТД\n") + A("Проверять ТОЛЬКО по базе (ГОСТ Ассистент / Техэксперт / Кодекс). " + "Модель статусы не знает и выдумывает.\n") + A("Для каждой позиции: найти документ → взять статус и дату → если статус " + "«не действующий»/«прекращено», найти замену → для ссылок в тексте дополнительно " + "прочитать текст указанного пункта и убедиться, что он О ТОМ.\n") + A(f"Всего уникальных обозначений: **{len(ntd)}**\n") + A("| обозначение | тип | страницы | дефект обозначения |") + A("|---|---|---|---|") + for key, rec in sorted(ntd.items(), key=lambda kv: (kv[1]["kind"], kv[1]["raw"])): + name = rec["raw"] + pages = ", ".join(map(str, rec["pages"][:12])) + if len(rec["pages"]) > 12: + pages += f" … +{len(rec['pages']) - 12}" + defect = ntd_completeness(name) or "" + A(f"| {name} | {rec['kind']} | {pages} | {defect} |") + + flagged = [(rec["raw"], ntd_completeness(rec["raw"])) + for rec in ntd.values() if ntd_completeness(rec["raw"])] + if flagged: + A("") + A(f"**Механически найденные дефекты обозначений: {len(flagged)}**\n") + A("| обозначение | дефект |") + A("|---|---|") + for k, d in sorted(flagged): + A(f"| {k} | {d} |") + A("") + A("## Контекст употребления\n") + for key, rec in sorted(ntd.items(), key=lambda kv: kv[1]["kind"]): + A(f"### {rec['raw']}") + for c in rec["ctx"][:2]: + A(f"> …{c}…") + A("") + return "\n".join(L) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Механическая проверка тома ЭОМ/ИОС1.1") + ap.add_argument("pdf", type=Path) + ap.add_argument("-o", "--out", type=Path, default=None) + ap.add_argument("--cipher", default=None, help="ожидаемый шифр комплекта, напр. МП-18/1604-ИОС1.1") + args = ap.parse_args() + + if not args.pdf.is_file(): + sys.exit(f"файл не найден: {args.pdf}") + + outdir = args.out or args.pdf.with_name(args.pdf.stem + "_check") + outdir.mkdir(parents=True, exist_ok=True) + + print(f"[1/6] извлечение: {args.pdf.name}") + pages = extract_pages(args.pdf) + (outdir / "pages.json").write_text( + json.dumps(pages, ensure_ascii=False), encoding="utf-8") + + print("[2/6] карта тома") + vmap = volume_map(pages) + + print("[3/6] шифры штампов") + ciph = cipher_report(scan_ciphers(pages), args.cipher) + + print("[4/6] обозначения НТД") + ntd = scan_ntd(pages) + + print("[5/6] нумерация таблиц и разбор нагрузок") + tabs = scan_tables(pages) + rows, totals = parse_load_tables(pages) + tables = rows_by_table(rows) + dropped, fire_ok = dropped_rows(tables) + arith = arithmetic(tables, totals) + sqrts = sqrt_checks(totals) + + print("[6/6] отчёты") + report = build_report(args.pdf, vmap, ciph, ntd, tabs, rows, totals, tables, + dropped, fire_ok, arith, sqrts, outdir) + (outdir / "report.md").write_text(report, encoding="utf-8", newline="\n") + (outdir / "ntd_task.md").write_text(build_ntd_task(ntd), encoding="utf-8", newline="\n") + + with (outdir / "rows.csv").open("w", encoding="utf-8-sig", newline="") as f: + w = csv.writer(f, delimiter=";") + w.writerow(["таблица", "строка", "наименование", "кол", "Ру", "Рр", "Qр", "Sр", "статус", "участвует"]) + for r in rows: + w.writerow([r.table, r.n, r.name, fmt(r.qty), fmt(r.ru), fmt(r.rr), + fmt(r.qr), fmt(r.sr), r.status, "да" if r.participates else "нет"]) + + print() + print(f"готово -> {outdir}") + print(f" report.md отчёт механической проверки") + print(f" ntd_task.md задание для базы НТД ({len(ntd)} обозначений)") + print(f" rows.csv {len(rows)} строк таблиц нагрузок") + print() + print(f" страниц {vmap['pages']} | чужих шифров: {len(ciph['aliens'])} | " + f"пропуски таблиц: {tabs['gaps'] or 'нет'} | выпавших строк: {len(dropped)} | " + f"пожарный резерв: {len(fire_ok)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())