Skip to main content
Glama

MCP de inventario

Lee las decenas de hojas de origen de Feishu del paquete de la solución (actualmente 31), las filtra, deduplica, normaliza y agrega, y las convierte en trece herramientas para que el agente las llame; cada semana las importa al libro de contabilidad de Feishu.

Cero dependencias: el MCP usa JSON-RPC sobre stdio, no hay node_modules, y para instalarlo en otra máquina solo se necesita node.

Instalación

En WorkBuddy, haz clic en «Lista de MCP → Editar configuración», o edita directamente ~/.workbuddy/mcp.json:

{
  "mcpServers": {
    "inventory": {
      "command": "node",
      "args": ["/path/to/inventory-mcp/server.mjs"]
    }
  }
}

Claude Desktop u otros clientes MCP funcionan igual; la forma de configuración es la misma.

Las trece herramientas

Herramienta

Parámetros (todos opcionales)

Qué devuelve

查库存

Tipo de activo / Modelo o Slot / Modelos / Marca / Almacén / Cuántas unidades / Cuántas líneas de detalle / Usar cantidad disponible / Con archivo sí o sí

Claves de material coincidentes, unidades de cada una, subtotales por almacén (con niveles), total, la línea de cierre (solo si se da «cuántas unidades»), si no alcanza relaja automáticamente un criterio y trae los candidatos juntos; muestra «lo que se consultó esta vez» (el slot parseado, para copiar en la siguiente ronda)

看分布

Tipo de activo / Cuántos primeros

Matriz «modelo × almacén» + total por almacén

找替代

Modelo / Tipo de activo / Cuántas unidades / Almacén / Cuántos por nivel

Clasifica en cuatro niveles según «cuánto puede probar la máquina»: exacto / por regla / dudoso / similar, y además da la «forma de completar»; los candidatos que un humano ya aprobó llevan una nota «aprobado por humano»

查SN

Modelo / Marca / Almacén / Tipo de activo / Clave de material / Máximo de líneas / Con archivo sí o sí

Números de serie uno por uno. Si supera los 50, escribe un CSV y solo devuelve la ruta, ver abajo

看变动

Comparado con qué día / Tipo de activo / Almacén / Cuántos primeros

Qué entró y salió comparado con la línea base semanal. Primero reporta el movimiento real calculado por SN; los cambios a nivel de clave se degradan, ver abajo

看源表

Tipo de activo / Almacén / Campo / Quiere el detalle de cada hoja o no

De qué tabla y qué columna salen estos números. Solo lee el paquete de la solución, no toca la red, ver abajo

看有哪些型号

Tipo de activo (obligatorio) / Con slot / Con unidades

Todas las escrituras estándar de esta categoría en el inventario + qué marcas hay y cuánto stock de cada una (lista cerrada). Cuando la escritura del cliente no es estándar, se elige de aquí, ver abajo

看筛选

Tipo de activo / Solo ver lo que cambió

Qué valores reconoce cada regla de filtrado y cuántas filas tiene cada uno, y qué cambió respecto a la última vez. Esto es materia prima, no una conclusión, ver abajo

写占用

N.º de orden de trabajo / Proyecto / Necesidad de piezas [{型号,数量}] / Escribir de verdad

Después de satisfacer la necesidad, registra las piezas en el libro de ocupación (último paso del ciclo cerrado). Pasa las filas de piezas que salen de «consultar orden de trabajo» del cmdb (solo memoria/disco/módulo óptico/tarjeta de red). Cada una se clasifica por cantidad disponible: satisfecha → ocupada/en aprobación (asociada a clave real); faltante → en compra en tránsito (crea/reutiliza una clave virtual de compra pendiente del mismo modelo). Por defecto es simulación en seco y devuelve el plan + informe; para escribir de verdad pasa 真写:true (bajo riesgo, se puede borrar, no muestra cuadro de confirmación); máquinas completas/consumibles/cables ya se filtraron en el lado del cmdb

发通知

N.º de orden de trabajo / Proyecto / Necesidad de piezas

Tras el emparejamiento, arma la notificación y la devuelve segmentada por destinatario (no la envía de verdad): satisfecho → un segmento para «gestión de activos (responsable del libro)»; faltante/pendiente → un segmento para «compras (contacto de compras)». Tú la copias y la pegas en el cuadro de diálogo de Feishu correspondiente y la envías tú mismo — el remitente eres tú, se evita el alcance disponible del bot y las políticas del tenant; «copiar → enviar» es el paso de revisión. Faltante = hay stock coincidente pero no alcanza; pendiente = el modelo no coincide con el inventario (por favor revisa la escritura), se lista por separado. Solo lee el libro, no envía mensajes, no necesita permiso de envío. Por qué no lo envía el bot directamente: probado en la práctica, enviar con identidad de usuario lo bloquea la política del tenant (230027), el mensaje privado del bot a otra persona requiere que esa persona esté en el alcance disponible de la app (230013), el bot para enviar a un grupo debe estar primero en el grupo (230002) — devolver texto y que lo envíes tú lo evita todo

日更

Ticket de confirmación / Elección del usuario

Actualiza a diario el libro + el registro + el tránsito, y emite el recibo de autocomprobación de la «verificación roja» de «lo que debía cambiar y no cambió» (no es solo sellar la marca de tiempo). Confirmación en dos pasos (escribir de verdad es escritura por lotes en el libro/registro): ① llamar sin parámetros → simulación en seco que emite el recibo (libro: altas/actualizaciones/puestos a 0; registro: cuántas claves nuevas; tránsito: cuántas quedan) + ticket de confirmación, sin escribir; ② primero debes llamar a AskUserQuestion para mostrar el recibo al usuario y preguntar si escribe de verdad, recién con el ticket + la elección del usuario se vuelve a llamar y escribe; tras escribir de verdad, ejecuta la verificación roja completa. Va por Feishu, no por la red de la empresa; la sincronización de órdenes de trabajo necesita la red de la empresa, se omite y se marca en el recibo. La lógica está en lib/日更运行.mjs; la versión de línea de comandos es 日更.mjs --真写 en la raíz

周更

Ticket de confirmación / Elección del usuario / Aceptar filtros

Se ejecuta cada jueves (superconjunto de 日更): relee las hojas de origen → compara con la línea base del jueves anterior (variación interanual semanal: salidas/entradas, qué claves llegaron realmente a cero → ocupación suspendida, si llegó el tránsito) → marcas pendientes de completar → guarda la línea base de esta semana → escribe el libro. Confirmación en dos pasos igual que 日更 (simulación en seco emite recibo + ticket → AskUserQuestion → escribir de verdad). Regla dura: si hay una hoja de origen que no se puede leer, no se guarda la línea base ni se escribe el libro. La lógica está en lib/周更运行.mjs; la capa fina de línea de comandos es 周更.mjs. Importar al libro es un subconjunto suyo (ambos llaman a 写台账), no se hace una herramienta separada

记下决定

Todos obligatorios: Tipo de activo / Lo que se pedía / Lo que se eligió / Conclusión / Base / Quién lo decidió

Registra las decisiones de sustitución que el humano tomó en el momento, para no volver a preguntar la próxima vez. La única herramienta que escribe «juicios» en disco, ver abajo

Los parámetros son siempre planos, los arrays solo contienen strings, sin objetos anidados — los modelos baratos toleran mal las estructuras anidadas. Si se pregunta por varias categorías de una vez, se escribe como 型号们: ["光模块:SR4","硬盘:960G"], que sigue siendo un array plano de strings.

Si un parámetro cambió de nombre, se rechaza en la capa de protocolo, no se ignora en silencio. El «cantidad» de 找替代 se fusionó el 2026-08-14 en 「要几根」(el mismo nombre que 查库存 — dos nombres para la misma cosa, el modelo tarde o temprano enviará el equivocado). La manifestación del silencio es: el modelo recibe una respuesta de aspecto normal, solo que el bloque de «si alcanza o no» desapareció por completo, un bloque faltante sin error. Así que el nombre antiguo devuelve directamente -32602 para que reenvíe con el otro nombre.

Cómo se emparejan los modelos: por slot, no hay camino de strings

«Modelo» no es coincidencia de subcadena. Se probó una vez un incidente real: preguntar OSFP112-800G-2*DR4-SM1310 con coincidencia de subcadena dio 0 aciertos, mientras que en el inventario hay 18.000 unidades de OSFP112-RHS-800G-2*DR4-SM1310 — con un segmento RHS de más en medio, la cadena completa no coincide, y el modelo reportó «no hay este producto en el inventario» basándose en eso. Ahora ambos lados se parsean a form/rate/std/media/wave y se comparan campo por campo, incluso las palabras clave desnudas funcionan (SR4 se parsea como {std:SR4, media:MM, wave:850}, acierta exactamente 35 claves).

La cadena es fija:

模型(对着 instructions 里两张常驻表:槽位词表 351 token + 品牌名单 93 token)
  定资产类型(必填,机器不猜)→ 把客户的乱写法翻成槽位 → 挑品牌标准名
        ↓
查库存 → 校验槽位值在词表里(不在当场报错并列出合法值)
       → 逐槽位相等才算命中 → 品牌精确匹配(不是包含)
        ↓
命中 0,或者命中了但不够「要几根」
        → 自动放宽收益最大的那一项,把明细直接带回来(不让模型再问一轮 ≈ 8,000 token)
        → 「没查到」+ 你的槽位是什么 + 差得最少的 5 个,每条带**逐槽位对照**
          (对上的和没对上的都列 —— 只列差异的话,人分不清「其余几项真的相同」
            还是「其余几项压根没比」)

La relajación no se hace solo cuando hay cero aciertos. Si hay 300 unidades que coinciden y el cliente pide 2.800, las 8.932 unidades tras relajar son la respuesta, y si solo se calcula cuando 命中 === 0, en este caso no se daría ni una palabra. El criterio es «si este nivel alcanza para 要几根», no «si hay aciertos» — por eso si no se rellena 要几根 no se dispara, la herramienta solo responde «cuánto hay».

La relajación es una operación de lectura: presentar candidatos no es afirmar que se pueden usar. En la respuesta se indica claramente qué criterio se relajó y que el resto de slots son idénticos campo por campo; «si se puede insertar» queda para que lo decidan el humano y el modelo (QSFP y QSFP28 son dos escrituras del mismo cajón, SR4 multimodo y LR4 monomodo no son compatibles). Cuando tras la relajación aparecen varios encapsulados, se añade una nota ❓ aquí debería preguntarse a un humano.

La división del trabajo es fija: el modelo hace traducción y selección, la máquina hace juicios. La salida de la traducción está restringida por el vocabulario y se puede verificar en el momento; «si dos grupos de slots son el mismo modelo» no se le entrega ni una línea al modelo — si se le entregara, el mismo par de modelos hoy sería el mismo y mañana sería diferente. El criterio es «si la respuesta está en un conjunto enumerable»: si está (tipos de activo 4, modelos de una categoría 80, marcas 32, valores de slot 24) → se le da al modelo para que elija, y si elige mal se puede verificar en el momento; si no está (si A puede sustituir a B) → lo calcula la máquina.

No se adivina el tipo de activo. Antes se probaban los cuatro tipos de parseadores y ganaba el que rellenara más slots — de 189 modelos reales no se adivinaron 61, y otros 5 fueron reconocidos por varias categorías a la vez (128GB 2Rx4 PC5-5600B lo reconocen tanto el módulo óptico como la memoria), decidiéndose por un voto de diferencia. Ahora, si no está en el inventario esa escritura y no se da el tipo de activo, se reporta un error para que la persona lo complete.

Pero solo hay un atributo que realmente no puede determinar la categoría; el resto sí puede (el 2026-08-15 se parsearon todas las escrituras reales de las cuatro categorías): de los 101 slot=valor, 96 solo los ha usado una categoría; los compartidos son solo 5 valores de rate10G / 25G / 100G / 200G / 400G, que existen tanto en módulos ópticos como en tarjetas de red. El cap de la memoria (16/64/96/128G) y el cap del disco (desde 480G) no chocan ni uno. Por eso en las instructions solo se escribió esta única pista (unos 122 tokens, se envía una vez por sesión), no se construyó un índice de «capacidad → tipo de activo» — donde el índice es fuerte (QSFP28→módulo óptico, 3.84TB→disco) el modelo ya acierta; donde el índice es débil (solo un 400G) también se queda atascado igual, construir la tabla es añadir determinismo donde el modelo ya acierta, no ayuda donde no puede, y añade un sistema más que hay que proteger contra la obsolescencia.

Los dos agujeros del camino de slots (medidos el 2026-08-23)

Al preguntar por un modelo concreto, puede devolver el inventario de todo el tipo de activo, mientras que el paquete de respuesta dice «emparejar por slot, solo cuentan los que coinciden campo por campo en el inventario». La raíz de los dos agujeros es la misma: 比一个() solo recorre los slots centrales, y «los slots que la necesidad no dio → continue» (no restringe). Si se continúa hasta el final, es «cada fila coincide campo por campo».

Agujero uno: la necesidad no tiene ningún slot central. Se puede llegar por dos caminos:

Cómo ocurre

Medición real

El parseo del modelo produce un conjunto de slots vacío. De los 189 modelos reales en el inventario, 23 son así (disco 12 / tarjeta de red 9 / memoria 2), todos números de pieza de fabricante: MZQL23T8HCLS-00B7C (Samsung), SSDPF2KX038T1 (Intel), MCX653105A-HDAT (Mellanox), 900-9D3D4-00NN-H (NVIDIA) — en los números de pieza no hay especificaciones que un humano pueda leer

Preguntar MZQL23T8HCLS-00B7C → devuelve disco 62/62 tipos

Los slots dados explícitamente caen todos fuera del core. El lanes del módulo óptico y el media del disco están en el vocabulario y 解析槽位串 los deja pasar, pero no están en el core, así que no participan en la comparación

lanes=2*DR4 → módulo óptico 80/80; media=SSD → disco 62/62

La dirección es la peor — sobrerreportar: si falta, la persona pregunta; si sobra, la persona ya lo autorizó.

Solución: 按槽位配 primero calcula «cuántos slots realmente se van a comparar» (= core ∩ necesidad). Si es 0, ya no devuelve coincidencias, sino dos ramas:

  • La huella literal de esta escritura está en el inventario → devuelve solo esas pocas, marcadas solo por literal, y aclara «puede haber otras escrituras del mismo modelo en el inventario, esta vez no se buscaron».

  • La literal tampoco está → devuelve «no se pudo consultar» + qué hacer (traducir al vocabulario a slots centrales y reconsultar, o llamar a 看有哪些型号 para obtener la lista y elegir).

La literal compara ledger.字面指纹 (mayúsculas + quitar separadores, el mismo criterio que 占用写.归一型号 y el agrupamiento de normalize), no igualdad estricta — la igualdad estricta se midió una vez, y si el cliente escribe el número de pieza en minúsculas no se encuentra nada (-21 registros). No se hace coincidencia de prefijo/contención: ese camino fue eliminado a propósito de este repositorio, porque pierde el mismo modelo con «un segmento de más en medio» (medido: pierde 18.000 unidades).

Agujero dos: solo se resuelve una parte de los slots centrales, pero se reporta como coincidencia exacta. Esto es mucho más común que el agujero uno — de los 189 modelos reales, 86 tienen cobertura parcial. Los campos no resueltos no participan en la comparación, lo cual tiene sentido semánticamente (lo no restringido no se filtra), pero la frase «solo cuenta la coincidencia campo por campo» no cambió ni una palabra, así que «realmente exacto» y «solo se restringió una quinta parte» se ven idénticos en el paquete de respuesta. Factor de amplificación medido:

Cuántos slots centrales resueltos

Promedio de tipos reconocidos

Módulo óptico 5/5

1,3

Módulo óptico 4/5

2,4

Módulo óptico 3/5

4,0

Módulo óptico 1/5

38 (38 de los 80 tipos. Ese modelo es AFBR-709SMZ 850nm LASER PROD, de los cinco campos solo reconoció wave=850)

Disco 4/4

1,0

Disco 1/4

6,7

La solución no es negarse a responder (cuando el cliente dice 3.84T, devolver 11 tipos es correcto), sino decir la cobertura: 按槽位配 devuelve cobertura de parseo {participan en la comparación, no participan, cobertura total}, y 怎么筛的 cuando no hay cobertura total aclara «esto no es coincidencia exacta — de los 4 campos centrales solo se restringió cap; bus, form y gen no participaron en la comparación, así que entre las 11 líneas siguientes hay productos con bus/form/gen distintos. Al reportar a una persona, no digas «es este modelo»».

De paso se descubrió que parte de ese 100% de recall era falso. Esos 23 números de pieza antes acertaban toda la categoría, así que el valor real estaba naturalmente dentro → se contaba como recall. Tras la corrección, la línea 带项目尾巴 bajó a 88%, y al atribuir caso por caso, los 23 que cayeron son todos de este lote de números de pieza, ni una sola regresión real, por eso bajó la línea base (ver el comentario de la línea base en tests/召回.test.mjs). Las demás líneas volvieron a su nivel original gracias a la huella literal: 全小写/连字符换空格 volvieron a 100%, 去掉所有分隔符 volvió a 96% (los 8 restantes son brechas reales que ya no acertaban antes de la corrección).

