inventory-mcp
Инвентарный MCP
Читает те несколько десятков исходных таблиц Feishu из пакета решений (сейчас 31), фильтрует, дедуплицирует, нормализует, агрегирует и превращает в тринадцать инструментов для вызова агентом; еженедельно выгружает в реестр Feishu.
Ноль зависимостей: MCP работает через JSON-RPC поверх stdio, без node_modules, на другую машину достаточно поставить node.
Установка
В WorkBuddy нажмите «Список MCP → Изменить конфигурацию» или правьте ~/.workbuddy/mcp.json напрямую:
{
"mcpServers": {
"inventory": {
"command": "node",
"args": ["/path/to/inventory-mcp/server.mjs"]
}
}
}В Claude Desktop или другом MCP-клиенте то же самое, форма конфигурации одинаковая.
Тринадцать инструментов
Инструмент | Параметры (все необязательны) | Что возвращает |
| ТипАктива / Модель или Слот / Модели / Бренд / Склад / СклькоНужно / СтрокДетализации / НужнаДоступность / ОбязательноФайл | Совпавшие ключи материалов, количество по каждому, промежуточные итоги по складам (с эшелонами), итог, строка-завершение (только если задан «СклькоНужно»), при нехватке автоматически ослабляет один критерий и возвращает кандидатов вместе; эхо «что искали в этот раз» (разобранный слот, для копирования в следующий раунд) |
| ТипАктива / ПервыеСколько | Матрица «модель × склад» + итог по каждому складу |
| Модель / ТипАктива / СклькоНужно / Склад / ПосколькоНаУровень | Четыре уровня по «насколько машина может доказать»: точное / по правилам / сомнительное / близкое, плюс «способ набрать»; кандидаты, подтверждённые человеком, помечаются «подтверждено человеком» |
| Модель / Бренд / Склад / ТипАктива / КлючМатериала / МаксимумСтрок / ОбязательноФайл | Серийные номера поштучно. Больше 50 — пишет CSV и возвращает только путь, см. ниже |
| По сравнению с каким днём / ТипАктива / Склад / ПервыеСколько | Что пришло и ушло относительно недельного базиса. Сначала сообщает реальные приход/уход по SN, изменения на уровне ключей понижены, см. ниже |
| ТипАктива / Склад / Поле / НужнаЛиДетализацияПоКаждой | Из какой таблицы и какой колонки пришли эти числа. Только читает пакет решений, без сети, см. ниже |
| ТипАктива (обязательно) / СоСлотом / СКоличеством | Все стандартные написания этой категории в наличии + какие бренды и сколько у каждого (закрытый список). Когда клиент пишет нестандартно — выбирать отсюда, см. ниже |
| ТипАктива / ТолькоИзменившиеся | Какие значения знает каждая из правил фильтрации, по сколько строк, и что изменилось с прошлого раза. Это сырьё, а не вывод, см. ниже |
| НомерЗаявки / Проект / ПотребностьВКомплектующих | После удовлетворения потребности регистрирует комплектующие в реестре занятости (последний шаг замкнутого цикла). Передавать строки комплектующих из cmdb «ПроверитьЗаявку» (только память/диски/оптические модули/сетевые карты). Каждая позиция по доступности: удовлетворено → занято/в согласовании (с привязкой к реальным ключам), дефицит → закупка в пути (создать/переиспользовать виртуальный ключ ожидания закупки того же типа). По умолчанию сухой прогон — возвращает план+отчёт, реальная запись при |
| НомерЗаявки / Проект / ПотребностьВКомплектующих | После сопоставления собирает уведомление и возвращает по сегментам получателей (не отправляет по-настоящему): удовлетворено → сегмент для «Управляющему активами (владельцу реестра)», дефицит/неопределённость → сегмент для «Закупкам (контакт по закупкам)». Вы копируете и отправляете сами в соответствующий диалог Feishu — отправитель — вы, это обходит ограничения доступности бота/политики арендатора, «копировать→отправить» и есть шаг проверки. Дефицит = совпало с наличием, но не хватает; неопределённость = модель не совпала с наличием (проверьте написание), выносится отдельно. Только читает реестр, не отправляет сообщения, не требует прав на отправку. Почему не прямая отправка ботом: на практике отправка от имени пользователя блокируется политикой арендатора (230027), личные сообщения бота другому человеку требуют, чтобы тот был в области доступности приложения (230013), бот в группу — только если бот уже в группе (230002) — возвращаемый текст вы отправляете сами, всё это обходится |
| Подтверждение / ВыборПользователя | Ежедневно обновляет реестр+регистр+в пути, выдаёт чек-лист самопроверки «что должно было измениться, но не изменилось» (не просто штамп времени). Двухшаговое подтверждение (реальная запись — массовая запись в реестр/регистр): ① вызов без параметров → сухой прогон с чек-листом (реестр: добавлено/обновлено/обнулено, регистр: сколько ключей выдано, в пути: сколько осталось) + подтверждение, без записи; ② вы обязаны сначала вызвать AskUserQuestion, показать чек-лист пользователю и спросить, писать ли по-настоящему, повторный вызов с подтверждением+выбором пользователя только тогда пишет, после реальной записи — полный красный чек-лист. Через Feishu, не через корпоративную сеть; синхронизация заявок требует корпоративной сети, пропускается и помечается в чек-листе. Логика в |
| Подтверждение / ВыборПользователя / ПринятьФильтры | Запускается каждый четверг (надмножество ежедневного): перечитать исходные таблицы → сравнить с базисом прошлого четверга (недельное изменение: приход/уход, какие ключи реально обнулились → занятость повисла, прибыли ли закупки в пути) → бренды требуют дополнения → сохранить базис этой недели → записать реестр. Двухшаговое подтверждение как у ежедневного (сухой прогон с чек-листом+подтверждением → AskUserQuestion → реальная запись). Жёсткое правило: если исходная таблица не читается — базис не сохраняется, реестр не пишется. Логика в |
| Все обязательны: ТипАктива / ЧтоНужно / ЧтоСверху / Вывод / Основание / КтоРешил | Записывает принятое человеком решение о замене, чтобы в следующий раз не спрашивать. Единственный инструмент, который пишет «суждение» на диск, см. ниже |
Параметры всегда плоские, массивы содержат только строки, без вложенных объектов — дешёвые модели плохо переносят вложенные структуры.
Вопрос по нескольким категориям сразу пишется как Модели: ["оптическийМодуль:SR4","диск:960G"] — всё ещё плоский строковый массив.
Переименованный параметр отклоняется на уровне протокола, а не молча игнорируется. «Количество» у НайтиЗамену 2026-08-14 объединено в
«СклькоНужно» (то же имя, что у ПроверитьНаличие — одно и то же под двумя именами, модель рано или поздно отправит не то).
Проявление молчаливого игнорирования: модель получает на вид нормальный ответ, только блок «хватает ли» целиком пропал —
отсутствующий блок без ошибки. Поэтому старое имя возвращает -32602, заставляя переотправить с другим именем.
Как сопоставляются модели: через слот, строкового пути нет
«Модель» — это не подстрочное сопоставление. Один раз проверено на реальном инциденте: запрос OSFP112-800G-2*DR4-SM1310 подстрочным поиском дал 0,
а на складе 18 000 штук OSFP112-RHS-800G-2*DR4-SM1310 — в середине лишний сегмент RHS, вся строка не совпадает,
и модель на этом основании доложила «такого товара на складе нет». Теперь обе стороны разбираются на form/rate/std/media/wave и сравниваются по пунктам,
даже голые ключевые слова проходят (SR4 разбирается в {std:SR4, media:MM, wave:850}, точное попадание в 35 ключей).
Цепочка жёстко задана:
模型(对着 instructions 里两张常驻表:槽位词表 351 token + 品牌名单 93 token)
定资产类型(必填,机器不猜)→ 把客户的乱写法翻成槽位 → 挑品牌标准名
↓
查库存 → 校验槽位值在词表里(不在当场报错并列出合法值)
→ 逐槽位相等才算命中 → 品牌精确匹配(不是包含)
↓
命中 0,或者命中了但不够「要几根」
→ 自动放宽收益最大的那一项,把明细直接带回来(不让模型再问一轮 ≈ 8,000 token)
→ 「没查到」+ 你的槽位是什么 + 差得最少的 5 个,每条带**逐槽位对照**
(对上的和没对上的都列 —— 只列差异的话,人分不清「其余几项真的相同」
还是「其余几项压根没比」)Ослабление — не только при нуле попаданий. При 300 найденных и запросе 2 800 штук ответ — это 8 932 штуки после ослабления,
а если считать только при попаданий === 0, в этом случае не будет сказано ни слова. Критерий — «хватает ли на этом уровне СклькоНужно»,
а не «есть ли попадания» — поэтому без СклькоНужно оно не срабатывает, инструмент отвечает только «сколько есть».
Ослабление — операция чтения: показать кандидатов не значит утверждать, что они подходят. В ответе явно указано, что ослаблено и что остальные слоты совпадают по пунктам,
«вставится ли» оставлено человеку и модели (QSFP и QSFP28 — два написания одного разъёма,
SR4 многомод и LR4 одномод — несовместимы). Если после ослабления всплывает несколько разных корпусов — добавляется строка ❓ Здесь нужно спросить человека.
Разделение труда жёсткое: модель делает перевод и выбор, машина — суждения. Выход перевода ограничен словарём и проверяем на месте; «одна ли это модель у двух наборов слотов» — ни одной строки модели не передаётся: передай — и одна и та же пара моделей сегодня признана одной, завтра разными. Критерий — «есть ли ответ в перечислимом множестве»: есть (тип актива 4, моделей в категории 80, брендов 32, значений слотов 24) → дать модели выбирать, ошибку видно сразу; нет (может ли A заменить B) → считает машина.
Тип актива не угадывается. Раньше пробовались все четыре парсера категорий, побеждал тот, кто заполнил больше слотов — из 189 реальных моделей не угадано 61,
ещё 5 распознаны несколькими категориями одновременно (128GB 2Rx4 PC5-5600B распознают и оптический модуль, и память), решалось перевесом в один голос.
Теперь, если такого написания нет в базе и тип актива не указан, — ошибка с просьбой дополнить.
Но только одно свойство действительно не определяет категорию, остальные определяют (2026-08-15 разобраны все реальные написания четырёх категорий):
из 101 слот=значение 96 использованы только одной категорией, общих всего 5 значений rate —
10G / 25G / 100G / 200G / 400G, есть и у оптических модулей, и у сетевых карт. cap памяти (16/64/96/128G) и
cap дисков (от 480G) не пересекаются ни одним значением. Поэтому в instructions только эта одна подсказка (около 122 токенов,
отправляется один раз за сессию), индекс «ёмкость → тип актива» не строился — там, где индекс силён (QSFP28→оптический модуль,
3.84TB→диск), модель и так справляется, а где слаб (только 400G) — она так же застревает,
строить таблицу значит добавлять детерминизм там, где модель уже права, и не помогать там, где она не справляется, плюс ещё одна сущность, которую надо защищать от устаревания.
Две дыры на пути через слот (измерено 2026-08-23)
Спрашиваешь конкретную модель — может вернуться весь запас по типу актива, а в ответе написано «сопоставление по слотам, попаданием считается только посимвольное равенство».
Корень обеих дыр один: сравнитьОдин() обходит только основные слоты, а «слот, не заданный в потребности → continue» (не ограничивает).
Continue до конца — и получается «каждая строка совпадает по всем пунктам».
Дыра первая: в потребности нет ни одного основного слота. Достигается двумя путями:
Как происходит | Проверено на практике |
Разбор модели даёт пустой набор слотов. В базе 23 из 189 реальных моделей такие (диски 12 / сетевые карты 9 / память 2), все — заводские партийные номера: | Запрос |
Явно заданные слоты все вне core. |
|
Направление — худшее из возможных — переотчёт: при недосчёте человек переспросит, при переотчёте он сразу соглашается.
Исправление: подобратьПоСлоту сначала считает «сколько слотов реально пойдёт в сравнение» (= core ∩ потребность). При 0 вместо возврата совпадений — две ветки:
Буквальный отпечаток этого написания есть в базе → вернуть только его собственные позиции, пометить
только по букве, и явно сказать «в базе могут быть другие написания той же модели, в этот раз не искали».Буквально тоже нет → вернуть «не удалось проверить» + что делать (перевести по словарю в основные слоты и перепроверить, или вызвать
ПосмотретьКакиеЕстьМоделии выбрать из списка).
Буквальное сравнение — по ledger.буквенныйОтпечаток (верхний регистр + удаление разделителей, тот же стандарт, что записьЗанятости.нормализованнаяМодель и группа normalize),
не строгое равенство — строгое проверяли один раз, клиент написал партийный номер строчными — и всё потеряно (-21 позиция).
Без префиксного/подстрочного сопоставления: этот путь из репозитория намеренно удалён, он теряет ту же модель с «лишним сегментом в середине» (проверено: теряет 18 000 штук).
Дыра вторая: разобрана только часть основных слотов, а выдаётся как точное совпадение. Эта дыра куда распространённее первой — из 189 реальных моделей 86 покрыты частично. Неразобранные пункты в сравнении не участвуют, семантически это логично (неограниченное не фильтруется), но фраза «попаданием считается только посимвольное равенство» не изменилась ни на букву, и «действительно точное» и «ограничено одной пятой» в ответе выглядят одинаково. Проверенные коэффициенты увеличения:
Сколько основных слотов разобрано | В среднем распознаётся видов товара |
Оптический модуль 5/5 | 1.3 |
Оптический модуль 4/5 | 2.4 |
Оптический модуль 3/5 | 4.0 |
Оптический модуль 1/5 | 38 (38 из 80 видов. Та модель — |
Диск 4/4 | 1.0 |
Диск 1/4 | 6.7 |
Исправление — не отказ от ответа (когда клиент говорит 3.84T, вернуть 11 видов — правильно), а проговаривание покрытия:
подобратьПоСлоту возвращает покрытиеРазбора {участвуетВСравнении, неУчаствует, полноеПокрытие}, какОтфильтровано при неполном покрытии явно говорит
«это не точное совпадение — из 4 основных ограничен только cap, bus, form, gen в сравнении не участвовали,
поэтому среди этих 11 позиций есть товары с разными bus/form/gen. Человеку не говорите „это именно та модель“».
Попутно выяснилось, что часть того 100% полноты — фальшивая. Те 23 партийных номера раньше попадали в целую категорию, истинное значение естественно оказывалось внутри → считалось полнотой.
После исправления строка сХвостомПроекта упала до 88%, поатрибутная проверка подтвердила: все 23 упавшие — именно эти партийные номера, ни одной настоящей регрессии, поэтому базис и снизили
(см. комментарий к базису в tests/полнота.тест.mjs). Остальные строки вернулись на прежний уровень через буквенный отпечаток:
всеСтрочные/дефисНаПробел вернулись к 100%, удалитьВсеРазделители — к 96% (оставшиеся 8 позиций — настоящие пробелы, не попадавшие и до исправления).
Все три заслона вынуты из исходников и проверены, что краснеют: заслон дыры первой → проверитьНаличие.тест.mjs красный; буквальное обратно к строгому равенству → полнота.тест.mjs красный;
отрезок дыры второй → проверитьНаличие.тест.mjs красный.
Три «списка для модели», границы жёсткие
看源表 这个数从哪张表、哪一列来的 —— 答来源
看有哪些型号 这一类有哪些标准写法和品牌 —— 答清单,给模型挑
看筛选 每条规则认识哪些取值、变了什么 —— 答原材料,给模型判ПосмотретьФильтры: наружу отдаётся сырьё, а не вывод
Какие значения знает каждая из 45 правил (таблица × поле правила), по сколько строк, плюс diff с прошлым базисом.
Полный объём около 1 500 токенов, ТолькоИзменившиеся: true — около 170.
В прошлой версии здесь судила машина: «полнота попаданий упала больше чем на 20 процентных пунктов = ⚠ заметный спад». Этот порог был взят с потолка, помечен как непроверенный, а одно и то же изменение в трёх ситуациях значит совершенно разное — человек сам поменял правило фильтрации / в исходной таблице изменили колонку / действительно пришла партия товара в новом статусе, машина не различает. Проверено также: три таблицы годами держат 7% / 15% / 20% (полный реестр активов, большинство строк — «в сети», рабочие устройства), любой абсолютный порог будет ошибочно помечать их как аномалию.
Теперешнее разделение:
Кто | Что делает |
Машина | Собирает распределение значений по 45 правилам; делает diff с прошлым разом (чистый diff, без суждений); оставлен абсолютный критерий с нулём ложных срабатываний «прочитаны валидные строки, но ни одного попадания» |
Модель | К какой ситуации относится это изменение; критично ли, что какое-то значение исключено ( |
Человек | Катить ли базис — |
ПосмотретьКакиеЕстьМодели: закрытый список, выбирает модель
Все стандартные написания данной категории в наличии. Оптические модули 80 = 1 128 токенов, диски 62 = 512, память 17 = 285.
Когда клиент пишет нестандартно и модель не уверена, чему это соответствует в базе, — вызывает его: выбрать из списка точный вариант и уже по нему искать.
СоСлотом: true прикладывает к каждой позиции результат разбора (оптические модули вырастают до 4 025 токенов).
Заодно выдаются какие бренды есть в этой категории и сколько у каждого (по количеству по убыванию, только те, у кого есть товар) — в списке брендов из instructions
только «стандартное имя ← псевдоним», модель при выборе бренда не видит распределения и может выбрать тот, которого в базе вообще нет,
а потом получить 0 и не понять: «такого бренда нет» или «сам ошибся при переводе».
Оба перечисления выдёргиваются из пакета решений на лету, в server.mjs не зашиты (выдернутьПеречисления(), lib/ledger.mjs) —
то же правило, что «разбор слотов выдёргивается из Tampermonkey». Раньше каждое было скопировано 9 раз (inputSchema пяти инструментов),
добавили в пакете решений категорию активов или склад — здесь никакой реакции: модель не может отфильтровать, эта партия не находится, и без ошибки.
Если из пакета решений выдернуть не удалось — enum не даётся, а не даётся пустой enum — пустой enum означает «ничего нельзя заполнять»,
это молчаливый полный запрет; тогда параметр вырождается в свободную строку, и первый реальный вызов падает уже с настоящей причиной.
Критерий ㊳ в tests/прямой.тест.mjs напрямую грепает server.mjs — зашил одно значение — красный.
В наличии ≠ доступно: в наличии — из исходных таблиц пакета решений (в реальном времени), в занятости — сводка записей занятости из реестра,
доступно = в наличии − в занятости. Внешние обещания — по доступности. Чтение реестра проверено: 4,4 секунды, поэтому читается по требованию:
Вызов | Время (горячее) | Что отвечает |
| 2,5 с | В наличии, с фразой «это в наличии, а не доступное количество» |
| 5,1 с | Три числа: в наличии / занято / доступное количество |
| 2,5 с | Занятость не читает |
| 4,6 с | «Хватает ли» обязано вычитать занятое |
| 2,5 с | Занятость не читает (нужен перечень, а не «можно ли взять») |
| 2,5 с | Занятость не читает |
| 0 с | Читает только |
Значения по умолчанию у трёх инструментов намеренно разные: 查库存 спрашивает «сколько есть», 找替代 спрашивает «сможет ли подойти» —
последнее подразумевает «можно ли получить»; если сказано «хватает», а на деле занято, человек сходит на закупку впустую. Поэтому у 找替代 нет опции отключения.
Каждый кандидат у 找替代 одновременно даёт «в наличии» и «доступное количество», 够不够 и 小计 считаются по доступному количеству,
сортировка тоже по доступному количеству (много в наличии, но всё занято — вперёд не пускать). Разница двух чисел сама по себе и есть то, что нужно человеку.
В близкой группе отличающиеся ровно одним слотом, а в остальном полностью совпадающие помечаются отдельной фразой 只差这一项 (QSFP28-100G-SR4
против QSFP-100G-SR4 отличается только form). Человек утвердил: не считать одной моделью, но родственные классы рекомендовать —
поэтому они по-прежнему попадают только в близкую группу, никогда в точное/правило; помечается вычисленный факт «чем отличается, что остальное совпадает»,
без фразы «значит, можно заменить» — это знание об оптических модулях, а не то, что вычислимо из слотов.
Близкая группа по умолчанию даёт «разбиение по уровням», а не «первые N записей»: группировка по числу отличий, каждый уровень сообщает количество, корней, первых 2 представителей
(с полным описанием отличий и целой фразой 只差这一项). Замер: один запрос близкой группы, полные 192 записи —
отличий 1 — 18 записей/6 640 корней, отличий 2 — 16 записей/21 664 корня … отличий 5 — 57 записей/24 434 корня.
При выдаче только первых 5 после сортировки отсечённые 187 записей сводятся к одной фразе «ещё 187 спецификаций», и человек не видит, на каком порядке величины они отличаются;
после разбиения по уровням «отличий 1 — 18 записей» и «отличий 5 — 57 записей» — это два совершенно разных сигнала.
То же «число отличий» дополнительно разбивается по цене ослабления (2026-08-15): отличие в wave (1310 против 1300, можно поставить)
и отличие в std (DR4 против FR4, многомод на одномод, вообще не работает) — оба «отличий 1», смешанные в одном уровне, человеку приходится перелистывать весь уровень,
чтобы понять, какие стоит смотреть. Ключ уровня — «число отличий + самая тяжёлая цена ослабления среди них», каждый уровень несёт фразу
怎么读 (можно поставить → сначала смотри этот уровень; нельзя поставить → не предлагай, если у человека нет других оснований).
Берётся самая тяжёлая, а не средняя: если среди двух отличий хоть одно нельзя поставить, этот кандидат нельзя поставить, как бы ни было хорошо второе отличие.
Сортировка сначала по числу отличий, при равном числе — по цене от лёгкой к тяжёлой (критерии ㊳㊴㊵ + абляция 10).
Плоский список «близких кандидатов» и разбиение по уровням обязаны быть в одном порядке (критерии ㊶㊷ + абляция 11). При добавлении уровней чуть не закопали яму:
плоский список всё ещё сортировался по старому правилу (число отличий → ниже требования → корни по убыванию), при трёх кандидатах «отличий 1» всё скатывалось
к сортировке по корням по убыванию, а больше всего корней как раз могло оказаться у самого нежелательного — замер: отличие в std (нельзя поставить, 33 корня) первым,
отличие в wave (можно поставить, 11 корней) последним, а в разбиении по уровням ровно наоборот. Одна и та же партия в двух представлениях в обратном порядке:
смотрящий уровни первым делом видит самое нужное, просящий плоский список первым делом видит самое ненужное.
Теперь «самая тяжёлая цена» вошла в ключ сортировки, впереди числа корней: у товара, который не вставить, сколько бы корней ни было — бесполезно.
Отсортированный полный список не удалён (поведение усечения, сортировка внутри уровня, число отличий каждой записи — только он это проверяет; однажды вырезали —
8 критериев сразу остались без основания), нужен — явно передавай 每档几个.
Когда занятость не читается, не подменять «в наличии»: в кандидатах просто не давать поле «доступное количество» (дать «доступное количество», равное «в наличии»,
гораздо опаснее, чем не дать: оно выглядит как уже вычтенное число), 小计.按什么算的 и 够不够
оба сами сообщают, что считали по «в наличии», и несут ⚠. В tests/substitute.test.mjs абляция 4: убери этот слой — обязана покраснеть.
Когда реестр не читается, не ронять весь запрос, а сообщать ⚠ 可用量算不出来 — «никто не занял» и «не получается посчитать» — разные вещи.
Каждое возвращаемое значение несёт один и тот же блок «口径» (источник данных, время и личность чтения, общее число корней в наличии, как дополнялись бренды,
исключение брака, сверка SN, доля попаданий фильтра). Отдельного инструмента «проверить свежесть данных» не делаем — иначе модель не станет
сама его вызывать, и человек не увидит. Поля, начинающиеся с ⚠, в описании инструмента требуют от модели передавать дословно.
В блоке «口径» остаются только те поля, которые меняют этот ответ. Длительности по этапам, секунды чтения, цепочка фильтрации/дедупликации, кэш структуры,
изменения нормализации, источник критериев, исходное число строк реестра — это для отладки тем, кто пишет код; модель читает их каждый раз,
через несколько раундов это чистый шум — замер: занимают половину блока «口径», около двух десятых всего возвращаемого тела. По умолчанию не отправляются, INVENTORY_VERBOSE=1
— отправляются. Не удалены: при проблемах эти цифры — единственная зацепка для локализации. tests/scope.test.mjs держит
«ни одно из обязательных полей не урезано» — потеря одной цифры длительности никому не навредит, а потеря строки «бренд дополнен»
означает спрятать «этот бренд мы угадали», а спрятанное не выдаёт ошибок.
Первое поле возвращаемого тела — «как отвечать»
Дешёвые модели пишут результат сплошным потоком. Инструкция по формату помещается в первое поле возвращаемого тела, потому что оно читается сверху вниз; инструкция, стоящая после нескольких тысяч токенов данных, практически не действует; только в описании инструмента тоже нельзя — описание читается один раз в начале сессии, через несколько раундов отодвигается, а возвращаемое тело модель пересматривает при каждой организации ответа.
怎么答: 先出一张表:品牌 | 型号 | 库房 | 在库 | 占用中 | 可用量。
表下面用短句补这几条,一条一行:⚠ 开头的每一条原样带上、
同一型号在多个库房时按库房逐行列,不许加总成一个数、数据读取时间。
别写查询过程、别复述字段名、别加收尾总结段。Эшелон не идёт в шапку таблицы. Он нужен, чтобы решать «координировать ли эту партию между складами», а не как колонка для человека — человеку нужно «какой бренд, на каком складе, сколько». Вставка добавит каждой таблице колонку цифр, которую никто не смотрит.
Стоимость около 130 токенов/раз, взамен уходит целый абзац «я вызвал инструмент проверки запасов, получил следующие результаты…».
Он управляет только формой — какие поля обязательно передавать, по-прежнему решают соответствующие ⚠ и описание инструмента.
Эхо параметров: записать фактически использованные условия обратно
Многоходовые уточнения — самое уязвимое место модели, и ошибка не выдаёт себя: человек спросил «сколько в Миньхане 400G DR4», затем «а в Линьгане?» — модели приходится по памяти вспоминать, что она передала в прошлый раз — пропустила один слот — результат на большую часть больше, добавила лишний — на большую часть меньше; оба варианта дают на вид нормальное число, и человек не видит, что это не то, о чём он спрашивал.
查库存 теперь выводит эхо фактически использованных условий, ставя его перед данными (после нескольких тысяч токенов данных модель не прочитает):
这次查的: { 资产类型:'光模块', 槽位:'rate=400G,std=DR4,media=SM,wave=1310', 库房:'闵行' }
换条件时: 照抄「这次查的」改一项,别凭印象重写 —— 少一个槽位会多查出一大截、
多一个会少一大截,两种都不报错。Эхо — это разобранные слоты, а не дословный повтор параметров. В примере выше модель передала 型号:"400G DR4",
а стандарт DR4 автоматически вывел одномод и длину волны 1310 — фактически она спрашивала про четыре ограничения, сама не зная.
После эха она это видит и, если хочет ослабить, может точно удалить фрагмент wave=1310, а не переписывать всё целиком.
Обязательно сериализовать в строковую форму, которую принимает параметр 槽位 (k=v,k=v), нельзя возвращать объект —
объект выглядит структурнее, но модель не сможет вставить его обратно, и цикл оборвётся.
Критерий — это цикл, а не «есть ли это поле в эхе» (tests/protocol.test.mjs ㉜㉝㉞):
взять эхо как есть, перезапросить, итог обязан совпасть один в один. Если проверять только форму, запись «эхо в виде объекта» позеленеет, а функциональность сломана.
Без схемы «ID запроса»: она требует хранения состояния на стороне инструмента, эхо этого не требует.
Финальная строка: считает инструмент, модель копирует (lib/凑单.mjs)
После проверки партии моделей человеку нужна строка на каждую модель «хватает ли, откуда брать». Эта строка — основание, по которому человек идёт оформлять заказ — сказано «Шаньси 302 + Линьган 283» — человек по этому числу едет на два склада за товаром. Если модель сама складывает из деталей, ошибка сложения ничем не выдаст себя, и нехватка обнаружится только по прибытии груза. Поэтому считает машина:
一处就够 QSFPDD-400G-DR4:光迅·山西 满足
要凑好几处 QSFPDD-400G-DR4:光迅·山西 302 + 海光芯创·临港9号楼 283 = 585 满足
凑不够 QSFPDD-400G-DR4:全部 8 处合计 1073,缺 1727
没给「要几根」 QSFPDD-400G-DR4:光迅·山西 (附「这只是货最多的那一处,不代表够」)Три правила:
Каждое место в собранной сумме несёт своё количество. Написать
光迅+海光芯创·山西+临港9号楼 满足синтаксически верно и читается гладко, но человек не знает, сколько брать в Шаньси и сколько в Линьгане — а эта ошибка ничего не покрасит в красный.Когда не хватает, не раскладывать по местам, только итог и дефицит. Числа по N местам и так есть в «按库房», вставка их в эту строку лишь создаст впечатление «сумма этих и есть ответ».
Без «сколько нужно» нельзя писать «满足» (возвращать
够: null) — инструмент не считал, хватает ли, и «满足» в этом случае — модель делает вывод за человека.
«Минимум мест» обеспечивает жадный алгоритм, а жадный алгоритм в этой задаче и есть оптимум: взять k наибольших максимизирует сумму k слагаемых, поэтому первое достаточное k и есть минимальное k. Предпосылка — «каждое место можно взять целиком» — если когда-нибудь добавится верхняя граница вроде «со склада максимум 200 корней», это перестанет выполняться, и алгоритм придётся менять.
Смена машины / проблемы: сначала ./自检.mjs
./自检.mjs 全查一遍(会真读几张源表,约 8 秒)
./自检.mjs --快 跳过真读那步,不打网络У этой системы три вещи не лежат в этом репозитории, по одному коду не видно:
Чего не хватает | Проявление | Что делать |
| не запускается | Он идёт вместе с WorkBuddy, не ставится отдельно — установи WorkBuddy и открой его один раз. При смене расположения задай |
| не запускается | Ищется по порядку: |
права на чтение в Feishu | «таблица не читается» | Легче всего застревает и труднее всего виден — выглядит точно так же, как ошибка пути или обрыв сети. Обратись к тому, кто ведёт таблицу, за правами |
Логин настраивать не нужно: lark-cli использует ту же систему, что и WorkBuddy (identitySource: auto_detect),
установивший входит под своим аккаунтом Feishu, никаких ключей давать не нужно.
发通知 сейчас по-настоящему не отправляет (только собирает текст и возвращает, вставляешь сам), поэтому никакие права на отправку сообщений не нужны. Если в будущем нужна автоотправка ботом, замеренные пороги: отправка от имени пользователя блокируется политикой тенанта (230027); личное сообщение бота другому требует, чтобы тот человек был в доступной области приложения cli_aae5ee90f8f85cc5 (230013); отправка в группу требует, чтобы бот сначала был в группе (230002); отправка ботом требует --as bot + scope im:message (lark-cli auth login --recommend). Единственный путь с нулевым порогом — «бот шлёт в группу, где он есть, + <at user_id> @человека».
За каждой красной строкой следует «что делать», и одна сломанная вещь не блокирует следующие —
при смене машины человек хочет увидеть всё сразу, а не чинить по одному и перезапускать. Оба правила защищены критериями (tests/自检.test.mjs).
Три статические проверки (все в слоте 15 секунд)
Ошибки языкового уровня отдаём инструментам этого языка, свои регулярки не пишем. Все три установлены глобально (shellcheck / eslint через brew и npm -g),
в самом репозитории ноль JS-зависимостей — ESLint использует глобальный бинарник + один eslint.config.mjs в репозитории, без node_modules.
Инструмент | Что ловит | Что он вытащил при первом же прогоне |
| shell | только что написанные |
|
| три мёртвых участка кода; при проверке воспроизведения точно указал на |
| переименование по синтаксическому дереву (не проверка, а для правки кода) | — |
Включены только no-undef и no-unused-vars, ни одного правила стиля. Компромиссы этого репозитория (китайские идентификаторы, длинные комментарии,
встроенные тернарники) намеренны; заставить линтер управлять стилем — значит плодить шум, требующий исключений, а шум заставляет игнорировать и настоящие ошибки.
При подключении таких инструментов защищайся от трёх вещей, без любой из них он «зелёный, когда не работает»:
Не установлен — нельзя молча пропускать — иначе он «проходит» вечно.
Смотри, сколько файлов он просканировал — «0 файлов» и «0 проблем» выглядят одинаково, а первое — это отсутствие проверки. У shellcheck дополнительно смотри
SC1088: встретив незнакомый синтаксис, он прекращает разбор и всё равно выходит с ненулевым кодом, 260 строк сканирует 28, а выглядит работающим (вот почему вrun-tests.shфункции называютсяsec/run_one/teeth/chain).Признавай только код выхода, не текст вывода — первая версия матчила вывод eslint через
grep ' error ', а он печатает[Error/no-undef](заглавная, без пробела) — ни одного совпадения, и eslint выдавал ошибку, а эта проверка печатала ✓. Проверка, специально лечащая «зелёный при отказе», сама отказывала зелёной.
Массовые переименования — только через ast-grep, не sed / строковые замены. Замер (в одном и том же коде «窄读» в трёх ролях):
盲替换 注释、字符串、词义不同的地方全被换 —— 4 处里 3 处是错的
ast-grep 只换标识符那 2 处,注释和字符串一个字没动У sed \b не работает с китайским (s/\b中文\b/x/ не изменит ни одного символа, а ты думаешь, что изменил).
Есть ещё форма, которую shellcheck тоже не сообщает: когда за $var сразу идёт китайский знак препинания, bash считает эти байты
частью имени переменной ($es_code) печатает кракозябры). После переменной перед не-ASCII всегда пиши ${var}.
Третья форма — порождена самим тем переименованием. e9d5aa8 (08-17 23:28, та самая «функции переименованы в ASCII»)
при замене 牙 на teeth пропустил один вызов в параллельной цепочке (run-tests.sh:172). bash сообщает только в момент запуска
牙: command not found, код выхода не меняется, сводка по-прежнему печатает ✓ — и абляции четырёх сетевых цепочек
(сегментное чтение 4, инкремент 2, чтение только нужных таблиц 2, путь записи 2) целых 15 часов не выполнялись ни разу,
а ./run-tests.sh 全 каждый раз был полностью зелёным.
bash -nпроходит — имя команды разбирается только в момент запускаshellcheck -S warningс кодом выхода 0 — он не проверяет, определена ли функцияОбнаружили не через какой-то слой проверки, а потому что человек, просматривая вывод, увидел четыре строки
command not found, плюс «весь прогон 118 секунд, заметно короче зафиксированных 195» — число, которое не сходится
После починки перезапуск: 118 → 218 секунд, те 10 абляций, которые никогда не выполнялись, все покраснели (сами по себе они исправны, просто ни разу не исполнялись). До сих пор для этого места нет механизма, который покраснеет, есть только секунды, которые человек должен сравнивать — способ починки: в конце всего прогона считать, достаточно ли строк «есть зубы» для числа цепочек, ещё не сделано.
Путь диспетчеризации: пусть самый дешёвый слой тоже его ловит
tests/分发.test.mjs, 0 секунд, 8 критериев, без сети (идёт через 看源表, он читает только пакет решений).
Почему он отдельный: 2026-08-18, добавляя журнал вопросов-ответов, я написал в диспетчеризации 工具: name —
а name в этой области видимости вообще не существует. Последствие не ошибка, а 记这次 бросает ReferenceError,
в журнал не пишется ни строки, а запрос как обычно возвращает правильный результат — ни человек, ни модель не видят аномалии.
Поймал его tests/protocol.test.mjs (с сетью, несколько минут). А до этого
в слоте 17 секунд ни один тест не исполнял путь диспетчеризации tools/call:
资源/scope не поднимают подпроцесс, 通知 поднимает, но вызывает только tools/list.
Поэтому ошибка слоя диспетчеризации могла ждать самого медленного слоя.
Проверка воспроизведения: вернуть 工具: name → этот тест мгновенно краснеет (tools/call 超时 20 秒), после отката — зеленеет.
Он защищает именно слой диспетчеризации, а не бизнес-критерии какого-либо инструмента: работает ли tools/call, не испорчено ли возвращаемое тело,
действительно ли хук в диспетчеризации произвёл побочный эффект (если проверять только возвращаемое значение, молчаливо брошенное хук-исключение не видно),
переименованные старые параметры тут же отбиваются обратно, несуществующее имя инструмента даёт ошибку и перечень имеющихся, ping возвращает пустой result.
Про ping отдельно: это проверка живости, он не должен падать в запасной -32601 — клиент по нему судит, жив ли канал;
«метод не поддерживается» и «процесс уже умер» для клиента — одно и то же проявление, а server на самом деле в порядке.
Обобщённый урок записан в глобальные правила: каждый дешёвый путь проверки обязан реально покрывать тот код — путь, который исполняется только в самом дорогом слое, означает, что его ошибку видно дольше всех.
Список ресурсов — только последние несколько (lib/资源.mjs)
resources/list раньше перечислял 30 позиций, из них 28 — исторические экспортные снимки. Клиент тянет этот список, чтобы узнать
«что тут можно прочитать», и ответом не должны быть двадцать строк с временными метками. Теперь каждого типа максимум 3
(INVENTORY_RESOURCE_LIST_MAX настраивается), замер: 30 → 9: три статических ресурса + последние 3 экспорта + последние 3 недельных отчёта.
Дело не в боязни разросшегося списка — 清旧导出 и так оставляет в каждом каталоге только 20 копий, диском занимается кто-то другой. Дело в соотношении сигнал/шум.
Предпосылка усечения — «отсечённое ещё достаётся», два пути, оба обязательны: полный перечень в inventory://导出
(все имена файлов, размеры, пути), отдельный файл — по шаблону inventory://导出/{文件名}, имя дополняется.
resources/read никогда не ограничивался списком, любой uri читается как есть. Если когда-нибудь эти два пути уберут, усечение превратится
из «разумного снижения шума» в «нет в списке = нет такого файла» — tests/资源.test.mjs, ⑯ и ⑭, пара, защищает именно это.
Журнал вопросов-ответов: заменить оценочный набор с «я придумал» на «люди спрашивали»
lib/问答日志.mjs + ./问了什么.mjs. Каждый вызов инструмента пишет строку JSONL в
~/.cache/inventory-mcp/问答日志/2026-08.jsonl, по месяцам, хранятся только последние три месяца.
Зачем он: до 2026-08-18 не было ни одной строки журнала запросов — даже «сколько раз было 0 попаданий» неизвестно.
А tests/召回.test.mjs измеряет 189 настоящих моделей × 9 видов механических возмущений, и те 9 видов придумал я, а не люди:
96% говорит, что парсер не боится регистра и дефисов, но не говорит, что «то, о чём спрашивают люди, находится». Этот журнал — сырьё для замены оценочного набора.
./问了什么.mjs 这个月:按工具/资产类型/档位分布 + 耗时和返回体分位数
./问了什么.mjs 2026-07 指定月份
./问了什么.mjs --没答好 只列命中 0 和出错的 —— 这些才是拿去改召回的样本Три жёстких правила, у всех есть критерии в tests/问答日志.test.mjs:
Параметры пишутся как есть, без подстановки значений по умолчанию — «он не заполнил» и «он заполнил значение по умолчанию» — разные вещи; подставишь — не отличишь, пропустила ли модель поле.
Пишутся и неудачи — «0 попаданий» и «вообще не работает» — два класса проблем; смешаешь — не отличишь «этого товара действительно нет» от «цепочка сломана».
Нельзя замедлять запрос — добавление не ждётся, исключение целиком проглатывается; сломанный журнал не должен мешать человеку найти товар.
Запись сосредоточена в диспетчеризации инструментов в server.mjs, одно место покрывает тринадцать инструментов —
разнеси по инструментам, и рано или поздно новый инструмент забудут записать, а «один инструмент не записан» нигде не выдаст себя.
Заодно закрыта дыра в tests/不膨胀.test.mjs: он раньше сканировал только пять захардкоженных имён файлов,
поэтому новый модуль с mkdirSync он вообще не видел — каталог тихо рос, а критерий, охраняющий «растущее обязано иметь очистку», оставался зелёным.
После перехода на сканирование всех исходных файлов он при добавлении этого журнала тут же меня остановил
(日志目录 不在「该有的清理」名单里), пропустил только после регистрации 清旧日志.
Проверка против пропущенной настройки сама имеет список настроек с пропуском — самая ироничная форма отказа в этой системе.
Что занимает место в возвращаемом теле (замер 2026-08-18)
Один 查库存 возвращает 8840 символов; разобрав, основная масса не там, где я думал:
5487 字符 62% 明细(10 行) ← 其中 物料键 一个字段就占四成
1394 字符 16% 口径 ← 源表链接 645(答案覆盖 6 个库房)
652 字符 7% 怎么答
319 字符 4% 按库房
其余 13 个字段加起来 不到 11%Исправлено три места, стало 7480 символов (экономия 15%):
Детализация сортируется по эшелонам. До правки основная детализация ни разу не сортировалась (просто резались первые N строк), первым в глаза мог попасть
большой запас второго эшелона — а второй эшелон «требует координации (занят проектами)», первый — «свободное использование».
Первая строка — та, которую человек принимает за ответ, она обязана быть партией, которую проще всего реально получить.
Тот же порядок, что и в 按库房 (byTier), двум местам нельзя сортировать по-своему; при равном эшелоне и равном количестве — фиксированный порядок по модели, два прогона можно diff-ить.
Детализация в диалоге без 物料键. Это просто склейка 资产类型|品牌|型号|库房, а те четыре колонки и так на месте —
в диалоге это повтор самого длинного поля каждой строки. В файле, который выгружается, остаётся: он для заполнения записей о занятости,
четыре поля, склеенные вручную, склеятся с ошибкой (разделитель, пробел, регистр — всё должно совпадать один в один).
Дедупликация меток ссылок на исходные таблицы (闵行/闵行: → 闵行:). Сама ссылка не меняется —
она и так даёт только «склады, которые покрывает твой ответ», а не «все таблицы, прочитанные в этот раз».
Таблица цены ослабления: выбирать по цене, а не по объёму вылова (lib/substitute.mjs)
Когда товар не находится, инструмент «ослабляет одну позицию» и ищет ещё раз. Раньше выбор позиции был по максимальному вылову,
а максимальный вылов как раз у самой дорогой позиции — клиенту нужен 100G LR4 单模, отпускаем std
и тут же получаем 8 932 корня SR4 多模, спецификации по пунктам «совпадают», цифры красивые, а вставить — не светится.
Выбор по выгоде = рекомендация самого опасного пункта в первую очередь.
Таблица 方向 говорит, «считается ли кандидат выше требования пригодным»; эта таблица говорит, «если при ненаходе вообще не ограничивать этот пункт,
насколько велик риск» — две вещи, две таблицы:
Можно ослабить | Осторожно | Нельзя ослабить | |
Оптические модули |
|
|
|
Память |
|
|
|
Диски |
|
|
|
Сетевые карты | — |
|
|
wave отнесён к «можно ослабить» по результатам прогона, а не по здравому смыслу. Все оптические модули сгруппированы по std, посчитаны длины волн:
из 23 std только 2 соответствуют более чем одной длине волны, и оба — две записи одной и той же длины волны —
FR4 — это 1300 (597 корней)/1310 (64 корня), SR4 — 850 (15 872 корня)/840 (8 корней).
Ни один std не пересекает действительно разные оптические длины волн. Поэтому ослабление wave вылавливает различия записи, а не другой товар.
(Линия одномод↔многомод управляется media, эта клетка — «осторожно».)
Поведение: автоматически ослабляются только «можно ослабить»; «осторожно» выставляются и требуют явного подтверждения человека;
«нельзя ослабить» — каждая запись с фразой «не бери это за ответ», в конце.
Слоты без заданной цены считаются «нельзя ослабить» (запасной вариант 放宽代价是()) —
не заданная клетка не должна превращаться в «по умолчанию можно ослабить», tests/substitute.test.mjs ㉝ сверяет по пунктам с core парсера.
Отказ: единственное место, где эта система умеет говорить «нет»
До этой таблицы система не могла сказать «этого товара действительно нет»: нулевое попадание всегда объяснялось «совпадение не сошлось»,
а в подсказке было написано «нельзя говорить, что товара нет». Поэтому при запросе 100G LR4 单模, когда на складе только SR4 多模,
инструмент сообщал «есть 8 932 корня». Уметь сказать «нет» и сметь сказать «есть» — две стороны одного —
система, которая всегда говорит «есть», и в «есть» никого не убедит.
Критерий один: товар выловлен за счёт ослабления слота какого уровня. Все три уровня не выловили → по-прежнему «совпадение не сошлось»;
выловить удалось только через «нельзя ослабить» → в фразе «не найдено» прямо написано 这一次可以直说, а в 怎么答 правило
«нельзя говорить, что нет» одновременно даёт единственную лазейку. Замер:
槽位 rate=800G,std=SR8,media=SM,wave=1310
→ 「能捞到货的那几项全是不能放的(std)… 这一次可以直说「这个规格库里没有」」
(放开 std 有 18,377 根,但那是另一种货)记下决定: что человек утвердил, в следующий раз не спрашивать (lib/决定.mjs)
Это единственная часть системы, которая становится точнее от использования. До неё: клиент утвердил «QSFP28 закрываем QSFP», в следующем раунде спрашиваем с нуля — один и тот же вопрос раз в неделю, и каждый раз ответ может отличаться.
Почему не в modelAliases пакета решений. Эта таблица управляет «эти два написания — одна и та же модель товара»,
запись туда сольёт запасы обеих сторон в один ключ материала. А «A может закрыть B» не значит «A и есть B»:
QSFP-100G-SR4-MM850 может закрыть QSFP28-100G-SR4, но это две модели, два ключа, два запаса.
Смешение даст мгновенную деформацию чисел запасов, и без ошибки. Поэтому отдельный файл (决定.json, идёт вместе с кодом,
INVENTORY_DECISIONS меняется), влияет только на рекомендации, не на «сколько есть».
Четыре ограждения:
Основание и «кто утвердил» не могут быть пустыми — за это решение кому-то придётся отвечать. То же правило, что и «дополнение брендов».
Дату даёт вызывающая сторона, модуль сам не берёт — иначе нельзя прогнать дважды и проверить идемпотентность.
Для одной пары моделей хранится одна запись, проверка дубликатов не зависит от направления (человек утверждает «могут ли эти двое закрывать друг друга»), сравнение после нормализации написания (
QSFP-100Gиqsfp 100g— одна пара). При изменении решения предыдущая версия остаётся в改过: «на прошлой неделе можно, на этой нельзя» само по себе должно быть видно человеку.Кандидаты, утверждённые как «нельзя закрыть», не удаляются, только помечаются — удалишь, и человек не увидит «это мы уже решали», в следующий раз снова спросят.
Когда файл не читается (битый), 读决定 бросает сразу, не как пустую таблицу — пустая таблица означает молча обнулить утверждённые человеком решения,
а следующий запрос проявится лишь как «опять спрашивают». Но путь 挂决定 проглатывает исключение: битая таблица решений не должна ронять весь запрос.
查SN: много — в файл, не вываливать в диалог
Каждая запись исходной таблицы — один корень товара с SN; при агрегации в ключ материала эта колонка сжимается — 查SN (lib/sn.mjs)
восстанавливает её. Три вещи — всё содержимое этого инструмента:
① В снапшоте записано до нормализации — надо разворачивать обратно. Детализация SN приходит из 收SN() — той части SN\t资产类型|品牌|型号|库房,
последние три сегмента — как в исходной таблице (闵行 и 闵行库房 — это два разных значения, SAMSUNG и Samsung — тоже).
Поэтому сначала по 原始 из norm.rows (там записано ровно 品牌0|型号0) плюс таблица PLACE строится обратный индекс.
Без этого шага запрос «128G-память в Минхане» пропустит те 5 838 модулей, где в исходной таблице написано «闵行库房», а пропущенное не выдаёт ошибку — просто число меньше.
Если одно исходное написание матчится на два ключа материала — бросаем: значит, нормализация уже не функция, и считать по любому из них — гадание.
② Детализация SN идёт за возвращаемым значением load(), а не за снапшотом на диске. Снапшот пишется fire-and-forget,
когда холодное чтение только что вернулось, на диске ещё предыдущая версия; при горячем попадании перезаписи вообще нет. Если брать из результата (около 10 МБ),
SN и norm гарантированно из одного чтения, и в наличии = есть SN + нет SN сходится.
③ «Количество в наличии» и «количество с SN» всегда сообщаются раздельно. В исходной таблице колонка SN бывает пустой, числа не равны;
если слить в одно, человек начнёт использовать число SN как количество на складе. При расхождении в ответе — ⚠ есть товар без SN.
Ещё есть нераспознанные (в масштабе всего склада): SN, не матчащиеся ни на один ключ материала, в норме 0; если не 0 — значит, нормализация и приём снапшота
разошлись в выборе строк — это число нельзя глотать: проглотишь — эта партия исчезнет из всех SN-запросов.
Если больше 最多列几条 (по умолчанию 50, настраивается через INVENTORY_SN_INLINE) — в диалоге не перечисляем, а пишем
CSV с BOM, в ответе только путь и счётчики по группам ключей материала. Куда класть — два разных случая, по принципу «кто просил файл»:
человек явно попросил (一定要文件: true) → рабочий стол; инструмент сам положил, потому что слишком большой для диалога → ~/.cache/inventory-mcp/导出/.
Второе — внутреннее действие инструмента ради экономии токенов, человек его не просил, и оно не должно занимать его рабочий стол — последствия смешения проверены на практике:
за один вечер тестов на рабочем столе оказалось 20 CSV. В каждой из двух директорий хранятся последние 20 файлов, удаляются только имена, сгенерированные по собственному шаблону.
50 задано по токенам: один SN — примерно 5 токенов, 50 — около 250, весь ответ — того же порядка, что обычный 查库存
(около 1 400 токенов); дальше начинает ужиматься запас на следующие ходы, а когда человеку нужна тысяча SN,
ему нужен именно файл, а не прокрутка в диалоге. Файл пишется только при превышении лимита или явном 一定要文件 —
в обычном режиме не пишется, потому что написанное кто-то должен чистить, а чистить директорию, где при каждом запросе появляется файл, никто не будет.
Нулевое попадание отдельно оговаривается «это не значит, что товара нет на складе»: условие сравнивает запись после нормализации,
а голый 0 модель доложит как «такого товара нет».
看变动: настоящие движения считаются по SN, изменения на уровне ключей — шум
На вопрос «что изменилось» блок口径 SN对比 ответить не может — он сравнивает с «моментом, когда кто-то последний раз запускал этот
MCP», а любой запрос перезаписывает базовую линию (включая случайный запрос самого агента). Поэтому он почти
всегда показывает «совпадает». Настоящая базовая линия — 周基线/YYYY-MM-DD.tsv, которую по четвергам пишет ./周更.mjs,
看变动 читает именно её, только чтение, без записи — базовую линию не трогает.
Изменения на уровне ключей обманывают — это всё дизайнерское давление этого инструмента. Замер 08-06 → 08-13:
口径 | цифры |
уровень ключей: исчезло 30 ключей / появилось 48, в 45-м корпусе разом минус 3 573 модуля | выглядит как ЧП |
по SN: реально ушло 32 / реально пришло 57 | что реально двинулось |
просто изменилась запись | 11 336 модулей |
Разница — одна и та же партия, у которой пустой бренд дописали до CLT / 光迅 / H3C — ни один SN не сдвинулся. Если фильтровать только 45-й корпус, ещё чище:
изменилось 75 ключей, реальных движений 0 / 0. Поэтому порядок в ответе жёсткий: сначала реальные движения,
следом ⚠ не принимай смену записи за приход/расход, а «реально исчезли/появились» на уровне ключей — это то, что осталось после вычитания смены записи
(解释改名(), lib/weekly.mjs). Критерий — SN: если один и тот же SN есть с обеих сторон — значит, не двигался, как бы ни был записан ключ.
У ключа минус 5 модулей, из них 3 — просто смена записи → сообщаем «реально минус 2», не 5 и не 0.
В tests/weekly.test.mjs абляция 3 без этого слоя обязана покраснеть.
Запрос несуществующей базовой линии — ошибка со списком существующих, а не «изменений нет» — смешивать «такой базовой линии нет» и «за этот период ничего не двигалось» нельзя: человек решит, что учёт сходится.
看源表: из какой таблицы и какой колонки эти числа
Этот инструмент родился из реальной ошибки в ответе: кто-то спросил «все SN для 128G-памяти», я полез в главный реестр — в те три таблицы (детализация остатков/офлайн-реестр/записи занятости), увидел, что колонки SN нет, и ответил «SN-данных не существует» — а MCP вообще не читает реестр, он читает исходные таблицы из пакета планов, где каждая запись — это один SN. Видно только результат, не видно источника — и за доказательство принимается не то место.
Число таблиц берётся из пакета планов, не зашивать в документацию и критерии: 2026-08-14 после удаления CPU-плана стало 34 → 31,
два критерия с зашитой 34 в tests/protocol.test.mjs тут же покраснели — теперь считается на лету из пакета планов.
Читается только пакет планов (plans.json), ни одного сетевого запроса: нужно «как конфиг предписывает читать эту таблицу»,
а не «сколько в ней сейчас строк» — второе это дело 查库存.
Маппинги «имя колонки в исходной таблице ≠ наше имя поля» из map обязаны быть перечислены явно. По факту у 4 таблиц SN называется не SN:
SN: { 有: "31/31 张",
列名不一样的: [ "网卡·山西/山西广灵:叫「外部SN」",
"硬盘·山西/山西台账:叫「外部SN(必填)」",
"内存·临港9号楼/B-2项目-9号楼资产表:叫「CMDBSN」", … ] }Если сообщать просто «есть SN», человек пойдёт искать в исходной таблице несуществующую колонку. Аналогично «ячейка хранения» есть только у части таблиц, и среди этих 11 встречаются и «货架-区块», и «储位», и «箱号».
По умолчанию — сводка по полям, детализация по таблицам — только по явному запросу (замер: сводка 2 336 токенов, по таблицам 8 580).
Оба списка обрезаются — без обрезки сама сводка 4 227 токенов, абсурднее, чем детализация, которую она должна экономить;
после обрезки обязательно сообщается, сколько осталось таблиц (критерий ⑬ в tests/源表.test.mjs).
Откуда данные: два пути, переключает INVENTORY_SOURCE
direct(默认) ledger(INVENTORY_SOURCE=ledger)
31 张源表(28 张 + 临港移动7号楼 3 张) 飞书 线下台账 (340 行)
每张 1~2 次并发调用:列宽有缓存就直接只读一类 人每周从油猴导出件粘贴
冷启动约 11 秒 / 热态约 2.4 秒 2.5 秒
约 16 万根 / 360+ 个物料键 12.9 万根 / 325 个物料键
└──────────────┬──────────────┘
库房名统一 → 品牌归一 → 型号归一 → 按物料键聚合
物料键 = 资产类型 | 品牌 | 通用型号 | 位置(粗到库房)Оба пути используют одну и ту же нормализацию (normalize() из lib/ledger.mjs), разница только в источнике данных.
У прямого чтения на три шага больше: прогнать правила фильтрации build_stock по плану → дедупликация по SN (lib/dedup.py) → один SN = один модуль, агрегировать в форму реестра.
Критерий «по умолчанию direct» — он и быстрее, и полнее: горячий режим около 2 секунд против 2,5 у чтения реестра, плюс более 30 тысяч модулей,
и разницу можно объяснить — офлайн-реестр для 7-го корпуса Лингана не принят, остальное — недельное отставание плюс потери на ручных шагах.
ledger остаётся как запасной путь, не удаляем.
Здесь конкретные количества не зашиваются: исходные таблицы могут меняться по несколько раз в день (2026-08-13: 09:32 / 09:48 / 13:16), зашитое число на следующий день уже неверно, а устаревшая точная цифра вводит в заблуждение сильнее, чем её отсутствие. Нужно актуальное — запусти один раз, в блоке口径 есть.
Те 2 секунды горячего режима — параллельный запрос revision по 9 документам; если ничего не менялось — используется кэш в процессе, данные всегда локальны,
перетягиваются только при изменении. Используется revision, а не latest_modify_time — у последнего по факту задержка 2~5 секунд, сразу после правки запрос скажет «не менялось».
Пакет планов тоже часть «версии» (方案包指纹()): изменили правила фильтрации, добавили/убрали таблицу, поменяли маппинг колонок —
revision у тех 9 документов в Feishu не изменится ни у одного, а кэш должен инвалидироваться. Проверено: после удаления одного плана (минус 9 исходных таблиц)
горячий режим по-прежнему отвечал по всем исходным таблицам (тогда 34 / 162 514 модулей, сейчас в пакете планов 31). А первое, что человек делает, увидев ⚠ в колонке фильтра появилось новое значение —
это как раз править пакет планов — если его не учитывать, после правки придётся ждать, пока кто-то тронет какую-нибудь постороннюю таблицу.
Отпечаток считается по содержимому, не по mtime: копирование/синхронизация/восстановление меняют mtime, по mtime будет лишнее перечитывание на 6~7 секунд;
при недоступности — фиксированное значение, а не случайное, иначе каждый запрос будет решать «изменилось».
При каждом перечитывании выполняется ещё посимвольное сравнение по каждой записи: 160 тысяч записей в наличии сжимаются в SN → 资产类型|品牌|型号|库房 и сохраняются
(~/.cache/inventory-mcp/sn-snapshot.tsv, около 11 МБ, перезапись каждый раз), по SN вычисляется разность с предыдущей версией, результат — в блок口径:
сколько убавилось (расход), сколько прибавилось (приход), а также «SN не двигался, но запись модели изменилась» — сколько модулей. Последняя категория сообщается отдельно, потому что
по разности SN её не найти (есть с обеих сторон), но после агрегации она выглядит ровно как один приход и один расход — это самая частая причина «за две недели модели не сходятся».
Детализация SN ложится в sn-diff.json, в блок口径 только числа и первые несколько ключей материала (экономия токенов).
Чтение старого снапшота и сетевые запросы идут параллельно, запись нового снапшота не ждётся — так что критический путь не занимается; горячий режим диск вообще не трогает.
Холодный старт дополнительно использует структурный кэш на диске (~/.cache/inventory-mcp/schema.json): хранит wiki→токен документа
и для каждой таблицы — на какой колонке какой столбец; эти два почти не меняются, при попадании каждая таблица экономит один проход «сначала прочитать заголовок».
В кэше также записано «эта таблица сегментируется, длина сегмента сошлась к N строкам, в прошлый раз было столько-то строк». Просрочка кэша не приводит к неверному чтению — в теле документа есть строка заголовка,
каждый раз она проверяется на пропущенные колонки; не сходится — кэш выбрасывается и идём медленным путём (验表头(), критерии ⑮~⑱ + абляция 4 в tests/direct.test.mjs).
Длина сегмента в кэше может быть только меньше текущей стратегии, не больше (夹段长()): без зажима после уменьшения 每段格子
закэшированная таблица продолжит использовать старую большую длину сегмента, новая константа на неё никогда не подействует — и при этом чтение корректное, сохранение сходится, все проверки зелёные, просто вдвое медленнее.
Инкрементальное перечитывание: перечитываются только те документы, у которых изменился revision
Раньше любое изменение любого документа вызывало полное перечитывание всех 31 таблиц. Теперь по revision на уровне документа решается, какие документы перечитывать; для неизменившихся берутся строки, сохранённые на диске (~/.cache/inventory-mcp/rows.json, по факту 36 МБ). Критерий тот же, что и на горячем пути
(только «изменилось содержимое или нет»), просто гранулярность уточнена с «использовать, только если не менялось ничего» до «использовать, если не менялся этот документ».
全部都变了(=全量) 14.5 秒 复用 0 张
变了最大那本(6 张) 12.4 秒 复用 25 张
变了最小那本(1 张) 7.5 秒 复用 30 张
一本都没变(热态) 2.8 秒 复用 30 张Выигрыш полностью зависит от того, какой документ изменился — самый большой, с 80 тысячами строк по оптическим модулям Минхана, экономит всего 2 секунды. Из тех 7,5 секунд реально чтение таблицы — только одна, остальное: один раунд запросов revision (около 2 секунд) + десериализация десятков МБ с диска (23 секунды,
раньше оценивал в 0,30,5 — ошибся почти на порядок) + фильтрация и дедупликация 1,4 секунды.
На диск, не в память: 330 тысяч строк в памяти по факту занимают 168 МБ кучи, а этот MCP — одиночный долгоживущий процесс, поднятый WorkBuddy, держать в памяти — значит держать постоянно. Сброс на диск меняет это на «память не растёт» — и заодно покрывает сценарий, который память не покрывает: при выходе и повторном открытии WorkBuddy процесс новый, раньше был гарантированный полный холодный перечитывание, теперь по revision с диска можно за один раунд сделать инкремент.
Три случая — полная отмена и возврат к полному чтению: изменился отпечаток пакета планов (revision у тех 9 документов не изменится ни у одного, но как читать каждую таблицу — изменилось всё, «какой документ изменился» не имеет смысла), force, на диске нет или отпечаток не сходится. Если на диске не записаны tok или revision — всегда перечитывать —
«не знаю, менялось ли» и «не менялось» — разные вещи.
Если перечитывание изменившегося документа не удалось, старые строки недоступны (复用 для изменившегося документа возвращает null) — идём по явной деградации 缺表:
тихо использовать старые строки — человек получит «выглядит полным, но устаревшее» число, что гораздо серьёзнее отсутствия таблицы.
Кэш строк хранит только успешно прочитанные таблицы — сохранить пустую значит закрепить «сейчас не прочиталось» как «в этой таблице просто нет товара».
Критерий один, но это вся безопасность функции: инкрементальный результат обязан побайтово совпадать с полным по каждому ключу материала
(tests/增量.test.mjs). «Сохранение сошлось» критерием быть не может — при переиспользовании на самом деле изменившегося документа
общее число упадёт, а каждый шаг сохранения останется зелёным. Сценарий создаётся через INVENTORY_FAKE_CHANGED=<фрагмент токена>,
переменная окружения читается при каждом вызове (смысл: «в одном процессе сначала полный прогон, потом второй прогон с имитацией изменения одного документа» —
если читать один раз, второй прогон не сможет измениться, тесту придётся поднимать подпроцесс и каждый прогон бить по сети полным чтением).
Читать только таблицы запрошенного типа + дедупликация в полёте (2026-08-17)
Вопрос «хватит ли оптических модулей» раньше читал все 31 исходную таблицу. Оптические модули — только 7 таблиц, остальные 24
(сетевые карты 9 + диски 9 + память 6) не нужны ни одной строкой. Хуже того: когда модель спрашивает «хватит ли этих двух моделей», она
шлёт два 查库存 одновременно, а кэш в процессе пишется только в момент завершения — поэтому оба читают всё по полной.
Замер (до изменения):
两个 load() 并发 55.0 秒,各自读回 332,763 行 ← 各读各的,双倍 API 调用
等第一个跑完再来第三个 2.0 秒 ← 这才走缓存После изменения:
只问光模块(冷) 42.6 秒 读 7 张 / 237,109 行 / 119,090 根
再问网卡 8.7 秒 读 9 张,行缓存累计 16 张
两个硬盘并发 8.6 秒 只读一遍,两边拿到同一个结果对象
接着全量 9.8 秒 31 张里 25 张直接复用 —— 前面几次只读一类顺手把缓存捂热了
全量之后再问光模块 2.0 秒 走「全部」那份缓存,不重读Какие инструменты читают один тип — критерий «может ли ответ этого инструмента использовать строки другого типа»:
查库存 / 看分布 / 找替代 / 看有哪些型号 — только один тип;
查SN (SN может принадлежать любому типу), 看变动 (посимвольное сравнение — в масштабе всего склада), 看筛选 (нужно распределение значений по всему складу) — обязаны читать всё.
Три правила, каждое — чтобы закрыть один вид тихой ошибки:
Чтение одного типа не должно катить ни одну базовую линию. Взять SN-снапшот из чтения только 7 таблиц и перезаписать им общескладской — значит закрепить «в этот раз сетевые карты не читались»
как «сетевых карт больше нет» — при следующем полном сравнении двести с лишним тысяч модулей посчитаются «лишними», а каждый шаг сохранения останется зелёным.
Поэтому чтение одного типа только отвечает на вопрос и не несёт функций мониторинга: не пишет SN-снапшот, не катит базовую линию попаданий, не пишет файлы разности,
и не сравнивает со старой базовой линией (половина данных против полной базовой линии — 归零 просигналит по каждой непрочитанной таблице).
口径 обязан сам сообщать о себе. Без строки ⚠ в этот раз прочитаны только часть исходных таблиц 在库总根数 тихо превращается из «весь склад» в
«этот тип», а число выглядит точно так же — модель ответит на «сколько всего» на порядок меньше.
Кэш строк складывается, а не перезаписывается. Чтение одного типа приносит строки только 7 таблиц; полная перезапись сотрёт остальные 24, и при следующем вопросе про сетевые карты снова полное холодное чтение — изменение, которое должно было экономить время, замедляет другие запросы.
Кэш «всего» отвечает на любой узкий вопрос, обратное — нет (надмножество, на уровне ответа фильтрация по типу всё равно есть). Без этой асимметрии после полного запроса вопрос про оптические модули читал бы всё заново — оптимизация в самом частом сценарии становилась бы замедлением.
Два аварийных выключателя, они же точки инъекции для абляции (если что-то нельзя выключить — значит, нельзя доказать, что оно работает):
INVENTORY_NO_NARROW=1 — возврат к полному чтению, INVENTORY_NO_INFLIGHT=1 — отключить дедупликацию в полёте.
Дедупликация между планами (один и тот же SN считается и сетевой картой, и диском) идёт поперёк типов активов — при чтении одного типа пересечение между типами не видно.
По факту в текущих данных 0 записей, так что сегодня ни на какие числа не влияет; если когда-нибудь станет не нулём, полный путь по-прежнему сообщит
⚠ правила фильтрации пересекаются.
Сколько секунд ты на самом деле ждёшь (замер 2026-08-17/18)
Сначала различие, в котором я сам ошибался: все цифры «холодное чтение 41 секунда» замерены в пустой временной директории (чтобы не трогать настоящий кэш) — это сценарий первого запуска на новой машине. Твой сценарий — перезапуск WorkBuddy: процесс новый, но кэш строк на диске на месте:
新进程 + 真缓存,问光模块 3.8 秒 ← 7 张全复用,读表 0 秒
同进程再问一次 1.9 秒
空目录冷读 7 张(新机器才遇到) 41.1 秒Если разложить те 3,8 секунды, 88% сходится в одно место:
2.00 秒 问 9 本文档的 revision(一趟网络,已经全部并发了)
0.14 秒 起 python 去重 33 万条
0.08 秒 JSON.parse 那 37.6 MB 行缓存
0.03 秒 从盘上读那 37.6 MB
0.01 秒 importТе 2 секунды не сжимаются, замерено: lark-cli — подъём одного подпроцесса + один сетевой раунд — это и есть пол 12 секунды —
9 параллельных metainfo и один metas/batch_query одинаково быстры (каждый 12 секунды, как и одиночный запрос).
Значит, путь «объединить в пакетный вызов» мёртв, а у batch_query к тому же только latest_modify_time (по факту задержка 2~5 секунд) и нет revision.
Поэтому выносим его из пути ожидания пользователя: период доверия
Через 1,5 секунды после старта MCP в фоне один раз прогревается, дальше каждый час фоновая сверка; запросы используют последнюю сверенную копию, ни одного сетевого запроса.
第一次(真核) 3.7 秒
信任期内 0.0 秒
后台定期核(绕过信任期) 2.1 秒
force(导台账 / 周更) 42.0 秒 ← 不受影响,永远真核真读Цена — данные устаревают максимум на час (фото от 2026-08-17). Поэтому:
Устаревание обязано быть видимым: в блок口径 строка
数据核对于: 2026-08-18 00:00:06(3 分钟前核对的,之后源表有没有被改过这次没查). Тихо использовать часовой давности число — самая недопустимая ошибка этой системы: каждый шаг сохранения зелёный, общее число правильное, и только реальная сверка с исходной таблицей это покажет.Фоновый проход не ест собственный период доверия (
_绕过信任) — иначе он каждый раз попадает в кэш и никогда не сверяется по-настоящему, и период доверия становится вечной ложью.INVENTORY_TRUST_MS=0— возврат к «сверять при каждом запросе».
Дальше ускорение — только в обход подпроцесса lark-cli, напрямую HTTP (экономия пола в 1~2 секунды), ценой собственной реализации аутентификации Feishu и обновления токенов — сейчас всё это на lark-cli.
Почему чтение таблиц именно такой скорости (замер 2026-08-15)
Чтение таблиц уже упёрлось в потолок параллелизма, дальше быстрее — только читать меньше. Три группы чередующихся замеров:
① 单张表切几段 闵行光模块 84,952 行 × 9 列,每档三轮取中位
5 段 7.7 秒 · 17 段 3.7 秒 · 34 段 3.2 秒 · 68 段 4.2 秒(掉头)
② 元数据怎么发 串行(拿行数→再发段) 20.1 秒 · 段并发 7.8 秒 · 元数据与段同批 5.0 秒
③ 全局并发几路 31 张 33 万行:4 路 28.5 秒 · 8 路 13.0 秒 · 32 路 14.2 秒Параллелизм перекрывает «ожидание», но не «передачу»: время запроса = ожидание (задержка раунда) + передача (занятость канала).
Параллелизм складывает несколько «ожиданий», но байтов от одновременной отправки меньше не становится. Поэтому кривая сначала падает, потом выходит на плато, потом чуть задирается —
в ③ 8 потоков уже заполняют всё, 32 потока даже чуть медленнее (десятки процессов lark-cli дерутся за CPU); в ① то же самое с 68 сегментами.
INVENTORY_CONCURRENCY по умолчанию 8, 每段格子 — 45 000 (17 сегментов), оба определены именно так.
Длина сегмента — не самые быстрые 34 сегмента, это обмен 0,5 секунды на запас по частоте: число вызовов растёт с числом сегментов (5 сегментов — около 36 вызовов / 17 — около 48 / 34 — около 65), а самый узкий лимит — 100 вызовов в минуту; цена пересечения — десятки секунд.
Пять проверок, выполняемых при каждом чтении
Правильность чисел держится не на «аккуратном подсчёте», а на том, что для каждого класса ошибок есть проверка, которая покраснеет. Пять проверок по убыванию серьёзности:
проверка | от чего защищает | что будет, если не защищает |
частота попаданий фильтра | изменилась запись колонки состояния в какой-то таблице («在库»→«在库中») | эта таблица не даст ни одного попадания, несколько тысяч модулей исчезнут разом, а каждый шаг сохранения останется зелёным (приход и расход убавились одинаково) |
сбой чтения исходной таблицы | не читаются некоторые из таблиц | товар из этих складов бесследно исчезает, человек думает «там нет», а не «там неизвестно» |
брак / битый текст | модель с «брак», колонка спецификации — | брак нормализацией сливается с годным и считается доступным запасом; битый текст загрязняет ключ материала |
посимвольное сравнение SN | непонятно, «минус 106 модулей» — это расход, смена записи или ошибка чтения | еженедельная сверка — на глазок |
запись модели не похожа на этот тип(только сообщает, не блокирует) | целая партия отнесена не к тому типу актива | ошибка вопиющая, но все проверки зелёные — в тот раз, когда 926 оптических модулей посчитались дисками, общее число сходилось, каждый шаг сохранения, SN-сравнение, частота попаданий — всё в норме, потому что товар действительно на месте, просто повешен не в тот тип. Единственный способ заметить — человек глянет на список ключей материала и подумает «почему диск называется |
«Обнуление» остаётся в машине, «насколько изменилось» — модели. Эта граница перенесена 2026-08-14: раньше машина ещё решала
«частота попаданий упала больше чем на 20 процентных пунктов = явный спад» — порог был взят с потолка, помечен как непроверенный, а одно и то же изменение в трёх контекстах значит совершенно разное (человек поменял правила / в исходной таблице поменяли колонку / реально пришёл товар в новом статусе) — машина не различает, какой из трёх.
Теперь машина сообщает только {表, 命中率: "99% → 12%", 差几个点}, а аномалия это или нет — решает модель, которая это читает (看筛选).
Обнуление частоты попаданий — жёсткий сигнал с нулём ложных срабатываний: валидные строки есть, а не попало ни одной — это не может быть нормальной бизнес-логикой. Поэтому это абсолютный критерий, базовая линия не нужна — раньше было написано «сообщать, только если прошлый раз попаданий > 0», и таблица, которая никогда не попадала, молчала вечно: в первый раз нет базовой линии — не сообщаем, во второй в базовой линии тоже 0 — условие никогда не выполняется. А это ровно то, от чего детектор должен защищать. Формат сообщения:
⚠ 有源表的筛选一条都没命中:
网卡(导入) · 闵行/闵行网卡在库清单信息:读到 4575 行有效数据,但筛选一条都没命中(上次命中 2841 行)
这几乎一定是那张表的状态列写法改了,不是货清空了。去核对源表的筛选字段,别按下面的数字下结论。
**这条会一直报到修好为止** —— 有异常就不滚命中基线,不然警报会把自己吞掉。Алерт не должен пожирать сам себя. Базовая линия частоты попаданий (table-hit.json) раньше перезаписывалась при каждом чтении, и вот что выходило:
таблицу сломали → сообщили один раз → базовая линия скатилась в «попаданий 0» → больше никогда не сообщается. Теперь
该滚基线() катит базовую линию, только если этот проход чистый; при обнулении или спаде — не трогает, и аномалия сообщается, пока не починят.
Единственный способ снять — человек явно признаёт: ./周更.mjs --认下筛选 (при --dry не признаёт).
Автоматического признания нет — алерт, который может сам себя погасить, равен отсутствию алерта.
Той же болезнью болел и SN-снапшот: он перезаписывался при каждом чтении, поэтому SN对比 в блок口径 сравнивает с «моментом, когда кто-то последний раз запускал
этот MCP» и почти всегда показывает «совпадает». Решение на той половине — 看变动 читает недельную базовую линию (только чтение, без записи).
Абсолютная частота попаданий критерием не является. По факту три таблицы годами с низкими попаданиями, и правила у всех правильные:
таблица | правило | фактические значения этой колонки |
B-2临港9号楼 · 光模块(7%) |
| онлайн 52 341 · 库房 4 280 · RMA出库 1 330 · «在线,无对应关系» 853 |
JYJY临港9号楼 · 光模块(15%) | то же | онлайн 48 777 · 库房 10 575 · 调拨出库 7 910 · 待定 50 |
移动7号楼 · 内存(20%) |
| 出库 23 689 · 在库 6 031 · 故障 32 |
Это полные реестры активов, большинство строк — уже установленное и работающее оборудование. Любой абсолютный порог объявит их аномалией.
Пятая: запись модели не похожа на этот тип (类不对的键, lib/ledger.mjs)
Критерий — «другой тип распарсил своих ключевых слотов больше, чем свой», порог 2, вымерен, а не взят с потолка (2026-08-16, 374 реальных ключа + та партия исторически ошибочных ключей):
真实键 差 ≤ 0 的 372 个 · 差 1 的 2 个 · 差 ≥ 2 的 0 个
历史错键 硬盘|QSFP112-400G-DR4-SM1310 自己 1 槽位 vs 光模块 5 → 差 4
正常硬盘 硬盘|7.68T NVMe U.2 Gen4 自己 4 槽位 vs 光模块 0 → 差 -4Посередине два пустых интервала, так что у 2 есть реальный запас. Критерии ㉕㉖㉗㉘ + абляция 9, из них ㉘ специально охраняет «порог нельзя ослаблять».
Он ловит не одну причину: межплановая дедупликация отдаёт товар плану, встретившемуся первым; в пакете планов неверно отфильтрован 配件类型;
в какой-то таблице неверный маппинг колонок map; в исходной таблице кто-то записал товар не в ту категорию — четыре явления, один вид.
Первая версия критерия «свой парсер дал 0 слотов» не сработала: парсер дисков принимает 400G за ёмкость и выдаёт 1 слот,
исторически ошибочные ключи не ловились ни одним. Это ровно то, как здесь проявляется «между четырьмя типами пересекаются только те 5 значений rate» —
тот вывод достаточен по числу видов записей, но для «сравнения числа слотов между типами» его не хватает: одно пересечение — и условие сломано.
Только сообщает, не блокирует. Это эвристика, не тот же уровень, что жёсткий сигнал с нулём ложных срабатываний вроде «обнуления частоты попаданий».
Какие измерения, если ошибутся, не обнаружить
Пять проверок покрывают ту часть, где есть второй источник для сверки, а не всё. Если ошиблось одно измерение, а суммарно всё сохраняется — обнаружимость зависит от того, есть ли в исходной таблице второй угол, под которым это видно:
измерение | что будет, если ошиблось | есть ли второй источник | текущее состояние |
тип актива | целая партия отнесена не к тому типу, суммарно сохраняется | есть — запись модели позволяет обратную реконструкцию | пятая проверка |
склад | целая партия переехала на другой склад, суммарно сохраняется | нет — сегмент склада в ключе материала приходит из конфига плана ( | только «конфиг верен» |
бренд | целая партия сменила бренд, суммарно сохраняется | нет — исходная таблица одна | защита только от «сравнительные таблицы двух планов дерутся» ( |
статус в наличии | отфильтровали лишнее → товара стало больше | нет | частота попаданий ловит только «отфильтровали всё» (обнуление); отфильтровали лишнее — частота наоборот растёт, выглядит здоровее |
Последняя строка — самое важное в этой таблице: «отфильтровали лишнее» коварнее, чем «отфильтровали лишнее». Человек и так меньше настороже к росту числа, чем к падению, а единственный детектор машины смотрит ровно в противоположную сторону.
Название таблицы как второй источник — пробовали, не работает: из 31 таблицы 3 ложных срабатывания (таблица B-2项目9号楼 в алиас-таблице PLACE зарегистрирована как B-2临港9号楼, разница в два знака), 10% ложных срабатываний в виде алерта при каждом запуске превратятся просто в шум.
Этот API не расскажет вам, что произошло
У write-API многоразмерных таблиц Feishu есть устойчивый стиль: говорит «успешно», но из возвращаемого значения вы не узнаете, что произошло. За 2026-08-15~16 наступили на это пять раз, вместе это полезнее, чем разбросанные по коду комментарии:
Команда | Чего она вам не говорит |
| Возвращает |
| Не проверяет, существует ли record ID. Обновление по несуществующему ID так же возвращает |
| В теле ответа нет table_id, только |
| Только эхо-вывод тела запроса, форму не проверяет. И ошибочное, и правильное возвращают |
Запись полей-формул | Молча проглатывается через |
Поэтому «после записи обязательно перечитывать, возвращаемым значениям не верить» в этом проекте — не консерватизм, а единственно возможный подход.
Инцидент 2026-08-16 подтвердил это с обеих сторон: перечитывание один раз спасло (./导台账.mjs при повторном запуске сообщил «значения не сходятся, 0 записей»),
а в тот раз, когда упало до перечитывания, оно не спасло — поэтому теперь даже при ошибке записи нужно пройти весь путь чтения обратно (см. lib/ledger-write.mjs).
Форма тела запроса существует в одном экземпляре: 创建请求体() / 更新请求体() в lib/ledger-write.mjs,
контрактные тесты используют именно эти две функции. Раньше в тестах был вручную написанный {update_records:{id:{数量:100}}},
и он ловил «lark-cli снова поменял контракт», но не ловил «наш код собрал неправильно» —
а при реальном сбое 8-16 как раз был смежный случай (контракт поменялся, мы не успели). Два литерала существуют независимо,
кто изменил один — другой об этом не узнает. За это отвечают две абляции 写路径: подменить две производственные функции на неправильную форму,
критерий обязан покраснеть (на практике ABLATE=1 выдаёт именно тот самый 800010701 Request validation failed) —
если кто-то когда-нибудь снова вручную пропишет это в тесте, абляция перестанет краснеть, и run-tests.sh на месте сообщит «ни один критерий не покраснел».
Столкновение с rate limit: явная ошибка, сжатая до «приходится гадать»
Feishu считает скорость в минуту по API × приложение × тенант, самый узкий диапазон — 100 раз/мин, при превышении возвращает HTTP 429 + code 99991400.
Это явная ошибка, но по пути наверх она превращается просто в «этот запуск не удался» — неотличимо от «таблица не читается» или «сессия истекла».
Цена реальная: за 2026-08-16 из-за этого прогнали четыре лишних цикла регрессии (каждый по несколько минут), и каждый раз судить «это лимит или реальная поломка» приходилось через косвенные рассуждения — два раза краснело в разных местах (#74 / #55), случайное расположение похоже на проблему окружения, кодовая проблема останавливалась бы стабильно в одном месте.
Теперь все три уровня её распознают:
Где | Что делает |
| При столкновении — откат с повтором (3 сек, 8 сек), пока ждёт — пишет в stderr; если после откатов всё ещё бьётся — ставит метку |
| Сообщает раздельно: при лимите говорит «код в порядке, подождите минуту и запустите снова», при реальной недоступности — «проверьте сессию и сеть» |
| Каждый упавший — из-за лимита → код выхода 3, |
Три ключевых момента, каждый выстрадан на практике:
Откат только два раза, максимум 11 секунд — не «чем больше, тем лучше». Сначала написали [3,8,20], потом поняли, что это конфликтует с
120-секундным таймаутом вызывающей стороны — 31 таблица × три отката на каждую растянут весь цикл чтения на несколько минут, и «явный лимит» снова превращается
в «непонятный таймаут» — ровно та же проблема, которую мы и пытаемся починить.
«Не засчитано» нельзя печатать зелёным. Только если «каждый упавший — из-за лимита», возвращаем 3; если хоть один настоящий сбой — возвращаем 1 —
иначе лимит станет щитом, прикрывающим настоящее красное жёлтым. Самое важное — цикл абляций: 牙() смотрит только «ненулевой = покраснел»,
абляция, которая вообще не выполнилась, всё равно считается зубом и печатает «✓ N/N абляций покраснели».
是频控失败() намеренно написана узко. Хотели включить и «таймаут» (исторически при лимите это проявлялось как tools/call, зависший на полные
120 секунд), но тогда «сервер реально завис» тоже молча понижался бы до «этот прогон не засчитан». Цена: если лимит проявится как чистый таймаут,
не оставив ни слова, здесь его не распознают и по-прежнему сообщат красным. Лучше лишний раз красное.
INVENTORY_FAKE_RATELIMIT=N создаёт этот сценарий (первые N вызовов всегда возвращают лимит), INVENTORY_BACKOFF_MS
сжимает ожидание до миллисекунд. Точка внедрения стоит на уровне 退避重试(), а не внутри call() — если вставить внутрь,
в путь записи台账 внедрить не получится, а этот путь запускается всего раз пятьдесят в год, и при столкновении ни у кого не будет второго шанса увидеть картину на месте.
Сначала я говорил, что эту штуку «можно проверить только когда реально столкнёшься». Это было неверно — в том же репозитории
INVENTORY_FAKE_FAIL,INVENTORY_FAKE_CHANGEDуже дважды использовали этот паттерн, и причина была записана мной же в комментариях. Иметь готовое решение под рукой и не вспомнить о нём — стоит запомнить крепче, чем просто не знать.
В колонке фильтра появляется новое значение (收筛选取值 / 比取值)
То, что не ловит hit rate, — хронический класс: кто-то меняет «в наличии» на «в наличии сейчас», старые записи остаются в старой записи, число попаданий будет падать неделя за неделей — до нуля не дойдёт, до порога в 20 пунктов тоже не дотянется, а когда заметят — уже прошло несколько недель. Поэтому добавляем ещё один дискретный сигнал: берём исходные строки до фильтра, считаем для каждой таблицы множество значений каждой колонки фильтра; появилось значение, которого нет в базовой линии — сообщаем. Почти ноль ложных срабатываний, цена — 0,3 секунды на полное чтение и 7,8 КБ файла базовой линии.
⚠ 筛选列冒出了没见过的取值:
光模块 · 临港9号楼/B-2临港9号楼 · 资产状态:冒出新取值「在线,无对应关系」853 行
这批行现在没被算进任何一边。如果它其实是「在库」的另一种写法,那批货正在静默丢失;
如果是新的业务状态(借出、待检…),把它加进方案包的筛选规则再跑。Сообщаем только о появлении, не об исчезновении: какой-то статус на этой неделе никто не использовал — это нормально. Если в базовой линии нет этой таблицы и этой колонки — тоже не сообщаем,
иначе первый запуск примет каждое значение за новое. Точно так же подчиняется 该滚基线() — если сообщили, базовую линию не обновляем, сообщаем до тех пор, пока не обработают.
Ошибка чтения таблицы больше не бросается одним махом: бросаем только если не читаются вообще все исходные таблицы (это проблема сессии или сети, отдавать половинку бессмысленно).
Если упали только несколько — продолжаем, но склад, который вообще не прочитался, обязан явно занимать строку, количество — null:
{ "库房": "七宝", "根数": null, "读不到": "网卡、硬盘、内存、光模块的表都没读到 —— 这里不是 0 根,是不知道" }Если дать ему исчезнуть из массива, человек прочитает это как «там нет товара» — а это разница на порядок. При наличии упавших таблиц не пишем в кэш процесса, иначе в следующий горячий запуск эта урезанная порция будет использована как хорошая, а у тех документов revision ни на байт не изменился — и так будет использоваться вечно.
Сценарий создаётся через INVENTORY_FAKE_FAIL='подстрока названия склада/таблицы' — без точки внедрения этот путь деградации никогда не проверить.
Написание моделей: две ступени нормализации
Первая ступень — буквальная (字面指纹 + 挑标准写法): съедает только различия в регистре, пробелах, дефисах, точках.
На практике схлопнула 11 групп: CX7-400G ⟷ Cx7 400G, 128G ⟷ 128g, SFP-25G-SR-LC ⟷ SFP.25G-SR.LC и подобные.
Слот-парсер их не спасёт — парсер понимает семантику спецификаций, а не манеру набора.
Критерий выбора стандартного написания на этой ступени не должен касаться количества: больше заглавных > больше дефисов > длиннее > лексикографически — все четыре — свойства самой строки модели. В старой версии правила было «побеждает тот, у кого больше корней», а количество меняется каждую неделю — стандартное написание это третий сегмент ключа материала, как только оно меняется, значения, выбранные в выпадающем списке台账, повисают в воздухе.
Вторая ступень — слоты: разбираем слоты корпус/скорость/стандарт/среда/длина волны, объединяем только если все ключевые слоты совпадают. Критерии этой ступени в рантайме выдёргиваются из скрипта油猴, не копируются.
Четыре ямы на пути прямого чтения (все закрыты в коде)
Яма | Как закрыта |
Вся таблица оптических модулей Минхана при чтении выдаёт | У всех таблиц читаются только колонки, нужные |
Та таблица длиной 84 952 строки × 9 колонок, даже один класс не влезает в 10 МБ — самый большой кусок всего склада (более 80 тысяч корней) целиком не читается | Читаем по сегментам строк и склеиваем, если сегмент слишком большой — на месте режем пополам и пробуем снова ( |
Заголовок — это rich text: у «品牌(必填)」 в Шаньси-оптике это белый «品牌» + красный «(必填)」 два сегмента, возвращается структурой |
|
Таблицу Линьган-Чайнаюникон используют три плана — сетевые карты/жёсткие диски/оптические модули, | Дедупликация по порядку планов, количество исключённых попадает в блок口径 — если оно растёт, значит правила фильтра пора чинить |
По умолчанию читается текст формулы (в колонке «物料键»台账 340 строк сплошных | Всегда с |
У lark-cli ошибки приходят двумя путями, раньше распознавался только один (выяснено 2026-08-15): |
|
Позиция грубо до склада, ячейку не трогаем
403库房 / CK2-TEMP / 二楼小仓库 в исходных таблицах — это ячейки, 74 вида, в ключ материала не попадают никогда —
позиция берётся из region источника (闵行 / 临港9号楼 / 移动7号楼 / 移动45号楼 / 七宝 / 山西 / 临港联通). Ячейки — дело кладовщика.
Идём через нативный интерфейс values, не через обёрнутый +csv-get: последний при превышении 10 МБ молча отдаёт только часть (ok:true, данные тоже хорошие, просто есть has_more:true) — из-за этого я однажды посчитал проценты на выборке в 9,4% и думал, что покрыл всю таблицу. Нативный интерфейс при превышении сразу сообщает 90221 — молчаливый сбой превращается в явный.
Сам台账 — это человек еженедельно вставляет экспорт из油猴, не в реальном времени. «Время чтения» в блоке口径 — момент этой цифры; версия Feishu остаётся в meta.revision, код по ней судит, менялось ли что-то, в блок口径 не кладёт — для человека это строка цифр без информации.
Закупка в пути: уже пообещали, но товар ещё не пришёл (2026-08-17)
В таблице занятости появился четвёртый тип замкнутого статуса «закупка в пути». Делается так: человек вручную добавляет строку в таблицу материалов, бренд и склад оставляет пустыми (эти два сегмента определятся только по прибытии), занятость вешается на неё. Проверено на практике:
光模块||QSFPDD-400G-DR4 | 在库 0 · 占用 640 · 预计到货 2026-08-31
备注 智设合【2026】年325-004 · 工单 29640Опасно не −640 на бумаге, а момент прибытия. Товар ложится на настоящий ключ из четырёх сегментов, который выдаёт исходная таблица
(光模块|海光芯创|QSFPDD-400G-DR4-SM1310|闵行), занятость по этому ключу — 0,
и та же партия 640 корней считается дважды в двух местах: пустой ключ говорит «пообещали», настоящий ключ говорит «доступно».
Человек, обещающий по цифре настоящего ключа, — это переобещание. В台账 у 400G DR4 готовых настоящих ключей 11, суммарно 607 корней —
того же порядка, что эта закупка.
Защита от этого — проверка в обратную сторону
挂可用量 раньше проверял только в одну сторону: в исходной таблице есть, в台账 нет (该导没导 / 不进台账的).
Обратное направление «в台账 есть занятость, а в исходной таблице этого ключа нет» было полностью невидимо — а именно туда и падает закупка в пути.
Теперь добавлено 没挂上的占用, появляется в трёх местах:
Где | Почему |
| Стоит перед данными, не входит ни в блок口径, ни в отладочный файл — это не «как посчитано это число», а «если отгружать по этому числу — будет переобещание» |
| Здесь ещё опаснее: вывод напрямую станет «закупать не нужно» |
| Еженедельно нужно смотреть именно «пришла ли закупка в пути», ритм и так недельный |
Без фильтра по типу актива или модели, сообщаем всё. Таких ключей крайне мало (на практике по всему складу 1), а цена пропуска — переобещание; сама логика фильтра станет точкой, которая зелёная, когда не сработала — отфильтровал неправильно, и молча не сообщил. Несколько строк шума в обмен на ноль пропусков. Тот же характер, что и «фильтр не дал ни одного попадания»: сообщаем, пока кто-то не обработает.
Дата идёт за каждой записью. Действия человека, получившего «640 корней в пути», полностью определяются датой —
ждать прибытия или искать другой путь. Если не заполнено — прямо говорим «ожидаемая дата прибытия не указана, уточните, когда придёт»,
не выдумываем: выдуманная дата приведёт к тому, что кто-то построит график по несуществующему дню прибытия.
预计到货 в 读占用 — мягкое требование (нет колонки — просто нет даты), а 物料键 / 占用中
без них не посчитать доступное количество — жёсткое — у них не общий throw.
От человека нужно только одно действие
После прибытия — в строке записи занятости переставить «связанный материал» на настоящий ключ. После этого пустая строка становится 在库 0 / 占用 0, алерт исчезает сам, эти 640 корней корректно вычитаются на настоящем ключе.
Выпадающий список 闭环状态 MCP не читает ни одного символа — он в таблице записей занятости, а MCP читает таблицу материалов.
Меняют его для колонки 占用时长 в Feishu (🟠ждём товар / 🔴ожидание затянулось), для человека, чтобы самому смотреть.
Два места без критериев, о которых нужно знать:
Неправильный перенос не поймать. Проверяется только «перенесли ли», не «перенесли ли правильно». Перенесли на
QSFP112вместоQSFPDDили на неверный склад — ни звука — потому что после переноса на этом ключе в исходной таблице действительно есть товар.Частичное или распределённое прибытие требует разбиения строк. Одна запись занятости может указывать только на один ключ материала.
Три варианта, которые обсуждали и не сделали
Почему не сделали | |
Использовать | Нужно читать ещё одну таблицу + около 50 строк; человек решил пока не делать |
Отдельная таблица для закупок в пути | Если обещание хранится в двух таблицах, не остаётся ни одного места, где можно ответить на вопрос «что всего пообещал этот наряд» |
Расширить таблицу записей занятости (добавить три колонки модель/номер/ETA, | Формально самое аккуратное, но |
В еженедельном обновлении пропускать строки с непустыми «примечания» или «ожидаемой датой» | Посчитали: из 399 строк подходит 1, из них с ненулевым 在库 — 0 строк — это правило сегодня пустое. Чтобы оно работало, нужен ручной ввод 在库, а это добавит второго владельца единственной колонке |
台账: экспорт раз в неделю, только добавление
台账 — это таблица материалов «таблицы управления занятостью комплектующих» (многоразмерная таблица). MCP читает исходные таблицы и считает 在库, сторона台账 считает занятость,
обе стороны сходятся по ключу материала, 可用量 = 实时在库 − 台账占用中.
台账 принимает только комплектующие, уходящие по процессу выдачи (台账范围, определён в lib/ledger.mjs): оптические модули / жёсткие диски / сетевые карты / память.
То, что идёт вместе с машиной и не выдаётся отдельно по ключу материала, не принимается — попав в台账, оно лишь добавит в выпадающий список несколько ключей, которые никто никогда не выберет.
При импорте сначала фильтр, потом проверка, в лог пишется «отсечено N ключей материала вне диапазона台账».
Это множество и набор планов (plans.json) сейчас случайно одинаковой ширины, но управляют они двумя разными вещами: набор планов управляет
«какие исходные таблицы читает MCP», 台账范围 управляет «какие активы должны попадать в таблицу занятости». При добавлении класса активов в набор планов нужно отдельно
решать, попадает ли он в台账, не по умолчанию — иначе новый класс молча попадёт в台账 и породит пачку ключей, которые никто не выберет.
Поэтому «в台账 нет этого ключа» бывает двух видов, 挂可用量 считает их раздельно, блок口径 сообщает раздельно. Если свести в одно число,
то при существовании любого класса «который и не должен попадать» оно вечно больше нуля, человек через несколько недель привыкнет игнорировать,
а когда реально новый товар не заведут — не увидит, и будет зря гонять по неисправимой рекомендации «запустите导台账»:
Сигнал | Смысл | Что делать |
| в диапазоне, в台账 нет | Запустить |
| вне диапазона, идёт с машиной | Ничего делать не нужно, его «可用量» — это число 在库 |
./导台账.mjs --dry # 先看一遍:新增几条、更新几条、置 0 几条
./导台账.mjs # 真写
./周更.mjs # 每周四:重读 → 和上周基线比 → 出报告 → 品牌待补清单 → 存基线 → 写台账В --dry важнее всего смотреть на «обнулённые», и по каждой спрашивать «эта партия исчезла или сменила ключ».
--dry сообщает только «было 在库 → 0», не говорит, есть ли сейчас в этой клетке что-то другое — а именно второе различает
«реально выбыло» и «переименовано/переклассифицировано». Критерий — взять «тип актива|бренд|склад» ключа и проверить в данных этой недели в реальном времени:
в этой клетке есть другая модель = скорее всего переименование, клетка пуста = эта клетка действительно очищена.
Проверка 2026-08-14: из 24 обнулённых 19 пришлись на «жёсткий диск · Линьган-Чайнаюникон», а модель —
QSFP112-400G-DR4-SM1310, то есть запись в стиле оптического модуля — в сумме ровно 926 корней,
та же партия, что и в исправлении того дня «оптические модули Линьган-Чайнаюникон читались как жёсткие диски». В этом случае обнуление правильно —
это ровно те ошибочные ключи, которые и должна была вычистить та починка.
Правила записи не меняются ни на йоту, из раздела дизайна §3.3 стороны台账: upsert по ключу материала, для материалов, не появившихся в этом импорте,
количество 在库 обнуляется, а не удаляется запись. После обнуления запись остаётся, ключ записи занятости по-прежнему совпадает,
уровень автоматически становится «ожидает закупки» — на складе нет, а людям должны — именно нужная семантика. В коде никогда не вызывается
record-delete: удалишь — запись занятости повиснет в воздухе, а сторона материалов полностью молчалива (可用量 тихо вырастет обратно,
уровень всё ещё пишет «достаточно», проверка данных пуста).
Четыре шлюза, любой не пройден — вся партия не пишется (не «пропустить плохие, записать хорошие»):
Шлюз | Что отсекает |
Есть исходная таблица, которую не прочитали | Этот склад будет принят за «обнулён» и весь обнулён, а товар на месте |
Ключ невалиден (сегмент пуст/содержит вертикальную черту) | Пустой ключ «всосёт занятость всех пустых ключей», и полностью молча |
Ключ по принадлежности совпадает с несколькими старыми ключами | На этой неделе два старых ключа слились в одну группу, машина не угадывает, какой оставить |
Сверка перечитыванием после записи | «API вернул ok, а значения пустые» — самая дорогая ошибка этой таблицы |
Ключ, однажды выданный, замораживается (lib/keyreg.mjs)
Запись занятости хранит строку ключа, а третий сегмент ключа — стандартное написание, «выбранное» нормализацией — на практике из 26 групп слияния
в 9 группах решалось по количеству корней (CX6-25G双口(287) против CX6-25G*2(158), одна выдача — и перевес сменился).
После перевеса старый ключ обнуляется, новый всплывает, запись занятости висит на старом ключе — и эта занятость никогда не закроется.
Заморозка не через сериализацию слотов (сетевые карты вообще не разбираются на слоты), а через готовое поле 原始 в normalize:
если вновь вычисленный ключ разделяет с каким-либо уже выданным ключом любое исходное написание — считаем это тем же товаром и продолжаем старый ключ.
Критерий — «общее написание», а не «строки ключей совпадают», как бы ни переворачивалось стандартное написание, идентичность не меняется. Факт продолжения старого ключа
сообщается (в台账 по-прежнему отображается старое написание, человеку может показаться странным). Реестр в
~/.cache/inventory-mcp/键注册表.json, обновляется только после записи台账 — если запись台账 провалилась, ключ не считается выданным.
Ямы, на которые наступали (все проверены на практике): +record-list по умолчанию страница 100, максимум 200, без перелистывания записи台账 после 100 штук
считаются «не существующими» и дублируются, а API всю дорогу возвращает ok; --record-id у +record-delete — повторяемый параметр,
если передать склеенную пробелами строку — будет «Record id must start with rec», хотя ID на самом деле валиден;
возврат колоночный (data.data двумерный массив + data.fields имена колонок), а не объект fields на каждую запись.
Как восполнять бренд
Пустую колонку бренда в исходной таблице можно восполнить тремя способами — достоверность у них разная, поэтому храним раздельно и отчитываемся раздельно:
Источник | Гранулярность | Где хранится | В блоке口径 отчитываемся как |
| сопоставление написаний (Samsung→三星) |
| нормализация |
выгрузка из CMDB | попроверка по каждому SN |
| бренд, восполненный по SN |
ручная пакетная оценка | склад + тип актива |
| бренд восполнен |
Восполненные значения ещё раз прогоняем через brandAliases — CMDB пишет CLT, склад пишет 海光芯创,
если не прогнать, в реестре окажется один и тот же производитель под двумя именами и два набора ключей. После нормализации есть ещё одна проверка, которая бросает исключение:
если в результате остался хоть один бренд, для которого «в справочнике есть стандартное написание», — сразу ошибка, ничего не возвращаем.
Что не удалось восполнить — попадает в «бренды, ожидающие восполнения», каждый четверг формируется список SN (~/.cache/inventory-mcp/周报/品牌待补-*.csv)
и уходит на проверку в CMDB. Передаём SN, а не модель, потому что в CMDB бренд конкретной единицы товара находится только по SN.
При запросе бренд передан неверно: инструмент знает ответ — не стоит возвращать просто 0
查库存 по бренду — это точное совпадение (kw(r.brand) === kw(条件.品牌)) — на стороне данных при чтении таблицы уже выполнена
нормализация через brandAliases, поэтому на стороне запроса требуется передавать стандартное имя. Но клиент называет бренд по-английски, сокращённо, или набирает половину —
если модель не перевела, она получает 0 без каких-либо подсказок, и это выглядит точно так же, как «этой партии действительно нет»,
а последнее сообщается человеку как есть. Тот набор мер спасения при нулевом попадании (что ослабить, несколько вариантов с наименьшим расхождением) срабатывает только когда заданы модель или слот,
а запрос вида {品牌:'Samsung'} туда вообще не попадает.
Теперь при нулевом попадании и переданном бренде в ответе добавляется пункт «бренд», четыре случая отвечаются раздельно
(品牌怎么救, lib/ledger.mjs):
Передано | Что отвечаем |
| дело не в бренде, а в остальных условиях — этот случай чаще всего ошибочно читают как «этого бренда нет в наличии» |
| стандартное имя — «三星» (всего N штук по базе), замените на него и повторите запрос |
| в списке нет, но есть «海光芯创» — кандидаты берутся из реально существующих в базе брендов, а не выдумываются |
| в списке нет, в наличии по базе вот эти. Те, где 0 штук, не перечисляем — перечислить значит увести модель на пустой результат |
Порядок четырёх случаев менять нельзя: brandAliases допускает самосопоставление, стандартное имя появляется в списке собственных алиасов,
если сначала проверять алиасы, то при передаче стандартного имени ответят «вы указали алиас» и уведут человека не в ту сторону. tests/ledger.test.mjs ㉙ охраняет это правило.
Критерий один на всех, живёт в lib/slots.mjs
Логика «являются ли два написания одной и той же единицей товара» находится в lib/slots.mjs, lib/ledger.mjs импортирует её напрямую.
До 2026-08-16 её здесь не было — она жила в userscripts/feishu-warehouse-composer/slots.mjs,
MCP-рантайм вырезал фрагмент по строке-маркеру export function 建槽位( и выполнял через new Function.
Вся эта конструкция (переопределение пути через переменную окружения, маркер вырезания, бросок если не вырезалось) существовала ради ограничения «критерий один на всех, живёт в userscript».
Ограничение исчезло — поэтому переезд: тот userscript (компоновщик склада Feishu) больше не обновляется — на стороне Feishu с 2026-08-11
перешли на официальный интерфейс lark-cli, путь через реверс-инжиниринг вышел на пенсию. Зависеть от пути, который никто не поддерживает, опаснее, чем иметь вторую копию:
копия может разойтись, но расхождение можно поймать по критерию; а если путь однажды исчезнет, ошибка будет «не вырезается 建槽位»,
и никто не поймёт, что это значит. Та копия в userscript остаётся для его собственных нужд, синхронизации между ними больше нет —
он не обновляется, значит и расходиться не будет.
Расхождение критерия «та же единица» не вызовет ошибку, оно проявится только как «удвоение остатков» или «та же единица не находится». Проверено на практике:
QSFP28-100G-SR4 (многомодовый) и QSFP28-100G-LR4 (одномодовый) — без шага «стандартно выводить тип волокна и длину волны»
они определяются как одна и та же единица, отправишь — вставишь, не загорается. Поэтому он заслуживает собственного дома, а не житья на чужих хлебах.
Проверка в run-tests.sh развернулась в обратную сторону — раньше проверяла «обязательно читать ту копию в userscript», теперь проверяет три вещи:
файл критерия существует и принадлежит этому репозиторию, lib/ledger.mjs импортирует именно его, и кроме lib/slots.mjs нет второго
литерала словаря слотов. Проверяется механизм зависимости, а не «упоминание»: первая версия была написана как grep по строке пути
и закрашивала красным даже комментарий, объясняющий эту историю — критерий, написанный слишком широко, так же плох, как слишком узкий.
Таблица соответствия брендов по-прежнему снаружи, её дом — brandAliases в plans.json (та самая, что правится через интерфейс «таблица соответствия брендов» в userscript,
INVENTORY_PLANS может переопределить). Здесь только чтение, копию не храним, не прочиталось — бросаем; одно и то же написание в двух планах
сопоставлено разным брендам — тоже сразу бросаем, молча не берём одно из двух. Её отличие от критерия слотов в том, что её ещё кто-то правит через интерфейс,
поэтому ей место там, где её правят, а не переезд сюда и превращение во вторую копию.
Запуск тестов
./run-tests.sh # 改的过程中跑:23 秒,305 条判据 + 61 个消融,不打网络
./run-tests.sh 全 # 收尾 / 提交前跑一次:89~190 秒(缓存热时 89),打网络的五条链并行
./新旧.sh # 跑着的那个 MCP 是不是最新代码 —— 它是 WorkBuddy 启动时拉起的单例,新开对话不换进程
./自检.mjs # 这套东西能不能跑起来:lark-cli / 登录态 / 方案包 / 真读源表 / 台账 / 三件静态检查
./自检.mjs --快 # 同上,跳过真读源表那步(不打网络)Разбиение на два уровня — вымерено: 5 тестов с сетью плюс их абляции занимают 630 секунд из полных 635, а 15 тестов без сети плюс 14 групп абляций — всего 5 секунд. Изменил строку кода и ждёшь десять минут, чтобы узнать, не сломал ли — этот цикл настолько длинный, что никто не будет гонять его в процессе правок — значит, его прогоняют один раз в конце, а это самый поздний момент, когда можно обнаружить проблему.
Уровень 全 — пять цепочек параллельно (замер 2026-08-17: 624 секунды → 195 секунд, ни одного критерия и ни одной абляции не потеряно).
Пять основных прогонов проверялись отдельно: последовательно 275 секунд, параллельно 92 секунды, ни один не срезан частотным ограничением — сделанный в тот день retry с backoff выдержал всплески.
Цепочка 写路径 обязана идти последовательно и не параллелить саму себя: она создаёт в продовой базе таблицы с одинаковым префиксом имени,
finally чистит по префиксу — если два процесса запустятся одновременно, они удалят таблицы друг друга, а проявится это как «таблица внезапно исчезла»,
выглядит как ошибка интерфейса. Вся цепочка (основной прогон + абляции) выполняется последовательно в одном подпроцессе, параллельно с другими цепочками.
Вывод печатается в фиксированном порядке, а не в порядке завершения — вывод двух прогонов должен быть напрямую diff-сравним. Секунды по каждой цепочке тоже собираются обратно (после распараллеливания они однажды исчезли из секундомера, а «что не измерить — то не оптимизировать» и есть причина существования этого уровня: 624→195 найдено именно по секундомеру).
В конце уровня по умолчанию явно говорится, какие пункты не проверялись и за что каждый из них отвечает. Код возврата по-прежнему 0 (он действительно прошёл то, что гонял), но прогнав только половину, нельзя печатать так, будто прогнал всё: слить «не могу судить» и «пройдено» в один неразличимый зелёный — это самая недопустимая ошибка этой системы.
Shell-скрипты отдаём shellcheck, свои регулярки не пишем (brew install shellcheck, не установлен — закрашиваем красным — молча пропустить значит вечно «пройдено»). Он распознаёт тот тип, на котором этот репозиторий реально падал: 计时="" → SC2276 This is interpreted as a command name containing '=' — bash выполнит это как команду, сообщит command not found и продолжит работать дальше, переменная вечно пустая, а скрипт зелёным доходит до конца (bash -n этого не видит, синтаксис легален). 2026-08-17 — пять падений за день.
**При подключении защищаемся от второй вещи: остановка разбора на середине не считается. ** Китайские имена функций (秒() {) bash понимает, shellcheck — нет, на этой строке он сообщает SC1088, прекращает разбор и всё равно выходит с ненулевым кодом — выглядит, будто работает, а на деле из 260 строк просканировано только 28, и все предыдущие appears unused — фальшивка (он не видел мест использования). Поэтому имена функций в run-tests.sh — sec / run_one / teeth / chain, а не китайские: китайские имена легальны, но они запирают дверь перед единственным shell-линтером этого репозитория.
Номера критериев получаются прогоном (ok #37 …), а не вручную проставленные кружочки — ㊱–㊿ давно закончились на 90 критериях,
при ручной нумерации рано или поздно будут повторы, и FAIL ㊹ не поймёшь, какой это критерий. Добавлять критерий — номер поддерживать не нужно.
Число абляций подсчитывается, а не записано в run-tests.sh (tests/消融.mjs). Каждый тестовый файл объявляет
认消融(N), сколько у него абляций, набор пробует от 1 и дальше, пока тест не вернёт код 2.
Код возврата — четыре уровня, шапка файла tests/消融.mjs — единственное определение, не хватает какого-то уровня — не различить соответствующие два случая:
Код | Что означает | Без него что с чем спутают |
0 | всё зелёное (при абляции = эта абляция вечно зелёная, обязательно сообщить) | — |
1 | какой-то критерий упал | — |
2 | такого номера абляции нет, стоп | «такой абляции вообще нет» примут за «абляция сработала» |
3 | упало, но одна запись — это упор в частотное ограничение Feishu → этот прогон не засчитан | «в эту минуту сеть была слишком занята» примут за «код сломан»; в круге абляций ещё хуже — абляция, которую вообще не выполнили, посчитается зубом |
3 — это не «пройдено». Он заставляет весь набор выйти с ненулевым кодом и требует перезапуска — так что даже если в каком-то прогоне затесался настоящий сбой и его пометили ⏸, чистый прогон всё равно закрасит его красным, худший случай — увидишь на один круг позже, а не не увидишь вовсе.
Это выстрадано на практике: 分段读 добавил 3-ю абляцию, а в run-tests.sh всё ещё было for m in 1 2 —
эта абляция никогда не запускалась, а сводка всё равно печатала «✓ 2/2 абляции покраснели» — новый критерий добавлен,
никогда не проверено, что он краснеет, а весь набор тестов зелёный. Заодно закрыта ещё одна дыра: 决定.test.mjs раньше на нераспознанный номер абляции
ничего не делал (if…else if без else), ABLATE=3 не запускал ни обычный путь, ни путь абляции,
все четыре критерия падали, а по коду возврата это выглядело один в один как «абляция 3 сработала».
Тест | Бьёт по сети | Что охраняет |
| Нет | Нормализация + исключение бракованных + нормализация литералов + деградация при отсутствии таблицы + дополнение брендов + написание модели не похоже на этот класс (пятая проверка) + сказать, когда бренд передан неверно. 33 критерия |
| Нет | Путь прямого чтения. 60 критериев: разбор URL, пакет схем не распознал URL — должен бросить, позиция берётся из «региона», а не из «склада», один SN — одна строка, дедупликация между схемами, агрегация не сходится — бросить, проверка кэша структуры, длина сегмента кэша может быть только меньше текущей стратегии, два пути ошибок lark-cli сводятся к одному, SN сравнивается по каждой записи, частота попаданий фильтра, вся цепочка от распознавания частотного ограничения до кода выхода пакета (включая «не-лимитные сбои не ретраятся ни разу» и « |
| Нет | Поиск замены + цена ослабления. 59 критериев: на тупиковом пути показывать «Вы не это искали?» (㊺㊻㊼, добавлено 2026-08-24) — когда клиент дописывает название проекта к номеру детали производителя ( |
| Нет | Еженедельное обновление: проверка ключей, три класса недельных различий, выравнивание списка ожидающего пополнения и снимка, объяснение переименований (шум на уровне ключей, который нельзя убрать, — красный), «ключ обнулился» и «реестр зависнет» раздельно (по исчезновению исходной записи утверждать, что нормализованный ключ зависнет, — ложное срабатывание каждую неделю). 20 критериев |
| Нет | Смотреть исходную таблицу. 13 критериев: столбцы с другими именами обязательно называть, таблицы с отсутствующими полями называть, пустое сопоставление считать «нет», а не «называется пустым», правила фильтрации и строку заголовка отдавать как есть, если список обрезан — сказать, сколько листов осталось |
| Нет | Урезание блока口径. 7 критериев: отладочные поля по умолчанию не отправляются / |
| Нет | Поиск SN. 19 критериев: разные исходные написания возвращают один ключ, |
| Нет | Ресурсы / шаблоны подсказок / дополнение параметров, плюс |
| Нет | Уведомление |
| Нет | Регрессия recall. 189 реальных моделей × 9 видов искажений клиентских написаний, запускается на |
| Нет | Решения, принятые человеком. 17 критериев: обоснование/кто принял — не пусто, два прогона дают одинаковое состояние, дедупликация после нормализации направления и написания, при изменении мнения остаётся предыдущая версия, принятое «не может заменить» не исчезает, битый файл — бросить, но не ронять запрос |
| Нет | Финальная строка. 10 критериев: одно место без количества, несколько мест с количеством, число использованных мест минимально, не хватает — не писать «выполнено» и не раскладывать по местам, без «сколько нужно» выводы не делать, добирать по доступному количеству, порядок определён |
| Нет | После сопоставления собрать текст уведомления, вернуть по сегментам получателей (реально не отправлять). 9 критериев: разделение на выполнено/не хватает/ожидает (по ключу попадания различать «совпало, но мало» и «не совпало с остатками», с указанием ЦОД), сегмент资管 + сегмент закупки (сегменты не хватает/ожидает, только ожидает — сегмент не хватает не выводится), нормализованная модель и отпечаток литерала normalize в одном口径 (варианты с разделителями не теряются при сопоставлении) |
| Нет | Двухшаговый затвор ежедневного MCP-инструмента. 3 критерия: шаг ② без записи не выполняется, повторное воспроизведение израсходованного билета/поддельный билет — билет недействителен (① и реальная запись требуют чтения/записи Feishu-реестра, офлайн не проверить — опора на красный от |
| Нет | Двухшаговый затвор еженедельного MCP-инструмента + достаточно ли свежий SN-снимок. 10 критериев: шаг ② без записи не выполняется, повторное воспроизведение/поддельный билет — билет недействителен; и |
| Нет | Экранирование CSV / BOM / раздельно рабочий стол и кэш / старые файлы вытесняются по временной метке (сортировка по имени файла позволит |
| Нет | Статически сканировать цель каждого |
Свои абляции | Три бьют (сегментное чтение / инкремент / путь записи) | Убрать одно несущее место логики — обязано покраснеть, иначе утверждение вечно зелёное. Число здесь не пишу — один раз зафиксировал, один раз уехало, точное значение — строка |
| Да | Таблицы больше 10 МБ читаются сегментами + при попадании в |
| Да | Инкрементальное перечитывание. 6 критериев, важен только один: результат инкремента обязан быть побайтово идентичен полному по каждому ключу материала (нельзя брать «сохранение сходится» за критерий — переиспользован документ, который на самом деле изменился, общее число меньше, а сохранение на каждом шаге всё равно зелёное). Сценарий создаётся через |
| Нет | Собственная регрессия |
| Нет | Не уехали ли захардкоженные числа в README. 3 критерия: в каждой таблице тестов без сети есть строка, число критериев совпадает с фактическим прогоном, каждый входной скрипт в корне репозитория упомянут в документации. Добавлен, потому что один аудит обнаружил два новых теста, вообще не попавших в таблицу — такой дрейф не сообщается об ошибке, он просто превращает документацию в похожую на настоящую, но не сходящуюся вещь |
| Нет | Путь диспетчеризации |
| Нет | Записывать, о чём модель реально спрашивала. 9 критериев: параметры пишутся как есть, без подстановки значений по умолчанию, записываются и сбои (попаданий 0 и «вообще не работает» — два разных класса проблем), не замедлять запрос, разбивка по месяцам с хранением только последних нескольких, можно отключить |
| Нет | Ключи зафиксированы + охват реестра + два направления занятости + массовая смена написания в исходной таблице. 27 критериев: смена канонического написания — переиспользовать старый ключ, между складами не переиспользовать, синтетическая группа — сообщить о конфликте; «должен был импортироваться, но не импортировался» и «не идёт через выдачу из реестра» считаются раздельно (в исходной таблице есть, в реестре нет); «в реестре занятость есть, в исходной таблице этого ключа нет» тоже сообщать (место, куда попадает в пути, — пропустишь, будет перевыдача), дата должна идти с каждой записью, не заполнено — не выдумывать; |
| Нет | Устаревание кэша «калибровка модели по SN» должно быть видно ( |
| Нет | Главный путь ( |
| Нет | Слой представления ( |
| Нет | Снимок контракта, который видит модель (имена/описания/таблица параметров/annotations) + инварианты. Единственное место в регрессии, где реально поднимается сервер — при переносе таблицы инструментов в |
| Нет | Направление зависимостей отдано линтеру ( |
| Нет | Трещотка мёртвого кода, |
| Нет | Поиск файлов нельзя выводить из «где лежит код» ( |
| Нет | Кто решает в строке версии протокола при рукопожатии. 12 критериев: версия, сообщённая клиентом, поддерживается — вернуть как есть, не поддерживается — вернуть нашу последнюю, эхо запрещено (2026-08-22 исправлен настоящий баг: раньше безусловно возвращалась версия собеседника, и его проверка совместимости «ты вернул то же, что я просил» всегда выполнялась, несовместимость не обнаруживалась никогда — классический «зелёный при отказе»); мусорная строка, будущая версия, пустая строка, вообще не сообщил — все четыре сводятся к нашей последней; последний критерий проверяет реальный подъём сервера и получение ответа (получил null — все одиннадцать выше не в счёт) |
| Нет | «Что нового с момента последней записи в реестр» + SN-доказательства ( |
| Нет | Затвор решения о переименовании, общий для ежедневного и еженедельного обновления ( |
| Нет | Красная проверка квитанции самотеста |
| Да | Таблицы класса активов, о котором спрашивает только чтение + дедупликация в пути. 11 критериев, самый важный — при чтении одного класса ни один базовый уровень не должен прокрутиться (половина данных накрывает SN-снимок всего склада, следующий полный прогон посчитает непрочитанные двести с лишним тысяч «лишними», а сохранение на каждом шаге всё равно зелёное). Плюс охраняется:口径 обязан сам сообщить «прочитаны только части исходных таблиц», построчный кэш накладывается, не перекрывая, кэш «все» отвечает на узкие вопросы, полный прогон обязан прокрутить снимок (взаимное опровержение с предыдущим). Абляция через два аварийных переключателя ( |
| Да, и реально пишет | Контракт двух команд записи в многомерную таблицу. Реально создаётся одноразовая таблица (префикс |
| Да | Проходит ли цепочка initialize / tools/list / tools/call, плюс уведомления о прогрессе (два поведения с progressToken и без — взаимное опровержение), поиск SN двумя путями — через файл и инлайн, старое имя параметра — ошибка на месте, запись решения — реальная запись на диск (во временный файл). 99 критериев |
Абляция — не для галочки, ловила трижды: brandMap изначально строился как константа при загрузке модуля, тест меняет BRAND, но на него это не влияет, абляция 1 всегда зелёная; в первой версии абляции прямого чтения соответствующий критерий сам себя освобождал (ABLATE === '1' ? true : ...), два из трёх абляционных тестов не краснели; в абляции по буквальной нормализации первая версия выбрала не те образцы — взяла CX7-400G单口 vs Cx7 400G 单口 в качестве образцов, парсер слотов сам их объединяет, буквальная нормализация для него не критична, заменила на 128g (строчная g не парсится в ёмкость) и SFP.25G-SR.LC (точка не парсится в спецификацию) — только тогда путь реально идёт исключительно через буквальную нормализацию.
«Абляция не краснеет» бывает по двум причинам, вторая коварнее: критерий освободил сам себя, либо образец вообще не проходит через этот путь.
Прогон на столкновение с частотным лимитом
INVENTORY_FAKE_RATELIMIT=99999 INVENTORY_BACKOFF_MS=1,1 ./run-tests.sh # 该印 ⏸,退 3
./run-tests.sh # 该全绿,退 0Обратный прогон — не для проформы, 2026-08-16 за один раз выявил четыре реальные проблемы, три из них — в самом механизме частотного лимита, написанном в тот же день:
收尾()изначально требовал, чтобы «каждая подвешенная запись была частотным лимитом» — только тогда считалось, что проверка не прошла — а分段读вешает 5 записей, из которых только 1 с кодом, остальные — цепная реакция после того, как данные не удалось получить, поэтому механизм, созданный специально для частотного лимита, не срабатывает при реальном частотном лимите.В
direct.test.mjsдочерний процесс, проверяющий код выхода, передаёт{...process.env}напрямую, затаскивая туда и переменные, использованные для инъекции — тест, не бьющий по сети, краснеет из-за инъекции в другом месте, песочница теста протекает.В
protocol.test.mjsчетырнадцать голыхJSON.parse(…content[0].text), когда инструмент отвечает человеческим языком, выбрасываетсяUnexpected token '查', "查不了:wiki 换"…— от исходного текста осталось только десять первых символов, аcode=99991400стоит на 40-м символе.В
写路径.test.mjsсобственныйcli()— голыйpexec, при ненулевом выходе lark-cli тело ошибки лежит в stdout, а этот объект вообще не читается, ошибка сводится к одной фразеCommand failed:. Если бы он реально писал в production-базу, это как раз тот путь, который легче всего упирается в лимит.
Общее у всех четырёх: «при сбое они зелёные/жёлтые», и увидеть это можно только если реально воспроизвести сценарий.
Что ещё не сделано
Регистрация занятости не сделана (запись в таблицу «записи о занятости»
tbl8GkzD4stgYcay). Запись в таблицу материалов уже сделана (./导台账.mjs), исходные таблицы только на чтение. Три жёстких правила сначала внедрить: агент не может заполнять «проверяющего», запись с request ID и с проверкой на дубликат перед записью, повторное чтение перед записью: если обнаружено, что уже занято — прервать. Эту таблицу 2026-08-15 прощупали, четыре вещи отличаются от того, что было записано раньше:① Занятость и отгрузка — два «типа» в одной таблице, а не две таблицы. Считается через
带符号数量(занятость+数量, отгрузка-数量),剩余占用— это формула, вычисляющая нетто. Строка отгрузки ещё должна в关联工单указывать обратно на ту строку занятости, которую она сторнирует (проверено: 29124 занятость +2 → 29154 отгрузка -2 указывает на неё → остаток занятости 0, статус ⚪закрыто).② Материал уже link-поле (
关联物料), не text.本行物料键— формула, вычисляемая из него, так что человек выбирает материал из выпадающего списка, а не вбивает 40 символов вручную — прежняя запись «сейчас text» уже устарела.③ «link через API молча теряет данные» не подтверждается (2026-08-15 проверено: записали и удалили):
+record-batch-createс关联物料: [{"id":"rec..."}]записывается, при обратном чтении link-значение совпадает один в один, и формула本行物料键реально вычислила光模块|海光芯创|QSFP112-400G-DR4-SM1310|闵行— связь живая, а не пустышка. При неверной форме записи интерфейс явно выдаёт ошибку (800010701 Cell value does not match any supported shape), и в hint даёт правильную форму, а не молча проглатывает.④ Но в ответе нет record_id:
+record-batch-createвозвращаетok:true, аrecords— пустой массив, по возвращаемому значению не видно, записалось ли и что именно. Поэтому правило «после записи обязательно перечитать и проверить, возвращаемому значению не верить» на этой таблице не страховка, а необходимость. Удаление записей требует--yes.Самопроверка перед записью уже есть: в этой таблице есть формульное поле
数据检查, в котором прописаны все правила — не заполнен тип / не выбран материал («эту занятость никто не спишет»)/ количество должно быть больше 0 / один и тот же工单 и материал записаны в несколько строк («остаток занятости будет занижен»)/ сторнирование превышает занятость / нет даты / нет归属 затрат / у отгрузки не выбран关联工单 / у отгрузки не заполнен статус闭环. После записи перечитать это поле — и сразу видно, правильно ли, не надо реализовывать правила заново в коде.Третий сегмент ключа материала — всё ещё строка модели, сделана только нормализация на буквальном уровне. Чисто наборные различия (регистр/разделители) уже отсечены, но семантически одинаковые, буквально разные всё ещё образуют отдельные ключи. Радикальное решение — заменить ключ на сериализацию слотов, строку модели понизить до отображаемого имени. Проверено: из 26 групп слияния у 9 групп стандартное написание определяется по количеству корней (
CX6-25G双口(287)vsCX6-25G*2(158), разница 129 корней, одна отгрузка может перевернуть ситуацию), после переворота недельное сравнение выдаст ложные «исчезновение + появление».Для сетевых карт правила замены не делать (решено 2026-08-15, не в списке задач). В
方向.网卡все три пункта —null, значит, его файл правил неизбежно пуст — можно ли CX6 заменить на CX5, можно ли двухпортовую на однопортовую — это аппаратные знания, таблица слотов не вычислит, стоимость составления этой таблицы выше выгоды. Пустой массив обязан сопровождаться пояснением: при попадании в таблицу不做规则替代в возвращаемом значении прямо сказать «для этого типа правила замены не делаем + почему», взаимоисключающе с «⚠ направление замены для этого типа ещё не определено» — второе читается как незакрытая задача, из-за чего будут вечно ждать того, чего не будет. Сетевые карты как обычно получают точное совпадение и близкие уровни. Чтобы начать — удалить этот пункт и заполнить方向, менять в двух местах одновременно (lib/substitute.mjs, критерии ㉟㊱㊲ + абляция 9).Квота — не препятствие, уже выяснено. У Feishu нет «месячного лимита» — официальная страница
frequency-controlдаёт лимит в минуту/секунду на API × приложение × тенанта, самый узкий диапазон — 100 раз/мин, таблицы для базовой и бизнес-версии почти одинаковы. При срабатывании возвращается HTTP 429 +code 99991400, заголовок ответаx-ogw-ratelimit-resetпрямо говорит, сколько ждать. Но путь прямого чтения достаёт до лимита: 31 исходная таблица, один холодный старт — около 48 вызовов (30 таблиц по одному разу + таблица минханских оптических модулей 1 вызов метаданных + 17 сегментов), при пустом кэше — ещё один проход заголовков. Два холодных старта подряд в течение минуты — и упёрлись в лимит, после этого время падает с 8 секунд до 1921 секунды — это не теория, 2026-08-14 при разборе медленного чтения таблиц на это натыкались, тогда ошибочно списали на объём данных. В горячем состоянии всего 9 вызовов (проверка revision 9 документов), путь через台账 — 12 вызова. Три человека по одной установке, редкие точечные запросы — до лимита не дотянуться, но не стоит дважды подряд гонять полный объём в течение минуты. Чем мельче сегменты, тем больше вызовов, прежде чем менять每段格子, посчитай эту арифметику.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Manage your Savanto store from your AI: catalog, content, prompts, and analytics, by chat.
Agent 知识共享市场 — 让 AI Agent 搜索/购买/上传经验记忆。34 个 Tools,支持记忆搜索、购买、上传、评价、团队协作。
Agent-native product catalog for AI shopping agents. 296M+ products, 28 countries.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/320432893-cell/inventory-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server