Las tres compuertas se quitaron del código fuente y se verificó que se ponen rojas: la del agujero uno → 查库存.test.mjs rojo; la literal vuelta a igualdad estricta → 召回.test.mjs rojo; la del agujero dos → 查库存.test.mjs rojo.

Las tres «listas para que el modelo vea», con límites fijos

看源表         这个数从哪张表、哪一列来的        —— 答来源
看有哪些型号    这一类有哪些标准写法和品牌         —— 答清单,给模型挑
看筛选         每条规则认识哪些取值、变了什么      —— 答原材料,给模型判

看筛选: lo que se entrega es materia prima, no una conclusión

Las 45 reglas (tabla × campo de regla) qué valores reconoce cada una, cuántas filas tiene cada uno, más el diff con la línea base anterior. El total son unos 1.500 tokens; con 只看变了的: true, unos 170.

En la versión anterior esto lo juzgaba la máquina: «si la tasa de acierto cae más de 20 puntos porcentuales = ⚠ descenso evidente». Ese umbral estaba inventado, marcado como no verificado, y el mismo cambio significa cosas completamente distintas en tres contextos — una persona cambió activamente la regla de filtrado / se modificó una columna de la hoja de origen / realmente entró un lote de mercancía con estado nuevo; la máquina no puede distinguir cuál es. Medido en la práctica, además, hay tres tablas que están permanentemente al 7% / 15% / 20% (libro maestro de activos completo, la mayoría de las filas son equipos en uso «en línea»), y cualquier umbral absoluto las reportará falsamente como anomalías.

La división actual:

Quién

Qué hace

Máquina

Recoge la distribución de valores de las 45 reglas; hace diff con la anterior (diff puro, sin juicio); se mantiene el criterio absoluto de cero falsos positivos de «se leyeron filas válidas pero ninguna acertó»

Modelo

A qué contexto pertenecen estos cambios; si excluir un valor es problemático o no (como 借测 288 行)

Persona

Si se rueda la línea base o no./周更.mjs --认下筛选, la herramienta no puede tocar este interruptor. Una alarma que uno mismo puede limpiar equivale a no tener alarma

看有哪些型号: lista cerrada, para que elija el modelo

Todas las escrituras estándar de una categoría en el inventario. Módulo óptico 80 = 1.128 tokens, disco 62 = 512, memoria 17 = 285. Cuando la escritura del cliente no es estándar y el modelo no está seguro de a cuál del inventario corresponde, se llama a esta — se elige una concreta de la lista y luego se consulta. Con 带槽位: true, cada entrada lleva adjunto el resultado del parseo (el módulo óptico sube a 4.025 tokens). También se dan qué marcas hay en esta categoría y cuánto stock tiene cada una (en orden descendente por unidades, solo las que tienen stock) — la lista de marcas de las instructions solo tiene «nombre estándar ← alias», y el modelo no ve la distribución al elegir marca, puede elegir una que ni siquiera existe en el inventario, y luego recibe 0 sin poder distinguir entre «no existe esta marca» y «me equivoqué al buscar».

Esas dos enumeraciones se extraen en vivo del paquete de la solución, no están escritas a fuego en server.mjs (现抠枚举(), en lib/ledger.mjs) — la misma regla que «el parseo de slots se extrae en vivo de Tampermonkey». Antes cada una estaba copiada 9 veces (el inputSchema de cinco herramientas), y si el paquete de la solución añadía una categoría de activo o un almacén, aquí no pasaba nada: el modelo no podía filtrar por ello, ese lote de mercancía no se podía consultar, y sin error. Si no se puede extraer del paquete de la solución, no se da enum, en lugar de dar un enum vacío — un enum vacío equivale a «no se puede rellenar nada», es una prohibición total silenciosa; en ese caso el parámetro degenera a string libre, y la primera llamada real fallará con la causa real. El criterio ㊳ de tests/direct.test.mjs hace grep directo a server.mjs; si se escribe uno a fuego, se pone rojo.

En inventario ≠ cantidad disponible: el inventario viene de las hojas de origen del paquete de la solución (en tiempo real); lo ocupado viene del resumen de registros de ocupación del libro; cantidad disponible = inventario − ocupado. El compromiso hacia afuera se basa en la cantidad disponible. Leer el libro tarda 4,4 segundos medido, así que se lee bajo demanda:

调用

耗时(热态)

答什么

查库存(默认)

2.5 秒

在库,带一句「这是在库不是可用量」

查库存 要可用量=true

5.1 秒

在库 / 占用中 / 可用量三个数

看分布

2.5 秒

不读占用

找替代默认就读

4.6 秒

「够不够」必须扣掉被占的

查SN

2.5 秒

不读占用(要的是清单,不是能不能拿)

看变动

2.5 秒

不读占用

看源表

0 秒

只读 plans.json,一次网络都不打

三个工具的默认值故意不一样查库存 问的是「有多少」,找替代 问的是「能不能顶上」—— 后者隐含「能不能拿到」,说够了但实际被占了,人会白跑一趟采购。所以找替代不给关闭选项。

找替代 的每条候选同时给「在库」和「可用量」够不够小计 都按可用量算, 排序也按可用量(在库多但被占光的不许排前面)。两个数的差本身就是人要知道的。 相近档里只差一个槽位、其余逐项相同的单独标一句 只差这一项QSFP28-100G-SR4 vs 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 vs 1300,能放) 和差一个 std(DR4 vs FR4,多模换单模、根本不通)都是「差 1 项」,混在一层里人得整层翻完 才知道哪几个值得看。分层键是「差几项 + 这几项里最重的那个放宽代价」,每层还带一句 怎么读(能放 → 先看这一层;不能放 → 除非人有别的依据否则别推)。 取最重不取平均:差两项里只要有一项不能放,这个候选就是不能放,另一项多好都不改结论。 排序先按差几项、同差几项按代价从轻到重(判据 ㊳㊴㊵ + 消融 10)。

平铺的「相近候选」和分层必须同序(判据 ㊶㊷ + 消融 11)。加分层的时候差点埋一个坑: 平铺列表还按老规矩排(差几项 → 比需求低 → 根数降序),三个候选都「差 1 项」时一路落到 按根数降序,而根数最多的恰好可能是最不该推的 —— 实测差 std(不能放,33 根)排第一、 差 wave(能放,11 根)排最后,而分层里正好倒过来。同一批货两种视图顺序相反, 看分层的人第一眼看到最该看的,要平铺列表的人第一眼看到最不该看的。 现在「最重代价」进了排序键,排在根数前面:一个插不上的货有再多根也没用。 排序后的那份全量列表没删(截断行为、层内排序、每条差异数这些只有它验得了,砍过一次 8 条判据当场没了依据),要它就显式传 每档几个

占用读不到时不退回在库冒充:候选里干脆不给「可用量」这个字段(给一个等于在库的 「可用量」比不给危险得多,它看着是个已经扣过的数),小计.按什么算的够不够 两处都自报是按在库判的、并带 ⚠。tests/substitute.test.mjs 消融 4 拿掉这一层就必须变红。

台账读不到时不让整个查询失败,改报 ⚠ 可用量算不出来——「没人占」和「算不出来」是两回事。

每个返回值都挂同一个「口径」块(数据来源、读取时间和身份、在库总根数、品牌怎么补的、 坏件排除、SN 对比、筛选命中率)。不做成独立的「查数据新鲜度」工具——那样模型不会 主动去查,人就看不到。 开头的字段工具描述里要求模型必须原样转达。

口径块里只留会改变这次回答的字段。各段耗时、读取秒、筛选去重链路、结构缓存、 归一改动、判据来源、台账原始行数这几项是给写代码的人调试用的,模型每次都要读完, 几轮下来是纯噪音——实测占口径块一半、占整个返回体约两成。默认不发,INVENTORY_VERBOSE=1 才发。不是删掉:出问题时那些数字是唯一的定位线索。tests/scope.test.mjs 守住 「该发的一条都没被瘦掉」——少发一个耗时数字没人受伤,少发一条「品牌是补的」 就是把「这个牌子是我们猜的」藏了起来,而藏起来不会报错。

返回体第一个字段是「怎么答」

便宜的模型会把结果写成一大段流水账。格式指令放在返回体的第一个字段,因为它是 从上往下读的,指令排在几千 token 数据后面基本不生效;只写在工具描述里也不行—— 描述在会话开头读一次,几轮之后就被挤远了,而返回体是它每次组织答案时都要重看的。

怎么答: 先出一张表:品牌 | 型号 | 库房 | 在库 | 占用中 | 可用量。
        表下面用短句补这几条,一条一行:⚠ 开头的每一条原样带上、
        同一型号在多个库房时按库房逐行列,不许加总成一个数、数据读取时间。
        别写查询过程、别复述字段名、别加收尾总结段。

梯队不进表头。 它是拿来判「这批货要不要跨库房协调」的,不是给人看的列 —— 人要的是「哪个牌子、在哪个库房、有多少」。塞进去会让每张表多一列没人看的数字。

代价约 130 token/次,换掉的是一整段「我调用了查库存工具,查询到以下结果……」。 它只管形状——哪些字段必须转达仍归各自的 和工具描述。

参数回显:把这次实际用的条件写回去

多轮追问是模型最容易错的地方,而且错了不报错:人问完「闵行有多少 400G DR4」接着说 「那临港呢」,模型要靠回忆自己上一轮传了什么 —— 记漏一个槽位查出来多一大截、 多带一个查出来少一大截,两种都拿到一个看着正常的数,人也看不出那不是他问的东西。

查库存 现在把这次实际用的条件回显出来,排在数据前面(排在几千 token 数据后面模型读不到):

这次查的: { 资产类型:'光模块', 槽位:'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 --快     跳过真读那步,不打网络

这套东西有三样不在这个仓库里,光看代码看不出来:

缺什么

表现

怎么办

lark-cli

起不来

跟 WorkBuddy 走,不是单独装的 —— 装 WorkBuddy 并打开一次。换了位置设 INVENTORY_LARK_CLI

plans.json

起不来

按顺序找INVENTORY_PLANS → 仓库根的 plans.json(方案包目录)换机器时拷进仓库根就能跑,里面没有任何密钥。它的家在油猴那个仓库、由「品牌对照表」界面维护,所以这边不留副本

飞书那边的读权限

「读不到表」

最容易卡、也最看不出来的一环 —— 它和路径错、网络断长得一模一样。找表的维护方开权限

登录态不用配:lark-cli 用的就是 WorkBuddy 那套(identitySource: auto_detect), 装的人登录自己的飞书账号就行,不用给任何密钥

发通知 现在不真发(只拼文本返回,你自己粘发),所以不需要任何发消息权限。若以后要 bot 自动发,实测出来的门槛:用户身份发被租户策略挡(230027);bot 私信到别人要那人进 app cli_aae5ee90f8f85cc5可用范围230013)、发群要 bot 先在群里230002);bot 发消息要 --as bot + im:message scope(lark-cli auth login --recommend)。唯一零门槛路径是「bot 发它在的群 + <at user_id> @人」。

每条红的后面都跟一句「怎么办」,而且一项坏了不挡后面的 —— 换机器时人想一次看全,不想修一个跑一次。这两条都有判据守着(tests/自检.test.mjs)。

三件静态检查(都在 15 秒那档里)

语言级的错交给该语言的工具,不自己写正则。三件都是全局装的(shellcheck / eslint 用 brew 和 npm -g), 仓库本身零 JS 依赖 —— ESLint 用全局二进制 + 仓库里一个 eslint.config.mjs,不要 node_modules

工具

抓什么

今天它第一次跑就挑出的东西

shellcheck

shell

我刚写的 ls | wc -l(SC2012)、[ -n "$(grep …)" ](SC2143)

eslint

JS 的 no-undef / no-unused-vars

三处死代码;以及回放验证时精确指到 server.mjs:1408:11 'name' is not defined

ast-grep

按语法树改名(不是检查,是改代码时用)

只开 no-undefno-unused-vars,风格类一条不开。 这个仓库的取舍(中文标识符、长注释、 内联三元)是有意的,让 linter 管风格只会制造一堆要豁免的噪音,而噪音会让人连真错也一起忽略

接这类工具时防三件事,缺一件它就会「失效时是绿的」:

  • 没装不许静默跳过 —— 那样它永远「通过」。

  • 看它扫了几个文件 —— 报 0 个文件和报 0 个问题长得一样,而前者是没检查。 shellcheck 还要额外看 SC1088:它遇到不认识的语法会停止解析、照样退非 0, 260 行只扫 28 行而看起来在干活(这就是 run-tests.sh 里函数名叫 sec/run_one/teeth/chain 的原因)。

  • 只认退出码,不认输出文本 —— 第一版拿 grep ' error ' 匹配 eslint 输出, 而它印的是 [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.mjs0 秒、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 和「压根跑不通」是两类问题,混在一起就分不出「这个货真没有」和「这条链坏了」。

  • 不许拖慢查询 —— 追加写不 await、异常整个吞掉;日志坏了不该让人查不到货。

记的收口在 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 多模,规格逐项「相同」、数字很好看,插上不亮。 按收益挑等于优先推荐最危险的那一项。

方向 表说的是「候选比需求高算不算能用」;这张表说的是「查不到时把这一项整个不约束, 风险有多大」—— 两件事,两张表:

能放

慎放

不能放

光模块

form wave

media(单模↔多模)

rate std

内存

speed rank

cap

gen(DDR4↔DDR5)

硬盘

gen

cap form(2.5↔3.5 寸)

bus(SAS↔NVMe)

网卡

chip

rate ports

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-100Gqsfp 100g 是同一对)。改口时把上一版留在 改过 里: 「上周说能顶、这周说不能顶」本身就是要给人看的。

  • 拍「不能顶」的候选不删掉,只标 —— 删了人看不出「这个我们判过」,下次还会有人再问。

读不出来(文件坏了)时 读决定 直接抛,不当空表 —— 当空表等于把人拍过的决定悄悄清零, 而下一次查询只表现为「又来问一遍」。但 挂决定 那条路吞掉异常:决定表坏了不该让查询整个失败。

查SN:大了就给文件,不往对话里倒

源表每条记录就是一根货、带 SN,聚合成物料键时那一列被压掉了 —— 查SNlib/sn.mjs) 把它还原回来。三件事是这个工具的全部内容:

① La forma en la instantánea es la previa a la normalización; hay que volver a mapearla. Los detalles de SN provienen de 收SN() de SN\t资产类型|品牌|型号|库房; los tres últimos segmentos están tal cual en la tabla fuente (闵行 y 闵行库房 son dos valores, SAMSUNG y Samsung también lo son). Así que primero se usa 原始 de norm.rows (que guarda exactamente 品牌0|型号0) más la tabla PLACE para construir un índice de búsqueda inversa. Sin este paso, buscar «128G de memoria en 闵行» perderá los 5.838 sticks que en la tabla fuente están escritos como «闵行库房», y lo que se pierde no da error, solo el número sale más pequeño. Si una forma original coincide con dos claves de material, se lanza — eso significa que la normalización ya no es una función, y entonces calcular con cualquiera de las dos es adivinar.

② Los detalles de SN siguen el valor de retorno de load(), no leen la instantánea del disco. La instantánea se escribe fire-and-forget; cuando la lectura en frío acaba de volver, en disco todavía está la anterior; en estado caliente con acierto ni siquiera se reescribe. Traerla desde el resultado (unos 10 MB), SN y norm son seguro de la misma lectura, y en库存 = conSN + sinSN cuadra.

③ «Unidades en stock» y «unidades con SN» se reportan siempre por separado. La columna SN de la tabla fuente tiene celdas vacías; los dos números no son iguales; si se fusionan en uno, la gente usará el número de SN como si fuera el de stock. Cuando no cuadran, la respuesta lleva ⚠ hay mercancía sin SN. Además está 认不出的 (criterio de todo el almacén): SN que no se corresponden con ninguna clave de material, normalmente 0; si no es 0, significa que la normalización y la captura de instantánea no están tomando las mismas filas — este número no se puede tragar; si se traga, ese lote desaparece de todas las consultas de SN.

Si supera 最多列几条 (por defecto 50, ajustable con INVENTORY_SN_INLINE), ya no se listan en el diálogo; en su lugar se escribe un CSV con BOM, y la respuesta solo da la ruta y el recuento agrupado por clave de material. El destino se divide en dos según «quién quiere este archivo»: si lo pide explícitamente una persona (一定要文件: true) → Escritorio; si la herramienta lo materializa porque es demasiado grande para el diálogo → ~/.cache/inventory-mcp/导出/. Esto último es una acción interna de la herramienta para ahorrar tokens; la persona no lo ha pedido y no debería ocupar su escritorio — mezclarlo en una sola cosa tiene consecuencia probada: una tarde de pruebas dejó 20 CSV pegados en el escritorio. Cada directorio conserva las 20 más recientes, y solo se borran los nombres generados por uno mismo según el formato. 50 está fijado por tokens: un SN son unos 5 tokens, 50 son unos 250, todo el cuerpo de respuesta es del mismo orden que un 查库存 normal (unos 1.400 tokens); a partir de ahí empieza a quitar margen a las siguientes rondas, y cuando alguien quiere mil SN, lo que quiere es ese archivo, no hacer scroll en el diálogo. El archivo solo se escribe al superar el límite o con 一定要文件 explícito — en condiciones normales no se escribe, porque si se escribe alguien tiene que limpiarlo, y nadie va a limpiar un directorio que genera un archivo en cada consulta.

Cero coincidencias se comenta aparte con «esto no significa que no haya esta mercancía en el almacén»: la condición compara la forma normalizada, devolver un 0 seco hará que el modelo lo reporte como «no hay esta mercancía».

看变动: las entradas y salidas reales se calculan por SN; los cambios a nivel de clave son ruido

La pregunta «qué ha cambiado», el SN对比 del bloque de criterio no la puede responder — compara «la última vez que alguien ejecutó este MCP», y cualquier consulta sobrescribe la línea base (incluida la que el propio agente hace de pasada). Así que casi siempre muestra «coincide». La línea base real es la que guarda ./周更.mjs los jueves en 周基线/YYYY-MM-DD.tsv; 看变动 lee esa, solo lectura, no escribe, no toca la línea base.

Los cambios a nivel de clave engañan; esa es toda la presión de diseño de esta herramienta. Medido del 08-06 → 08-13:

Criterio

Número

Nivel de clave: desaparecen 30 claves / aparecen 48 claves, el edificio 45 de golpe pierde 3.573 unidades

parece que ha pasado algo gordo

Por SN: salida real 32 unidades / entrada real 57 unidades

lo que realmente se movió

Solo ha cambiado la forma de escribir

11.336 unidades

La diferencia es todo el mismo lote al que se le ha completado la marca vacía como CLT / 光迅 / H3C — ni un solo SN se ha movido. Filtrando solo el edificio 45 de 移动 es más limpio: 75 claves cambiaron, entradas/salidas reales 0 / 0. Así que el orden del cuerpo de respuesta es fijo: entradas/salidas reales primero, ⚠ no confundir cambio de forma con entrada/salida detrás, y las «desapariciones/apariciones reales» a nivel de clave son las que quedan después de quitar los cambios de forma (解释改名(), lib/weekly.mjs). El criterio es el SN: si un SN está en ambos lados, no se ha movido, da igual cómo esté escrita la clave.

Una clave pierde 5 unidades, de las cuales 3 solo han cambiado de forma → se reporta «han faltado 2 de verdad», no 5 ni 0. tests/weekly.test.mjs ablación 3, si se quita esta capa, tiene que ponerse rojo.

Si se pide una línea base que no existe, da error y lista las que hay, no devuelve un «no ha habido cambios» — mezclar «no existe esta línea base» con «no se ha movido nada en este periodo» haría que la gente creyera que la cuenta está cuadrada.

看源表: de qué tabla y de qué columna salen estos números

Esta herramienta nació de un error real de respuesta: alguien preguntó «todos los SN de 128G de memoria», fui a mirar las tres tablas del libro mayor (detalle de existencias / libro mayor offline / registro de ocupación), vi que no había columna SN, y respondí «no existen datos de SN» — pero el MCP no lee el libro mayor, lee las tablas fuente del paquete de planes, donde cada registro es un SN. Solo ver el resultado sin ver la fuente hace que se use el sitio equivocado como evidencia.

El número de tablas se toma del paquete de planes, no se fija en la documentación ni en los criterios: el 2026-08-14, al eliminar el plan de CPU, pasó de 34 a 31, y las dos comprobaciones que fijaban 34 en tests/protocol.test.mjs se pusieron rojas en el acto — ahora se calcula en vivo desde el paquete de planes.

Solo lee el paquete de planes (plans.json), ni una sola llamada de red: lo que se quiere es «cómo dice la configuración que se lee esta tabla», no «cuántas filas tiene esta tabla ahora» — eso es cosa de 查库存.

Los mapeos en map donde «el nombre de columna de la tabla fuente ≠ nuestro nombre de campo» deben listarse explícitamente. Medido: 4 tablas tienen el SN con otro nombre:

SN: { 有: "31/31 张",
      列名不一样的: [ "网卡·山西/山西广灵:叫「外部SN」",
                      "硬盘·山西/山西台账:叫「外部SN(必填)」",
                      "内存·临港9号楼/B-2项目-9号楼资产表:叫「CMDBSN」", … ] }

Reportar solo «tiene SN» haría que la gente busque en la tabla fuente un nombre de columna que no existe. Igual con «货位»: solo algunas tablas lo tienen, y entre estas 11 hay unas que lo llaman «货架-区块», otras «储位», otras «箱号».

Por defecto solo se da el resumen por campo; el detalle por tabla hay que pedirlo explícitamente (medido: resumen 2.336 tokens, por tabla 8.580). Ambas listas se recortan — si no se recorta, el propio resumen son 4.227 tokens, más absurdo que el detalle que pretende ahorrar; al recortar hay que reportar cuántas quedan (tests/源表.test.mjs criterio ⑬).

De dónde vienen los datos: dos caminos, los corta INVENTORY_SOURCE

direct(默认)                        ledger(INVENTORY_SOURCE=ledger)
31 张源表(28 张 + 临港移动7号楼 3 张)    飞书 线下台账 (340 行)
  每张 1~2 次并发调用:列宽有缓存就直接只读一类     人每周从油猴导出件粘贴
  冷启动约 11 秒 / 热态约 2.4 秒          2.5 秒
  约 16 万根 / 360+ 个物料键            12.9 万根 / 325 个物料键
       └──────────────┬──────────────┘
          库房名统一 → 品牌归一 → 型号归一 → 按物料键聚合
               物料键 = 资产类型 | 品牌 | 通用型号 | 位置(粗到库房)

Los dos caminos comparten la misma normalización (normalize() de lib/ledger.mjs); la diferencia está solo en de dónde vienen los datos. El camino de lectura directa tiene tres pasos más: aplicar las reglas de filtrado de build_stock según el plan → deduplicar por SN (lib/dedup.py) → un SN se cuenta como una unidad y se agrega en forma de libro mayor.

El criterio para que el default sea direct es que es rápido y completo: en estado caliente unos 2 segundos, comparable a los 2,5 de leer el libro mayor, con más de 30.000 unidades de más, y la diferencia se explica — el libro mayor offline del edificio 7 de 移动 en 临港 no está recogido; el resto es una semana de retraso más lo que se escapa en los pasos manuales. ledger se queda como vía de escape, no se borra.

Aquí no se fija un número concreto de unidades: las tablas fuente pueden cambiarse varias veces al día (el 2026-08-13 hubo cambios a las 09:32 / 09:48 / 13:16), un número fijado al día siguiente ya es incorrecto, y un número exacto caducado engaña más que no tener ninguno. Para el número actual, se ejecuta una vez; está en el bloque de criterio.

Los 2 segundos en caliente son consultar revision de 9 documentos en paralelo; si ninguno ha cambiado se usa la caché en proceso, los datos están siempre en local, y solo se vuelve a traer si algo cambió. Se usa revision y no latest_modify_time — este último tiene medido un retraso de 2~5 segundos; justo después de un cambio, la consulta diría que no ha cambiado.

El paquete de planes también cuenta como parte de la «versión» (方案包指纹()): si cambian las reglas de filtrado, se añade o quita una tabla, cambia un mapeo de columnas, las revisiones de los 9 documentos de Feishu no cambian ni una, pero la caché debería invalidarse. Medido: al eliminar un plan (9 tablas fuente menos), la consulta siguiente en caliente seguía respondiendo con los datos de todas las tablas fuente (entonces 34 tablas / 162.514 unidades; ahora el paquete de planes tiene 31). Y lo primero que hace una persona al ver ⚠ la columna de filtrado tiene valores nuevos es precisamente modificar el paquete de planes — si no se cuenta, después de cambiarlo hay que esperar a que alguien toque alguna tabla irrelevante para que surta efecto. La huella se calcula por contenido, no por mtime: copiar/sincronizar/restaurar cambia el mtime, y juzgar por mtime haría releer 6~7 segundos en vano; si no se puede leer, se da un valor fijo en vez de un aleatorio, porque si no cada consulta se interpretaría como «ha cambiado».

Cada relectura hace además una comparación registro a registro: las 160.000 existencias se comprimen a SN → 资产类型|品牌|型号|库房 y se guardan (~/.cache/inventory-mcp/sn-snapshot.tsv, unos 11 MB, se sobrescribe cada vez), se calcula la diferencia con la anterior por SN, y el resultado entra en el bloque de criterio: cuántas han faltado (salidas), cuántas han aparecido (entradas), y cuántas tienen «el SN sin moverse pero la forma del modelo cambiada». Esta última categoría se reporta aparte porque por diferencia de SN no se detecta (está en ambos lados), pero agregada tiene exactamente el mismo aspecto que una entrada y una salida — es la causa más común de «en dos semanas el modelo del inventario no cuadra». El detalle de SN cae en sn-diff.json; en el bloque de criterio solo van los números y las primeras claves de material (para ahorrar tokens). La lectura de la instantánea antigua y las peticiones de red van en paralelo; escribir la nueva instantánea no espera, así que no ocupa la ruta crítica; en caliente ni se toca el disco.

El arranque en frío tiene además una caché de estructura en disco (~/.cache/inventory-mcp/schema.json): guarda wiki→token de documento y en qué columna está cada columna usada de cada tabla; estas dos cosas casi no cambian, y al acertar cada tabla se ahorra el viaje de «leer primero la cabecera». La caché también recuerda «esta tabla va por segmentos, el tamaño de segmento ha convergido a cuántas filas, cuántas filas tenía la última vez». La caché caducada no hace leer mal — el cuerpo lleva su propia fila de cabecera, y cada vez se valida en vivo si faltan columnas; si no cuadra, se descarta la caché y se va por el camino lento (验表头(), criterios ⑮~⑱ de tests/direct.test.mjs + ablación 4 lo vigilan). El tamaño de segmento en caché solo puede ser menor que la estrategia actual, nunca mayor (夹段长()): si no se recorta, al reducir 每段格子 las tablas ya cacheadas siguen usando el tamaño de segmento grande antiguo, la nueva constante nunca les aplica, y además lee bien, conserva, y las comprobaciones salen todas verdes, solo que el doble de lento.

Relectura incremental: solo se relee el documento cuya revision ha cambiado

Antes, si cualquier documento cambiaba, se releían las 31 tablas completas. Ahora se decide qué documentos releer según la revision a nivel de documento; los que no han cambiado usan directamente las filas guardadas en disco (~/.cache/inventory-mcp/rows.json, medido 36 MB). El criterio es el mismo que el de la ruta en caliente (solo reconoce si el contenido ha cambiado), solo que la granularidad pasa de «solo si ninguno ha cambiado se usa» a «si este no ha cambiado, se usa este».

全部都变了(=全量)   14.5 秒  复用 0 张
变了最大那本(6 张)  12.4 秒  复用 25 张
变了最小那本(1 张)   7.5 秒  复用 30 张
一本都没变(热态)     2.8 秒  复用 30 张

El beneficio depende completamente de cuál haya cambiado — la más grande, la de los módulos ópticos de 闵行 con 80.000 filas, solo ahorra 2 segundos. De esos 7,5 segundos, la lectura real de tablas es solo una; el resto se va en consultar una ronda de revisiones (unos 2 segundos) + deserializar decenas de MB desde disco (23 segundos, antes estimado en 0,30,5 segundos, error de casi un orden de magnitud) + filtrado y deduplicación 1,4 segundos.

A disco, no en memoria: 330.000 filas en memoria ocupan medido 168 MB de heap, y este MCP es un proceso único de larga duración que levanta WorkBuddy; dejarlo en memoria es ocuparlo para siempre. Pasarlo a disco cambia eso por que la memoria no crece — y además cubre un escenario que la versión en memoria no cubre: cuando WorkBuddy se cierra y se vuelve a abrir, el proceso es nuevo; antes era inevitable una lectura en frío completa; ahora con las revisiones del disco se puede hacer una ronda y ser incremental.

Tres casos invalidan todo y vuelven al completo: la huella del paquete de planes cambia (las revisiones de esos 9 no cambian ni una, pero cómo se lee cada tabla cambia por completo, no hay «cuál ha cambiado» que valga), force, o que en disco no esté o la huella no cuadre. Si en disco no hay tok o revision guardados, se relee todo — «no sé si ha cambiado» y «no ha cambiado» son dos cosas distintas.

Si la relectura del documento que sí cambió falla, no se pueden obtener las filas antiguas (复用 devuelve null para documentos cambiados), y se va por la degradación explícita de 缺表: usar filas antiguas en silencio daría a la gente un número «que parece completo pero está caducado», mucho más grave que una tabla que falta. La caché de filas solo guarda tablas que se leyeron con éxito — guardar una vacía equivale a fijar «esta vez no se pudo leer» como «esta tabla no tiene mercancía».

El criterio es solo uno, pero es toda la seguridad de esta función: el resultado incremental debe ser idéntico al completo, clave de material por clave de material (tests/增量.test.mjs). No vale usar «la conservación se cumple» como criterio — si se reutiliza un documento que en realidad cambió, el total baja un trozo y cada paso de conservación sigue en verde. El escenario se monta con INVENTORY_FAKE_CHANGED=<fragmento de token>, la variable de entorno se lee en cada llamada (el uso es «en el mismo proceso, primero una pasada completa, luego fingir que un documento cambió y una segunda pasada»; si se leyera de una vez, la segunda pasada no podría cambiarlo, el test tendría que lanzar subprocesos y cada pasada haría una ronda completa de red).

Solo leer las tablas del tipo de activo consultado + deduplicación en vuelo (2026-08-17)

Preguntar «¿hay suficientes módulos ópticos?» antes leía las 31 tablas fuente completas. Los módulos ópticos solo ocupan 7 tablas; las otras 24 (tarjetas de red 9 + discos 9 + memoria 6) no usan ni una fila. Peor aún: cuando el modelo pregunta «¿hay suficiente de estos dos modelos?», lanza dos 查库存 a la vez, y la caché en proceso solo se escribe al terminar la ejecución, así que cada uno lee una pasada completa.

Medido (antes del cambio):

两个 load() 并发        55.0 秒,各自读回 332,763 行   ← 各读各的,双倍 API 调用
等第一个跑完再来第三个     2.0 秒                       ← 这才走缓存

Después del cambio:

只问光模块(冷)    42.6 秒   读 7 张 / 237,109 行 / 119,090 根
再问网卡           8.7 秒   读 9 张,行缓存累计 16 张
两个硬盘并发        8.6 秒   只读一遍,两边拿到同一个结果对象
接着全量           9.8 秒   31 张里 25 张直接复用 —— 前面几次只读一类顺手把缓存捂热了
全量之后再问光模块    2.0 秒   走「全部」那份缓存,不重读

Qué herramientas leen solo una clase: el criterio es «¿la respuesta de esta herramienta va a usar filas de otra clase?»: 查库存 / 看分布 / 找替代 / 看有哪些型号 leen solo una clase; 查SN (un SN puede pertenecer a cualquier clase), 看变动 (la comparación registro a registro es de todo el almacén), 看筛选 (quiere la distribución de valores de todo el almacén) tienen que leer todo.

Tres reglas, cada una para tapar un error silencioso:

Leer solo una clase no puede rodar ni una línea base. Sobrescribir la instantánea SN de todo el almacén con la de solo 7 tablas equivale a fijar «esta vez no se leyeron las tarjetas de red» como «la mercancía de tarjetas de red ha desaparecido por completo» — en la siguiente comparación completa, doscientas mil unidades cuentan todas como «de más», y cada paso de conservación sigue en verde. Así que leer solo una clase solo responde la pregunta, no asume vigilancia: no escribe instantánea SN, no rueda línea base de aciertos, no escribe archivos de diferencias, y tampoco compara con la línea base antigua (media parte de datos contra línea base completa; 归零 alertaría por cada tabla no leída).

El criterio debe autodeclararse. Sin la frase ⚠ esta vez solo se leyeron tablas fuente parciales, 在库总根数 pasa silenciosamente de «todo el almacén» a «esta clase», y el número tiene exactamente el mismo aspecto, así que el modelo responde «¿cuántas unidades en total?» con un orden de magnitud menos.

La caché de filas se superpone, no se sobrescribe. Leer solo una clase trae las filas de 7 tablas; sobrescribir todo borraría las otras 24, y la siguiente consulta de tarjetas de red tendría que hacer una lectura en frío completa — un cambio pensado para ahorrar tiempo acaba haciendo más lenta otra consulta.

La caché «completa» puede responder cualquier pregunta estrecha; al revés no (superconjunto; la capa de respuesta igual filtra por tipo). Sin esta asimetría, después de consultar el completo, preguntar por módulos ópticos releería en vano; la optimización se volvería más lenta justo en el caso más común.

Dos interruptores de escape, que a la vez son los puntos de inyección de la ablación (si algo no se puede apagar, no se puede demostrar que funciona): INVENTORY_NO_NARROW=1 vuelve a leer todo, INVENTORY_NO_INFLIGHT=1 apaga la deduplicación en vuelo.

La deduplicación entre planes (un mismo SN cuenta como tarjeta de red y como disco) es entre tipos de activo; al leer solo una clase no se ven los choques entre clases. Medido con los datos actuales: 0 registros, así que hoy no afecta a ningún número; el día que no sea cero, el camino completo seguirá reportando ⚠ las reglas de filtrado se solapan.

Lo que realmente esperas son unos segundos (medido 2026-08-17/18)

Primero una distinción que yo mismo confundí: todos los números de «lectura en frío 41 segundos» se midieron en un directorio temporal vacío (para no tocar la caché real); ese es el escenario de la primera ejecución en una máquina nueva. Tu escenario es reiniciar WorkBuddy — el proceso es nuevo, pero la caché de filas en disco sigue ahí:

新进程 + 真缓存,问光模块        3.8 秒   ← 7 张全复用,读表 0 秒
同进程再问一次                   1.9 秒
空目录冷读 7 张(新机器才遇到)   41.1 秒

Esos 3,8 segundos, desglosados, el 88% se concentra en un solo sitio:

2.00 秒   问 9 本文档的 revision(一趟网络,已经全部并发了)
0.14 秒   起 python 去重 33 万条
0.08 秒   JSON.parse 那 37.6 MB 行缓存
0.03 秒   从盘上读那 37.6 MB
0.01 秒   import

Esos 2 segundos no se pueden bajar, está medido: lark-cli levantar un subproceso + un viaje de red es de por sí un suelo de 12 segundos — 9 metainfo en paralelo y 1 metas/batch_query tardan lo mismo (12 segundos cada uno, igual que uno solo). Así que «fusionar en una llamada por lotes» es un camino muerto; además batch_query solo tiene latest_modify_time (medido con 2~5 segundos de retraso) y no tiene revision.

Así que se saca de la ruta de espera del usuario: periodo de confianza

1,5 segundos después de arrancar el MCP, en segundo plano se hace una pasada en caliente; después cada hora se verifica en segundo plano; la consulta usa directamente la última verificada, sin ni un solo viaje de red.

第一次(真核)                    3.7 秒
信任期内                          0.0 秒
后台定期核(绕过信任期)           2.1 秒
force(导台账 / 周更)            42.0 秒   ← 不受影响,永远真核真读

El coste es que los datos tienen como máximo una hora de antigüedad (foto tomada el 2026-08-17). Así que:

  • La antigüedad tiene que ser visible: el bloque de criterio lleva 数据核对于: 2026-08-18 00:00:06(verificado hace 3 minutos; si las tablas fuente se han modificado después, esta vez no se ha comprobado). Usar en silencio un número de hace una hora es el error que esta cosa no puede cometer — cada paso de conservación sigue en verde, el total sigue correcto; solo yendo a verificar la tabla fuente de verdad se nota.

  • La pasada en segundo plano no consume su propio periodo de confianza (_绕过信任); si no, cada vez acertaría en caché y nunca verificaría de verdad, y el periodo de confianza se convertiría en una mentira que nunca caduca.

  • INVENTORY_TRUST_MS=0 vuelve a «verificar en cada consulta».

Para ir más rápido solo queda saltarse el subproceso de lark-cli y lanzar HTTP directamente (ahorrando ese suelo de 1~2 segundos), a costa de asumir uno mismo la autenticación y el refresco de tokens de Feishu — ahora todo eso lo gestiona lark-cli.

Por qué leer tablas va a esta velocidad (medido 2026-08-15)

Leer tablas ya está al límite del paralelismo; para ir más rápido solo queda leer menos. Tres grupos de mediciones alternadas:

① 单张表切几段       闵行光模块 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 秒

El paralelismo solapa la «espera», no la «transferencia»: el tiempo de una petición = espera (ida y vuelta) + transferencia (ocupa el canal). El paralelismo apila varias «esperas», pero los bytes no disminuyen por enviarlos a la vez. Así que la curva baja, se aplana y luego sube ligeramente — en ③, 8 vías ya llenan el canal; 32 vías van un poco más lentas (decenas de procesos lark-cli peleando por la CPU); en ①, 68 segmentos igual. INVENTORY_CONCURRENCY por defecto es 8, 每段格子 es 45.000 (17 segmentos); ambos se fijaron así.

El tamaño de segmento no coge los 34 segmentos más rápidos; es cambiar 0,5 segundos por margen de frecuencia: el número de llamadas va con el número de segmentos (5 segmentos ≈ 36 llamadas / 17 segmentos ≈ 48 / 34 segmentos ≈ 65), y la franja más estrecha es 100 llamadas/minuto; chocar con el límite cuesta decenas de segundos.

Las cinco comprobaciones que se ejecutan en cada lectura

Que los números sean correctos no depende de «calcular con cuidado», sino de que cada tipo de error tenga una comprobación que se ponga roja. Las cinco, por gravedad:

Comprobación

Qué bloquea

Consecuencia si no bloquea

Tasa de acierto del filtrado

Que cambie la forma de escribir la columna de estado de alguna tabla («在库» → «在库中»)

Esa tabla no acierta ni un registro, miles de unidades desaparecen de golpe, y cada paso de conservación sigue en verde (entradas y salidas bajan lo mismo)

Fallo de lectura de tabla fuente

Que no se puedan leer algunas

La mercancía de esos almacenes desaparece sin más; la gente cree que «ahí no hay» en vez de «ahí no se sabe»

Piezas defectuosas / caracteres corruptos

Modelos con «坏», columna de especificaciones con [object Object]

Las piezas defectuosas se normalizan junto con las buenas y se cuentan como stock utilizable; los caracteres corruptos contaminan la clave de material

Comparación registro a registro de SN

No poder explicar si «faltan 106 unidades» es salida, cambio de forma o error de lectura

La conciliación semanal se hace a ojo

La forma del modelo no parece de esta clase (solo avisa, no bloquea)

Que un lote entero se clasifique en el tipo de activo equivocado

Error garrafal pero todas las comprobaciones en verde — las 926 unidades de módulos ópticos clasificadas como discos: el total cuadra, cada paso de conservación, la comparación SN normal, la tasa de acierto normal, porque la mercancía está toda, solo colgada de la clase equivocada. La única forma de detectarlo es que una persona mire la lista de claves de material y piense «¿por qué un disco se llama QSFP112-400G-DR4-SM1310

«Llegar a cero» se queda en la máquina; «cuánto ha cambiado» se lo dejas al modelo. Esa línea se movió el 2026-08-14: antes la máquina también juzgaba «tasa de acierto cae más de 20 puntos porcentuales = caída clara»; ese umbral estaba puesto a ojo, marcado como sin validar, y el mismo cambio significa tres cosas completamente distintas según el contexto (la persona cambió las reglas / cambiaron las columnas de la tabla fuente / ha entrado mercancía con un estado nuevo); la máquina no distingue cuál es. Ahora la máquina solo reporta {表, 命中率: "99% → 12%", 差几个点}; si es anomalía lo decide el modelo que lo lee (看筛选).

Que la tasa de acierto llegue a cero es una señal dura con cero falsos positivos: filas válidas siguen ahí pero ninguna acierta; no puede ser negocio normal. Así que es criterio absoluto, no necesita línea base — antes estaba escrito «solo avisar si el acierto anterior > 0», y así una tabla que nunca acertaba quedaba en silencio para siempre: la primera vez no había línea base y no avisaba; la segunda, la línea base también registraba 0, y la condición nunca se cumplía. Eso es exactamente lo que este detector tiene que evitar. Cómo se reporta:

⚠ 有源表的筛选一条都没命中:
  网卡(导入) · 闵行/闵行网卡在库清单信息:读到 4575 行有效数据,但筛选一条都没命中(上次命中 2841 行)
  这几乎一定是那张表的状态列写法改了,不是货清空了。去核对源表的筛选字段,别按下面的数字下结论。
  **这条会一直报到修好为止** —— 有异常就不滚命中基线,不然警报会把自己吞掉。

Las alertas no pueden comerse a sí mismas. La línea base de aciertos (table-hit.json) antes se sobrescribía en cada lectura, y así: una tabla se estropea → avisa una vez → la línea base rueda a «acierto 0» → a partir de ahí nunca más avisa. Ahora 该滚基线() solo la deja rodar si esta vez está limpia; si hay un cero o una caída, se queda quieta, y la anomalía se reporta hasta que se arregla. La única forma de desactivarla es que la persona la reconozca explícitamente: ./周更.mjs --认下筛选 (con --dry no se reconoce). Sin resquicio de auto-reconocimiento — una alerta que puede limpiarse sola es como no tener alerta.

La instantánea SN tiene la misma enfermedad: se sobrescribe en cada lectura, así que el SN对比 del bloque de criterio compara «la última vez que alguien ejecutó este MCP» y casi siempre muestra «coincide». La solución en esa mitad es que 看变动 lea la línea base semanal (solo lectura, no escribe).

La tasa de acierto absoluta no es criterio. Medido: tres tablas llevan años con acierto bajo y las reglas son correctas:

Tabla

Regla

Valores reales de esa columna

B-2临港9号楼 · módulos ópticos (7%)

资产状态 = 库房

En línea 52.341 · 库房 4.280 · RMA出库 1.330 · «En línea, sin correspondencia» 853

JYJY临港9号楼 · módulos ópticos (15%)

Ídem

En línea 48.777 · 库房 10.575 · 调拨出库 7.910 · Pendiente 50

移动7号楼 · memoria (20%)

出库/在库 = 在库

出库 23.689 · 在库 6.031 · 故障 32

Esas son libros mayores de activos completos; la mayoría de las filas son equipos ya montados y en uso. Cualquier umbral absoluto las marcaría como anomalía en falso.

La quinta: la forma del modelo no parece de esta clase (类不对的键, lib/ledger.mjs)

El criterio es «las ranuras centrales que parsea otra clase son más que las propias», umbral 2, medido no puesto a ojo (2026-08-16, 374 claves reales + ese lote histórico de claves erróneas):

真实键    差 ≤ 0 的 372 个 · 差 1 的 2 个 · 差 ≥ 2 的 0 个
历史错键  硬盘|QSFP112-400G-DR4-SM1310   自己 1 槽位 vs 光模块 5  → 差 4
正常硬盘  硬盘|7.68T NVMe U.2 Gen4       自己 4 槽位 vs 光模块 0  → 差 -4

En medio hay dos niveles vacíos, así que el 2 tiene margen real. Criterios ㉕㉖㉗㉘ + ablación 9, donde ㉘ vigila específicamente que «el umbral no se puede relajar».

No caza solo una causa: la deduplicación entre planes adjudica la mercancía al plan que aparece primero, el 配件类型 del paquete de planes filtra mal, el mapeo de columnas de map de alguna tabla está mal, alguien en la tabla fuente ha metido mercancía bajo la clasificación equivocada — cuatro fenómenos, misma pinta.

El primer criterio, «parsea 0 ranuras propias», no funciona: el parser de discos trata 400G como capacidad y parsea 1; ninguna de las claves erróneas históricas se cazaba. Es exactamente la manifestación aquí de «entre las cuatro clases solo chocan esos 5 valores de rate» — esa conclusión vale por número de formas de escritura, pero no vale para «comparar ranuras entre clases»; con un choque ya rompe la condición.

Solo avisa, no bloquea. Es heurístico; no está al mismo nivel que la señal dura de cero falsos positivos de «tasa de acierto del filtrado en cero».

Qué dimensiones, si fallan, no se detectan

Las cinco comprobaciones cubren la parte que «tiene una segunda fuente con la que contrastar», no todo. Si una dimensión falla y el total sigue conservándose, si se puede descubrir depende de si la tabla fuente tiene un segundo ángulo para mirarla:

Dimensión

Qué pasa si falla

¿Hay segunda fuente?

Estado actual

Tipo de activo

Un lote entero se clasifica mal; el total se conserva

— la forma del modelo permite deducirlo

Quinta comprobación

Almacén

Un lote entero se mueve de almacén; el total se conserva

No — el segmento de almacén de la clave de material viene de la configuración del paquete de planes (region), y la columna de la tabla fuente mapeada como «库房» contiene en realidad ubicaciones (403库房 / CK2-H03-A01 / 库房303), no nombres de almacén

Solo se puede confiar en que la configuración esté bien

Marca

Un lote entero cambia de marca; el total se conserva

No — la tabla fuente solo tiene una copia

Solo se evita que «las tablas de correspondencia de dos planes se peleen» (loadBrandMap lanza); no se evita que «cuatro planes fallen de forma consistente»

Estado en stock

Filtrar de más → la mercancía aparece de la nada

No

La tasa de acierto solo caza «filtrar de menos» (llegar a cero); filtrar de más hace subir la tasa de acierto, parece más sano

La última fila es la que más hay que recordar de esta tabla: «filtrar de más» es más traicionero que «filtrar de menos». La gente desconfía menos de que un número crezca que de que decrezca, y el único detector de la máquina mira justo en la dirección contraria.

Probar con el nombre de la tabla como segunda fuente no funcionó: de 31 tablas, 3 dieron falsos positivos (un nombre de tabla como B-2项目9号楼 está registrado en la tabla de alias PLACE como B-2临港9号楼, difieren en dos caracteres), y una tasa de falsos positivos del 10 % convertida en una alerta que se ejecuta cada vez solo genera ruido.

Esta interfaz no te dice qué ha pasado

La interfaz de escritura de la base multidimensional de Feishu tiene un estilo constante: dice que ha tenido éxito, pero no puedes saber qué ha pasado a partir del valor de retorno. Entre el 2026-08-15 y el 16 caí en esto cinco veces; verlo junto es más útil que los comentarios dispersos:

Comando

Lo que no te dice

+record-batch-create

Devuelve ok:true, records es un array vacío — ni cuántas se crearon ni cuál es el record_id

+record-batch-update

No comprueba si el record ID existe. Actualizar con un ID inexistente devuelve igualmente ok:true, y en la tabla no cambia ni una letra (la propia ayuda lo dice)

+table-create

En el cuerpo de retorno no hay table_id, solo devuelve {fields:[…]}. Hay que buscarla por nombre después de crearla

--dry-run

Solo devuelve el cuerpo de la solicitud, no valida la forma. Tanto lo incorrecto como lo correcto devuelven ok:true — usarlo para validar hace que creas que no hay problema

Escribir campos de fórmula

Es tragado silenciosamente por ignored_fields, sin ningún aviso

Por eso «después de escribir hay que releer, no fiarse del valor de retorno» en este proyecto no es conservadurismo, es la única opción viable. El incidente del 2026-08-16 se verificó por ambas caras: la relectura detuvo una vez (./导台账.mjs al reejecutar reportó «los valores no coinciden, 0 registros»), y la vez que se rompió antes de la relectura no la detuvo — por eso ahora incluso un fallo de escritura tiene que completar la relectura (ver lib/ledger-write.mjs).

La forma del cuerpo de la solicitud solo existe en un lugar: 创建请求体() / 更新请求体() están en lib/ledger-write.mjs, y las pruebas de contrato usan exactamente esas dos funciones. Antes la prueba tenía escrita a mano una {update_records:{id:{数量:100}}}, así que podía detectar «lark-cli ha vuelto a cambiar el contrato», pero no podía detectar «nuestro código ha montado mal la forma» — y la vez que realmente explotó el 8-16 era precisamente el caso adyacente a esto último (el contrato cambió, nosotros no lo seguimos). Las dos literales eran válidas por separado, y ninguna sabía si la otra había cambiado. Lo que protege esto son las dos ablaciones de 写路径: sustituir las dos funciones de producción por una forma incorrecta, el criterio debe ponerse en rojo (en la práctica ABLATE=1 reporta exactamente el 800010701 Request validation failed de aquel día) — si algún día alguien vuelve a escribir una literal a mano en la prueba, la ablación deja de ponerse en rojo, y run-tests.sh reporta al momento «ningún criterio lo ha puesto en rojo».

Chocar con el límite de frecuencia: un error explícito, comprimido hasta tener que adivinarlo

Feishu calcula la tasa por minuto según API × aplicación × tenant, el escalón más estrecho es 100 veces/minuto, y al chocar devuelve HTTP 429 + code 99991400. Es un error explícito, pero al subir por toda la cadena solo queda «esta vez ha fallado», indistinguible de «no se puede leer la tabla» o «la sesión ha caducado».

El coste es real: el 2026-08-16 por esto se ejecutaron cuatro rondas extra de regresión (cada una de varios minutos), y cada vez para juzgar «¿es limitación de frecuencia o está realmente roto?» se dependía de razonamiento indirecto — las dos veces en rojo estaban en posiciones distintas (#74 / #55), si la posición es aleatoria parece un problema de entorno; un problema de código se detiene de forma estable en el mismo sitio.

Ahora las tres capas lo reconocen:

Dónde

Qué hace

退避重试() en lib/direct.mjs

Al chocar, retroceso y reintento (3 segundos, 8 segundos), mientras espera avisa por stderr; si tras el retroceso sigue chocando, marca 撞频控 y lo sube

fetchAll fracasa por completo

Reporta por separado: si es límite de frecuencia dice «el código no tiene problema, espera un minuto y vuelve a ejecutar», solo si realmente no se puede leer dice «comprueba el estado de sesión y la red»

收尾() en tests/消融.mjs

Si cada una de las que fallan es por frecuencia → código de salida 3, run-tests.sh imprime «⏸ esta ronda no se ha validado», todo el conjunto es distinto de 0

Tres puntos clave, y cada uno se descubrió a base de tropezar:

El retroceso solo retrocede dos veces, como máximo 11 segundos, no cuantas más mejor. Al principio escribí [3,8,20], y luego me di cuenta de que chocaría con el timeout de 120 segundos de la parte llamante — si cada una de las 31 tablas retrocede tres veces, la lectura completa de tablas puede alargarse a varios minutos, y entonces «limitación explícita» vuelve a convertirse en «timeout inexplicable», justo la versión del problema que esta vez se quería arreglar.

«No se ha validado» no puede imprimirse en verde. Solo si «cada una de las que fallan es por frecuencia» se sale con 3; si se mezcla un fallo real se sale con 1 — si no, la limitación de frecuencia se convierte en un escudo que tapa un rojo real con un amarillo. Lo más importante es el bucle de ablación: 牙() solo mira «si es distinto de 0 cuenta como que se ha puesto en rojo», una ablación que ni siquiera se ha ejecutado igualmente cuenta como un diente e imprime «✓ N/N ablaciones se han puesto en rojo».

是频控失败() está deliberadamente escrito estrecho. Pensé en incluir también el «timeout» (históricamente, chocar con el límite se manifestaba como tools/call bloqueado durante 120 segundos completos), pero eso degradaría silenciosamente también «el servidor realmente se ha colgado» a «esta ronda no se ha validado». El coste es: si la limitación se manifiesta como un timeout puro, sin dejar ni una letra, aquí no se reconoce y se reporta en rojo igualmente. Mejor más rojo.

INVENTORY_FAKE_RATELIMIT=N crea este escenario (las primeras N llamadas devuelven siempre límite de frecuencia), e INVENTORY_BACKOFF_MS comprime la espera a milisegundos. El punto de inyección está en la capa de 退避重试() y no dentro de call() — si estuviera dentro, la ruta de escritura del libro mayor no podría inyectarse, y esa ruta solo se ejecuta unas cincuenta veces al año; si choca, nadie tiene una segunda oportunidad de ver la escena.

Al principio dije que esto «solo se puede validar cuando choque de verdad». Eso era un error — en el mismo repositorio, INVENTORY_FAKE_FAIL e INVENTORY_FAKE_CHANGED ya habían usado este patrón dos veces, y la razón la había escrito yo mismo en los comentarios. Tener una solución ya hecha a mano y no acordarse de usarla merece más anotarse que no saberla.

La columna de filtro saca nuevos valores (收筛选取值 / 比取值)

Lo que la tasa de aciertos no puede capturar es la clase crónica: alguien cambia «en stock» por «en stock actual», los registros antiguos siguen con la escritura antigua, y el número de aciertos solo irá bajando semana a semana — llegar a cero no dispara nada, el umbral de 20 puntos tampoco se alcanza, y cuando se descubre ya han pasado varias semanas. Por eso se añade una señal discreta más: tomar las filas originales antes del filtro, contar el conjunto de valores de la columna de filtro de cada tabla; si aparece un valor que no está en la línea base, se reporta. Casi cero falsos positivos, a costa de 0,3 segundos más por lectura completa y 7,8 KB en el archivo de línea base.

⚠ 筛选列冒出了没见过的取值:
  光模块 · 临港9号楼/B-2临港9号楼 · 资产状态:冒出新取值「在线,无对应关系」853 行
  这批行现在没被算进任何一边。如果它其实是「在库」的另一种写法,那批货正在静默丢失;
  如果是新的业务状态(借出、待检…),把它加进方案包的筛选规则再跑。

Solo se reportan nuevos, no desapariciones: que un estado no lo use nadie esta semana es normal. Si la línea base no tiene esa columna de esa tabla, tampoco se reporta, si no, la primera ejecución trataría cada valor como nuevo. También está sujeto a 该滚基线() — si se reporta, no se rota la línea base, y se sigue reportando hasta que se gestione.

El fallo de lectura de tablas ya no lanza de forma uniforme, solo lanza si todas las tablas fuente son ilegibles (eso es problema de sesión o red, no tiene sentido dar media parte). Si solo fallan unas pocas, se continúa, pero el almacén completamente ilegible debe ocupar explícitamente una fila, con la cantidad raíz escrita como null:

{ "库房": "七宝", "根数": null, "读不到": "网卡、硬盘、内存、光模块的表都没读到 —— 这里不是 0 根,是不知道" }

Si desaparece del array, la gente lo leerá como «ahí no hay mercancía» — estas dos cosas difieren en un orden de magnitud. Cuando hay tablas con fallo, no se escribe la caché en proceso, si no, la próxima vez en estado caliente usará estos datos incompletos como si fueran buenos, y como la revisión de esos documentos no ha cambiado ni un bit, los seguirá usando para siempre.

Para crear este escenario se usa INVENTORY_FAKE_FAIL='subcadena de 方案/库房/表名' — sin punto de inyección, esta ruta de degradación nunca se podría validar.

Escritura del modelo: dos capas de normalización

Primera capa literal (字面指纹 + 挑标准写法): solo absorbe diferencias de mayúsculas/minúsculas, espacios, guiones y puntos. En la práctica fusiona 11 grupos, como CX7-400GCx7 400G, 128G128g, SFP-25G-SR-LCSFP.25G-SR.LC. El análisis de ranuras no puede salvarlos — el analizador reconoce la semántica de especificaciones, no la forma de teclear.

El criterio para elegir la escritura estándar en esta capa no puede tocar la cantidad raíz: más mayúsculas > más guiones > más longitud > orden lexicográfico, las cuatro reglas son propiedades de la propia cadena del modelo. La regla original incluía «gana el que tiene más cantidad raíz», y la cantidad raíz cambia cada semana — la escritura estándar es el tercer segmento de la clave de material, y si cambia, los valores ya seleccionados en el desplegable del libro mayor quedan en el aire.

Segunda capa de ranuras: se analizan las ranuras de encapsulado/velocidad/estándar/medio/longitud de onda, y solo se fusionan si las ranuras centrales son totalmente iguales. El criterio de esta capa se extrae en tiempo de ejecución del script de Tampermonkey, no se copia.

Los cuatro problemas pisados en esta ruta de lectura directa (todos bloqueados en el código)

Problema

Cómo se bloquea

La tabla de módulos ópticos de Minhang reporta 90221 al leerla entera, supera los 10 MB

Todas las tablas solo leen las columnas que usan map + includeRules, el rango no lleva número de fila al final (poner un número grande como A1:H999999 en cambio reporta exceso de límite, Feishu calcula el volumen por el rango solicitado). Si falta una columna, lanza, no degrada silenciosamente a campos ausentes

Esa tabla llega a 84 952 filas × 9 columnas, y leer solo una clase también supera los 10 MB — el bloque más grande de todo el almacén (más de 80 000 raíces) no se puede leer entero

Leer por segmentos de filas y luego unir, si el segmento es demasiado grande, se parte a la mitad sobre la marcha y se reintenta (分段读, lib/direct.mjs). No existe «el tamaño de segmento correcto»: Feishu mide en bytes, nosotros contamos celdas, una tabla con columna de notas supera el límite en 175 000 celdas, mientras que una tabla con campos cortos llega a 760 000 celdas antes de superarlo. Por eso 每段格子 es solo una suposición inicial; si supera, converge por log₂ hasta el mínimo de 200 filas. Si falla cualquier segmento, toda la tabla se descarta — los datos de media tabla parecen normales, solo que el total es un poco menor, y nada se pondría en rojo. El tamaño de segmento convergido se guarda en la caché de estructura, y ya no se vuelve a intentar esa lectura completa que estaba destinada a fallar

El encabezado es texto enriquecido: el «品牌(必填)」 de los módulos ópticos de Shanxi son dos segmentos, «品牌» en blanco + «(必填)」 en rojo, en la estructura devuelta

取文本() extrae todos los text y los concatena. Es la semántica copiada de cell() en build_stock.py, y en tests/direct.test.mjs hay un criterio que compara ambos resultados con la misma muestra, para evitar deriva

La de Unicom de Lingang la comparten tres planes (tarjeta de red/disco duro/módulo óptico), includeRules no filtró del todo, y 1 333 raíces se contaron dos veces

Deduplicar en orden de paquete de plan, y la cantidad eliminada se reporta al bloque de métricas — si sube, significa que la regla de filtro necesita arreglo

Por defecto se lee el texto de fórmula (la columna «物料键» del libro mayor, 340 filas, es toda IF($A2="",...))

Siempre llevar valueRenderOption=UnformattedValue. Si no, algún día que la columna de cantidad use una fórmula, Number("=SUM(...)") || 0 calculará silenciosamente 0 raíces, y la comprobación de conservación seguirá en verde

lark-cli reporta errores por dos vías, y antes solo se reconocía una (aclarado el 2026-08-15): 90221 va por código de salida 1 + stderr, y con regex se extrae el code; 91402 va por código de salida 0 + stdout, el code está escondido en error.code y la parte llamante lee j.code, obteniendo undefined

认错误码() unifica las dos vías: al fallar, el code es siempre una cadena, siempre en el nivel superior. No es un problema de que el error se vea feoString(j.code)==='90221' es siempre falso para la segunda vía, y «la tabla ha crecido, pasemos a lectura por segmentos» degeneraría en «esta tabla no se puede leer». Al investigar lecturas lentas vi una vez code=undefined, y en su momento lo atribuí a timeout de red; la atribución fue errónea. run-tests.sh tiene una comprobación estructural: los archivos en lib/ que ramifican por código de error deben usar 认错误码

Ubicación gruesa hasta el almacén, la ubicación de estantería no importa

El 403库房 / CK2-TEMP / 二楼小仓库 en la tabla fuente son ubicaciones de estantería, 74 tipos, y nunca entran en la clave de material — la ubicación toma el region de la fuente (闵行 / 临港9号楼 / 移动7号楼 / 移动45号楼 / 七宝 / 山西 / 临港联通). La ubicación de estantería es trabajo del gestor de almacén.

Se usa la interfaz nativa values, no el encapsulado +csv-get: este último, al superar los 10 MB, silenciosamente solo te da una parte (ok:true, los datos también son buenos, solo hay un has_more:true), y por eso calculé porcentajes sobre una muestra del 9,4 % creyendo que cubría toda la tabla. La interfaz nativa al superar el límite reporta directamente 90221, convirtiendo el fallo silencioso en fallo explícito.

El libro mayor en sí es pegado manualmente cada semana por una persona desde la exportación del script de Tampermonkey, no es en tiempo real. El «tiempo de lectura» en el bloque de métricas es el momento de estas cifras; el número de versión de Feishu queda en meta.revision, y el código lo usa para juzgar si ha cambiado, no lo mete en el bloque de métricas — para una persona es una cadena de números sin información.

Compra en tránsito: ya prometido, pero la mercancía aún no ha llegado (2026-08-17)

En la tabla de ocupación ha aparecido un cuarto estado de cierre de ciclo, «compra en tránsito». El método es: una persona añade manualmente una fila en la tabla de materiales, dejando vacíos marca y almacén (esos dos segmentos se deciden cuando llegue la mercancía), y la ocupación cuelga de ella. En la práctica, esa fila:

光模块||QSFPDD-400G-DR4 |     在库 0 · 占用 640 · 预计到货 2026-08-31
                              备注 智设合【2026】年325-004 · 工单 29640

Lo peligroso no es el −640 en los libros, es el momento de la llegada. La mercancía cae en una clave real de cuatro segmentos producida por la tabla fuente (光模块|海光芯创|QSFPDD-400G-DR4-SM1310|闵行), la ocupación de esa clave es 0, y así el mismo lote de 640 raíces se cuenta dos veces en dos sitios: la clave vacía dice «prometido», la clave real dice «disponible». Si una persona promete con el número de la clave real, es sobreventa. En el libro mayor, la clave real existente de 400G DR4 tiene 11, con un total de 607 raíces, del mismo orden de magnitud que esta compra.

Lo que lo bloquea es una comprobación en dirección contraria

挂可用量 antes solo comprobaba en una dirección: la tabla fuente tiene, el libro mayor no (该导没导 / 不进台账的). La dirección contraria, «el libro mayor tiene ocupación, pero la tabla fuente no tiene esta clave», era completamente invisible — que es justo donde cae el tránsito. Ahora se ha añadido 没挂上的占用, que aparece en tres sitios:

Dónde

Por qué

查库存

Va delante de los datos, no entra en el bloque de métricas ni en el archivo de depuración — no es «cómo se calcula este número», es «si se envía según este número, se sobrevende»

找替代

Aquí es más letal: la conclusión se convierte directamente en «no hace falta comprar»

./周更.mjs

Lo que hay que mirar cada semana es «si el tránsito ha llegado o no», el ritmo ya es semanal

No se filtra por tipo de activo ni por modelo, se reporta todo. Este tipo de claves es muy raro (en la práctica, 1 en todo el almacén), y el coste de no reportar es la sobreventa; la propia lógica de filtrado se convierte en un punto que está en verde cuando falla — si el filtro está mal, no reporta en silencio. Unas pocas líneas de ruido a cambio de cero omisiones. Mismo carácter que «el filtro no ha acertado ni una vez»: se sigue reportando hasta que alguien lo gestione.

La fecha va con cada registro. La acción de una persona al recibir «hay 640 raíces en tránsito» está completamente determinada por la fecha — esperar a que llegue, o buscar otra vía. Si no está rellenada, se dice explícitamente «no se ha rellenado la llegada prevista, pregunta cuándo puede llegar», no se inventa una: si se inventa, la consecuencia es que alguien planifica el calendario según una fecha de llegada que no existe. 预计到货 en 读占用 es un requisito blando (si falta la columna solo falta una fecha), mientras que 物料键 / 占用中 si faltan no se puede calcular la cantidad disponible, es duro — los dos no comparten un mismo throw.

La persona solo tiene que hacer una acción

Cuando llegue la mercancía, cambiar el «material asociado» de la fila de registro de ocupación para que apunte a la clave real. Al cambiarlo, la fila vacía pasa a en stock 0 / ocupación 0, la alerta desaparece sola, y esas 640 raíces se descuentan correctamente en la clave real.

El desplegable 闭环状态 MCP no lo mira ni una letra — está en la tabla de registros de ocupación, y MCP lee la tabla de materiales. Cambiarlo es para la columna 占用时长 de Feishu (🟠 esperando mercancía / 🔴 espera vencida), para que la persona lo vea.

Dos cosas sin criterio, que hay que saber:

  • Un traslado erróneo no se detecta. Solo se comprueba «si se ha trasladado», no «si se ha trasladado bien». Trasladar a QSFP112 en lugar de QSFPDD, o trasladar al almacén equivocado, no dice ni una palabra — porque después del traslado, esa clave sí tiene mercancía en la tabla fuente.

  • La llegada parcial o dispersa requiere dividir la fila. Un registro de ocupación solo puede apuntar a una clave de material.

Tres cosas discutidas y no hechas

Por qué no se hizo

Usar 闭环状态 como interruptor (si sigue en «compra en tránsito» no se descuenta; si se cambia, se descuenta del total de la misma mercancía)

Habría que leer una tabla más + unas 50 líneas; la persona dijo que primero no se hiciera

Abrir una tabla aparte para el tránsito

Si la promesa se guarda en dos tablas, no hay ningún lugar que pueda responder «qué ha prometido en total esta orden de trabajo»

Ampliar la tabla de registros de ocupación (añadir tres columnas: modelo/número de pedido/ETA, y degradar 关联物料 a opcional)

Formalmente es lo más estándar, pero las cuatro fórmulas de 剩余占用 / 同工单同物料几行 / 数据检查 tendrían que ramificarse, y las fórmulas de Feishu no se pueden probar — es la capa menos validable de todo el proyecto

Que la actualización semanal omita las filas con «nota o llegada prevista no vacíos»

Se midió: de 399 filas, 1 cumple, y de esas, con stock distinto de 0 hay 0 filas — esta regla hoy está vacía. Para que se sostenga, habría que rellenar el stock manualmente, y eso introduciría un segundo propietario en la única columna de propietario único, 在库数量

Libro mayor: exportar una vez por semana, solo crece, nunca decrece

El libro mayor es la tabla de materiales de la «tabla de gestión de ocupación de accesorios» (base multidimensional). MCP lee las tablas fuente para calcular el stock, el lado del libro mayor calcula la ocupación, ambos se emparejan por clave de material, y cantidad disponible = stock en tiempo real − ocupación en curso del libro mayor.

El libro mayor solo recibe los accesorios del flujo de retirada (台账范围, definido en lib/ledger.mjs): módulos ópticos / discos duros / tarjetas de red / memoria. Lo que va con la máquina completa y no se retira por clave de material no se recibe — si entrara, solo añadiría unas cuantas claves al desplegable que nadie elegiría jamás. Al importar, primero se filtra y luego se valida; el log imprime «se han bloqueado N claves de material fuera del ámbito del libro mayor».

Este conjunto y el paquete de planes (plans.json) ahora son casualmente igual de anchos, pero los dos gestionan cosas distintas: el paquete de planes gestiona «qué tablas fuente lee MCP», y 台账范围 gestiona «qué activos deben entrar en el libro mayor de ocupación». Al añadir una clase de activo al paquete de planes hay que juzgar por separado si entra en el libro mayor, no asumir que sigue automáticamente — si no, la clase nueva entrará silenciosamente en el libro mayor, generando un lote de claves que nadie elige.

«El libro mayor no tiene esta clave» por tanto tiene dos tipos, y 挂可用量 los cuenta por separado, y el bloque de métricas los reporta por separado. Si se fusionaran en un solo número, mientras exista cualquier clase de activo que «no debería entrar», ese número sería siempre mayor que 0, la gente lo ignoraría por costumbre tras unas semanas, y cuando realmente hubiera mercancía nueva sin importar no se notaría, y además se seguiría la recomendación inútil de «ejecuta una vez 导台账» que no arregla nada:

Señal

Significado

Qué hacer

还没进台账的键

Dentro del ámbito, pero no está en el libro mayor

Ejecutar ./导台账.mjs

不走台账领用的键

Fuera del ámbito, va con la máquina completa

No hay que hacer nada, su «cantidad disponible» es el stock

./导台账.mjs --dry     # 先看一遍:新增几条、更新几条、置 0 几条
./导台账.mjs           # 真写
./周更.mjs             # 每周四:重读 → 和上周基线比 → 出报告 → 品牌待补清单 → 存基线 → 写台账

En --dry, lo más importante de mirar son los «puestos a 0», y hay que preguntar por cada uno «¿este lote ha desaparecido, o ha cambiado de clave?». --dry solo reporta «stock original → 0», no te dice si en esa celda ahora hay otra mercancía — solo esto último permite distinguir «salida real» de «cambio de nombre/reclasificación». El criterio es tomar el «tipo de activo|marca|almacén» de la clave y consultarlo en los datos en tiempo real de esta semana: si en esa celda hay otro modelo = probablemente cambio de nombre; si esa celda está vacía = esta celda se ha vaciado de verdad.

En la prueba real del 2026-08-14: de 24 puestas a 0, 19 caían en «disco duro · Unicom de Lingang», pero el modelo era QSFP112-400G-DR4-SM1310, una escritura de módulo óptico — sumando exactamente 926 raíces, el mismo lote que el «módulo óptico de Unicom de Lingang leído como disco duro» que se arregló ese día. En este caso poner a 0 es correcto, es precisamente la clave errónea que esta corrección debía limpiar.

Las reglas de escritura no cambian ni una, vienen de la sección de diseño §3.3 del lado del libro mayor: upsert por clave de material, y la cantidad de stock de los materiales que no aparecen en esta importación se pone a 0, no se borra el registro. Tras poner a 0, el registro sigue existiendo, la clave del registro de ocupación sigue emparejando, y el nivel cambia automáticamente a «pendiente de compra» — hay mercancía en el almacén pero se debe a alguien, que es justo la semántica deseada. En el código nunca se llama record-delete: si se borra, el registro de ocupación queda colgando, y el lado del material es completamente silencioso (la cantidad disponible subiría silenciosamente, el nivel seguiría diciendo «suficiente», y la comprobación de datos estaría vacía).

Cuatro compuertas, y si alguna no pasa, todo el lote no se escribe (no es «saltar las malas y escribir las buenas»):

Compuerta

Qué bloquea

Hay una tabla fuente no leída

Ese almacén se trataría como «puesto a cero» y todo se pondría a 0, pero la mercancía está ahí

Clave no válida (un segmento vacío o con barra vertical)

La clave vacía «absorbería la ocupación de todas las claves vacías», y completamente en silencio

La clave no coincide con varias claves antiguas

Esta semana se ha fusionado la escritura de dos claves antiguas en un grupo; la máquina no adivina cuál dejar

Relectura y verificación tras escribir

«La API devuelve ok pero el valor está vacío» es el error más caro de esta tabla

Una vez emitida, la clave queda bloqueada (lib/keyreg.mjs)

El registro de ocupación guarda la cadena de la clave, y el tercer segmento de la clave es la escritura estándar «elegida» por la normalización — en la práctica, de 26 grupos fusionados, 9 se decidieron por cantidad raíz (CX6-25G双口(287) vs CX6-25G*2(158), una sola salida de almacén da la vuelta al resultado). Tras el vuelco, la clave antigua se pone a cero y aparece la nueva, pero el registro de ocupación sigue colgado de la clave antigua, y esa ocupación nunca se liquida.

El bloqueo no se basa en la serialización de ranuras (las tarjetas de red ni siquiera se pueden analizar en ranuras), sino en el campo 原始 ya existente de normalize: si la clave recién calculada comparte cualquier escritura original con una clave ya emitida, se juzga que es la misma mercancía y se mantiene la clave antigua. El criterio es «compartir escritura», no «la cadena de la clave es igual»; no importa cómo cambie la escritura estándar, la identidad no cambia. Mantener la clave antigua se reporta (en el libro mayor se sigue mostrando la escritura antigua, y la persona puede pensar que está mal). El registro está en ~/.cache/inventory-mcp/键注册表.json, y solo se actualiza después de escribir el libro mayor — si la escritura del libro mayor falla, la clave no debería considerarse emitida.

Problemas pisados (todos probados en la práctica): +record-list por defecto pagina de 100, máximo 200; si no se pasa página, los registros del libro mayor más allá de 100 se tratan como «inexistentes» y se crean duplicados, mientras la API devuelve ok en todo el camino; --record-id de +record-delete es un parámetro repetible, y pasarlo como una cadena unida con espacios reporta «Record id must start with rec» aunque el ID sea legal; el retorno es por columnas (data.data es un array bidimensional + data.fields son los nombres de columna), no un objeto fields por registro.

Cómo completar la marca

Hay tres formas de completar la columna de marca vacía en la tabla de origen, con distintos niveles de fiabilidad, por lo que se separan y se reportan por separado:

Fuente

Granularidad

Dónde se coloca

Se reporta en el bloque de criterios como

brandAliases

Mapeo de escrituras (Samsung→三星)

plans.json (la extensión de油猴 también lo lee)

Cambio de normalización

Exportación de CMDB

Verificación SN por SN

~/.cache/inventory-mcp/sn-品牌.tsv

Marca completada por SN

Juicio manual por lotes

Almacén + tipo de activo

品牌补充 en lib/ledger.mjs

La marca está completada

Los valores completados también deben pasar por brandAliases — CMDB escribe CLT, el inventario escribe 海光芯创, si no se pasa por ahí, en el libro mayor quedaría la misma empresa con dos nombres y dos conjuntos de claves. Después de la normalización hay una verificación que lanza error: si en el resultado queda alguna marca que «tenga una escritura estándar en la tabla de referencia», se reporta error directamente y no se devuelve.

Lo que no se puede completar va a «marcas pendientes de completar», y cada jueves se genera una lista de SN (~/.cache/inventory-mcp/周报/品牌待补-*.csv) para consultar en CMDB. Se da el SN y no el modelo, porque en CMDB solo consultando por SN se puede obtener la marca de una unidad concreta.

Cuando se consulta y la marca se pasa mal: la herramienta sabe la respuesta, no devuelvas solo un 0

La marca en 查库存 es de coincidencia exacta (kw(r.brand) === kw(条件.品牌)) — en el lado de los datos, al leer la tabla ya se ha normalizado con brandAliases, por lo que el lado de la consulta exige el nombre estándar. Pero la marca que dice el cliente está en inglés, es una abreviatura, está a medio escribir, y si el modelo no la traduce obtiene un 0 sin ninguna pista, que es idéntico a «este lote realmente no tiene», y este último se reporta tal cual a la persona. El plan de rescate para cero coincidencias (qué criterio relajar, los pocos que menos difieren) solo se calcula cuando se da el modelo o la ranura, una consulta como {品牌:'Samsung'} ni siquiera entra ahí.

Ahora, cuando hay 0 coincidencias y se ha dado una marca, el cuerpo de respuesta incluye un apartado más de «la marca», respondiendo por separado las cuatro situaciones (品牌怎么救, lib/ledger.mjs):

Lo que se pasa

Qué se responde

三星 (el nombre ya es correcto)

No es problema de la marca, es problema de los demás criterios — este es el que más fácil se malinterpreta como «no hay stock de esta marca»

Samsung (alias)

El nombre estándar es «三星» (N unidades en toda la base), cámbialo por ese y vuelve a consultar

海光 (a medio escribir)

No está en la lista, pero existe «海光芯创» — los candidatos vienen de marcas que realmente existen en la base, no son inventadas

浪潮 (desconocida)

No está en la lista, las que tienen stock en la base son estas. Las de 0 unidades no se listan — listarlas sería llevar al modelo hacia un resultado vacío

El orden de las cuatro situaciones no se puede invertir: brandAliases permite auto-mapeo, el nombre estándar aparece en su propia lista de alias, si se juzga primero el alias, al pasar el nombre estándar se respondería «lo que pusiste es un alias», llevando a la persona en la dirección equivocada. tests/ledger.test.mjs ㉙ protege esta regla.

El criterio solo tiene una copia, y su casa está en lib/slots.mjs

La lógica para juzgar «si dos escrituras son el mismo producto» está en lib/slots.mjs, y lib/ledger.mjs lo importa directamente.

Antes del 2026-08-16 no estaba aquí — vivía en userscripts/feishu-warehouse-composer/slots.mjs, y el runtime de MCP cortaba un fragmento usando el marcador de cadena export function 建槽位( y lo ejecutaba con new Function. Todo ese conjunto (anulación de ruta por variable de entorno, marcador de corte, lanzar error si no se puede cortar) existía solo para cumplir la restricción de «el criterio tiene una sola copia y su casa está en la extensión de油猴».

La restricción desapareció, así que se muda: ese script de油猴 (el compositor de almacén de Feishu) ya no se actualiza — en el lado de Feishu, desde el 2026-08-11 se pasó a la interfaz oficial de lark-cli, y la vía de ingeniería inversa se retiró. Depender de una ruta que nadie mantiene es más peligroso que tener una copia duplicada: una copia puede derivar, pero si deriva hay un criterio que lo detecta; en cambio, si la ruta algún día desaparece, lo que se reporta es «no se puede cortar 建槽位», y nadie entiende qué significa eso. La copia de油猴 se queda allí para su propio uso, y ya no hay relación de sincronización entre ambas — como ya no se actualiza, tampoco derivará.

Que el criterio de mismo producto derive no lanza error, solo se manifiesta como «stock duplicado» o «el mismo producto no se encuentra». Probado en la práctica: QSFP28-100G-SR4 (multimodo) y QSFP28-100G-LR4 (monomodo), si falta el paso de «inferir tipo de fibra y longitud de onda estándar» se juzgan como el mismo producto, se envían y al insertarlos no encienden. Por eso merece tener su propia casa, en lugar de vivir a expensas de otra.

La verificación en run-tests.sh invirtió su dirección — antes comprobaba «debe leer la copia de油猴», ahora comprueba tres cosas: que el archivo de criterio exista y sea de este repositorio, que lib/ledger.mjs lo importe, y que no haya una segunda copia del literal de vocabulario de ranuras fuera de lib/slots.mjs. Comprueba el mecanismo de dependencia, no «la mención»: la primera versión era un grep de la cadena de ruta, y también marcaba en rojo el comentario que explica esta historia — un criterio demasiado amplio es tan malo como uno demasiado estrecho.

La tabla de correspondencia de marcas sigue fuera, y su casa es brandAliases en plans.json (la que se modifica desde la interfaz de «tabla de correspondencia de marcas» de油猴, y INVENTORY_PLANS puede anularla). Aquí solo se lee, no se guarda copia; si no se puede leer, se lanza error; si la misma escritura mapea a marcas distintas en dos planes, también se lanza error directamente, no se toma una en silencio. Su diferencia con el criterio de ranuras es: esa tabla todavía la modifica alguien desde la interfaz, así que debe quedarse donde se modifica, en lugar de mudarse aquí y convertirse en una segunda copia.

Ejecutar las pruebas

./run-tests.sh        # 改的过程中跑:23 秒,305 条判据 + 61 个消融,不打网络
./run-tests.sh 全      # 收尾 / 提交前跑一次:89~190 秒(缓存热时 89),打网络的五条链并行
./新旧.sh              # 跑着的那个 MCP 是不是最新代码 —— 它是 WorkBuddy 启动时拉起的单例,新开对话不换进程
./自检.mjs             # 这套东西能不能跑起来:lark-cli / 登录态 / 方案包 / 真读源表 / 台账 / 三件静态检查
./自检.mjs --快        # 同上,跳过真读源表那步(不打网络)

La división en dos niveles se midió: los 5 tests que usan red más sus ablaciones ocupan 630 de los 635 segundos del conjunto completo, mientras que los 15 tests sin red más 14 grupos de ablaciones suman 5 segundos. Si cambias una línea de código y tienes que esperar diez minutos para saber si la has roto, ese ciclo es tan largo que nadie lo ejecuta durante el proceso de modificación — así que solo se ejecuta una vez al final, y ese es precisamente el momento más tardío para descubrir problemas.

El nivel ejecuta las cinco cadenas en paralelo (medido el 2026-08-17: de 624 segundos a 195 segundos, sin perder ni un criterio ni una ablación). Los cinco tests principales se probaron por separado: 275 segundos en serie, 92 segundos en paralelo, y ninguno fue bloqueado por el control de frecuencia — el reintento con retroceso hecho ese mismo día absorbió los picos.

La cadena 写路径 debe ir en serie, y no se le permite paralelizarse consigo misma: crea tablas con prefijo de nombre idéntico en la base de producción, y en finally limpia por prefijo; si dos procesos corren a la vez, se borran las tablas del otro, y la forma de fallar es «la tabla desapareció de repente», que parece un error de interfaz. Toda su cadena (test principal + ablaciones) corre secuencialmente en un subproceso, en paralelo con las demás cadenas.

La salida se imprime en orden fijo, no en orden de finalización — la salida de dos ejecuciones debe poder compararse directamente con diff. Los segundos de cada cadena también se recuperan (después del paralelismo desaparecieron una vez del cronómetro, y «lo que no se puede medir no se puede optimizar» es precisamente la razón de existir de este nivel: el paso de 624→195 se encontró mirando el cronómetro).

Al final del nivel por defecto se indica explícitamente qué elementos no se verificaron y qué protege cada uno. El código de salida sigue siendo 0 (efectivamente pasó lo que ejecutó), pero si solo se ejecutó la mitad, no se permite imprimir como si se hubiera ejecutado todo: fusionar «no se puede juzgar» y «aprobado» en un mismo verde indistinguible es el tipo de error más grave que puede cometer este sistema.

Los scripts de shell se entregan a shellcheck, no se escriben expresiones regulares propias (brew install shellcheck; si no está instalado, se marca en rojo — omitirlo en silencio equivale a aprobar siempre). Reconoce el tipo de error que este repositorio realmente sufrió: 计时=""SC2276 This is interpreted as a command name containing '=', bash lo ejecuta como comando, reporta command not found y luego continúa ejecutándose, la variable queda vacía para siempre y el script termina en verde sin problema (bash -n no lo detecta, la sintaxis es legal). El 2026-08-17 cayó cinco veces en un día.

Al integrarlo, prevén la segunda cosa: si el análisis se detiene a mitad, no cuenta. Los nombres de función en chino (秒() {) bash los acepta, pero shellcheck no, y en esa línea reporta SC1088 y detiene el análisis, aun así sale con código distinto de 0 — parece que está trabajando, pero en realidad de 260 líneas solo escaneó 28, y los appears unused anteriores son todos falsos (no vio los lugares donde se usan). Por eso los nombres de función en run-tests.sh son sec / run_one / teeth / chain y no en chino: los nombres de función en chino son legales, pero dejan fuera al único linter de shell de este repositorio.

Los números de criterio se generan en la ejecución (ok #37 …), no son círculos numerados escritos a mano — ㊱–㊿ se agotaron en los 90 criterios, y si se escriben a mano tarde o temprano se repiten los números, y un FAIL ㊹ no permite distinguir cuál es. Añadir criterios no requiere mantener la numeración.

El número de ablaciones se cuenta, no está escrito en run-tests.sh (tests/消融.mjs). Cada archivo de test declara cuántas tiene con 认消融(N), y el conjunto prueba desde 1 hasta que el test devuelve el código de salida 2.

El código de salida tiene cuatro niveles, y la cabecera de tests/消融.mjs es la única definición; si falta un nivel, no se pueden distinguir los dos casos correspondientes:

Código

Qué significa

Si falta, qué se confundiría con qué

0

Todo verde (en ablación = esta ablación es siempre verde, debe reportarse)

1

Algún criterio ha fallado

2

No existe ese número de ablación, detenerse

«Esa ablación no existe» se confunde con «la ablación es válida»

3

Ha fallado, pero una de las líneas chocó con el control de frecuencia de Feishu → esta ronda no se ha verificado

«La red estaba demasiado ocupada ese minuto» se confunde con «el código está roto»; en el círculo de ablaciones es peor, una ablación que ni siquiera se ejecutó se cuenta como un diente

3 no es aprobar. Hace que todo el conjunto salga con código distinto de 0 y exige volver a ejecutar — así que aunque en una ronda se mezcle un fallo real y se marque junto con ⏸, la pasada limpia lo imprimirá en rojo igualmente; lo peor que puede pasar es verlo un ciclo más tarde, no que no se vea.

Esto se descubrió a base de tropezar: 分段读 añadió la tercera ablación, y en run-tests.sh seguía escrito for m in 1 2, esa ablación nunca se ejecutó, y el resumen seguía imprimiendo «✓ 2/2 ablaciones se volvieron rojas» — se añadió un criterio nuevo, nunca se verificó que se volviera rojo, y todo el conjunto de tests estaba en verde. De paso se tapó otro: 决定.test.mjs originalmente no hacía nada con números de ablación desconocidos (if…else if sin else), así que ABLATE=3 no ejecutaba ni la ruta normal ni la de ablación, los cuatro criterios fallaban juntos, y desde el código de salida era idéntico a «la ablación 3 funcionó».

测试

打不打网络

守什么

tests/ledger.test.mjs

不打

归一 + 坏件排除 + 字面规范化 + 缺表降级 + 品牌补充 + 型号写法不像这一类(第五道检查)+ 品牌传错时说一句。33 条判据

tests/direct.test.mjs

不打

直读那条路。60 条判据:URL 解析、方案包认不出 URL 要抛、位置取「地区」不取「库房」、一条 SN 一根、跨方案剔重、聚合不守恒要抛、结构缓存校验、缓存段长只许比当前策略小lark-cli 两条报错路归一、SN 逐单对比、筛选命中率、撞频控从认出来到套件退出码整条链(含「不是限流的失败一次都不重试」和「\b429\b 不匹配 rec429xx」)

tests/substitute.test.mjs

不打

找替代 + 放宽代价。59 条判据:死路上摆「你是不是想找」(㊺㊻㊼,2026-08-24 加)—— 客户在厂商料号后面缀项目名(MZQL23T8HCLS-00B7C-JD项目)时,槽位解析不出、字面也对不上,这条路原来一个线索都不给,而答案就在库里。判据是指纹包含(库里那条的字面指纹整个包在你给的里面,或反过来),召回样本 23 条死路救回 21 条、每次候选中位 1 个。不用编辑距离:阈值 ≤4 才救得全,而 4 恰好是 JD项目 的指纹长度 —— 那是对测试自己那条变形规则的过拟合。两边都要挡空指纹fq.includes('') 恒真,只挡一边的话型号写成 / 的脏行会出现在每一次候选里,实测一次摆 61 条)。摆出来的永远不叫「命中」、也不进召回率 —— 人挑一个再查一遍才算找回来。「精确匹配」那一档里没约束的槽位混着什么,要点名(㊸㊹,2026-08-24 加)—— 客户写「400G DR4」不提封装时,QSFP112 / OSFP-RHS / QSFP-DD 三种封装会一起进精确档,而它们是物理上不同的笼子,整档报成「和你要的一模一样」会让人发出插不进去的货。反过来也要报:没约束那项其实只有一个值时(库里 246 个真实型号里 27 个是这样)要明说「这次确实是一模一样」,否则人为一个已经收窄到底的结果白重查一遍。消融 12 把分布拿掉,㊸㊹ 两条都必须红;厂商料号(一个核心槽位都解析不出来)三档全空 + 说清「没法判断什么算它的替代」(2026-08-23 修的:原来它们把整类列进「精确匹配」,也就是一句「这些和你要的一模一样」,实测 30 种网卡里 20 种被这么列 —— 比查库存那个洞更糟,那边只是数字大,这边是一句会被照着发货的断言),同时要说一句「库里就有这个」免得人以为没货;其余:四档的边界、 那一档要带依据、只差一项的要点名其余哪几项相同、不许写成「能替代」、相近档分层(含按代价拆层)、平铺列表和分层同序、网卡「决定不做」和「还没定」不许混、占用读不到时不拿在库冒充;放宽按代价排不按根数排、自动放宽只挑「能放」、只有「不能放」的能捞到货时才准说「没有」、每个核心槽位都定过代价

tests/weekly.test.mjs

不打

周更:键校验、周差异三类、待补清单和快照对齐、改名解释(键级噪音摘不掉就变红)、「键归零」和「台账会悬空」分开(拿原始写法的消失去断言归一后的键会悬空,每周误报一次)。20 条判据

tests/源表.test.mjs

不打

看源表。13 条判据:列名不一样的必须点名、缺字段的表要点名、空映射当「没有」不当「叫空」、筛选规则和表头行原样给、名单截了要说还剩几张

tests/scope.test.mjs

不打

口径块瘦身。7 条判据:调试字段默认不发 / INVENTORY_VERBOSE=1 全回来 / 影响回答对错的一条都不许瘦掉 / 留下的值和详细档一致。探针是 tests/scope-probe.mjs(环境变量是模块加载时读的,只能起子进程验)

tests/sn.test.mjs

不打

查SN。19 条判据:多种原始写法收回同一个键、闵行库房 要折成 闵行、认不出的 SN 不许静默丢、在库和有SN分开、一写法对两键要抛、CSV 带 BOM / 逗号转义 / 文件名去重、写盘读回一致

tests/资源.test.mjs

不打

资源 / 提示模板 / 参数补全,外加 resources/list 每类只列最近几个、截掉的仍然够得着。17 条判据

tests/通知.test.mjs

不打

list_changed 通知。6 条判据

tests/召回.test.mjs

不打

召回率回归。189 个真型号 × 9 种客户写法扰动,跑的是 tests/fixtures/召回样本.json(不打网络)。原样必须 100%、整体 ≥95%、不许低于上次基线。5 条判据。新增的第 4 条守「问句不许混进召回率」(2026-08-24):带项目尾巴 掉的那 23 条现在会摆「你是不是想找」候选(指纹包含,救出 21 条),它单独进 问句 那一列,命中数必须还是 166 —— 混进去的话召回从 88% 跳到 99%,而一个人都没多拿到一根货。这条边界只能自己守:召回基线是 >= 比较,指标虚高它一声不吭。消融 2 就是把问句算成命中那种改法。命中分「走槽位」和「走字面」两列:库里 23 个厂商料号(MZQL23T8HCLS-00B7C 这类)一个核心槽位都解析不出来,走字面指纹那条,它照样算「查回了自己」,但不许靠「放宽一项」凑数。2026-08-23 带项目尾巴 的基线从 100 降到 88 —— 唯一一次降基线,掉的 23 条逐条归因过、全是那批料号原来靠「命中整类」白拿的分,理由写在测试文件的基线注释里

tests/决定.test.mjs

不打

人拍过的决定。17 条判据:依据/谁拍的不许空、跑两遍状态一样、方向和写法都归一之后判重、改口留上一版、拍「不能顶」的不许消失、坏文件要抛但不许拖垮查询

tests/凑单.test.mjs

不打

收尾那一行。10 条判据:一处不写数量、多处各自带数量、用的处数是最少的、凑不够不写「满足」也不逐处铺开、没给「要几根」不许下结论、按可用量凑、次序确定

tests/发通知.test.mjs

不打

匹配完拼好通知文本、按收件人分段返回(不真发)。9 条判据:分满足/缺货/待定(命中键区分「匹配上但不够」和「没对上库存」、带机房)、资管段+采购段(缺货/待定两段、只有待定时不出缺货段)、归一型号和 normalize 字面指纹同口径(分隔符变体不漏配)

tests/日更工具.test.mjs

不打

日更 MCP 工具的两步闸。3 条判据:第②步不写不执行、重放已消费票/伪造票都票据无效(①和真写要读写飞书台账,离线测不了,靠 日更.test.mjs 的算红 + 真站手验)

tests/周更工具.test.mjs

不打

周更 MCP 工具的两步闸 + SN 快照够不够新。10 条判据:第②步不写不执行、重放/伪造票都票据无效;以及 快照判据——原来这儿是 setTimeout(2000) 干等落盘(写 10MB 实测几十毫秒,白等约 1.95 秒),而万一慢于 2 秒或落盘那条路把异常吞了(.catch(() => {})),读到的是上一次的快照:周环比照样算出一个数,「和上周比」悄悄变成「和上上周比」。最贵的是存基线——那时候 这周行 是空的,存下去下周一比全仓二十几万根全算「新增」,一路全绿。现在改成 await 真正的落盘 promise,没滚就停掉周环比/待补/存基线三件事并报红(①和真写要读写飞书台账+基线,离线测不了,靠 weekly.test.mjs 的差异算法 + 真站手验)

tests/导出.test.mjs

不打

CSV 转义 / BOM / 桌面和缓存两个去处分开 / 旧文件按时间戳淘汰(按文件名排会让上周的 分布- 挤掉今天的 SN-)。31 条判据。自动落盘的目录里同一组只留最新一份(2026-08-22;实测 21 个文件里 16 个是同一组 分布-全部,只是时间戳不同);桌面豁免——桌面那份是人明确要的,同一组三份不许自己消失,只受「留几份」总量约束。这条默认值抽成 按组收吗(目录) 单独可验,因为它错了会删人桌面上的东西。外加无主文件 7 条(㉑~㉗):清理器只碰对得上格式的名字,副作用是别的文件永远清不掉、而且没有任何一处会提起它(实测残留 导出/probe.csv)。报出来但一个都不删桌面一律返回空 —— 桌面上全是人自己的东西,在那儿报无主离「顺手删掉」只差一步(这条拿掉闸消融验过必红)。

tests/不膨胀.test.mjs

不打

静态扫每个 mkdirSync 的目标,逐个对上清理函数登记表 —— 新开一个目录忘了配清理就红。外加:起 server 子进程的测试必须把「会改别人答案的状态」指到临时目录(SN 快照是 看变动 的基线、周报和导出会被 列资源 扫;不隔离的话生产状态被搅乱,而别的测试读它就变成「单跑绿、全档偶尔红」)。8 条判据

各自的消融

三个打(分段读 / 增量 / 写路径)

每个拿掉一处承重逻辑,都必须变红,否则断言是恒绿的。个数这里不写 —— 写死过一次就漂过一次,跑一遍看汇总栏那行 ✓ N/N 才是准的

tests/分段读.test.mjs

超 10 MB 的表分段读 + 撞了 90221 自适应砍半。8 条判据

tests/增量.test.mjs

增量重读。6 条判据,只有一条要紧:增量结果必须和全量逐个物料键一模一样(不能拿「守恒过了」当判据 —— 复用了一本其实变了的文档,总数少一截而每步守恒照样绿)。造场景靠 INVENTORY_FAKE_CHANGED,指到临时目录跑、不碰真缓存

tests/自检.test.mjs

不打

./自检.mjs 自己的回归 + 方案包路径怎么找 + 缓存里的无主文件。10 条判据:正常时退 0、缺方案包要红、红的那条后面跟着「怎么办」(只报「坏了」的检查等于没有)、一项坏了后面照样跑完、lark-cli 找不到时说清它跟 WorkBuddy 走;外加路径三条:按顺序找、别处不再自己写一份默认路径(原来四个文件各写一份)、INVENTORY_PLANS 指到不存在的文件时不许静默降级(人以为在用 A 实际在用 B,而两份方案包的差别不报错、只表现为数字对不上);⑨⑩ 缓存根目录里的无主文件要报、退非 0,而 缓存常住 名单上的 rows.json 不许被算进去(报了它下一步就是被人顺手删掉全量行缓存)。判据①②跑之前把缓存指到临时目录(INVENTORY_CACHE_DIR)—— 不指的话「一切正常时全打勾」的红绿取决于跑测试的人机器上攒了什么,跟代码对不对没关系

tests/文档没漂.test.mjs

不打

README 里写死的数字有没有漂。3 条判据:每个不打网络的测试表里都有一行、写的判据数和跑出来的一致、仓库根每个入口脚本文档里都提到了。加它是因为一次审计发现两个新测试压根没进表格 —— 这类漂移不报错,只会让文档变成一份看着像真的、对不上的东西

tests/分发.test.mjs

不打

tools/call 那条分发路径,0 秒。8 条判据:分发通不通、返回体没被改坏、分发处的钩子真的产生了副作用(只验返回值的话钩子静默抛异常看不出来)、改过名的老参数当场顶回去、没有的工具名报错、ping 回空 result、返回体里不许出现台账那条写死的 wiki token。在它之前这一档里没有任何测试执行过 tools/call

tests/问答日志.test.mjs

不打

记下模型实际问了什么。9 条判据:参数原样记不补默认值、失败也记(命中 0 和「压根跑不通」是两类问题)、不许拖慢查询、按月切只留最近几个月、关得掉

tests/keyreg.test.mjs

不打

键锁死 + 台账范围 + 占用的两个方向 + 源表整批改写法。27 条判据:标准写法翻盘要沿用老键、跨库房不沿用、合成一组要报冲突;「该导没导」和「不走台账领用」分开计数(源表有、台账没有);「台账有占用、源表没这个键」也要报(在途落进去的地方,漏了就是超发),日期要跟着每一条走、没填不许编;找前身 只对「跑完在库归 0 的同类型同品牌同库房老键」出候选、槽位解析不出的不出候选,认改名 只许撤这次新发的键、并完之后 对键 真的不再发新键

tests/规格新鲜度.test.mjs

不打

「按 SN 校准型号」那份缓存旧了要能看出来(lib/规格新鲜度.mjs)。15 条判据。背景:源表的型号,硬盘写粗略容量(3.84T)、内存写厂商料号(128GB 2Rx4 PC5-5600B-RA0-1010-XT),人读不懂也匹配不准;CMDB 按 SN 逐根记着精确规格(3.84T NVMe U.2 Gen4128G DDR5 5600),所以查询时按 SN 换成 CMDB 那份(direct.mjssn规格())。这份缓存离线生成、不会自己更新,新到的货和 CMDB 改过的规格都不在里面,而变旧的表现是「那些货的型号还是老样子」——不报错、不变红。守四件:① 只算硬盘+内存(光模块/网卡源表本来就写人读的规格串,一根都不许算进「该覆盖」);② 缓存不存在时报 0 且不抛(日更不能因为这个中止),并说清是「从来没生成过」;③ 新鲜的不报警(天天报人就当噪音了),7 天才报;④ 报警里必须说清「这事不报错」和具体多少天 —— 不说人不会当回事。距今天数夹到 ≥0(刚写完的文件 mtime 可能比 Date.now() 晚几毫秒,Math.floor 会算出 -1)

tests/查库存.test.mjs

不打

主路lib/查库存.mjs,九个工具里被叫得最多的那个)。2026-08-22 从 server.mjs 搬出来时只验到「行为没变」,一条自己的判据都没有 —— 在那之前它只在「起真服务器 + 打真网」那一档被间接跑到,改一行要等几分钟。35 条判据,依赖全从工厂注进去(取数/相关缺表/取枚举/取源表数),7 处消融验过必红。挑判据的口子是错了会不会报错,这个模块的错几乎全是静默的:①⑥ 槽位值不在词表里要当场红(静默查 0 根 = 和「库里真没有」长得一样);⑦⑧ 品牌精确匹配不是包含H3C 捞走 H3C-新华三 会多算,不报错);⑨⑩ 完全读不到的库房根数是 null 不进合计(当 0 加进去 = 把「不知道」算成「没有」);⑪⑫ 明细梯队在前量在后(第一行就是人当成答案的那一行,而二梯队要跨库房协调);⑬2026-08-23 抓到的真 bug:解析器碰上认不出的写法不报错、返回 {},而空槽位集对每一行都逐项相等 —— 问 中文型号 回的是整个资产类型的全部库存,还附一句「按槽位匹配」。方向是最坏的那个(多报:少报人会追问,多报人直接许出去了)。修法是「解析出空集 = 没解析出来」,消融 5 重演旧行为必红;⑰ 槽位填错当场抛且一次表都没读;⑱⑲ 枚举和源表张数每次现取(热重载后的 let,按值快照会让新资产类型永远认不出);⑰~匹配有多严要说清(详见「槽位这条路的两个洞」那节):全解出来的说「这是精确匹配」,只解出一部分的明说「这不是精确匹配」并点名哪几项没参与比较,只给非核心槽位(lanes/media 在词表里、不在 core 里)当没约束处理、不许整类全回,厂商料号只按字面(比指纹)回它自己那条并标出来;㉖㉗ 参数回显是解析后的槽位串且能原样贴回去(多轮追问记漏一个槽位多查一大截、多带一个少一大截,两种都不报错);㉒ 缺表警告排在数据块前面;㉓ 明细几行:0 真的一行不给;㉝㉞㉟ 明细按库房聚拢(同一库房的行连成一段——排序键少了库房那一层,同梯队内两个库房会按根数交错,而模型要 group by 库房才写得出「一个库房一行」,它在脑子里合并的时候梯队顺序就散了,人看到二梯队冒在最前面。样本是对抗性的:二梯队那个库房的量是全场最大)

tests/答法.test.mjs

不打

展示层(lib/答法.mjs:答法/答法差/口径差/明细行/落地行数)。这一层错了全是静默的 —— 回包照常有、数字照常对,只是模型看到的指令变了或该转达的警告没转达。32 条判据,时间靠注入不靠 sleep。守四件:① 格式指令的形状(永远要求原样带 ⚠、禁流水账);② 去重不许吃掉警告 —— ⚠/读取时间/源表链接 值没变也必发(一个警告只报一次然后消失,读起来就像已经解决了),去重掉的要明说「其余同前」,警告消失了要主动说「已解除」;③ 会话边界(2026-08-22 修的真 bug:原来去重状态永不重置,而 MCP server 是长驻进程、实测跑过 23 小时 —— 同事 A 早上问过之后,同事 B 下午拿到的是「其余同前」,而 B 从没看过那个「前」。现在按「离上次调用 10 分钟」切会话);④ 明细行的分支(物料键只在落文件时带、占用字段不编、「随整机走不该在台账里」和「漏录了」不许混成一个)。11 处消融验过必红

tests/工具契约.test.mjs

不打

给模型看的那份契约(名字/说明/参数表/annotations)的快照 + 不变量。回归里唯一真起一次服务器的地方 —— 2026-08-22 把工具表搬进 lib/工具表.mjsnode --check 过了、全套绿了,服务器却起不来(漏搬两个常量、运行时才 ReferenceError),就是因为之前没有一条会执行到「起得来吗」。7 条判据:真起服务器并报拿到几个工具(0 或偏小=没检查)、每个都有名字、说明非空(说明是模型唯一的使用手册)、参数表是 object schema、无重名、required 点名的键 properties 里真有、整份契约哈希对基线。契约变了不一定是坏事,但必须人看过 diff 之后 node tests/工具契约.test.mjs 祝福,不许悄悄漂。6 处消融验过必红(改一个字的说明 / 改 annotations.title / 删一个工具 / 服务器起不来 / 说明变空 / required 点空头承诺)

tests/依赖方向.test.mjs

不打

依赖方向交给 linter 强制(.dependency-cruiser.mjs),不靠纪律。7 条判据:干净必绿且要报它看见了几个模块几条依赖(37/129;0 或偏小 = 没扫到、不是没问题,tach 就这么打过假勾);四条规则各造一个违规样本必红——禁循环(有一边会拿到半初始化的模块)、lib/ 不许反向 import 入口(否则整张工具表被拖进每个用它的地方)、不许 import 伸出仓库(换机器断在运行时)、禁孤儿;样本清掉必须重新绿

tests/死代码.test.mjs

不打

死代码棘轮,knip 走真的 import 图(grep 数不准:中文标识符和注释里提到的名字分不开)。5 条判据,只许降不许涨每次跑都自己种一个未用导出、knip 必须逮到(第一版拿"报了几个文件有发现"当自证,存量清零后那个数就是 0、在代码最干净时假红;样本还必须种进一个已被 import 的文件——种成新文件会被算进 unused files、和 exports 是两类);存量已清到 0 条(2026-08-21 清的:32 个全是多余的 export,摘掉 export 后 eslint 判定它们在本文件内仍在用,真死代码 0 个),基线在 tests/死代码基线.json,新增零消费者导出必红并点名是谁,清掉了也红、提醒把基线跟着降(不降的话余量会被下一个人悄悄用掉)。存量本身怎么处置归人拍板,这条只管别再长

tests/资源位置.test.mjs

不打

找文件不许从「代码放在哪」推(lib/资源位置.mjs)。17 条判据:候选列表长得对 ≠ 真找得到(2026-08-24 逮到的「失效时是绿的」样板)—— 原来只验「候选里有一条以 lib/dedup.py 结尾」,而从 tests/ 里跑时那条是 tests/lib/dedup.py、压根不存在,判据照样绿;真炸的地方在 档(增量/只读要用的表python3: can't open file)。现在直接验 挑存在的() 落到一个真存在的文件上,并验 direct.mjs 找不到 dedup.py 时抛的是「试过这些位置 + 该设哪个变量」那个自带修法的错,不是降级成候选第一条丢给 python3;入口目录跟着 process.argv[1] 走,把模块拷到别的目录后结果不变(这正是 import.meta.url 做不到的那点);候选顺序 = 自己的环境变量 → INVENTORY_HOME → 入口/产物旁边 → ~/.config/inventory-mcp → 调用方给的已知位置;显式指定了却不存在要抛、不许降级;找不到的报错要列全试过的路径 + 点名该设哪个变量。带棘轮:非测试代码里出现一处 import.meta.url 就红(注释里写不算),并自报扫了几个 .mjs(0 或偏小 = 没检查)。守的是打包成单文件后还能找到 plans.json/决定.json/dedup.py —— 那类错打包时不报、运行时才炸

tests/版本协商.test.mjs

不打

握手时那一句协议版本谁说了算。12 条判据:客户端报的版本我方支持就原样回,不支持就回我方最新、不许回声(2026-08-22 修的真 bug:原来无条件把对面报的版本回过去,于是对面的兼容性检查「你回的和我要的一样吗」永远成立、永远发现不了不兼容 —— 这是典型的失效时是绿的);乱字符串、未来版本、空串、干脆不报,四种都归到我方最新;最后一条验真起到了服务器并拿到了回复(拿到 null 的话上面十一条全不作数)

tests/新到.test.mjs

不打

「离上次写台账新进来了什么」+ SN 证据(lib/台账基线.mjs / lib/新到.mjs)。14 条判据:没有基线 ≠ 没有新到(读不到基线返回 null 不是空数组,否则第一次跑会把全库算成新到、而那个数看着完全正常);按物料归并 + CSV 一根一行(清单落文件,实测一次到货 2 万根,塞进上下文会冲掉);搬库房/补品牌不算改名;SN 证据不受「老键归零」约束——源表只改一部分记录时槽位那条路一个候选都出不来、SN 照样认得出,且 SN 候选排最前

tests/改名确认.test.mjs

不打

判改名那道闸,日更周更共用一份lib/改名确认.mjs)。20 条判据:候选带老键原在库、备选必须跟着走(实测「最像的」有 3/28 是错的,正确前身在备选里);认定的改名 省略 ≠ 判过了都不是(省略拒写)、显式 [] 放行、编的配对拒写;命令行没有默认放行的口子(--都不是改名 / --改名 <json>);最后同时跑日更和周更两个工具,验它们拿到的是同一套闸

tests/日更.test.mjs

不打

日更.mjs 自检回执的红检查(lib/日更检查.mjs算红)。13 条判据:注册表报了新发键但条目数没涨=红、更新于不是今天=红、在途读不到=红;dry 模式跳过注册表检查、在途读不到照红;新发 0 键条目数不变不误报;占用挂在「被判成不是改名」的归零老键上=红(2026-08-20 那次 6,144 根许出去的货结不清,当时全绿),两个条件缺一不红、干跑也照红

tests/只读要用的表.test.mjs

只读问到的那类资产的表 + 在途去重。11 条判据,最要紧的一条是只读一类时一个基线都不许滚(半份数据覆盖全仓 SN 快照,下次全量会把没读的二十几万根全算成「多了」,而每步守恒照样绿)。另外守着:口径要自报「只读了部分源表」、行缓存叠加不覆盖、「全部」那份缓存能答窄问题、全量滚快照(和前一条互为反证)。消融靠两个逃生开关注入(INVENTORY_NO_NARROW / INVENTORY_NO_INFLIGHT

tests/写路径.test.mjs

打,而且真写

写多维表格那两条命令的契约。真建一张一次性的表(ZZ-写路径测试-勿动 前缀)、写、读回、删掉,不碰任何业务表。5 条判据,两条是 2026-08-16 那次事故直接教出来的:「每条记录各自的新值真的写进去了」(不是「命令没报错」)、「拿不存在的记录 ID 更新接口照样回 ok」(把「写完必须回读」变成会红的判据)。默认跑 —— 那条写路径从写出来到崩、回归里一次都没覆盖过,「只在改了写路径时手动跑」等于永远不跑。请求体的形状从 lib/ledger-write.mjs 那两个函数拿,不在测试里手写一份

tests/protocol.test.mjs

initialize / tools/list / tools/call 这条链能不能通,外加进度通知(带没带 progressToken 的两种行为互为反证)、查SN 走文件和走内联两条路、老参数名要当场报错、记下决定走真的写盘(指到临时文件)。99 条判据

La ablación no es un adorno; ha atrapado tres veces: brandMap originalmente se construía como constante al cargar el módulo, y al modificar BRAND en las pruebas no le afectaba, la ablación 1 siempre estaba en verde; en la primera versión de la ablación de lectura directa, el propio criterio correspondiente se autoeximía (ABLATE === '1' ? true : ...), y de los tres ablaciones, dos no se ponían en rojo; en la primera versión de la ablación de normalización literal, la muestra estaba mal elegida — se usó CX7-400G单口 vs Cx7 400G 单口 como muestra, y el analizador de ranuras podía unificarlas por sí mismo, la normalización literal no era la que soportaba el peso; al cambiarla por 128g (la g minúscula no permite parsear la capacidad) y SFP.25G-SR.LC (el punto no permite parsear la especificación), solo entonces se recorría realmente ese único camino literal.

«La ablación no se pone en rojo» tiene dos causas, y la segunda es más sutil: el criterio se autoeximió, o la muestra ni siquiera pasa por ese camino.

Reproducir el choque contra el límite de frecuencia

INVENTORY_FAKE_RATELIMIT=99999 INVENTORY_BACKOFF_MS=1,1 ./run-tests.sh   # 该印 ⏸,退 3
./run-tests.sh                                                          # 该全绿,退 0

Recorrer esto a la inversa no es un trámite; el 2026-08-16, de una sola pasada se atraparon cuatro problemas reales, tres eran del propio mecanismo de control de frecuencia recién escrito ese mismo día:

  1. 收尾() originalmente exigía que «cada una de las colgadas fuera de control de frecuencia» para considerar que no se había verificado — pero 分段读 cuelga 5 entradas y solo 1 lleva código, el resto son reacciones en cadena tras no poder obtener datos, así que el mecanismo hecho específicamente para el control de frecuencia no surte efecto ante un control de frecuencia real.

  2. En direct.test.mjs, el subproceso que verifica el código de salida pasaba directamente {...process.env}, arrastrando también las variables de inyección — una prueba que no toca la red se pone en rojo por una inyección de otro lugar, el sandbox de la prueba tenía una fuga.

  3. En protocol.test.mjs, catorce JSON.parse(…content[0].text) sin protección; cuando la herramienta responde en lenguaje humano, lanza Unexpected token '查', "查不了:wiki 换"…el texto original solo conserva los primeros diez caracteres, y code=99991400 está en el carácter 40.

  4. En 写路径.test.mjs, su propio cli() es un pexec pelado; cuando lark-cli sale con código distinto de 0, el cuerpo del error está en stdout, y ese objeto ni siquiera se lee; el error queda reducido a un solo Command failed:. Si realmente escribiera en la base de producción, sería justamente la que más fácil choca contra el límite de cuota.

El punto común de los cuatro: todos son «verdes/amarillos cuando fallan», y solo se ven si realmente se construye el escenario.

Lo que aún no se ha hecho

  • Registro de ocupación no hecho (escribir la tabla «registro de ocupación» tbl8GkzD4stgYcay). La escritura de la tabla de materiales ya está hecha (./导台账.mjs), y las tablas de origen son solo de lectura. Tres reglas duras se implementan primero: el agente no puede rellenar «verificador», la escritura lleva ID de solicitud y se verifica duplicados antes de escribir, y si al releer antes de escribir se descubre que ya está ocupado, se aborta. Esta tabla se exploró una vez el 2026-08-15, y cuatro cosas son distintas de lo que se había registrado:

    ① Ocupación y salida de almacén son dos «tipos» de la misma tabla, no dos tablas. Se suman/restan con cantidad con signo (ocupación +cantidad, salida -cantidad), y ocupación restante es el neto calculado por fórmula. La fila de salida además debe apuntar en 工单 asociado a la fila de ocupación que contrarresta (verificado: 29124 ocupación +2 → 29154 salida -2 apuntando a ella → ocupación restante 0, estado ⚪liquidado).

    ② El material ya es un campo link (material asociado), no text. clave de material de esta fila es una fórmula calculada a partir de él, así que la persona selecciona el material desde el desplegable, no teclea 40 caracteres — lo que se había registrado de «ahora es text» ya está obsoleto.

    ③ «El link por API pierde datos silenciosamente» no se sostiene (verificado el 2026-08-15 escribiendo una y luego borrándola): +record-batch-create con material asociado: [{"id":"rec..."}] sí escribe, el valor del link al releer es idéntico, y la fórmula clave de material de esta fila realmente calculó 光模块|海光芯创|QSFP112-400G-DR4-SM1310|闵行la asociación está viva, no se guardó una cáscara vacía. Cuando la forma está mal escrita, la interfaz da un error explícito (800010701 Cell value does not match any supported shape), y además da la forma correcta en el hint, no lo traga en silencio.

    ④ Pero el cuerpo de respuesta no devuelve record_id: +record-batch-create responde ok:true y records es un array vacío, y desde el valor de retorno no se ve si escribió o qué escribió. Así que la regla de «tras escribir hay que releer y verificar, no confiar en el valor de retorno» en esta tabla no es un seguro, es una necesidad. Para borrar registros se necesita --yes.

    Para la autocomprobación previa a la escritura ya existe algo: esta tabla tiene un campo de fórmula verificación de datos con todas las reglas escritas dentro — tipo sin rellenar / material sin seleccionar («esta ocupación no se puede descontar a nadie») / cantidad debe ser mayor que 0 / mismo工单 y mismo material con varias filas («la ocupación restante quedará baja») / contrarresto que excede la ocupación / falta fecha / falta atribución de costo / salida sin工单 asociado seleccionado / salida sin estado de cierre rellenado. Tras escribir, releer este campo dice si está bien; no vuelvas a implementar las reglas en el código.

  • El tercer segmento de la clave de material sigue siendo la cadena del modelo, solo se hizo normalización a nivel literal. Las diferencias puramente de tipeo (mayúsculas/minúsculas, separadores) ya están bloqueadas, pero las que son semánticamente iguales y literalmente distintas seguirán formando cada una su propia clave. La solución de raíz es cambiar la clave a serialización de ranuras y degradar la cadena del modelo a nombre de visualización. Verificado: de los 26 grupos fusionados, 9 grupos tienen su forma estándar decidida por el número de unidades (CX6-25G双口(287) vs CX6-25G*2(158), una diferencia de 129 unidades, y una sola salida de almacén puede dar vuelta el resultado); al dar vuelta, la comparación semanal reporta un falso «desaparición + aparición».

  • Las tarjetas de red no hacen sustitución por reglas (decidido el 2026-08-15, no es una tarea pendiente). Los tres elementos de 方向.网卡 son null, así que su archivo de reglas es necesariamente vacío — si CX6 puede sustituir a CX5, si la doble boca puede sustituir a la simple, es conocimiento de hardware, la tabla de ranuras no puede calcularlo, y el costo de fijar esta tabla supera el beneficio. El array vacío debe llevar explicación: cuando se acierta en la tabla sin sustitución por reglas, el valor de retorno debe decir explícitamente «este tipo no hace sustitución por reglas + por qué», y es mutuamente excluyente con «⚠ la dirección de sustitución de este tipo aún no está definida» — esto último se lee como una tarea pendiente, y hará que la gente espere algo que nunca llegará. Las tarjetas de red siguen dando coincidencia exacta y capas de similitud como siempre. Para empezar a hacerlo, elimina ese elemento y rellena 方向, moviendo ambos lugares a la vez (lib/substitute.mjs, criterios ㉟㊱㊲ + ablación 9).

  • La cuota no es un obstáculo, ya está aclarado. Feishu no tiene «cuota mensual» — la página oficial frequency-control da tasas por minuto/segundo por API × aplicación × tenant, la franja más estrecha es 100 veces/minuto, y las tablas de la versión básica y la comercial son casi iguales. Al dispararse responde HTTP 429 + code 99991400, y la cabecera de respuesta x-ogw-ratelimit-reset te dice directamente cuántos segundos esperar. Pero el camino de lectura directa sí alcanza: 31 tablas de origen, un arranque en frío son unas 48 llamadas (30 tablas una vez cada una + la tabla de módulos ópticos de Minhang 1 vez de metadatos + 17 segmentos), y con caché vacía una pasada más de encabezados. Dos arranques en frío seguidos dentro de un minuto chocan contra la línea, y al chocar pasa de 8 segundos a 1921 segundos — esto no es teoría; el 2026-08-14, al investigar la lentitud de lectura de tablas, se pisó, y en ese momento se malinterpretó como volumen de datos. En estado caliente solo son 9 llamadas (consultar la revisión de 9 documentos), y el camino del libro de cuentas es 12. Si tres personas instalan cada una su copia y consultan por separado, no alcanza, pero no corras dos veces el total en menos de un minuto. Cuantos más segmentos, más llamadas; antes de cambiar 每段格子, calcula primero esta cuenta.

-
license - not tested
Not graded
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

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.

View all MCP Connectors

Latest Blog Posts

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